Skip to content
Closed
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
116 changes: 116 additions & 0 deletions bin/fm-gh-account-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# shellcheck shell=bash
# Per-project GitHub account selection for spawned worker gh operations.
# Usage: . bin/fm-gh-account-lib.sh
#
# The captain runs two GitHub accounts isolated per project. Git transport is
# already routed durably (SSH host aliases + insteadOf + includeIf), but the gh
# CLI selects its token from GH_CONFIG_DIR (default ~/.config/gh), and that
# default account is not SSO-authorized for every org. When firstmate launches a
# ship/scout worker, fm-spawn.sh resolves the project's account from its origin
# remote and exports GH_CONFIG_DIR into the worker's pane so every gh call the
# worker (and the no-mistakes pipeline it drives) makes uses the right account.
#
# The gh account configs live only in the PRIMARY firstmate home's data/gh-config
# (one subdir per account, e.g. data/gh-config/pwxgh). A crewmate spawned from a
# secondmate home reaches the same primary-home configs via the local-route
# parent record (fm-secondmate-parent-lib.sh), so both a main-home and a
# secondmate-home crewmate authenticate against the same authorized store.
#
# When an origin does not map to a known account (an unrelated repo), or the
# resolved config dir is absent, these functions produce nothing and fm-spawn
# leaves the environment unchanged - never guessing an account.

# shellcheck source=bin/fm-secondmate-parent-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-parent-lib.sh"

# The account map. Each row: <account>|<ssh-alias-host>|<github.com-namespace>.
# <account> is also the data/gh-config subdir name. Add a row to extend; keep it
# the single home for the mapping rather than scattering org names elsewhere.
_fm_gh_account_table() {
cat <<'TABLE'
knowttl|github.com-knowttl|knowttl
pwxgh|github.com-pwxgh|powerex-development
TABLE
}

# Map an origin URL to an account name. Prints the account and returns 0 on a
# match; prints nothing and returns 1 otherwise. Handles the https, ssh://,
# scp-like git@host:path, and bare ssh-alias host:path origin forms.
fm_gh_account_for_origin() {
local origin=$1 rest host path ns
[ -n "$origin" ] || return 1
case "$origin" in
*://*)
# scheme://[user@]host/path
rest=${origin#*://}
rest=${rest#*@}
host=${rest%%/*}
path=${rest#*/}
;;
*@*:*)
# user@host:path (scp-like)
rest=${origin#*@}
host=${rest%%:*}
path=${rest#*:}
;;
*:*)
# host:path (scp-like, no user - e.g. a bare ssh-alias origin)
host=${origin%%:*}
path=${origin#*:}
;;
*)
return 1
;;
esac
ns=${path%%/*}

local account alias_host namespace
while IFS='|' read -r account alias_host namespace; do
[ -n "$account" ] || continue
if [ "$host" = "$alias_host" ]; then
printf '%s\n' "$account"
return 0
fi
if [ "$host" = github.com ] && [ "$ns" = "$namespace" ]; then
printf '%s\n' "$account"
return 0
fi
done < <(_fm_gh_account_table)
return 1
}

# Resolve the primary firstmate home's gh-config base for the given home. For a
# main home this is <home>/data/gh-config; for a secondmate home it is the
# local-route parent home's data/gh-config, so the authorized configs are reached
# from either. Prints the base dir and returns 0 only when it exists; returns 1
# for a remote or unresolvable parent, or a missing base.
fm_gh_config_base_for_home() {
local home=$1 base
[ -n "$home" ] || return 1
if [ -f "$home/.fm-secondmate-home" ]; then
fm_secondmate_parent_record_parse "$home/.fm-secondmate-parent" || return 1
[ "$FM_SECONDMATE_PARENT_ROUTE" = local ] || return 1
[ -n "$FM_SECONDMATE_PARENT_HOME" ] || return 1
base="$FM_SECONDMATE_PARENT_HOME/data/gh-config"
else
base="$home/data/gh-config"
fi
[ -d "$base" ] || return 1
printf '%s\n' "$base"
}

# Resolve the GH_CONFIG_DIR a worker launched for <git_dir> under <home> should
# use. Reads <git_dir>'s origin, maps it to an account, and composes the account
# subdir under the primary home's gh-config base. Prints the absolute dir and
# returns 0 only when the origin maps to a known account and that account's
# config dir exists; otherwise prints nothing and returns 1.
fm_gh_config_dir_for_spawn() {
local home=$1 git_dir=$2 origin account base dir
[ -n "$git_dir" ] || return 1
origin=$(git -C "$git_dir" remote get-url origin 2>/dev/null) || return 1
account=$(fm_gh_account_for_origin "$origin") || return 1
base=$(fm_gh_config_base_for_home "$home") || return 1
dir="$base/$account"
[ -d "$dir" ] || return 1
printf '%s\n' "$dir"
}
15 changes: 15 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ SUB_HOME_MARKER=".fm-secondmate-home"
. "$SCRIPT_DIR/fm-trace-context-lib.sh"
# shellcheck source=bin/fm-remote-readiness-lib.sh
. "$SCRIPT_DIR/fm-remote-readiness-lib.sh"
# shellcheck source=bin/fm-gh-account-lib.sh
. "$SCRIPT_DIR/fm-gh-account-lib.sh"
# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn
# a direct report (see bin/fm-gate-refuse-lib.sh).
fm_refuse_if_gate_agent
Expand Down Expand Up @@ -2716,6 +2718,19 @@ spawn_record_traceparent() {
# process (go build, go test, ...) inherit it. Sent before the launch command so
# the env is set when the agent starts; the brief sleep lets the export land.
spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp"
# Select the per-project GitHub account for the worker's gh calls (and the
# no-mistakes pipeline it drives) by exporting GH_CONFIG_DIR at the correct
# account config in the primary home's data/gh-config. Sent through the same
# channel as GOTMPDIR, before launch, so the env is set when the agent starts.
# Only for project workers (ship/scout); a secondmate spawn is not project work.
# Resolves from the project's origin remote and stays silent when the origin does
# not map to a known account or the config is absent, leaving the env unchanged.
if [ "$KIND" != secondmate ]; then
GH_CONFIG_DIR_EXPORT=$(fm_gh_config_dir_for_spawn "$FM_HOME" "$PROJ_ABS" 2>/dev/null) || GH_CONFIG_DIR_EXPORT=
if [ -n "$GH_CONFIG_DIR_EXPORT" ]; then
spawn_send_text_line "$T" "export GH_CONFIG_DIR=$GH_CONFIG_DIR_EXPORT"
fi
fi
# Send through the exact channel that already ships GOTMPDIR, so every backend
# and harness - ship, scout, and secondmate - gets it before launch. Skipped
# entirely when trace context is off.
Expand Down
240 changes: 240 additions & 0 deletions tests/fm-gh-account.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# Behavior tests for per-project GitHub account selection (fm-gh-account-lib.sh).
#
# fm-spawn.sh sources this library and, for a ship/scout worker, exports
# GH_CONFIG_DIR at the account config resolved from the project's origin remote,
# so every gh call the worker makes authenticates against the right account.
#
# These tests drive the library's public functions directly - the same functions
# fm-spawn calls - against real origin URLs, real temporary git repos, and real
# fake home directories. They never assert the library's source bytes.
set -u

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=bin/fm-gh-account-lib.sh
. "$ROOT/bin/fm-gh-account-lib.sh"

fail() {
printf 'not ok - %s\n' "$1" >&2
exit 1
}

pass() {
printf 'ok - %s\n' "$1"
}

TMP_ROOT=
cleanup() {
if [ -n "${TMP_ROOT:-}" ]; then
rm -rf "$TMP_ROOT"
fi
}
trap cleanup EXIT
TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-gh-account-tests.XXXXXX")

# --- fm_gh_account_for_origin: the pure origin -> account mapping ---

assert_account() { # <origin> <expected-account>
local origin=$1 want=$2 got
got=$(fm_gh_account_for_origin "$origin") \
|| fail "origin '$origin' did not map (expected '$want')"
[ "$got" = "$want" ] \
|| fail "origin '$origin' mapped to '$got', expected '$want'"
}

assert_no_account() { # <origin>
local origin=$1 got
if got=$(fm_gh_account_for_origin "$origin"); then
fail "origin '$origin' mapped to '$got' but should map to no account"
fi
[ -z "$got" ] || fail "origin '$origin' printed '$got' on no-account result"
}

test_account_mapping() {
# knowttl: ssh-alias host and github.com namespace, across origin forms.
assert_account 'git@github.com-knowttl:knowttl/awx-axi.git' knowttl
assert_account 'github.com-knowttl:knowttl/awx-axi.git' knowttl
assert_account 'https://github.com/knowttl/awx-axi.git' knowttl
assert_account 'git@github.com:knowttl/awx-axi.git' knowttl
assert_account 'ssh://git@github.com/knowttl/awx-axi.git' knowttl

# pwxgh: ssh-alias host and powerex-development namespace, across origin forms.
assert_account 'git@github.com-pwxgh:powerex-development/ops.cs.azure-epac.git' pwxgh
assert_account 'github.com-pwxgh:powerex-development/ops.cs.azure-epac.git' pwxgh
assert_account 'https://github.com/powerex-development/ops.cs.azure-epac.git' pwxgh
assert_account 'git@github.com:powerex-development/ops.cs.azure-epac.git' pwxgh

# Unrelated origins map to no account (never guess).
assert_no_account 'git@github.com:someorg/repo.git'
assert_no_account 'https://github.com/other-user/thing.git'
assert_no_account 'git@gitlab.com:knowttl/repo.git'
assert_no_account 'git@github.com-unknown:foo/repo.git'
assert_no_account ''

pass "fm_gh_account_for_origin maps both account forms and rejects unrelated origins"
}

# --- fm_gh_config_base_for_home: primary-home gh-config base resolution ---

test_config_base_main_home() {
local home="$TMP_ROOT/main-home"
mkdir -p "$home/data/gh-config"
local got
got=$(fm_gh_config_base_for_home "$home") \
|| fail "main home base did not resolve"
[ "$got" = "$home/data/gh-config" ] \
|| fail "main home base resolved to '$got'"
pass "fm_gh_config_base_for_home resolves a main home's own data/gh-config"
}

test_config_base_secondmate_local_parent() {
# A secondmate home's own gh-config is absent; it must resolve the local-route
# parent (primary) home's data/gh-config instead.
local primary="$TMP_ROOT/primary"
local sm="$TMP_ROOT/sm-local"
mkdir -p "$primary/data/gh-config"
mkdir -p "$sm"
printf 'seed-id\n' > "$sm/.fm-secondmate-home"
cat > "$sm/.fm-secondmate-parent" <<EOF
schema=fm-secondmate-parent.v1
route=local
parent_home=$primary
EOF
local got
got=$(fm_gh_config_base_for_home "$sm") \
|| fail "secondmate home did not resolve its parent's base"
[ "$got" = "$primary/data/gh-config" ] \
|| fail "secondmate home base resolved to '$got', expected '$primary/data/gh-config'"
pass "fm_gh_config_base_for_home resolves a secondmate home to its local parent's data/gh-config"
}

test_config_base_secondmate_remote_parent() {
# A remote-route parent shares no filesystem, so no gh-config base resolves.
local sm="$TMP_ROOT/sm-remote"
mkdir -p "$sm"
printf 'seed-id\n' > "$sm/.fm-secondmate-home"
cat > "$sm/.fm-secondmate-parent" <<EOF
schema=fm-secondmate-parent.v1
route=remote
parent_host=github.com-example
EOF
if fm_gh_config_base_for_home "$sm" >/dev/null; then
fail "remote-route secondmate home resolved a base but should not"
fi
pass "fm_gh_config_base_for_home refuses a remote-route secondmate home"
}

test_config_base_missing_dir() {
# A home whose data/gh-config does not exist resolves nothing.
local home="$TMP_ROOT/no-config-home"
mkdir -p "$home/data"
if fm_gh_config_base_for_home "$home" >/dev/null; then
fail "home without data/gh-config resolved a base but should not"
fi
pass "fm_gh_config_base_for_home refuses a home with no data/gh-config"
}

# --- fm_gh_config_dir_for_spawn: end-to-end from a repo's origin to GH_CONFIG_DIR ---

make_repo_with_origin() { # <name> <origin-url> -> prints repo path
local name=$1 origin=$2 repo
repo="$TMP_ROOT/$name"
mkdir -p "$repo"
git -C "$repo" init -q
git -C "$repo" remote add origin "$origin"
printf '%s' "$repo"
}

test_spawn_dir_mapped_project() {
# A main home with both account configs present.
local home="$TMP_ROOT/spawn-main"
mkdir -p "$home/data/gh-config/knowttl" "$home/data/gh-config/pwxgh"

local repo got
# knowttl via ssh-alias origin.
repo=$(make_repo_with_origin knowttl-repo 'git@github.com-knowttl:knowttl/awx-axi.git')
got=$(fm_gh_config_dir_for_spawn "$home" "$repo") \
|| fail "knowttl repo did not resolve a spawn config dir"
[ "$got" = "$home/data/gh-config/knowttl" ] \
|| fail "knowttl spawn dir resolved to '$got'"

# pwxgh via https origin.
repo=$(make_repo_with_origin pwxgh-repo 'https://github.com/powerex-development/ops.cs.azure-epac.git')
got=$(fm_gh_config_dir_for_spawn "$home" "$repo") \
|| fail "pwxgh repo did not resolve a spawn config dir"
[ "$got" = "$home/data/gh-config/pwxgh" ] \
|| fail "pwxgh spawn dir resolved to '$got'"

pass "fm_gh_config_dir_for_spawn carries the expected GH_CONFIG_DIR for a mapped project"
}

test_spawn_dir_secondmate_reaches_primary() {
# A secondmate-home worker reaches the primary home's account configs.
local primary="$TMP_ROOT/spawn-primary"
local sm="$TMP_ROOT/spawn-sm"
mkdir -p "$primary/data/gh-config/pwxgh"
mkdir -p "$sm"
printf 'seed-id\n' > "$sm/.fm-secondmate-home"
cat > "$sm/.fm-secondmate-parent" <<EOF
schema=fm-secondmate-parent.v1
route=local
parent_home=$primary
EOF
local repo got
repo=$(make_repo_with_origin sm-repo 'git@github.com-pwxgh:powerex-development/ops.cs.azure-epac.git')
got=$(fm_gh_config_dir_for_spawn "$sm" "$repo") \
|| fail "secondmate-home worker did not resolve the primary's config dir"
[ "$got" = "$primary/data/gh-config/pwxgh" ] \
|| fail "secondmate-home spawn dir resolved to '$got', expected '$primary/data/gh-config/pwxgh'"
pass "fm_gh_config_dir_for_spawn lets a secondmate-home worker reach the primary's account config"
}

test_spawn_dir_unmapped_project() {
# An unrelated origin forces no GH_CONFIG_DIR even with configs present.
local home="$TMP_ROOT/spawn-unmapped"
mkdir -p "$home/data/gh-config/knowttl" "$home/data/gh-config/pwxgh"
local repo got
repo=$(make_repo_with_origin unrelated-repo 'git@github.com:someorg/repo.git')
if got=$(fm_gh_config_dir_for_spawn "$home" "$repo"); then
fail "unrelated origin resolved '$got' but should force no GH_CONFIG_DIR"
fi
[ -z "$got" ] || fail "unrelated origin printed '$got'"
pass "fm_gh_config_dir_for_spawn forces no GH_CONFIG_DIR for an unmapped project"
}

test_spawn_dir_missing_account_config() {
# A mapped origin whose account config subdir is absent resolves nothing,
# rather than pointing gh at a nonexistent store.
local home="$TMP_ROOT/spawn-partial"
mkdir -p "$home/data/gh-config/knowttl" # pwxgh deliberately absent
local repo got
repo=$(make_repo_with_origin partial-repo 'git@github.com-pwxgh:powerex-development/ops.cs.azure-epac.git')
if got=$(fm_gh_config_dir_for_spawn "$home" "$repo"); then
fail "mapped origin with absent account config resolved '$got' but should not"
fi
pass "fm_gh_config_dir_for_spawn resolves nothing when the mapped account config is absent"
}

test_spawn_dir_no_origin() {
# A repo with no origin remote resolves nothing.
local home="$TMP_ROOT/spawn-noorigin"
mkdir -p "$home/data/gh-config/knowttl"
local repo="$TMP_ROOT/noorigin-repo" got
mkdir -p "$repo"
git -C "$repo" init -q
if got=$(fm_gh_config_dir_for_spawn "$home" "$repo"); then
fail "repo with no origin resolved '$got' but should not"
fi
pass "fm_gh_config_dir_for_spawn resolves nothing when the repo has no origin"
}

test_account_mapping
test_config_base_main_home
test_config_base_secondmate_local_parent
test_config_base_secondmate_remote_parent
test_config_base_missing_dir
test_spawn_dir_mapped_project
test_spawn_dir_secondmate_reaches_primary
test_spawn_dir_unmapped_project
test_spawn_dir_missing_account_config
test_spawn_dir_no_origin
Loading