本記事は連載「ServiceNow標準機能だけで作るPull型自動化連携」の第5回です。
第4回では、Table APIで実行待ちのSCTASKを取得し、In Progress + autoengine1 に確保しました。
今回は、ServiceNow側にScripted REST APIを1本作り、SCTASKの詳細(変数を含む)を取得して、実行開始を宣言する部分を実装します。
この回で作るもの
1本のAPIで、2つの使い方をします。
| 使い方 | パラメータ | 動作 | 状態の変化 |
|---|---|---|---|
| 詳細取得 |
sctask のみ |
SCTASKとRITM変数を返す | なし |
| 実行開始 |
sctask + expected_current_assigned_to + assigned_to
|
現在の担当が期待値と一致する場合だけ更新し、更新後の詳細を返す |
autoengine1 → autoengine1running
|
1. このAPIの設計方針
| 方針 | 内容 |
|---|---|
| SCTASK番号で指定する | 外部からsys_idを受け取らない。インスタンスに依存しない |
ユーザーはuser_nameで指定する |
autoengine1running のような名前で指定し、内部でsys_idに解決する |
| 条件付き更新(Compare-and-set) |
expected_current_assigned_to と現在値が一致する場合だけ更新する |
| 片方だけの指定はエラー |
assigned_to と expected_current_assigned_to は必ずペアで指定する |
| 更新できる担当を制限する |
autoengine1 で始まる自動化ユーザーにしか変更できない |
| RITM変数は原則すべて返す | 依頼種別ごとに必要な変数が違うため、APIでは絞り込まない。人向けの表示用変数とルーティング用変数だけ除外する |
resultを二重にしない |
ServiceNowが外側にresultを付けるので、Script内では付けない |
1.1 なぜ条件付き更新が必要なのか
第4回の確保はTable APIの単純な更新でした。そのため、何らかの理由で同じSCTASKが2回確保されても、確保の段階では気づけません。
そこで、実際に作業を始める直前に、「現在の担当がautoengine1である場合だけautoengine1runningに変える」という条件付き更新を行います。
- 1回目の呼び出し:現在値
autoengine1が期待値と一致 → 更新成功(200) - 2回目の呼び出し:現在値は既に
autoengine1running→ 期待値と不一致 → 409
ワーカーは、200が返ってきた場合だけ作業を実行します。これで、同じSCTASKの作業が二重に実行されることを防げます。
2. Scripted REST APIを作成する
2.1 Scripted REST Service
All > System Web Services > Scripted Web Services > Scripted REST APIs で New を選択します。
| フィールド | 設定値 |
|---|---|
| Name | Automation Integration API |
| API ID | automation_integration |
保存すると、Base API pathが /api/<namespace>/automation_integration のように決まります。<namespace> はインスタンスやスコープによって異なります。
第6回で作る結果登録APIも、同じScripted REST Serviceの中にResourceとして追加します。
2.2 Resource
Resources関連リストで New を選択します。
| フィールド | 設定値 |
|---|---|
| Name | Get Task |
| HTTP method | GET |
| Relative path | /task |
| Requires authentication | true |
| Active | true |
本記事では、エンドポイントを次のように表記します。バージョン付きのパスにするかどうかは、インスタンスの設定に合わせてください。
GET /api/<namespace>/automation_integration/v1/task
2.3 Query parameters
| パラメータ | 必須 | 内容 |
|---|---|---|
sctask |
必須 | SCTASK番号 |
assigned_to |
条件付き必須 | 変更先のuser_name
|
expected_current_assigned_to |
条件付き必須 | 変更前に期待する現在のuser_name
|
assigned_to |
expected_current_assigned_to |
動作 |
|---|---|---|
| 未指定 | 未指定 | 詳細取得のみ |
| 指定 | 指定 | 期待値と一致した場合だけ更新し、更新後の詳細を返す |
| 指定 | 未指定 | 400 ASSIGNEE_PARAMETERS_MUST_BE_PAIRED
|
| 未指定 | 指定 | 400 ASSIGNEE_PARAMETERS_MUST_BE_PAIRED
|
3. Resource Script
Script欄に、次のコードを貼り付けます。
ServiceNowのScript欄へ貼り付けるときは、&& や < がHTMLエスケープ(&&、<)されていないことを確認してください。コピー元によっては変換されることがあります。
(function process(request, response) {
'use strict';
var params = request.queryParams || {};
var sctaskNumber = normalize(params.sctask);
var requestedAssignee = normalize(params.assigned_to);
var expectedCurrentAssignee = normalize(
params.expected_current_assigned_to
);
if (!sctaskNumber) {
return fail(response, 400, 'INVALID_REQUEST',
'Query parameter sctask is required.');
}
if (!/^SCTASK[0-9]+$/i.test(sctaskNumber)) {
return fail(response, 400, 'INVALID_SCTASK',
'sctask must be an SCTASK number.');
}
if ((requestedAssignee && !expectedCurrentAssignee) ||
(!requestedAssignee && expectedCurrentAssignee)) {
return fail(response, 400,
'ASSIGNEE_PARAMETERS_MUST_BE_PAIRED',
'assigned_to and expected_current_assigned_to must be specified together.');
}
var task = new GlideRecordSecure('sc_task');
task.addQuery('number', sctaskNumber);
task.setLimit(2);
task.query();
if (!task.next()) {
return fail(response, 404, 'SCTASK_NOT_FOUND',
'The specified SCTASK was not found.');
}
if (task.hasNext()) {
return fail(response, 409, 'SCTASK_NOT_UNIQUE',
'The specified SCTASK could not be resolved uniquely.');
}
if (!task.canRead()) {
return fail(response, 403, 'READ_FORBIDDEN',
'The API user cannot read the specified SCTASK.');
}
var updated = false;
if (requestedAssignee && expectedCurrentAssignee) {
var currentAssignee = getReferenceUserName(task, 'assigned_to');
if (currentAssignee !== expectedCurrentAssignee) {
return failWithDetails(response, 409,
'ASSIGNED_TO_MISMATCH',
'The current assigned_to does not match the expected value.', {
current_assigned_to: currentAssignee,
expected_current_assigned_to: expectedCurrentAssignee,
requested_assigned_to: requestedAssignee
});
}
if (currentAssignee === requestedAssignee) {
return failWithDetails(response, 409,
'ASSIGNED_TO_ALREADY_SET',
'The requested assigned_to is already set.', {
current_assigned_to: currentAssignee,
expected_current_assigned_to: expectedCurrentAssignee,
requested_assigned_to: requestedAssignee
});
}
if (!isAllowedAutomationUser(requestedAssignee)) {
return fail(response, 403, 'ASSIGNEE_NOT_ALLOWED',
'The specified assigned_to value is not allowed.');
}
var user = new GlideRecordSecure('sys_user');
user.addQuery('user_name', requestedAssignee);
user.addQuery('active', true);
user.setLimit(2);
user.query();
if (!user.next()) {
return fail(response, 404, 'ASSIGNEE_NOT_FOUND',
'An active user matching assigned_to was not found.');
}
if (user.hasNext()) {
return fail(response, 409, 'ASSIGNEE_NOT_UNIQUE',
'assigned_to could not be resolved uniquely.');
}
if (!task.assigned_to.canWrite()) {
return fail(response, 403, 'UPDATE_FORBIDDEN',
'The API user cannot update assigned_to.');
}
// 外部から固定内部IDは受け取らない。
// user_nameで動的に解決したユーザーを参照フィールドへ設定する。
task.setValue('assigned_to', user.getUniqueValue());
if (!task.update()) {
return fail(response, 500, 'UPDATE_FAILED',
'assigned_to could not be updated.');
}
updated = true;
task.get('number', sctaskNumber);
}
response.setStatus(200);
// ServiceNowが外側にresultを付与するため、ここではresultを追加しない。
response.setBody({
updated: updated,
task: buildTaskResponse(task)
});
function buildTaskResponse(scTask) {
var ritm = null;
var requestRecord = null;
var variables = {};
if (!scTask.request_item.nil()) {
ritm = scTask.request_item.getRefRecord();
}
if (ritm && ritm.isValidRecord()) {
variables = readVariables(ritm);
if (!ritm.request.nil()) {
requestRecord = ritm.request.getRefRecord();
}
}
return {
sctask: scTask.getValue('number') || '',
short_description: scTask.getValue('short_description') || '',
description: scTask.getValue('description') || '',
state: scTask.getDisplayValue('state') || '',
active: scTask.getValue('active') === 'true',
assignment_group: getReferenceName(
scTask, 'assignment_group'
),
assigned_to: getReferenceUserName(scTask, 'assigned_to'),
priority: scTask.getDisplayValue('priority') || '',
opened_at: scTask.getDisplayValue('opened_at') || '',
updated_at: scTask.getDisplayValue('sys_updated_on') || '',
request: requestRecord && requestRecord.isValidRecord()
? requestRecord.getValue('number') || '' : '',
ritm: ritm && ritm.isValidRecord()
? ritm.getValue('number') || '' : '',
catalog_item: ritm && ritm.isValidRecord()
? ritm.cat_item.getDisplayValue() || '' : '',
requested_for: ritm && ritm.isValidRecord()
? getReferenceUserName(ritm, 'requested_for') : '',
variables: variables
};
}
function readVariables(ritmRecord) {
var result = {};
var elements = ritmRecord.variables.getElements();
for (var i = 0; i < elements.length; i++) {
var element = elements[i];
var name = element.getName();
if (isVariableBlocked(name)) {
continue;
}
result[name] = element.getDisplayValue() ||
element.toString() || '';
}
return result;
}
function isVariableBlocked(name) {
var blocked = {
execution_result_display: true, // 人向け表示
failure_assignment_group: true // 障害時ルーティング
};
return blocked[name] === true;
}
function isAllowedAutomationUser(userName) {
return /^autoengine1[a-z0-9_]*$/i.test(userName);
}
function getReferenceUserName(record, fieldName) {
if (!record || !record.isValidRecord() ||
record.getElement(fieldName).nil()) {
return '';
}
var referenced = record.getElement(fieldName).getRefRecord();
return referenced && referenced.isValidRecord()
? referenced.getValue('user_name') || '' : '';
}
function getReferenceName(record, fieldName) {
if (!record || !record.isValidRecord() ||
record.getElement(fieldName).nil()) {
return '';
}
var referenced = record.getElement(fieldName).getRefRecord();
return referenced && referenced.isValidRecord()
? referenced.getValue('name') || '' : '';
}
function normalize(value) {
return value === null || value === undefined
? '' : value.toString().trim();
}
function fail(res, status, code, message) {
res.setStatus(status);
res.setBody({
error: {
code: code,
message: message
}
});
return;
}
function failWithDetails(res, status, code, message, details) {
res.setStatus(status);
res.setBody({
error: {
code: code,
message: message,
details: details || {}
}
});
return;
}
})(request, response);
3.1 コードのポイント
GlideRecordSecureを使う
GlideRecord ではなく GlideRecordSecure を使い、APIサービスユーザーのACLに従って読み書きします。読めないSCTASKは403、Assigned toを更新できない場合も403を返します。
ユーザー名からsys_idへの変換はAPI内部で行う
外部からは autoengine1running という名前だけを受け取ります。Activeなユーザーを user_name で検索し、一意に見つかった場合だけ、そのsys_idをAssigned toに設定します。
更新できる担当を自動化ユーザーに限定する
isAllowedAutomationUser() で、autoengine1 で始まるユーザー名にしか変更できないようにしています。このAPIを使って、SCTASKを人の担当者に付け替えることはできません。
自動化エンジン2(autoengine2系)も使う場合は、isAllowedAutomationUser() の正規表現を拡張してください。
RITM変数の読み取り
readVariables() は、RITMの変数を全て読み取ります。除外するのは次の2つだけです。
| 除外する変数 | 理由 |
|---|---|
execution_result_display |
依頼者向けの表示用 |
failure_assignment_group |
失敗時のルーティング用。自動化エンジンは知る必要がない |
execution_result_json は除外しません。前の作業の結果を、後続の作業で参照できるようにするためです。
4. レスポンス
4.1 詳細取得(200)
{
"result": {
"updated": false,
"task": {
"sctask": "SCTASK0010006",
"short_description": "REQTYPE_LINUXLOG_ACTION1",
"description": "Automation Execution",
"state": "In Progress",
"active": true,
"assignment_group": "autoengine1group",
"assigned_to": "autoengine1",
"request": "REQ0010001",
"ritm": "RITM0010001",
"catalog_item": "Linuxログ取得",
"variables": {
"target_host": "server01.example.local",
"log_path": "/Apps/XXX/logs",
"destination": "shared-server",
"execution_result_json": "[]",
"description": "Application log retrieval"
}
}
}
}
レスポンスにsys_idは含まれません。SCTASK、RITM、REQは番号で、グループとユーザーは名前で返します。
4.2 実行開始の成功(200)
updated が true、assigned_to が autoengine1running になります。
{
"result": {
"updated": true,
"task": {
"sctask": "SCTASK0010006",
"assigned_to": "autoengine1running"
}
}
}
(task の他の項目は省略しています)
4.3 エラー
{
"result": {
"error": {
"code": "ASSIGNED_TO_MISMATCH",
"message": "The current assigned_to does not match the expected value.",
"details": {
"current_assigned_to": "autoengine1running",
"expected_current_assigned_to": "autoengine1",
"requested_assigned_to": "autoengine1running"
}
}
}
}
| HTTP | code | 発生条件 |
|---|---|---|
| 400 | INVALID_REQUEST |
sctask が未指定 |
| 400 | INVALID_SCTASK |
sctask がSCTASK番号の形式でない |
| 400 | ASSIGNEE_PARAMETERS_MUST_BE_PAIRED |
assigned_to と expected_current_assigned_to の片方だけを指定 |
| 403 | READ_FORBIDDEN |
APIサービスユーザーがSCTASKを読めない |
| 403 | ASSIGNEE_NOT_ALLOWED |
自動化ユーザー以外へ変更しようとした |
| 403 | UPDATE_FORBIDDEN |
APIサービスユーザーがAssigned toを更新できない |
| 404 | SCTASK_NOT_FOUND |
SCTASKが存在しない |
| 404 | ASSIGNEE_NOT_FOUND |
変更先のActiveユーザーが存在しない |
| 409 | ASSIGNED_TO_MISMATCH |
現在の担当が期待値と異なる |
| 409 | ASSIGNED_TO_ALREADY_SET |
現在の担当が既に変更先と同じ |
| 409 |
SCTASK_NOT_UNIQUE / ASSIGNEE_NOT_UNIQUE
|
番号やユーザー名から一意に特定できない |
| 500 | UPDATE_FAILED |
更新に失敗した |
5. curlで試す
5.1 接続設定
export SN_INSTANCE="https://<instance>.service-now.com"
export SN_USER="svc_autoengine1_api"
export SN_PASSWORD='********'
export TASK_API_PATH="/api/<namespace>/automation_integration/v1/task"
5.2 詳細取得
curl --silent --show-error \
--user "${SN_USER}:${SN_PASSWORD}" \
--header "Accept: application/json" \
--get "${SN_INSTANCE}${TASK_API_PATH}" \
--data-urlencode "sctask=SCTASK0010006" | jq .
5.3 実行開始
curl --silent --show-error \
--user "${SN_USER}:${SN_PASSWORD}" \
--header "Accept: application/json" \
--get "${SN_INSTANCE}${TASK_API_PATH}" \
--data-urlencode "sctask=SCTASK0010006" \
--data-urlencode "expected_current_assigned_to=autoengine1" \
--data-urlencode "assigned_to=autoengine1running" | jq .
同じコマンドをもう一度実行すると、409 ASSIGNED_TO_MISMATCH が返ることを確認してください。
5.4 ペア不足
curl --silent --show-error \
--user "${SN_USER}:${SN_PASSWORD}" \
--header "Accept: application/json" \
--get "${SN_INSTANCE}${TASK_API_PATH}" \
--data-urlencode "sctask=SCTASK0010006" \
--data-urlencode "assigned_to=autoengine1running" | jq .
400 ASSIGNEE_PARAMETERS_MUST_BE_PAIRED が返り、SCTASKは更新されません。
6. 自動化エンジン側の組み込み
第4回の確保に続けて、詳細取得、処理の選択、実行開始を行います。
get_task_detail() {
local task_number="$1"
api --get "${SN_INSTANCE}${TASK_API_PATH}" \
--data-urlencode "sctask=${task_number}"
}
start_task() {
local task_number="$1"
api --get "${SN_INSTANCE}${TASK_API_PATH}" \
--data-urlencode "sctask=${task_number}" \
--data-urlencode "expected_current_assigned_to=${WAITING_ASSIGNEE}" \
--data-urlencode "assigned_to=${RUNNING_ASSIGNEE}"
}
# --- 第4回の確保の後に続ける ---
detail="$(get_task_detail "$task_number")"
task_name="$(jq -r '.result.task.short_description // empty' <<<"$detail")"
if [[ -z "$task_name" ]]; then
echo "ERROR: short_description is empty for ${task_number}." >&2
exit 1
fi
start_response="$(start_task "$task_number")"
if [[ "$(jq -r '.result.updated // false' <<<"$start_response")" != "true" ]]; then
echo "ERROR: assigned_to update failed for ${task_number}." >&2
jq . <<<"$start_response" >&2
exit 1
fi
# short_descriptionで処理を選ぶ
case "$task_name" in
REQTYPE_LINUXLOG_ACTION1) echo "run: linux_log_retrieve" ;;
REQTYPE_LINUXLOG_ACTION2) echo "run: linux_log_archive" ;;
REQTYPE_LSOF_ACTION1) echo "run: linux_lsof" ;;
*) echo "unsupported: ${task_name}" ;;
esac
AAPを使う場合は、short_description とPlaybookを対応付けます。
REQTYPE_LINUXLOG_ACTION1:
playbook: linux_log_retrieve.yml
REQTYPE_LINUXLOG_ACTION2:
playbook: linux_log_archive.yml
REQTYPE_LSOF_ACTION1:
playbook: linux_lsof.yml
変数は .result.task.variables から取り出して、そのまま処理に渡します。
target_host="$(jq -r '.result.task.variables.target_host // empty' <<<"$detail")"
log_path="$(jq -r '.result.task.variables.log_path // empty' <<<"$detail")"
7. 動作確認
-
sctaskだけで詳細を取得できる -
レスポンスの
resultが一重になっている -
short_descriptionに処理キー(REQTYPE_LINUXLOG_ACTION1)が返る -
variablesにRITMの変数が入っている(execution_result_displayとfailure_assignment_groupは入っていない) - レスポンスにsys_idが含まれていない
-
実行開始で
updatedがtrueになり、画面上でAssigned toがautoengine1runningになる - 同じ実行開始をもう一度呼ぶと409になる
- 片方のパラメータだけを指定すると400になる
- 存在しないSCTASK番号を指定すると404になる
- 自動化ユーザー以外へ変更しようとすると403になる
次回予告
次回は、同じScripted REST Serviceに結果登録APIを追加します。SUCCESSのときはSCTASKをClosed Completeにして次の作業へ進め、FAILEDのときはfailure_assignment_groupへ引き継ぎます。結果をRITMにJSON履歴として追記し、Work Notesに整形して残す方法も解説します。
連載記事
- 第1回 なぜ今Pull型連携なのか
- 第2回 全体アーキテクチャと設計方針
- 第3回 Catalog Item・Variable Set・Execution Planの準備
- 第4回 Table APIで実行待ちタスクを取得・確保する
- 第5回 Scripted REST APIでタスク詳細取得と実行開始(本記事)
※ 本記事の内容は個人の見解であり、所属する組織の公式な見解ではありません。