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
6 changes: 6 additions & 0 deletions scripts/check-installed-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" \
Expand Down
17 changes: 11 additions & 6 deletions scripts/remote-reconcile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -26,30 +26,35 @@ 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
}

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() {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions scripts/test-install-worker-controller.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions scripts/test_remote_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down