Skip to content

Commit 200b339

Browse files
committed
Wire up LIS-side courier for remote commands
STS endpoint (app/remote/v2/pending-commands.php): - Bearer-auth via the existing STS TokensService - Applies incoming statusUpdates to matching rows (nonce + lab scoped, terminal states never clobbered) - Returns up to 10 pending commands per lab, respecting not_before / expires_at / depends_on gates, and marks returned rows as picked - Tracked via addApiTracking like every other v2 endpoint LIS courier (app/tasks/remote/pending-commands.php): - Gated behind global_config.remote_commands_enabled (default off) - On each sync-sts tick: posts queued status files from var/remote-commands/results/, receives new commands, dispatches each, writes a status file for the next tick - Root-privileged commands (upgrade, upgrade-prepare, upgrade-apply, refresh-perms, restart-apache) land as markers in var/remote-commands/pending/ for the privileged runner in step 5 - Non-root commands dispatch in-process via handlers in app/tasks/remote/command-handlers/ - Idempotent: refuses to re-run a command whose result hasn't been acknowledged yet resend-results handler: - Spawns results-sender.php via proc_open with explicit argv (no shell) so the existing per-module loops + chunking are reused unchanged - Returns exitCode, duration, and a tail of the combined output for the STS-side status row Wired into composer.json sync-sts chain after receive-requests. Zero effect until a lab opts in by setting remote_commands_enabled. Until step 5 ships the runner, root commands sit in pending/ and STS shows them as 'picked' indefinitely — harmless.
1 parent 8b2b7e6 commit 200b339

4 files changed

Lines changed: 586 additions & 1 deletion

File tree

app/remote/v2/pending-commands.php

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
// /remote/v2/pending-commands.php
4+
// Remote command queue endpoint. LIS posts labId + statusUpdates for any
5+
// commands it has just processed, and gets back the next batch of pending
6+
// commands for that lab. One round-trip per sync tick.
7+
8+
use App\Services\ApiService;
9+
use App\Utilities\JsonUtility;
10+
use App\Utilities\MiscUtility;
11+
use App\Registries\AppRegistry;
12+
use App\Services\CommonService;
13+
use App\Utilities\DateUtility;
14+
use App\Utilities\LoggerUtility;
15+
use App\Services\DatabaseService;
16+
use App\Exceptions\SystemException;
17+
use App\Services\STS\TokensService;
18+
use App\Registries\ContainerRegistry;
19+
use Psr\Http\Message\ServerRequestInterface;
20+
21+
header('Content-Type: application/json');
22+
23+
/** @var DatabaseService $db */
24+
$db = ContainerRegistry::get(DatabaseService::class);
25+
26+
/** @var CommonService $general */
27+
$general = ContainerRegistry::get(CommonService::class);
28+
29+
/** @var ApiService $apiService */
30+
$apiService = ContainerRegistry::get(ApiService::class);
31+
32+
/** @var TokensService $stsTokensService */
33+
$stsTokensService = ContainerRegistry::get(TokensService::class);
34+
35+
$payload = ['status' => 'error', 'error' => 'Unknown'];
36+
37+
try {
38+
/** @var ServerRequestInterface $request */
39+
$request = AppRegistry::get('request');
40+
41+
$data = $apiService->getJsonFromRequest($request, true);
42+
$apiRequestId = $apiService->getHeader($request, 'X-Request-ID');
43+
$transactionId = $apiRequestId ?? MiscUtility::generateULID();
44+
45+
$authToken = ApiService::extractBearerToken($request);
46+
47+
$labId = $data['labId'] ?? null;
48+
if (empty($labId)) {
49+
throw new SystemException('Lab ID is missing in the request', 400);
50+
}
51+
52+
$token = $stsTokensService->validateToken($authToken, $labId);
53+
if (!$token) {
54+
throw new SystemException('Unauthorized Access', 401);
55+
}
56+
57+
$now = DateUtility::getCurrentDateTime();
58+
$terminalStatuses = ['completed', 'failed', 'expired', 'cancelled'];
59+
60+
// 1) Apply incoming status updates (only for rows belonging to this lab).
61+
$statusUpdates = is_array($data['statusUpdates'] ?? null) ? $data['statusUpdates'] : [];
62+
$ackIds = [];
63+
foreach ($statusUpdates as $update) {
64+
$commandId = $update['commandId'] ?? null;
65+
$status = $update['status'] ?? null;
66+
$nonce = $update['nonce'] ?? null;
67+
68+
if (empty($commandId) || empty($status)) {
69+
continue;
70+
}
71+
72+
$validStatuses = [
73+
'picked','running','preparing','prepared','applying',
74+
'completed','failed','expired','cancelled'
75+
];
76+
if (!in_array($status, $validStatuses, true)) {
77+
continue;
78+
}
79+
80+
$updateData = ['status' => $status];
81+
if (!empty($update['result'])) {
82+
$updateData['result'] = is_string($update['result'])
83+
? $update['result']
84+
: json_encode($update['result']);
85+
}
86+
if (!empty($update['lastError'])) {
87+
$updateData['last_error'] = mb_substr((string) $update['lastError'], 0, 4000);
88+
}
89+
if (in_array($status, $terminalStatuses, true)) {
90+
$updateData['completed_at'] = $now;
91+
}
92+
if ($status === 'picked' && empty($updateData['picked_at'])) {
93+
$updateData['picked_at'] = $now;
94+
}
95+
96+
$db->reset();
97+
$db->where('command_id', $commandId);
98+
$db->where('lab_id', (int) $labId);
99+
if (!empty($nonce)) {
100+
$db->where('nonce', $nonce);
101+
}
102+
// Don't clobber already-terminal rows.
103+
$db->where('status', $terminalStatuses, 'NOT IN');
104+
$db->update('s_lis_remote_commands', $updateData);
105+
$ackIds[] = $commandId;
106+
}
107+
108+
// 2) Fetch next batch of pending commands for this lab.
109+
$db->reset();
110+
$db->where('lab_id', (int) $labId);
111+
$db->where('status', 'pending');
112+
$db->where("(not_before IS NULL OR not_before <= '$now')");
113+
$db->where("(expires_at IS NULL OR expires_at > '$now')");
114+
// Dependency gate: if depends_on is set, the referenced command must exist,
115+
// belong to the same lab, and be in a prepared/completed state.
116+
$db->where(
117+
"(depends_on IS NULL OR EXISTS (
118+
SELECT 1 FROM s_lis_remote_commands d
119+
WHERE d.command_id = s_lis_remote_commands.depends_on
120+
AND d.lab_id = s_lis_remote_commands.lab_id
121+
AND d.status IN ('prepared','completed')
122+
))"
123+
);
124+
$db->orderBy('requested_at', 'ASC');
125+
$db->pageLimit = 10;
126+
$rows = $db->get('s_lis_remote_commands', [1, 10],
127+
'command_id, command, params, nonce, not_before, expires_at, depends_on');
128+
129+
// 3) Mark them picked.
130+
$commands = [];
131+
if (!empty($rows)) {
132+
$pickedIds = [];
133+
foreach ($rows as $row) {
134+
$pickedIds[] = $row['command_id'];
135+
$commands[] = [
136+
'commandId' => $row['command_id'],
137+
'command' => $row['command'],
138+
'params' => !empty($row['params']) ? json_decode((string) $row['params'], true) : new stdClass(),
139+
'nonce' => $row['nonce'],
140+
'notBefore' => $row['not_before'],
141+
'expiresAt' => $row['expires_at'],
142+
'dependsOn' => $row['depends_on'],
143+
];
144+
}
145+
$db->reset();
146+
$db->where('command_id', $pickedIds, 'IN');
147+
$db->update('s_lis_remote_commands', [
148+
'status' => 'picked',
149+
'picked_at' => $now,
150+
]);
151+
}
152+
153+
$payload = [
154+
'status' => 'success',
155+
'commands' => $commands,
156+
'acknowledged' => $ackIds,
157+
'serverTime' => $now,
158+
];
159+
160+
$general->addApiTracking(
161+
$transactionId,
162+
'intelis-system',
163+
count($commands),
164+
'pending-commands',
165+
'system',
166+
$_SERVER['REQUEST_URI'] ?? '',
167+
JsonUtility::encodeUtf8Json($data),
168+
JsonUtility::encodeUtf8Json($payload),
169+
'json',
170+
$labId,
171+
null,
172+
$authToken
173+
);
174+
} catch (SystemException $e) {
175+
http_response_code($e->getCode() >= 400 && $e->getCode() < 600 ? $e->getCode() : 500);
176+
$payload = ['status' => 'error', 'error' => $e->getMessage()];
177+
LoggerUtility::logError('pending-commands endpoint: ' . $e->getMessage(), [
178+
'last_db_query' => $db->getLastQuery(),
179+
'last_db_error' => $db->getLastError(),
180+
]);
181+
} catch (Throwable $e) {
182+
http_response_code(500);
183+
$payload = ['status' => 'error', 'error' => 'Server error'];
184+
LoggerUtility::logError('pending-commands endpoint: unexpected error', [
185+
'message' => $e->getMessage(),
186+
'file' => $e->getFile(),
187+
'line' => $e->getLine(),
188+
'last_db_query' => $db->getLastQuery(),
189+
'last_db_error' => $db->getLastError(),
190+
]);
191+
}
192+
193+
echo ApiService::generateJsonResponse($payload, $request ?? null);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
// Command handler: resend-results
4+
//
5+
// Invoked by the pending-commands courier in its own PHP process. Spawns
6+
// results-sender.php with the requested module + days filter via proc_open
7+
// (no shell; args passed as explicit argv) so the existing per-module loops
8+
// + chunking + acknowledgement flow are reused unchanged.
9+
//
10+
// Expected params:
11+
// - module (optional) one of: vl, eid, covid19, hepatitis, tb, cd4, generic-tests
12+
// - days (optional) integer 1..3650 — resend records modified within last N days
13+
//
14+
// Returns an array that becomes the `result` field on the status row back at STS.
15+
16+
/** @var array $params */
17+
/** @var array $command */
18+
19+
$module = $params['module'] ?? null;
20+
$days = isset($params['days']) ? (int) $params['days'] : null;
21+
22+
$validModules = ['vl', 'eid', 'covid19', 'hepatitis', 'tb', 'cd4', 'generic-tests'];
23+
if (!empty($module) && !in_array($module, $validModules, true)) {
24+
return [
25+
'status' => 'failed',
26+
'error' => 'Invalid module: ' . $module,
27+
];
28+
}
29+
if ($days !== null && ($days < 1 || $days > 3650)) {
30+
return [
31+
'status' => 'failed',
32+
'error' => 'Days out of range (1..3650)',
33+
];
34+
}
35+
36+
$scriptPath = APPLICATION_PATH . DIRECTORY_SEPARATOR . 'tasks'
37+
. DIRECTORY_SEPARATOR . 'remote' . DIRECTORY_SEPARATOR . 'results-sender.php';
38+
39+
// Build explicit argv — no shell interpretation; proc_open with an array
40+
// bypasses the shell entirely on *nix.
41+
$argv = [PHP_BINARY, $scriptPath];
42+
if (!empty($module)) {
43+
$argv[] = '-t';
44+
$argv[] = $module;
45+
}
46+
if ($days !== null && $days > 0) {
47+
$argv[] = (string) $days;
48+
}
49+
// Match manual cron convention so timestamp rewrites stay consistent.
50+
$argv[] = 'silent';
51+
52+
$descriptorSpec = [
53+
0 => ['pipe', 'r'],
54+
1 => ['pipe', 'w'],
55+
2 => ['pipe', 'w'],
56+
];
57+
58+
$start = microtime(true);
59+
$process = proc_open($argv, $descriptorSpec, $pipes);
60+
61+
if (!is_resource($process)) {
62+
return [
63+
'status' => 'failed',
64+
'error' => 'Failed to spawn results-sender subprocess',
65+
];
66+
}
67+
68+
fclose($pipes[0]);
69+
$stdout = stream_get_contents($pipes[1]);
70+
$stderr = stream_get_contents($pipes[2]);
71+
fclose($pipes[1]);
72+
fclose($pipes[2]);
73+
74+
$exitCode = proc_close($process);
75+
$duration = round(microtime(true) - $start, 2);
76+
77+
$combinedLines = array_merge(
78+
explode("\n", rtrim((string) $stdout, "\n")),
79+
explode("\n", rtrim((string) $stderr, "\n"))
80+
);
81+
82+
return [
83+
'status' => $exitCode === 0 ? 'completed' : 'failed',
84+
'exitCode' => $exitCode,
85+
'durationSeconds' => $duration,
86+
'module' => $module,
87+
'days' => $days,
88+
'outputTail' => implode("\n", array_slice(array_filter($combinedLines, 'strlen'), -30)),
89+
];

0 commit comments

Comments
 (0)