Chapter 28Lesson 02~235 minutes

REST API, CLI, Script Console, Groovy Administration, Safe Automation, and Administrative Guardrails: Guided Hands-On Workflow and Core Operations

Use a disposable controller and synthetic job to perform read-only REST inspection, trigger one exact build, follow queue-to-build identity, run a safe CLI query, compare that bounded surface with Script Console power, and implement an idempotent administration wrapper that validates controller and target before mutation.

Hands-onRESTQueue IDsCLI WebSocketRedactionIdempotency

Learning objectives

  • Create a disposable controller/job/agent scenario for administrative automation.
  • Store fake API authentication outside command-line arguments and logs.
  • Perform read-only REST inspection and trigger one exact job.
  • Follow the queue item into one numeric build and verify its result.
  • Use the current Jenkins CLI safely and implement a guarded administration wrapper.

1. Disposable scenario and guardrails

Use Jenkins 2.568.3 LTS with Java 21, built-in-node executors set to zero, and one trusted disposable agent labeled linux-ci. Create a folder admin-lab containing exactly one Pipeline job named exact-job. The automation user ch28-api needs only the permissions required to read and build this lab job; it must not have Overall/Administer or Script Console access.

Disposable target only. Do not point any command in this lesson at a production controller. The lab intentionally triggers one build. All other REST/CLI operations are read-only.

The Pipeline is deliberately small:

pipeline {
  agent { label 'linux-ci' }
  options { timestamps() }
  stages {
    stage('Identity') {
      steps {
        sh 'printf "job=%s\\nbuild=%s\\nnode=%s\\nworkspace=%s\\n" "$JOB_NAME" "$BUILD_NUMBER" "$NODE_NAME" "$WORKSPACE"'
      }
    }
    stage('Bounded work') {
      steps { sh 'sleep 3; printf "synthetic-ok\\n" > result.txt' }
    }
  }
  post { always { archiveArtifacts artifacts: 'result.txt', allowEmptyArchive: true } }
}

2. Prove controller and job identity before authentication

export JENKINS_URL='http://127.0.0.1:8080'
export EXPECTED_JOB='admin-lab/exact-job'
mkdir -p ch28-evidence
curl -fsS -D ch28-evidence/controller-headers.txt -o /dev/null "$JENKINS_URL/login"
grep -i '^X-Jenkins:' ch28-evidence/controller-headers.txt

Expected baseline: X-Jenkins: 2.568.3. In production, also validate the HTTPS certificate/hostname through the normal trust store; never use -k.

3. Create disposable API authentication without putting the token in argv

Create an API token for ch28-api through that disposable user’s Security page. Enter it interactively and write a curl configuration file with owner-only permissions:

mkdir -p "$HOME/.config/ch28"
chmod 700 "$HOME/.config/ch28"
read -rp 'Jenkins user: ' JENKINS_USER
read -rsp 'Disposable API token: ' JENKINS_API_TOKEN; printf '\n'
umask 077
printf 'user = "%s:%s"\n' "$JENKINS_USER" "$JENKINS_API_TOKEN" \
  > "$HOME/.config/ch28/curl.conf"
printf '%s:%s\n' "$JENKINS_USER" "$JENKINS_API_TOKEN" \
  > "$HOME/.config/ch28/cli-auth.txt"
unset JENKINS_API_TOKEN
stat -c '%a %n' "$HOME/.config/ch28/curl.conf" "$HOME/.config/ch28/cli-auth.txt"

Expected mode is 600. Do not copy either file into the workspace or evidence directory. Rotate/revoke the disposable token after cleanup.

4. Read the exact job through REST

JOB_URL="$JENKINS_URL/job/admin-lab/job/exact-job"
curl --config "$HOME/.config/ch28/curl.conf" -fsS \
  "$JOB_URL/api/json?tree=fullName,url,buildable,color,lastBuild[number,url,result]" \
  | tee ch28-evidence/job-before.json

Verify that fullName is exactly admin-lab/exact-job. If it is not, stop. This check prevents a typo or unexpected reverse-proxy/root URL from turning the later POST into a request against the wrong item.

5. Trigger exactly one build and preserve the queue identity

API-token authentication is exempt from crumbs, so the lab does not fetch one. Capture headers rather than assuming a response code:

curl --config "$HOME/.config/ch28/curl.conf" -fsS \
  -D ch28-evidence/trigger-headers.txt -o ch28-evidence/trigger-body.txt \
  -X POST "$JOB_URL/build"
grep -Ei '^(HTTP/|Location:)' ch28-evidence/trigger-headers.txt

A successful build trigger normally returns a queue location. Record the actual Location header, extract the numeric queue item ID, and query that exact object:

QUEUE_URL=$(awk 'BEGIN{IGNORECASE=1} /^Location:/ {gsub("\\r",""); print $2}' \
  ch28-evidence/trigger-headers.txt)
printf '%s\n' "$QUEUE_URL" | tee ch28-evidence/queue-url.txt
curl --config "$HOME/.config/ch28/curl.conf" -fsS \
  "${QUEUE_URL}api/json?tree=id,why,cancelled,task[name,url],executable[number,url]" \
  | tee ch28-evidence/queue.json

If executable is not present yet, wait briefly and query the same queue URL again. Do not scan for “the newest build.”

6. Resolve and verify the numeric build

Once the queue item reports an executable number/URL, store those values. Query that concrete run until building is false:

# Set BUILD_URL from the executable.url returned by the queue item.
curl --config "$HOME/.config/ch28/curl.conf" -fsS \
  "${BUILD_URL}api/json?tree=number,url,building,result,queueId,duration,estimatedDuration,actions[causes[*]]" \
  | tee ch28-evidence/build-final.json

Then inspect the build console or artifact list. The evidence chain is now trigger response → queue item → numeric build → result/artifact, rather than “POST returned success.”

7. Use Jenkins CLI for a read-only operator action

Download the CLI jar from the same controller and record its digest:

curl -fsS "$JENKINS_URL/jnlpJars/jenkins-cli.jar" -o "$HOME/.config/ch28/jenkins-cli.jar"
sha256sum "$HOME/.config/ch28/jenkins-cli.jar" | tee ch28-evidence/cli.sha256
java -jar "$HOME/.config/ch28/jenkins-cli.jar" \
  -s "$JENKINS_URL" -webSocket \
  -auth @"$HOME/.config/ch28/cli-auth.txt" who-am-i \
  | tee ch28-evidence/cli-whoami.txt

Modern Jenkins CLI defaults to WebSocket, but specifying it makes the lab assumption explicit. The auth file keeps user:token out of the command line. Never archive that file.

8. Compare with Script Console without making it the solution

On the disposable controller, a fully trusted administrator may open Manage Jenkins → Script Console and run this read-only snippet:

import jenkins.model.Jenkins

def item = Jenkins.get().getItemByFullName('admin-lab/exact-job')
println "fullName=${item?.fullName}"
println "class=${item?.class?.name}"

The output duplicates information obtainable through the API, yet the script executes in-process with vastly broader capability. That contrast is the lesson: because REST already satisfies the need, Script Console is the wrong routine interface.

9. Build an idempotent, target-verifying wrapper

Create safe_admin.py. It reads authentication from the permission-restricted file, verifies one exact job, triggers only that job, follows the queue, and contains a refusal guard for destructive aliases:

#!/usr/bin/env python3
import base64, json, os, sys, time, urllib.request, urllib.error

BASE = os.environ['JENKINS_URL'].rstrip('/')
EXPECTED = 'admin-lab/exact-job'
AUTH_FILE = os.path.expanduser('~/.config/ch28/cli-auth.txt')
user, token = open(AUTH_FILE, encoding='utf-8').read().strip().split(':', 1)
auth = 'Basic ' + base64.b64encode(f'{user}:{token}'.encode()).decode()

def request(url, method='GET'):
    req = urllib.request.Request(url, method=method, headers={'Authorization': auth})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return r.status, dict(r.headers), r.read()
    except urllib.error.HTTPError as e:
        body = e.read().decode('utf-8', 'replace')[:4000]
        raise RuntimeError(f'HTTP {e.code} for {url}: {body}')

def api(url):
    status, headers, body = request(url)
    return status, headers, json.loads(body)

job_url = BASE + '/job/admin-lab/job/exact-job'
_, _, job = api(job_url + '/api/json?tree=fullName,url,buildable')
if job.get('fullName') != EXPECTED or not job.get('buildable'):
    raise SystemExit('REFUSE: exact expected job identity/buildable state not proven')

status, headers, _ = request(job_url + '/build', method='POST')
queue_url = headers.get('Location')
if not queue_url:
    raise SystemExit(f'REFUSE: trigger status {status} returned no queue Location')
print('queue=', queue_url)

for _ in range(30):
    _, _, q = api(queue_url.rstrip('/') + '/api/json?tree=id,why,cancelled,task[name,url],executable[number,url]')
    if q.get('cancelled'):
        raise SystemExit('Queue item was cancelled')
    if q.get('executable'):
        build_url = q['executable']['url']
        break
    time.sleep(1)
else:
    raise SystemExit('Timed out waiting for executable identity')

while True:
    _, _, b = api(build_url + 'api/json?tree=number,url,building,result,queueId')
    if not b.get('building'):
        print(json.dumps(b, indent=2))
        if b.get('result') != 'SUCCESS':
            raise SystemExit(2)
        break
    time.sleep(1)

def refuse_ambiguous_destructive_target(target):
    if target in {'latest', 'lastBuild', 'lastSuccessfulBuild'} or not target.isdigit():
        raise SystemExit(f'REFUSE destructive target: {target!r} is not an explicit numeric build')

# Demonstration only: no DELETE request exists in this lab.
refuse_ambiguous_destructive_target('latest')

The final line should terminate with a refusal after the successful build verification. This is intentional proof that the client will not convert an alias into a destructive request.

10. Mini challenge: choose the correct layer

You need to change a globally configured mail URL currently owned by JCasC, then trigger admin-lab/exact-job. Which interfaces should you use? Correct answer: update/review/validate the JCasC source for the controller configuration, then use the bounded build endpoint or CLI build command for the exact job. Do not use Script Console to do both.

Next lesson

Configuration, Design Choices, and Tradeoffs

Compare REST, CLI, JCasC, Job DSL and Script Console; choose authentication, payload and rollback strategies according to the state being managed.

Knowledge check

Answer before revealing the explanation.

1. Why does the lab capture the Location header after POSTing /build?

2. Why store CLI authentication in an @file rather than writing user:token directly after -auth?

3. What should an idempotent wrapper do before any mutation?

4. Why is a 201/200 HTTP response not enough evidence of a successful Jenkins build?

5. What is the purpose of the Script Console comparison in this lesson?

Official references and version notes

Verified baseline — 17 September 2026. Labs target Jenkins 2.568.3 LTS with Java 21; Jenkins 2.568.3 is tested with Java 21 and 25. The lab uses only Jenkins core Remote API/CLI plus Script Security 1422.v06869826dd9b_ as the current Groovy-sandbox reference. On modern Jenkins, the CLI client defaults to WebSocket; HTTP mode is explicit, and file-based/environment authentication is preferred over exposing a token as a command-line argument. API-token-authenticated requests are exempt from CSRF crumbs; password/session POSTs require the crumb/session flow. Re-check endpoint/plugin/security documentation before reusing automation.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.