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
This commit is contained in:
+119
-22
@@ -1,24 +1,55 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# Charles rulings: questions-v10 OQ7 (2026-09-03) — GLPI Changes are logged
|
||||||
# from NOW ON for converge/infra work — no Redmine-only exception path.
|
# from NOW ON for converge/infra work, no Redmine-only exception path.
|
||||||
# Pair this with the Redmine ticket: put the ticket ref in --title.
|
# 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:
|
# Usage:
|
||||||
# echo "what changed, why, where" | \
|
# echo "what changed, why, where" | \
|
||||||
# glpi-change.sh create --title "[#769] <change summary>" [--urgency 2]
|
# glpi-change.sh create --title "[#NNN] <change summary>" [--urgency 2] [--impact 2] [--agent]
|
||||||
# glpi-change.sh show <change-id>
|
# glpi-change.sh transition <change-id> <status-int> [--agent]
|
||||||
|
# echo "note text" | glpi-change.sh followup <change-id> [--agent]
|
||||||
|
# glpi-change.sh show <change-id> [--agent]
|
||||||
|
# glpi-change.sh list [--status N] [--agent]
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
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%/}"
|
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() {
|
sess_request() {
|
||||||
curl -s -m 30 -H "Content-Type: application/json" \
|
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")
|
auth=(-H "Session-Token: $S_TOKEN" -H "App-Token: $GLPI_APP_TOKEN" -H "Content-Type: application/json")
|
||||||
|
|
||||||
|
# api <method> <path> [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
|
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
|
case "$cmd" in
|
||||||
create)
|
create)
|
||||||
title=""; urgency=3; impact=3; content=""
|
title=""; urgency=3; impact=3; content=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ ${#args[@]} -gt 0 ]; do
|
||||||
case "$1" in
|
case "${args[0]}" in
|
||||||
--title) title="$2"; shift 2 ;;
|
--title) title="${args[1]}"; args=("${args[@]:2}") ;;
|
||||||
--urgency) urgency="$2"; shift 2 ;;
|
--urgency) urgency="${args[1]}"; args=("${args[@]:2}") ;;
|
||||||
--impact) impact="$2"; shift 2 ;;
|
--impact) impact="${args[1]}"; args=("${args[@]:2}") ;;
|
||||||
*) die "unknown option $1" ;;
|
*) die "unknown option ${args[0]}" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[ -n "$title" ] || die "--title required"
|
[ -n "$title" ] || die "--title required"
|
||||||
content="$(cat)"
|
content="$(cat)"
|
||||||
[ -n "$content" ] || die "change body required on stdin"
|
[ -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" \
|
--argjson u "$urgency" --argjson i "$impact" \
|
||||||
'{input: {name: $t, content: $c, urgency: $u, impact: $i}}')"
|
'{input: {name: $t, content: $c, urgency: $u, impact: $i}}' > "$body"
|
||||||
resp="$(curl -s -m 30 -X POST "${auth[@]}" -d "$body" "$BASE/apirest.php/Change")"
|
# GLPI returns an ARRAY on create: [{"id":N,"message":...}] — handle both shapes.
|
||||||
cid="$(printf '%s' "$resp" | jq -r '.id // empty')"
|
resp="$(api POST Change "$body")"
|
||||||
|
rm -f "$body"
|
||||||
|
cid="$(printf '%s' "$resp" | jq -r '.id // .[0].id // empty')"
|
||||||
[ -n "$cid" ] || die "create failed: $resp"
|
[ -n "$cid" ] || die "create failed: $resp"
|
||||||
echo "created Change id=$cid — $BASE/front/change.form.php?id=$cid"
|
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 <change-id> <status-int>"
|
||||||
|
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 <change-id> (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)
|
show)
|
||||||
cid="${1:?change-id}"
|
cid="${args[0]:?change-id}"
|
||||||
curl -s -m 30 "${auth[@]}" "$BASE/apirest.php/Change/$cid" | jq .
|
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"
|
die "unknown command $cmd"
|
||||||
|
|||||||
Reference in New Issue
Block a user