mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Run 34421944328 Snipe-IT#1 (swarm) failed its CLI contract with "UNREACHABLE read call snipeit_get_license_summary: the provider answered 500". The mint ran provision_mcp_identity.php through container exec as root, so the first deploy's passport:keys wrote the key files root-owned (0660/0600), and the NFS export's No_Root_Squash kept them that way. Apache serves as apache and could not read the public key, so every token-checked request failed with 500 on every replica. The image's startup script chowns the keys to apache:root, but only when a container starts, and the mint ran after that. mcp_mint.yml now runs the same chown in the resolved container before minting, which also heals keys an earlier deploy left root-owned, and runs the mint itself with -u apache so keys it creates start out readable. The PHP script checks the token it just minted against its own API and exits 1 on anything but 200, so a broken mint fails the task that made it, within its retries, instead of surfacing as an unreachable provider in the CLI contract. Each retry revokes the same-name token before minting, so retries do not pile up live tokens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
3.9 KiB
PHP
129 lines
3.9 KiB
PHP
<?php
|
|
/**
|
|
* Give the MCP adapter a read-only Snipe-IT user and print its access token.
|
|
*
|
|
* Passport signs its tokens, so the deployment cannot pin one. What it pins is
|
|
* the user and its permission group; the token is captured from the mint that
|
|
* follows and handed to the adapter through its env file.
|
|
*
|
|
* Snipe-IT's image never provisions Passport's keys or its personal-access
|
|
* client, so createToken() would throw on a fresh deployment. Both are ensured
|
|
* here before anything is minted.
|
|
*
|
|
* Prints CHANGED or UNCHANGED on the first line and the token on the second.
|
|
* Exits 1 when Snipe-IT's own API refuses the token it just minted.
|
|
*
|
|
* Environment:
|
|
* MCP_USER: username to converge.
|
|
* MCP_PASSWORD: password to pin on it.
|
|
* MCP_TOKEN_NAME: personal access token name to converge.
|
|
* MCP_CURRENT_TOKEN: token the adapter holds today, or "" for none.
|
|
*/
|
|
|
|
// nocheck: mirrored-unit-test - boots Snipe-IT's Laravel kernel through
|
|
// bootstrap/app.php and mints through Passport against the live database; neither the
|
|
// autoloader nor the models exist outside the Snipe-IT image
|
|
|
|
require "vendor/autoload.php";
|
|
$app = require "bootstrap/app.php";
|
|
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
$username = getenv("MCP_USER");
|
|
$password = getenv("MCP_PASSWORD");
|
|
$tokenName = getenv("MCP_TOKEN_NAME");
|
|
$currentToken = trim((string) getenv("MCP_CURRENT_TOKEN"));
|
|
|
|
$permissions = json_encode([
|
|
"self.api" => "1",
|
|
"assets.view" => "1",
|
|
"licenses.view" => "1",
|
|
]);
|
|
|
|
function apiStatus(string $token): int
|
|
{
|
|
$probe = curl_init("http://127.0.0.1/api/v1/hardware?limit=1");
|
|
curl_setopt_array($probe, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
"Authorization: Bearer " . $token,
|
|
"Accept: application/json",
|
|
],
|
|
CURLOPT_TIMEOUT => 20,
|
|
]);
|
|
curl_exec($probe);
|
|
return (int) curl_getinfo($probe, CURLINFO_HTTP_CODE);
|
|
}
|
|
|
|
if ($currentToken !== "" && apiStatus($currentToken) === 200) {
|
|
echo "UNCHANGED\n";
|
|
echo $currentToken . "\n";
|
|
exit(0);
|
|
}
|
|
|
|
$changed = false;
|
|
|
|
if (!file_exists(storage_path("oauth-private.key"))) {
|
|
Artisan::call("passport:keys", ["--no-interaction" => true]);
|
|
$changed = true;
|
|
}
|
|
|
|
if (!DB::table("oauth_personal_access_clients")->exists()) {
|
|
Artisan::call("passport:client", [
|
|
"--personal" => true,
|
|
"--name" => "Infinito personal access client",
|
|
"--no-interaction" => true,
|
|
]);
|
|
$changed = true;
|
|
}
|
|
|
|
$group = \App\Models\Group::where("name", "mcp-readonly")->first();
|
|
if (!$group) {
|
|
$group = \App\Models\Group::create([
|
|
"name" => "mcp-readonly",
|
|
"permissions" => $permissions,
|
|
]);
|
|
$changed = true;
|
|
} elseif ($group->permissions !== $permissions) {
|
|
$group->permissions = $permissions;
|
|
$group->save();
|
|
$changed = true;
|
|
}
|
|
|
|
$user = \App\Models\User::where("username", $username)->whereNull("deleted_at")->first();
|
|
if (!$user) {
|
|
$user = new \App\Models\User();
|
|
$user->first_name = "MCP";
|
|
$user->last_name = "Adapter";
|
|
$user->username = $username;
|
|
$user->activated = 1;
|
|
$user->password = bcrypt($password);
|
|
$user->save();
|
|
$changed = true;
|
|
} elseif (!\Illuminate\Support\Facades\Hash::check($password, (string) $user->password)) {
|
|
$user->password = bcrypt($password);
|
|
$user->save();
|
|
$changed = true;
|
|
}
|
|
$user->groups()->sync([$group->id]);
|
|
|
|
$revoked = DB::table("oauth_access_tokens")
|
|
->where("user_id", $user->id)
|
|
->where("name", $tokenName)
|
|
->where("revoked", 0)
|
|
->update(["revoked" => 1]);
|
|
$changed = $changed || $revoked > 0;
|
|
|
|
$token = $user->createToken($tokenName)->accessToken;
|
|
|
|
$status = apiStatus($token);
|
|
if ($status !== 200) {
|
|
fwrite(STDERR, "Snipe-IT's own API answered {$status} to the token it just minted\n");
|
|
exit(1);
|
|
}
|
|
|
|
echo ($changed ? "CHANGED" : "UNCHANGED") . "\n";
|
|
echo $token . "\n";
|