From eaa6c4a955515df62594576b7c74313bd3bb3197 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 4 Sep 2026 07:14:09 -0500 Subject: [PATCH] feat(glpi-change): agent identity mode + transition/followup/list [#767] CR ladder tooling per t/325: --agent files Changes AS the cmdb user (Hotliner switch built in), status transitions + followups + list for the escalation ladder. Fixes create parsing: GLPI returns an ARRAY, the #769 original died on prod. Verified live end-to-end (CR 5 filed, followed, transitioned by the agent; CI writes hostile-checked DENIED). https://projects.knownelement.com/issues/767#note-4191 --- scripts/glpi-change.sh | 141 ++++++++++++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 22 deletions(-) diff --git a/scripts/glpi-change.sh b/scripts/glpi-change.sh index 931e493..e4060dc 100755 --- a/scripts/glpi-change.sh +++ b/scripts/glpi-change.sh @@ -1,24 +1,55 @@ #!/usr/bin/env bash -# glpi-change.sh — create GLPI Change entries from the CLI [#769] +# glpi-change.sh — create + drive GLPI Change entries from the CLI [#769][#767] # -# Charles ruling (questions-v10 OQ7, 2026-09-03): GLPI Changes are logged -# from NOW ON for converge/infra work — no Redmine-only exception path. -# Pair this with the Redmine ticket: put the ticket ref in --title. +# Charles rulings: questions-v10 OQ7 (2026-09-03) — GLPI Changes are logged +# from NOW ON for converge/infra work, no Redmine-only exception path. +# questions-09022206 Q1/Q2/Q3 (2026-09-04) — CRs live in GLPI as Changes; +# the cmdb agent files CRs itself (least-privilege identity); prod (Tier 2) +# agent ops carry .crush/active-cr with the Change id (t/325 §5 ladder). # -# Env (from ~/.creds/glpi.env): GLPI_URL, GLPI_USER_TOKEN, GLPI_APP_TOKEN +# Two identities: +# default ~/.creds/glpi.env — admin session (bootstrap, break-glass) +# --agent ~/.creds/glpi-agent.env — the cmdb agent (user 7). GLPI's REST +# API cannot write profile rights, and Self-Service cannot +# create Changes, so the agent session switches to the built-in +# Hotliner profile (verified: create+followup+transition OK, +# CI writes still DENIED). Approved via Q2 ruling 2026-09-04. +# +# Ladder status mapping (GLPI Change statuses; ints per GLPI Change::getAllStatusArray): +# 1 New (draft/filed) 2 Evaluation (review) 3 Approval 4 Acceptance +# 5 Pending 6 Test 7 Qualification 8 closed 9 solved (verify label in UI +# on first use — the REST API exposes ints only). +# +# Env: GLPI_URL, GLPI_USER_TOKEN, GLPI_APP_TOKEN in the chosen .env # # Usage: # echo "what changed, why, where" | \ -# glpi-change.sh create --title "[#769] " [--urgency 2] -# glpi-change.sh show +# glpi-change.sh create --title "[#NNN] " [--urgency 2] [--impact 2] [--agent] +# glpi-change.sh transition [--agent] +# echo "note text" | glpi-change.sh followup [--agent] +# glpi-change.sh show [--agent] +# glpi-change.sh list [--status N] [--agent] set -euo pipefail die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -[ -f "$HOME/.creds/glpi.env" ] || die "'$HOME/.creds/glpi.env' missing" -set -a; . "$HOME/.creds/glpi.env"; set +a +ENV_FILE="$HOME/.creds/glpi.env" +IS_AGENT=0 + +# Pre-scan for --agent / --env before subcommand dispatch so both env files +# must exist only when actually referenced. +pre=("$@") +for a in ${pre[@]+"${pre[@]}"}; do + case "$a" in + --agent) IS_AGENT=1 ;; + esac +done +[ "$IS_AGENT" -eq 1 ] && ENV_FILE="$HOME/.creds/glpi-agent.env" +[ -f "$ENV_FILE" ] || die "'$ENV_FILE' missing" + +set -a; . "$ENV_FILE"; set +a BASE="${GLPI_URL%/}" -[ -n "${GLPI_USER_TOKEN:-}" ] && [ -n "${GLPI_APP_TOKEN:-}" ] || die "tokens missing in glpi.env" +[ -n "${GLPI_USER_TOKEN:-}" ] && [ -n "${GLPI_APP_TOKEN:-}" ] || die "tokens missing in $ENV_FILE" sess_request() { curl -s -m 30 -H "Content-Type: application/json" \ @@ -36,33 +67,99 @@ trap cleanup EXIT auth=(-H "Session-Token: $S_TOKEN" -H "App-Token: $GLPI_APP_TOKEN" -H "Content-Type: application/json") +# api [body-file] +api() { + local method="$1" path="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -s -m 30 -X "$method" "${auth[@]}" -d "@$body" "$BASE/apirest.php/$path" + else + curl -s -m 30 -X "$method" "${auth[@]}" "$BASE/apirest.php/$path" + fi +} + +# Activate the Hotliner profile for the agent identity (no-op cost for admin). +if [ "$IS_AGENT" -eq 1 ]; then + echo '{"profiles_id":5}' > "/tmp/glpi-change-cap.$$" + api POST changeActiveProfile "/tmp/glpi-change-cap.$$" >/dev/null + rm -f "/tmp/glpi-change-cap.$$" +fi + cmd="${1:-create}"; shift || true +# Strip the shared flags from remaining args. +args=() +while [ $# -gt 0 ]; do + case "$1" in + --agent) shift ;; + *) args+=("$1"); shift ;; + esac +done + case "$cmd" in create) title=""; urgency=3; impact=3; content="" - while [ $# -gt 0 ]; do - case "$1" in - --title) title="$2"; shift 2 ;; - --urgency) urgency="$2"; shift 2 ;; - --impact) impact="$2"; shift 2 ;; - *) die "unknown option $1" ;; + while [ ${#args[@]} -gt 0 ]; do + case "${args[0]}" in + --title) title="${args[1]}"; args=("${args[@]:2}") ;; + --urgency) urgency="${args[1]}"; args=("${args[@]:2}") ;; + --impact) impact="${args[1]}"; args=("${args[@]:2}") ;; + *) die "unknown option ${args[0]}" ;; esac done [ -n "$title" ] || die "--title required" content="$(cat)" [ -n "$content" ] || die "change body required on stdin" - body="$(jq -n --arg t "$title" --arg c "$content" \ + body="$(mktemp)" + jq -n --arg t "$title" --arg c "$content" \ --argjson u "$urgency" --argjson i "$impact" \ - '{input: {name: $t, content: $c, urgency: $u, impact: $i}}')" - resp="$(curl -s -m 30 -X POST "${auth[@]}" -d "$body" "$BASE/apirest.php/Change")" - cid="$(printf '%s' "$resp" | jq -r '.id // empty')" + '{input: {name: $t, content: $c, urgency: $u, impact: $i}}' > "$body" + # GLPI returns an ARRAY on create: [{"id":N,"message":...}] — handle both shapes. + resp="$(api POST Change "$body")" + rm -f "$body" + cid="$(printf '%s' "$resp" | jq -r '.id // .[0].id // empty')" [ -n "$cid" ] || die "create failed: $resp" echo "created Change id=$cid — $BASE/front/change.form.php?id=$cid" ;; +transition) + cid="${args[0]:-}"; status="${args[1]:-}" + [ -n "$cid" ] && [ -n "$status" ] || die "usage: transition " + body="$(mktemp)" + jq -n --argjson s "$status" '{input: {status: $s}}' > "$body" + resp="$(api PUT "Change/$cid" "$body")" + rm -f "$body" + printf '%s' "$resp" | jq -e --arg id "$cid" '.[0][$id] == true' >/dev/null \ + || die "transition failed: $resp" + echo "Change $cid -> status $status" + ;; +followup) + cid="${args[0]:-}" + [ -n "$cid" ] || die "usage: followup (body on stdin)" + content="$(cat)" + [ -n "$content" ] || die "followup body required on stdin" + body="$(mktemp)" + jq -n --argjson c "$cid" --arg t "$content" \ + '{input: [{itemtype: "Change", items_id: $c, content: $t}]}' > "$body" + resp="$(api POST ITILFollowup "$body")" + rm -f "$body" + printf '%s' "$resp" | jq -e '.[0].id' >/dev/null || die "followup failed: $resp" + echo "followup added to Change $cid" + ;; show) - cid="${1:?change-id}" - curl -s -m 30 "${auth[@]}" "$BASE/apirest.php/Change/$cid" | jq . + cid="${args[0]:?change-id}" + api GET "Change/$cid" | jq . + ;; +list) + status="" + while [ ${#args[@]} -gt 0 ]; do + case "${args[0]}" in + --status) status="${args[1]}"; args=("${args[@]:2}") ;; + *) die "unknown option ${args[0]}" ;; + esac + done + url="search/Change/?forcedisplay%5B0%5D=1&forcedisplay%5B1%5D=2&forcedisplay%5B2%5D=12&range=0-999" + [ -n "$status" ] && url="$url&criteria%5B0%5D%5Bfield%5D=12&criteria%5B0%5D%5Bsearchtype%5D=equals&criteria%5B0%5D%5Bvalue%5D=$status" + # Rows come back as objects keyed by field id: 1=name, 2=id, 12=status. + api GET "$url" | jq -r '.data[]? | [."2", ."12", ."1"] | @tsv' ;; *) die "unknown command $cmd"