diff --git a/scripts/check-installed-state.sh b/scripts/check-installed-state.sh index 99407d99..8bde0581 100755 --- a/scripts/check-installed-state.sh +++ b/scripts/check-installed-state.sh @@ -3,6 +3,7 @@ set -Eeuo pipefail state_file=${CI_FLEET_INSTALL_STATE_FILE:-/var/lib/ci-fleet/install-state.json} installer=${CI_FLEET_INSTALLER:-/opt/ci-fleet/manager/current/scripts/install-worker-controller.sh} +remote_reconciler=${CI_FLEET_REMOTE_RECONCILER:-/opt/ci-fleet/manager/current/scripts/remote-reconcile.sh} [[ -f "$state_file" ]] || { echo "ERROR: installed desired-state record is missing: $state_file" >&2; exit 2; } expected_owner=0 @@ -34,6 +35,11 @@ controller=${values[0]} config_repository=${values[1]} config_ref=${values[2]} +if [[ "$config_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + [[ -x "$remote_reconciler" ]] || { echo "ERROR: remote reconciler is unavailable: $remote_reconciler" >&2; exit 2; } + CI_FLEET_REMOTE_STATE_FILE="$state_file" exec "$remote_reconciler" --check-only --desired-ref "$config_ref" +fi + exec "$installer" --check \ --config-repo "$config_repository" \ --ref "$config_ref" \ diff --git a/scripts/remote-reconcile.sh b/scripts/remote-reconcile.sh index 2d331cbc..e615d185 100755 --- a/scripts/remote-reconcile.sh +++ b/scripts/remote-reconcile.sh @@ -6,7 +6,7 @@ # validates the configuration, checks for drift, and reconciles if needed. # # Usage: -# remote-reconcile.sh [--check-only] [--no-op] +# remote-reconcile.sh [--check-only] [--desired-ref SHA] [--no-op] set -Eeuo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -26,17 +26,19 @@ trap cleanup_temp EXIT mode=reconcile # reconcile or check-only no_op=false +desired_ref= max_attempts=${CI_FLEET_RECONCILE_MAX_ATTEMPTS:-3} usage() { cat >&2 <<'EOF' usage: - remote-reconcile.sh [--check-only] [--no-op] + remote-reconcile.sh [--check-only] [--desired-ref SHA] [--no-op] Fetches the desired-state repository at the current default-branch HEAD, validates it, and reconciles the controller if a newer commit is available. --check-only Validate and report without reconciling. + --desired-ref Check this exact commit instead of remote HEAD (check-only only). --no-op Log what would be done without side effects. EOF } @@ -44,12 +46,15 @@ EOF while (($#)); do case "$1" in --check-only) mode=check-only ;; + --desired-ref) shift; (($#)) || { echo 'ERROR: --desired-ref requires a commit' >&2; exit 2; }; desired_ref=$1 ;; --no-op) no_op=true ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown argument: $1" >&2; usage; exit 2 ;; esac shift done +[[ -z "$desired_ref" || "$mode" == check-only ]] || { echo 'ERROR: --desired-ref requires --check-only' >&2; exit 2; } +[[ -z "$desired_ref" || "$desired_ref" =~ ^[0-9a-f]{40}$ ]] || { echo 'ERROR: --desired-ref must be a full lowercase commit SHA' >&2; exit 2; } note() { printf 'RECONCILE %s\n' "$*"; } die() { @@ -135,16 +140,16 @@ generate_token() { # --- Remote fetch --- fetch_remote_config() { - local repo=$1 token=$2 + local repo=$1 token=$2 ref=${3:-HEAD} local fetch_dir=$temp_dir/config-repo mkdir -p "$fetch_dir" git init -q "$fetch_dir" # Use auth_url with embedded token for authenticated fetch local auth_url="https://x-access-token:${token}@github.com/${repo}.git" - GIT_TERMINAL_PROMPT=0 git -C "$fetch_dir" fetch -q --filter=blob:none --depth=1 origin HEAD 2>"$temp_dir/fetch_err" || { + GIT_TERMINAL_PROMPT=0 git -C "$fetch_dir" fetch -q --filter=blob:none --depth=1 origin "$ref" 2>"$temp_dir/fetch_err" || { local err # Retry with auth_url if plain fetch failed (private repo needs auth) - GIT_TERMINAL_PROMPT=0 git -C "$fetch_dir" fetch -q --filter=blob:none --depth=1 "$auth_url" HEAD 2>"$temp_dir/fetch_err" || { + GIT_TERMINAL_PROMPT=0 git -C "$fetch_dir" fetch -q --filter=blob:none --depth=1 "$auth_url" "$ref" 2>"$temp_dir/fetch_err" || { local err err=$(<"$temp_dir/fetch_err") [[ -n "$err" ]] || err="fetch failed" @@ -349,7 +354,7 @@ while ((attempt < max_attempts)); do # Fetch remote config note "FETCHING_CONFIG repo=${installed_config_repo}" - desired_commit=$(fetch_remote_config "$installed_config_repo" "$token") || { + desired_commit=$(fetch_remote_config "$installed_config_repo" "$token" "${desired_ref:-HEAD}") || { note "FETCH_FAILED attempt=${attempt}" ((attempt < max_attempts)) && { sleep 5; continue; } die "fetch exhausted after ${max_attempts} attempts" diff --git a/scripts/test-install-worker-controller.sh b/scripts/test-install-worker-controller.sh index e9a87061..4e34c07f 100755 --- a/scripts/test-install-worker-controller.sh +++ b/scripts/test-install-worker-controller.sh @@ -316,6 +316,24 @@ expect_failure 'install state must be owned by root with mode 0600' env CI_FLEET unset FAKE_WRONG_INSTALL_STATE_OWNER expect_success env CI_FLEET_INSTALL_STATE_FILE="$install_state" CI_FLEET_INSTALLER="$installer" "$repo_root/scripts/check-installed-state.sh" >/dev/null +remote_reconciler=$tmp/fake-remote-reconciler +remote_reconciler_log=$tmp/fake-remote-reconciler.log +# shellcheck disable=SC2016 +printf '#!/usr/bin/env bash\nprintf "%%s\\n%%s\\n" "$*" "$CI_FLEET_REMOTE_STATE_FILE" >"$REMOTE_RECONCILER_LOG"\n' >"$remote_reconciler" +chmod 0755 "$remote_reconciler" +python3 - "$install_state" <<'PY' +import json, sys +path = sys.argv[1] +state = json.load(open(path)) +state["config_repository"] = "example/private-config" +with open(path, "w") as output: + json.dump(state, output) +PY +expect_success env CI_FLEET_INSTALL_STATE_FILE="$install_state" CI_FLEET_INSTALLER="$installer" CI_FLEET_REMOTE_RECONCILER="$remote_reconciler" REMOTE_RECONCILER_LOG="$remote_reconciler_log" "$repo_root/scripts/check-installed-state.sh" +mapfile -t remote_call <"$remote_reconciler_log" +[[ ${remote_call[0]} == "--check-only --desired-ref $ref_one" && ${remote_call[1]} == "$install_state" ]] || fail 'remote drift check did not delegate the exact installed state to authenticated reconciliation' +expect_success "$installer" --install "${base_args[@]}" --ref "$ref_one" >/dev/null + export FAKE_DISABLED_TIMER=ci-fleet-cleanup.timer expect_failure 'DRIFT maintenance_timers' "$installer" --check "${base_args[@]}" --ref "$ref_one" unset FAKE_DISABLED_TIMER diff --git a/scripts/test_remote_reconcile.py b/scripts/test_remote_reconcile.py index 3cffde10..2c4851c3 100644 --- a/scripts/test_remote_reconcile.py +++ b/scripts/test_remote_reconcile.py @@ -197,6 +197,20 @@ def test_help_exits_0(self): self.assertEqual(result.returncode, 0) self.assertIn("usage", result.stdout + result.stderr) + def test_desired_ref_is_full_sha_and_check_only(self): + invalid = subprocess.run( + [str(RECONCILE_SCRIPT), "--check-only", "--desired-ref", "main"], + capture_output=True, text=True, env=self.env, + ) + mutating = subprocess.run( + [str(RECONCILE_SCRIPT), "--desired-ref", "a" * 40], + capture_output=True, text=True, env=self.env, + ) + self.assertEqual(invalid.returncode, 2) + self.assertIn("full lowercase commit SHA", invalid.stderr) + self.assertEqual(mutating.returncode, 2) + self.assertIn("requires --check-only", mutating.stderr) + def test_reconcile_state_saved_on_failure(self): """State file is saved even when reconciliation fails.""" result = subprocess.run(