Add bulk store iPad reboot script with WS1 SoftReset support.
Fleet-wide dry-run/execute script for MRD/MR1/CD iPads, plus MDM client helper and filter tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5e54ea6f57
commit
f06dd2d09a
6 changed files with 436 additions and 1 deletions
|
|
@ -424,7 +424,8 @@ DIGICERT_SEAT_EMAIL=your-email@company.com
|
|||
# -----------------------------------------------------------------------------
|
||||
# MDM / Workspace ONE (two instances)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Standard / Store MDM
|
||||
# Standard / Store MDM (also used by scripts/rebootStoreIpads.js SoftReset)
|
||||
# OAuth client needs REST API Devices Execute for remote reboot.
|
||||
WS1_CLIENT_ID=...
|
||||
WS1_CLIENT_SECRET=...
|
||||
WS1_TENANT_CODE=...
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -31,6 +31,7 @@ scripts/*
|
|||
!scripts/reclaimWebexHosts.js
|
||||
!scripts/removeAdvancedMessaging.js
|
||||
!scripts/findEmptyLocations.js
|
||||
!scripts/rebootStoreIpads.js
|
||||
!scripts/prismaProbe.js
|
||||
!scripts/lib/
|
||||
!scripts/lib/**
|
||||
|
|
|
|||
|
|
@ -194,4 +194,67 @@ export async function getAVMDMDevices(storeNum) {
|
|||
logger('mdm:av', `MDM AV filter: ${allDevices.length} total → ${avDevices.length} AV devices`, 'debug');
|
||||
return avDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a SoftReset (reboot) command via Workspace ONE UEM.
|
||||
* Mirrors appspace/mdm.js sendMDMRebootCommand — proven in production.
|
||||
*
|
||||
* @param {string|number} deviceId WS1 numeric Id (device.Id.Value), NOT serial
|
||||
* @returns {Promise<{ success: boolean, status?: number, error?: string }>}
|
||||
*/
|
||||
export async function sendMDMRebootCommand(deviceId) {
|
||||
if (!deviceId) {
|
||||
return { success: false, error: 'No WS1 deviceId supplied' };
|
||||
}
|
||||
|
||||
const url = `${MDM_BASE_URL}/api/mdm/devices/commands`;
|
||||
const params = { command: 'SoftReset', searchBy: 'DeviceId', id: deviceId };
|
||||
|
||||
async function doRequest(token) {
|
||||
return axios.post(url, null, {
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'aw-tenant-code': process.env.WS1_TENANT_CODE,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = await getMDMToken();
|
||||
} catch (err) {
|
||||
return { success: false, error: `Could not obtain MDM token: ${err.message}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await doRequest(token);
|
||||
return { success: true, status: resp.status };
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
logger('mdm:reboot', `Auth failed on SoftReset for ${deviceId} — retrying once`, 'warn');
|
||||
try {
|
||||
const retryResp = await doRequest(await getMDMToken());
|
||||
return { success: true, status: retryResp.status };
|
||||
} catch (retryErr) {
|
||||
return {
|
||||
success: false,
|
||||
status: retryErr.response?.status,
|
||||
error: retryErr.response?.data?.message
|
||||
|| retryErr.response?.data?.errorCode
|
||||
|| retryErr.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
error: err.response?.data?.message || err.response?.data?.errorCode || err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default mdmAxios;
|
||||
|
|
|
|||
70
scripts/lib/mdmIpadFilter.js
Normal file
70
scripts/lib/mdmIpadFilter.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// scripts/lib/mdmIpadFilter.js
|
||||
// Fleet filter for store iPads whose DeviceFriendlyName contains MRD, MR1, or CD.
|
||||
|
||||
const NAME_PATTERN = /(MRD|MR1|CD)/i;
|
||||
const IPAD_PATTERN = /iPad/i;
|
||||
|
||||
/**
|
||||
* @param {object} device raw WS1 search record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isIpadDevice(device) {
|
||||
if (!device) return false;
|
||||
const platform = String(device.Platform || device.platform || '');
|
||||
const model = String(device.Model || device.model || '');
|
||||
const os = String(device.OperatingSystem || device.osVersion || '');
|
||||
return IPAD_PATTERN.test(platform) || IPAD_PATTERN.test(model) || /iPadOS/i.test(os);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} friendlyName DeviceFriendlyName only (per operator requirement)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesStoreIpadName(friendlyName) {
|
||||
if (!friendlyName) return false;
|
||||
return NAME_PATTERN.test(String(friendlyName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} device raw WS1 search record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTargetStoreIpad(device) {
|
||||
if (!device) return false;
|
||||
const friendlyName = device.DeviceFriendlyName || '';
|
||||
return isIpadDevice(device) && matchesStoreIpadName(friendlyName);
|
||||
}
|
||||
|
||||
/**
|
||||
* WS1 numeric device Id required for SoftReset (appspace pattern).
|
||||
* @param {object} device
|
||||
* @returns {string|number|null}
|
||||
*/
|
||||
export function mdmDeviceId(device) {
|
||||
if (!device) return null;
|
||||
return device.Id?.Value ?? device.Id ?? device.id ?? device.DeviceId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract store number from DeviceFriendlyName for reporting.
|
||||
* @param {string} rawName
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function extractStoreFromFriendlyName(rawName) {
|
||||
if (!rawName) return null;
|
||||
return (
|
||||
rawName.match(/(\d{6})/)?.[1]
|
||||
|| rawName.match(/(\d{5})/)?.[1]
|
||||
|| rawName.match(/\d{2,4}/)?.[0]?.padStart(5, '0')
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} devices
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function filterTargetStoreIpads(devices) {
|
||||
if (!Array.isArray(devices)) return [];
|
||||
return devices.filter(isTargetStoreIpad);
|
||||
}
|
||||
229
scripts/rebootStoreIpads.js
Normal file
229
scripts/rebootStoreIpads.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bulk reboot store iPads (MRD / MR1 / CD in DeviceFriendlyName).
|
||||
*
|
||||
* Fleet-wide scan of Store Workspace ONE MDM, filter iPads by name, issue
|
||||
* SoftReset (same API as appspace restart-offline). Default is dry-run.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/rebootStoreIpads.js
|
||||
* node scripts/rebootStoreIpads.js --execute --yes --report reboot-ipads.csv
|
||||
*
|
||||
* Auth: WS1_CLIENT_ID, WS1_CLIENT_SECRET, WS1_TENANT_CODE (Store MDM).
|
||||
* Requires REST API Devices Execute on the OAuth client.
|
||||
*
|
||||
* Note: SoftReset only executes on Supervised (DEP) iOS/iPadOS devices.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline/promises';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
|
||||
import {
|
||||
filterTargetStoreIpads,
|
||||
mdmDeviceId,
|
||||
extractStoreFromFriendlyName,
|
||||
} from './lib/mdmIpadFilter.js';
|
||||
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
const MAX_FLEET_DEVICES = 25000;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
execute: false,
|
||||
yes: false,
|
||||
concurrency: DEFAULT_CONCURRENCY,
|
||||
limit: null,
|
||||
report: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (a) {
|
||||
case '--execute': out.execute = true; break;
|
||||
case '--yes': out.yes = true; break;
|
||||
case '--concurrency': out.concurrency = Math.max(1, Number(next()) || DEFAULT_CONCURRENCY); break;
|
||||
case '--limit': out.limit = Number(next()); break;
|
||||
case '--report': out.report = next(); break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
out.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${a}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/rebootStoreIpads.js [options]
|
||||
|
||||
Fleet-wide: scans Store WS1 MDM for iPads whose DeviceFriendlyName
|
||||
contains MRD, MR1, or CD, then issues SoftReset (reboot).
|
||||
|
||||
Options:
|
||||
--execute Send reboot commands (default: dry-run only)
|
||||
--yes Skip interactive confirm when --execute
|
||||
--concurrency <n> Parallel reboots (default: ${DEFAULT_CONCURRENCY})
|
||||
--limit <n> Cap number of devices processed
|
||||
--report <file.csv> Write audit CSV
|
||||
-h, --help Show this help
|
||||
|
||||
iOS/iPadOS devices must be Supervised (DEP) for SoftReset to execute.
|
||||
`);
|
||||
}
|
||||
|
||||
function toCsvRow(cells) {
|
||||
return cells.map((c) => {
|
||||
const s = String(c ?? '');
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}).join(',');
|
||||
}
|
||||
|
||||
function summarizeDevice(device) {
|
||||
const friendlyName = device.DeviceFriendlyName || '—';
|
||||
return {
|
||||
storeNum: extractStoreFromFriendlyName(friendlyName) || '—',
|
||||
friendlyName,
|
||||
serial: device.SerialNumber || '—',
|
||||
mdmId: mdmDeviceId(device),
|
||||
model: device.Model || '—',
|
||||
lastSeen: device.LastSeen || device.LastSystemSampleTime || '—',
|
||||
raw: device,
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmExecute(count) {
|
||||
const rl = readline.createInterface({ input, output });
|
||||
try {
|
||||
const answer = await rl.question(
|
||||
`About to SoftReset ${count} iPad(s). Type YES to continue: `,
|
||||
);
|
||||
return answer.trim().toUpperCase() === 'YES';
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printPreview(rows) {
|
||||
console.log(`\nMatched ${rows.length} iPad(s):\n`);
|
||||
const preview = rows.slice(0, 30);
|
||||
for (const r of preview) {
|
||||
console.log(
|
||||
` store=${r.storeNum} id=${r.mdmId} serial=${r.serial} name=${r.friendlyName}`,
|
||||
);
|
||||
}
|
||||
if (rows.length > preview.length) {
|
||||
console.log(` ... and ${rows.length - preview.length} more`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.WS1_CLIENT_ID || !process.env.WS1_CLIENT_SECRET || !process.env.WS1_TENANT_CODE) {
|
||||
console.error('Missing WS1_CLIENT_ID, WS1_CLIENT_SECRET, or WS1_TENANT_CODE in environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { getMDMDevicesByPlatform, sendMDMRebootCommand } = await import('../integrations/mdm/client.js');
|
||||
const { runPool } = await import('./lib/webexBulk.js');
|
||||
|
||||
console.log('Fetching fleet devices from Store MDM (paginated)...');
|
||||
const allDevices = await getMDMDevicesByPlatform(null, MAX_FLEET_DEVICES);
|
||||
console.log(`MDM returned ${allDevices.length} device(s).`);
|
||||
|
||||
let matches = filterTargetStoreIpads(allDevices).map(summarizeDevice);
|
||||
matches = matches.filter((m) => m.mdmId != null);
|
||||
const skippedNoId = filterTargetStoreIpads(allDevices).length - matches.length;
|
||||
|
||||
if (Number.isFinite(opts.limit) && opts.limit > 0) {
|
||||
matches = matches.slice(0, opts.limit);
|
||||
}
|
||||
|
||||
if (skippedNoId > 0) {
|
||||
console.warn(`Skipped ${skippedNoId} match(es) with no WS1 Id.Value.`);
|
||||
}
|
||||
|
||||
printPreview(matches);
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log('\nNo matching iPads found. Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.execute) {
|
||||
console.log('\nDry-run only — pass --execute to send SoftReset commands.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirmExecute(matches.length);
|
||||
if (!ok) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSending SoftReset to ${matches.length} device(s) (concurrency=${opts.concurrency})...`);
|
||||
|
||||
const results = await runPool(matches, opts.concurrency, async (row) => {
|
||||
const reboot = await sendMDMRebootCommand(row.mdmId);
|
||||
return {
|
||||
...row,
|
||||
status: reboot.success ? 'sent' : 'failed',
|
||||
httpStatus: reboot.status ?? '',
|
||||
error: reboot.error ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const sent = results.filter((r) => r.ok && r.value.status === 'sent').length;
|
||||
const failed = results.filter((r) => !r.ok || r.value.status === 'failed').length;
|
||||
|
||||
console.log(`\nDone: ${sent} sent, ${failed} failed.`);
|
||||
|
||||
const reportRows = results.map((r, i) => {
|
||||
if (r.ok) return r.value;
|
||||
const base = matches[i];
|
||||
return {
|
||||
...base,
|
||||
status: 'error',
|
||||
httpStatus: '',
|
||||
error: r.error?.message || String(r.error),
|
||||
};
|
||||
});
|
||||
|
||||
for (const row of reportRows) {
|
||||
if (row.status !== 'sent') {
|
||||
console.log(` FAIL ${row.friendlyName} (${row.serial}): ${row.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.report) {
|
||||
const header = ['store', 'friendlyName', 'serial', 'mdmId', 'model', 'lastSeen', 'status', 'httpStatus', 'error'];
|
||||
const lines = [
|
||||
toCsvRow(header),
|
||||
...reportRows.map((r) => toCsvRow([
|
||||
r.storeNum, r.friendlyName, r.serial, r.mdmId, r.model, r.lastSeen,
|
||||
r.status, r.httpStatus ?? '', r.error ?? '',
|
||||
])),
|
||||
];
|
||||
fs.writeFileSync(opts.report, `${lines.join('\n')}\n`);
|
||||
console.log(`\nWrote ${opts.report}`);
|
||||
}
|
||||
|
||||
console.log('\nNote: WS1 queues SoftReset; iPadOS must be Supervised for reboot to execute.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
71
tests/rebootStoreIpads.filter.test.js
Normal file
71
tests/rebootStoreIpads.filter.test.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// tests/rebootStoreIpads.filter.test.js
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
isIpadDevice,
|
||||
matchesStoreIpadName,
|
||||
isTargetStoreIpad,
|
||||
filterTargetStoreIpads,
|
||||
mdmDeviceId,
|
||||
extractStoreFromFriendlyName,
|
||||
} from '../scripts/lib/mdmIpadFilter.js';
|
||||
|
||||
test('matchesStoreIpadName matches MRD MR1 CD tokens', () => {
|
||||
assert.equal(matchesStoreIpadName('Store 0782 MRD iPad'), true);
|
||||
assert.equal(matchesStoreIpadName('0782-MR1-Fitting'), true);
|
||||
assert.equal(matchesStoreIpadName('Store CD Queue iPad'), true);
|
||||
assert.equal(matchesStoreIpadName('Store 0782 VW'), false);
|
||||
assert.equal(matchesStoreIpadName(''), false);
|
||||
});
|
||||
|
||||
test('isIpadDevice detects iPad platform/model', () => {
|
||||
assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'iPad (9th generation)' }), true);
|
||||
assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'Apple TV' }), false);
|
||||
});
|
||||
|
||||
test('isTargetStoreIpad requires iPad and friendly name match', () => {
|
||||
assert.equal(isTargetStoreIpad({
|
||||
DeviceFriendlyName: 'Store 0782 MRD iPad',
|
||||
Platform: 'Apple',
|
||||
Model: 'iPad Pro',
|
||||
Id: { Value: 12345 },
|
||||
}), true);
|
||||
assert.equal(isTargetStoreIpad({
|
||||
DeviceFriendlyName: 'Store 0782 MRD Apple TV',
|
||||
Platform: 'Apple',
|
||||
Model: 'Apple TV',
|
||||
Id: { Value: 1 },
|
||||
}), false);
|
||||
assert.equal(isTargetStoreIpad({
|
||||
DeviceFriendlyName: 'Store 0782 VW iPad',
|
||||
Platform: 'Apple',
|
||||
Model: 'iPad',
|
||||
Id: { Value: 1 },
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('mdmDeviceId reads Id.Value', () => {
|
||||
assert.equal(mdmDeviceId({ Id: { Value: 999 } }), 999);
|
||||
assert.equal(mdmDeviceId({ id: 'abc' }), 'abc');
|
||||
assert.equal(mdmDeviceId({}), null);
|
||||
});
|
||||
|
||||
test('extractStoreFromFriendlyName parses store numbers', () => {
|
||||
assert.equal(extractStoreFromFriendlyName('Store 007821 MRD iPad'), '007821');
|
||||
assert.equal(extractStoreFromFriendlyName('0782-MR1-Fitting'), '0782');
|
||||
assert.equal(extractStoreFromFriendlyName('no digits'), null);
|
||||
});
|
||||
|
||||
test('filterTargetStoreIpads returns only matches', () => {
|
||||
const devices = [
|
||||
{ DeviceFriendlyName: 'A MRD', Platform: 'Apple', Model: 'iPad', Id: { Value: 1 } },
|
||||
{ DeviceFriendlyName: 'B VW', Platform: 'Apple', Model: 'iPad', Id: { Value: 2 } },
|
||||
{ DeviceFriendlyName: 'C MR1', Platform: 'Apple', Model: 'iPad', Id: { Value: 3 } },
|
||||
];
|
||||
const out = filterTargetStoreIpads(devices);
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(out[0].Id.Value, 1);
|
||||
assert.equal(out[1].Id.Value, 3);
|
||||
});
|
||||
Loading…
Reference in a new issue