0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【連載 第5回】ServiceNow標準機能だけで作る「Pull型」自動化連携 ― Scripted REST APIでタスク詳細取得と実行開始

0
Posted at

本記事は連載「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エスケープ(&amp;&amp;、&lt;)されていないことを確認してください。コピー元によっては変換されることがあります。

(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に整形して残す方法も解説します。

連載記事


※ 本記事の内容は個人の見解であり、所属する組織の公式な見解ではありません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?