Merge pull request 'fix: [nomad-prep] P7 — make disinto init idempotent + add --dry-run (#800)' (#815) from fix/issue-800 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
34447d31dc
3 changed files with 212 additions and 58 deletions
84
bin/disinto
84
bin/disinto
|
|
@ -85,6 +85,7 @@ Init options:
|
||||||
--build Use local docker build instead of registry images (dev mode)
|
--build Use local docker build instead of registry images (dev mode)
|
||||||
--yes Skip confirmation prompts
|
--yes Skip confirmation prompts
|
||||||
--rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default)
|
--rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default)
|
||||||
|
--dry-run Print every intended action without executing
|
||||||
|
|
||||||
Hire an agent options:
|
Hire an agent options:
|
||||||
--formula <path> Path to role formula TOML (default: formulas/<role>.toml)
|
--formula <path> Path to role formula TOML (default: formulas/<role>.toml)
|
||||||
|
|
@ -653,7 +654,7 @@ disinto_init() {
|
||||||
shift
|
shift
|
||||||
|
|
||||||
# Parse flags
|
# Parse flags
|
||||||
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false
|
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false dry_run=false
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--branch) branch="$2"; shift 2 ;;
|
--branch) branch="$2"; shift 2 ;;
|
||||||
|
|
@ -664,6 +665,7 @@ disinto_init() {
|
||||||
--build) use_build=true; shift ;;
|
--build) use_build=true; shift ;;
|
||||||
--yes) auto_yes=true; shift ;;
|
--yes) auto_yes=true; shift ;;
|
||||||
--rotate-tokens) rotate_tokens=true; shift ;;
|
--rotate-tokens) rotate_tokens=true; shift ;;
|
||||||
|
--dry-run) dry_run=true; shift ;;
|
||||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -740,6 +742,86 @@ p.write_text(text)
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Dry-run mode: report intended actions and exit ─────────────────────────
|
||||||
|
if [ "$dry_run" = true ]; then
|
||||||
|
echo ""
|
||||||
|
echo "── Dry-run: intended actions ────────────────────────────"
|
||||||
|
local env_file="${FACTORY_ROOT}/.env"
|
||||||
|
local rr="${repo_root:-/home/${USER}/${project_name}}"
|
||||||
|
|
||||||
|
if [ "$bare" = false ]; then
|
||||||
|
[ -f "${FACTORY_ROOT}/docker-compose.yml" ] \
|
||||||
|
&& echo "[skip] docker-compose.yml (exists)" \
|
||||||
|
|| echo "[create] docker-compose.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$env_file" ] \
|
||||||
|
&& echo "[exists] .env" \
|
||||||
|
|| echo "[create] .env"
|
||||||
|
|
||||||
|
# Report token state from .env
|
||||||
|
if [ -f "$env_file" ]; then
|
||||||
|
local _var
|
||||||
|
for _var in FORGE_ADMIN_TOKEN HUMAN_TOKEN FORGE_TOKEN FORGE_REVIEW_TOKEN \
|
||||||
|
FORGE_PLANNER_TOKEN FORGE_GARDENER_TOKEN FORGE_VAULT_TOKEN \
|
||||||
|
FORGE_SUPERVISOR_TOKEN FORGE_PREDICTOR_TOKEN FORGE_ARCHITECT_TOKEN; do
|
||||||
|
if grep -q "^${_var}=" "$env_file" 2>/dev/null; then
|
||||||
|
echo "[keep] ${_var} (preserved)"
|
||||||
|
else
|
||||||
|
echo "[create] ${_var}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "[create] all tokens and passwords"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[ensure] Forgejo admin user 'disinto-admin'"
|
||||||
|
echo "[ensure] 8 bot users: dev-bot, review-bot, planner-bot, gardener-bot, vault-bot, supervisor-bot, predictor-bot, architect-bot"
|
||||||
|
echo "[ensure] 2 llama bot users: dev-qwen, dev-qwen-nightly"
|
||||||
|
echo "[ensure] .profile repos for all bots"
|
||||||
|
echo "[ensure] repo ${forge_repo} on Forgejo with collaborators"
|
||||||
|
echo "[run] preflight checks"
|
||||||
|
|
||||||
|
[ -d "${rr}/.git" ] \
|
||||||
|
&& echo "[skip] clone ${rr} (exists)" \
|
||||||
|
|| echo "[clone] ${repo_url} -> ${rr}"
|
||||||
|
|
||||||
|
echo "[push] to local Forgejo"
|
||||||
|
echo "[ensure] ops repo disinto-admin/${project_name}-ops"
|
||||||
|
echo "[ensure] branch protection on ${forge_repo}"
|
||||||
|
|
||||||
|
[ "$toml_exists" = true ] \
|
||||||
|
&& echo "[skip] ${toml_path} (exists)" \
|
||||||
|
|| echo "[create] ${toml_path}"
|
||||||
|
|
||||||
|
if [ "$bare" = false ]; then
|
||||||
|
echo "[ensure] Woodpecker OAuth2 app"
|
||||||
|
echo "[ensure] Chat OAuth2 app"
|
||||||
|
echo "[ensure] WOODPECKER_AGENT_SECRET in .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ensure] labels on ${forge_repo}"
|
||||||
|
|
||||||
|
[ -f "${rr}/VISION.md" ] \
|
||||||
|
&& echo "[skip] VISION.md (exists)" \
|
||||||
|
|| echo "[create] VISION.md"
|
||||||
|
|
||||||
|
echo "[copy] issue templates"
|
||||||
|
echo "[ensure] scheduling (cron or compose polling)"
|
||||||
|
|
||||||
|
if [ "$bare" = false ]; then
|
||||||
|
echo "[start] docker compose stack"
|
||||||
|
echo "[ensure] Woodpecker token + repo activation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ensure] CLAUDE_CONFIG_DIR"
|
||||||
|
echo "[ensure] state files (.dev-active, .reviewer-active, .gardener-active)"
|
||||||
|
echo ""
|
||||||
|
echo "Dry run complete — no changes made."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate compose files (unless --bare)
|
# Generate compose files (unless --bare)
|
||||||
if [ "$bare" = false ]; then
|
if [ "$bare" = false ]; then
|
||||||
local forge_port
|
local forge_port
|
||||||
|
|
|
||||||
|
|
@ -212,8 +212,8 @@ setup_forge() {
|
||||||
|
|
||||||
# Create human user (disinto-admin) as site admin if it doesn't exist
|
# Create human user (disinto-admin) as site admin if it doesn't exist
|
||||||
local human_user="disinto-admin"
|
local human_user="disinto-admin"
|
||||||
local human_pass
|
# human_user == admin_user; reuse admin_pass for basic-auth operations
|
||||||
human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
local human_pass="$admin_pass"
|
||||||
|
|
||||||
if ! curl -sf --max-time 5 -H "Authorization: token ${FORGE_TOKEN:-}" "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
if ! curl -sf --max-time 5 -H "Authorization: token ${FORGE_TOKEN:-}" "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||||
echo "Creating human user: ${human_user}"
|
echo "Creating human user: ${human_user}"
|
||||||
|
|
@ -245,63 +245,89 @@ setup_forge() {
|
||||||
echo "Human user: ${human_user} (already exists)"
|
echo "Human user: ${human_user} (already exists)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Delete existing admin token if present (token sha1 is only returned at creation time)
|
# Preserve admin token if already stored in .env (idempotent re-run)
|
||||||
local existing_token_id
|
local admin_token=""
|
||||||
existing_token_id=$(curl -sf \
|
if _token_exists_in_env "FORGE_ADMIN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then
|
||||||
-u "${admin_user}:${admin_pass}" \
|
admin_token=$(grep '^FORGE_ADMIN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-)
|
||||||
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
[ -n "$admin_token" ] && echo "Admin token: preserved (use --rotate-tokens to force)"
|
||||||
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
|
|
||||||
if [ -n "$existing_token_id" ]; then
|
|
||||||
curl -sf -X DELETE \
|
|
||||||
-u "${admin_user}:${admin_pass}" \
|
|
||||||
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create admin token (fresh, so sha1 is returned)
|
|
||||||
local admin_token
|
|
||||||
admin_token=$(curl -sf -X POST \
|
|
||||||
-u "${admin_user}:${admin_pass}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
|
||||||
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
|
|
||||||
| jq -r '.sha1 // empty') || admin_token=""
|
|
||||||
|
|
||||||
if [ -z "$admin_token" ]; then
|
if [ -z "$admin_token" ]; then
|
||||||
echo "Error: failed to obtain admin API token" >&2
|
# Delete existing admin token if present (token sha1 is only returned at creation time)
|
||||||
exit 1
|
local existing_token_id
|
||||||
fi
|
existing_token_id=$(curl -sf \
|
||||||
|
-u "${admin_user}:${admin_pass}" \
|
||||||
# Get or create human user token
|
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
||||||
local human_token=""
|
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
|
||||||
# Delete existing human token if present (token sha1 is only returned at creation time)
|
if [ -n "$existing_token_id" ]; then
|
||||||
local existing_human_token_id
|
curl -sf -X DELETE \
|
||||||
existing_human_token_id=$(curl -sf \
|
-u "${admin_user}:${admin_pass}" \
|
||||||
-u "${human_user}:${human_pass}" \
|
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
|
||||||
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
|
fi
|
||||||
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
|
|
||||||
if [ -n "$existing_human_token_id" ]; then
|
# Create admin token (fresh, so sha1 is returned)
|
||||||
curl -sf -X DELETE \
|
admin_token=$(curl -sf -X POST \
|
||||||
-u "${human_user}:${human_pass}" \
|
-u "${admin_user}:${admin_pass}" \
|
||||||
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
|
-H "Content-Type: application/json" \
|
||||||
fi
|
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
||||||
|
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
|
||||||
# Create human token (fresh, so sha1 is returned)
|
| jq -r '.sha1 // empty') || admin_token=""
|
||||||
human_token=$(curl -sf -X POST \
|
|
||||||
-u "${human_user}:${human_pass}" \
|
if [ -z "$admin_token" ]; then
|
||||||
-H "Content-Type: application/json" \
|
echo "Error: failed to obtain admin API token" >&2
|
||||||
"${forge_url}/api/v1/users/${human_user}/tokens" \
|
exit 1
|
||||||
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
|
fi
|
||||||
| jq -r '.sha1 // empty') || human_token=""
|
|
||||||
|
# Store admin token for idempotent re-runs
|
||||||
if [ -n "$human_token" ]; then
|
if grep -q '^FORGE_ADMIN_TOKEN=' "$env_file" 2>/dev/null; then
|
||||||
# Store human token in .env
|
sed -i "s|^FORGE_ADMIN_TOKEN=.*|FORGE_ADMIN_TOKEN=${admin_token}|" "$env_file"
|
||||||
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
|
else
|
||||||
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
|
printf 'FORGE_ADMIN_TOKEN=%s\n' "$admin_token" >> "$env_file"
|
||||||
else
|
fi
|
||||||
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
|
echo "Admin token: generated and saved (FORGE_ADMIN_TOKEN)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get or create human user token (human_user == admin_user; use admin_pass)
|
||||||
|
local human_token=""
|
||||||
|
if _token_exists_in_env "HUMAN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then
|
||||||
|
human_token=$(grep '^HUMAN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-)
|
||||||
|
if [ -n "$human_token" ]; then
|
||||||
|
export HUMAN_TOKEN="$human_token"
|
||||||
|
echo " Human token preserved (use --rotate-tokens to force)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$human_token" ]; then
|
||||||
|
# Delete existing human token if present (token sha1 is only returned at creation time)
|
||||||
|
local existing_human_token_id
|
||||||
|
existing_human_token_id=$(curl -sf \
|
||||||
|
-u "${admin_user}:${admin_pass}" \
|
||||||
|
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
|
||||||
|
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
|
||||||
|
if [ -n "$existing_human_token_id" ]; then
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-u "${admin_user}:${admin_pass}" \
|
||||||
|
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create human token (use admin_pass since human_user == admin_user)
|
||||||
|
human_token=$(curl -sf -X POST \
|
||||||
|
-u "${admin_user}:${admin_pass}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${forge_url}/api/v1/users/${human_user}/tokens" \
|
||||||
|
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
|
||||||
|
| jq -r '.sha1 // empty') || human_token=""
|
||||||
|
|
||||||
|
if [ -n "$human_token" ]; then
|
||||||
|
# Store human token in .env
|
||||||
|
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
|
||||||
|
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
|
||||||
|
else
|
||||||
|
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
|
||||||
|
fi
|
||||||
|
export HUMAN_TOKEN="$human_token"
|
||||||
|
echo " Human token generated and saved (HUMAN_TOKEN)"
|
||||||
fi
|
fi
|
||||||
export HUMAN_TOKEN="$human_token"
|
|
||||||
echo " Human token saved (HUMAN_TOKEN)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create bot users and tokens
|
# Create bot users and tokens
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ cleanup() {
|
||||||
pkill -f "mock-forgejo.py" 2>/dev/null || true
|
pkill -f "mock-forgejo.py" 2>/dev/null || true
|
||||||
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \
|
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \
|
||||||
"${FACTORY_ROOT}/projects/smoke-repo.toml" \
|
"${FACTORY_ROOT}/projects/smoke-repo.toml" \
|
||||||
/tmp/smoke-claude-shared /tmp/smoke-home-claude
|
/tmp/smoke-claude-shared /tmp/smoke-home-claude \
|
||||||
|
/tmp/smoke-env-before-rerun /tmp/smoke-env-before-dryrun
|
||||||
# Restore .env only if we created the backup
|
# Restore .env only if we created the backup
|
||||||
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
|
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
|
||||||
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
|
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
|
||||||
|
|
@ -178,8 +179,30 @@ else
|
||||||
fail "disinto init exited non-zero"
|
fail "disinto init exited non-zero"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Idempotency test: run init again ───────────────────────────────────────
|
# ── Dry-run test: must not modify state ────────────────────────────────────
|
||||||
|
echo "=== Dry-run test ==="
|
||||||
|
cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-dryrun
|
||||||
|
if bash "${FACTORY_ROOT}/bin/disinto" init \
|
||||||
|
"${TEST_SLUG}" \
|
||||||
|
--bare --yes --dry-run \
|
||||||
|
--forge-url "$FORGE_URL" \
|
||||||
|
--repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Dry run complete"; then
|
||||||
|
pass "disinto init --dry-run exited successfully"
|
||||||
|
else
|
||||||
|
fail "disinto init --dry-run did not complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify --dry-run did not modify .env
|
||||||
|
if diff -q /tmp/smoke-env-before-dryrun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then
|
||||||
|
pass "dry-run: .env unchanged"
|
||||||
|
else
|
||||||
|
fail "dry-run: .env was modified (should be read-only)"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/smoke-env-before-dryrun
|
||||||
|
|
||||||
|
# ── Idempotency test: run init again, verify .env is stable ────────────────
|
||||||
echo "=== Idempotency test: running disinto init again ==="
|
echo "=== Idempotency test: running disinto init again ==="
|
||||||
|
cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-rerun
|
||||||
if bash "${FACTORY_ROOT}/bin/disinto" init \
|
if bash "${FACTORY_ROOT}/bin/disinto" init \
|
||||||
"${TEST_SLUG}" \
|
"${TEST_SLUG}" \
|
||||||
--bare --yes \
|
--bare --yes \
|
||||||
|
|
@ -190,6 +213,29 @@ else
|
||||||
fail "disinto init (re-run) exited non-zero"
|
fail "disinto init (re-run) exited non-zero"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Verify .env is stable across re-runs (no token churn)
|
||||||
|
if diff -q /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then
|
||||||
|
pass "idempotency: .env unchanged on re-run"
|
||||||
|
else
|
||||||
|
fail "idempotency: .env changed on re-run (token churn detected)"
|
||||||
|
diff /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >&2 || true
|
||||||
|
fi
|
||||||
|
rm -f /tmp/smoke-env-before-rerun
|
||||||
|
|
||||||
|
# Verify FORGE_ADMIN_TOKEN is stored in .env
|
||||||
|
if grep -q '^FORGE_ADMIN_TOKEN=' "${FACTORY_ROOT}/.env"; then
|
||||||
|
pass ".env contains FORGE_ADMIN_TOKEN"
|
||||||
|
else
|
||||||
|
fail ".env missing FORGE_ADMIN_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify HUMAN_TOKEN is stored in .env
|
||||||
|
if grep -q '^HUMAN_TOKEN=' "${FACTORY_ROOT}/.env"; then
|
||||||
|
pass ".env contains HUMAN_TOKEN"
|
||||||
|
else
|
||||||
|
fail ".env missing HUMAN_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 4. Verify Forgejo state ─────────────────────────────────────────────────
|
# ── 4. Verify Forgejo state ─────────────────────────────────────────────────
|
||||||
echo "=== 4/6 Verifying Forgejo state ==="
|
echo "=== 4/6 Verifying Forgejo state ==="
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue