Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/loopover-ui/src/lib/ams-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
firstReference: "lib/contribution-profile-cache.js",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_CONTROL_PLANE",
firstReference: "lib/tenant-client.js",
defaultValue: "",
},
{
name: "LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN",
firstReference: "lib/tenant-client.js",
defaultValue: "",
},
{
name: "LOOPOVER_MINER_CONTROL_PLANE_URL",
firstReference: "lib/tenant-client.js",
defaultValue: "",
},
{
name: "LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB",
firstReference: "lib/deny-hook-synthesis.js",
Expand Down Expand Up @@ -213,6 +228,9 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_MINER_CLAIM_LEDGER_DB` | `lib/claim-ledger.js` | (none) |",
'| `LOOPOVER_MINER_CONFIG_DIR` | `lib/deny-hook-synthesis.js` | `""` |',
"| `LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB` | `lib/contribution-profile-cache.js` | (none) |",
'| `LOOPOVER_MINER_CONTROL_PLANE` | `lib/tenant-client.js` | `""` |',
'| `LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN` | `lib/tenant-client.js` | `""` |',
'| `LOOPOVER_MINER_CONTROL_PLANE_URL` | `lib/tenant-client.js` | `""` |',
'| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.js` | `""` |',
'| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.js` | `""` |',
'| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.js` | `""` |',
Expand Down
10 changes: 10 additions & 0 deletions packages/loopover-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,13 @@ The Phase 6 **hosted discovery-index** is **off by default** — unlike Orb flee
| `LOOPOVER_MINER_DISCOVERY_TELEMETRY` | `lib/discovery-index-client.js` | Second, independent opt-in for anonymized operational telemetry — can stay off while the plane itself is queried/claimed against. |

See [`docs/discovery-plane-operator-guide.md`](docs/discovery-plane-operator-guide.md) for the full invariant list (metadata-only, no compensation signals, credentials stay local).

## Optional hosted control plane — tenant admin (opt-in)

The `loopover-miner tenant` command group (create/list/destroy) provisions hosted tenant instances against the ORB+AMS hosting control-plane's provisioning API ([#7275](https://github.com/JSONbored/loopover/issues/7275)). It is **off by default** and completely inert unless opted in; unlike the discovery plane, these are deliberate **admin actions that fail loud** (a disabled/unconfigured/unreachable/error condition exits non-zero rather than degrading silently), via `lib/tenant-client.js`:

| Variable | Read by | Purpose |
| --- | --- | --- |
| `LOOPOVER_MINER_CONTROL_PLANE` | `lib/tenant-client.js` | Master opt-in (truthy string, off by default). No tenant admin traffic is possible unless set. |
| `LOOPOVER_MINER_CONTROL_PLANE_URL` | `lib/tenant-client.js` | Base URL of the hosted control-plane provisioning API. Required once the plane is enabled. |
| `LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN` | `lib/tenant-client.js` | Bearer admin credential for the provisioning API — distinct from any tenant's own per-instance secrets. Required once the plane is enabled. |
8 changes: 8 additions & 0 deletions packages/loopover-miner/bin/loopover-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { runClaimCli } from "../lib/claim-ledger-cli.js";
import { runPurge } from "../lib/purge-cli.js";
import { runQueueCli } from "../lib/portfolio-queue-cli.js";
import { runOrbExportCli } from "../lib/orb-export.js";
import { runTenantCli } from "../lib/tenant-cli.js";
import { installCliSignalHandlers } from "../lib/process-lifecycle.js";
import { captureMinerErrorAndFlush, initMinerSentry } from "../lib/sentry.js";
import { runStateCli } from "../lib/run-state-cli.js";
Expand Down Expand Up @@ -118,6 +119,13 @@ if (cliArgs[0] === "orb" && cliArgs[1] === "export") {
process.exit(await runOrbExportCli(cliArgs.slice(2)));
}

// `tenant` (#7275) talks to the hosting control-plane's provisioning API — a deliberate, Bearer-authed admin
// action that is inert unless LOOPOVER_MINER_CONTROL_PLANE is set. Grouped with `orb export` above (also a
// network command) rather than the strictly-local commands; it fails loud on any control-plane error.
if (cliArgs[0] === "tenant") {
process.exit(await runTenantCli(cliArgs[1], cliArgs.slice(2)));
}

if (cliArgs[0] === "claim") {
process.exit(runClaimCli(cliArgs[1], cliArgs.slice(2)));
}
Expand Down
3 changes: 3 additions & 0 deletions packages/loopover-miner/docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `LOOPOVER_MINER_CLAIM_LEDGER_DB` | `lib/claim-ledger.js` | (none) |
| `LOOPOVER_MINER_CONFIG_DIR` | `lib/deny-hook-synthesis.js` | `""` |
| `LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB` | `lib/contribution-profile-cache.js` | (none) |
| `LOOPOVER_MINER_CONTROL_PLANE` | `lib/tenant-client.js` | `""` |
| `LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN` | `lib/tenant-client.js` | `""` |
| `LOOPOVER_MINER_CONTROL_PLANE_URL` | `lib/tenant-client.js` | `""` |
| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.js` | `""` |
| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.js` | `""` |
| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.js` | `""` |
Expand Down
3 changes: 3 additions & 0 deletions packages/loopover-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export function printHelp(input) {
" loopover-miner state get <owner/repo> [--json]",
" loopover-miner state set <owner/repo> <idle|discovering|planning|preparing> [--dry-run] [--json]",
" loopover-miner orb export [--enable] [--send] [--dry-run] [--json] Build (and optionally send) the opt-in anonymized telemetry batch",
" loopover-miner tenant create <name> [--product <product>] [--json] Provision a hosted tenant via the control-plane API",
" loopover-miner tenant list [--json] List hosted tenants",
" loopover-miner tenant destroy <name> [--json] Tear down a hosted tenant",
" loopover-miner purge --repo <owner/repo> [--dry-run] [--json]",
" Right-to-be-forgotten: delete a repo's rows from every local store",
"",
Expand Down
34 changes: 34 additions & 0 deletions packages/loopover-miner/lib/tenant-cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { createTenant, destroyTenant, listTenants, TenantRecord } from "./tenant-client.js";

export type ParsedTenantCreateArgs =
| { name: string; json: boolean; product?: string }
| { error: string };

export type ParsedTenantNameArgs = { name: string; json: boolean } | { error: string };

export type ParsedTenantListArgs = { json: boolean } | { error: string };

export type RunTenantOptions = {
/** Read for the control-plane opt-in gate -- defaults to `process.env` inside the client. */
env?: Record<string, string | undefined>;
/** Injected fetch, forwarded to the client; defaults to the real global fetch. */
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
/** Injectable client functions so tests drive the CLI without a real control plane. */
createTenant?: typeof createTenant;
listTenants?: typeof listTenants;
destroyTenant?: typeof destroyTenant;
};

export function parseTenantCreateArgs(args: string[]): ParsedTenantCreateArgs;

export function parseTenantNameArgs(args: string[]): ParsedTenantNameArgs;

export function parseTenantListArgs(args: string[]): ParsedTenantListArgs;

export function runTenantCreate(args: string[], options?: RunTenantOptions): Promise<number>;

export function runTenantList(args: string[], options?: RunTenantOptions): Promise<number>;

export function runTenantDestroy(args: string[], options?: RunTenantOptions): Promise<number>;

export function runTenantCli(subcommand: string | undefined, args: string[], options?: RunTenantOptions): Promise<number>;
139 changes: 139 additions & 0 deletions packages/loopover-miner/lib/tenant-cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/** `tenant` CLI command group (#7275): create / list / destroy hosted tenant instances against the #7173 ORB+AMS
* hosting control-plane's provisioning API (#7180). Thin composition layer -- argv parsing plus a call into
* tenant-client.js, which owns the env-gated, Bearer-authed, FAIL-LOUD HTTP surface. Every failure the client
* throws (disabled/unconfigured plane, unreachable host, non-2xx, malformed body) is reported here as a non-zero
* exit with the client's own message; there is deliberately no silent-degrade path, because provisioning a tenant
* is a deliberate admin action whose failure an operator must see. Lifecycle `state` values are printed verbatim
* from the API -- this layer invents no state vocabulary of its own. */
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
import { createTenant, destroyTenant, listTenants } from "./tenant-client.js";

const TENANT_USAGE =
"Usage: loopover-miner tenant <create|list|destroy> [<name>] [--product <product>] [--json]";

/** Parse `create <name> [--product <p>] [--json]`. Returns `{ name, product, json }` or `{ error }`. */
export function parseTenantCreateArgs(args) {
let name = null;
let product = null;
let json = false;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === "--json") {
json = true;
continue;
}
if (token === "--product") {
const value = args[index + 1];
if (!value || value.startsWith("-")) return { error: TENANT_USAGE };
product = value;
index += 1;
continue;
}
if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
if (name !== null) return { error: TENANT_USAGE };
name = token;
}
if (name === null) return { error: TENANT_USAGE };
return { name, json, ...(product !== null ? { product } : {}) };
}

/** Parse `<name> [--json]` for the single-positional commands (destroy). Returns `{ name, json }` or `{ error }`. */
export function parseTenantNameArgs(args) {
let name = null;
let json = false;
for (const token of args) {
if (token === "--json") {
json = true;
continue;
}
if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
if (name !== null) return { error: TENANT_USAGE };
name = token;
}
if (name === null) return { error: TENANT_USAGE };
return { name, json };
}

/** Parse `list [--json]` (no positional). Returns `{ json }` or `{ error }`. */
export function parseTenantListArgs(args) {
let json = false;
for (const token of args) {
if (token === "--json") {
json = true;
continue;
}
return { error: `Unknown option: ${token}` };
}
return { json };
}

function renderTenantRecord(record) {
const name = typeof record.name === "string" ? record.name : "(unknown)";
const product = typeof record.product === "string" ? record.product : "(unknown)";
const state = typeof record.state === "string" ? record.state : "(unknown)";
return `${name} product=${product} state=${state}`;
}

export async function runTenantCreate(args, options = {}) {
const parsed = parseTenantCreateArgs(args);
if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error);
const create = options.createTenant ?? createTenant;
try {
const record = await create(parsed.name, {
env: options.env,
fetchImpl: options.fetchImpl,
...(parsed.product !== undefined ? { product: parsed.product } : {}),
});
if (parsed.json) {
console.log(JSON.stringify(record, null, 2));
} else {
console.log(`created ${renderTenantRecord(record)}`);
}
return 0;
} catch (error) {
return reportCliFailure(parsed.json, describeCliError(error));
}
}

export async function runTenantList(args, options = {}) {
const parsed = parseTenantListArgs(args);
if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error);
const list = options.listTenants ?? listTenants;
try {
const records = await list({ env: options.env, fetchImpl: options.fetchImpl });
if (parsed.json) {
console.log(JSON.stringify(records, null, 2));
} else if (records.length === 0) {
console.log("no tenants");
} else {
console.log(records.map(renderTenantRecord).join("\n"));
}
return 0;
} catch (error) {
return reportCliFailure(parsed.json, describeCliError(error));
}
}

export async function runTenantDestroy(args, options = {}) {
const parsed = parseTenantNameArgs(args);
if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error);
const destroy = options.destroyTenant ?? destroyTenant;
try {
const record = await destroy(parsed.name, { env: options.env, fetchImpl: options.fetchImpl });
if (parsed.json) {
console.log(JSON.stringify(record, null, 2));
} else {
console.log(`destroyed ${renderTenantRecord(record)}`);
}
return 0;
} catch (error) {
return reportCliFailure(parsed.json, describeCliError(error));
}
}

export async function runTenantCli(subcommand, args, options = {}) {
if (subcommand === "create") return runTenantCreate(args, options);
if (subcommand === "list") return runTenantList(args, options);
if (subcommand === "destroy") return runTenantDestroy(args, options);
return reportCliFailure(argsWantJson(args), TENANT_USAGE);
}
27 changes: 27 additions & 0 deletions packages/loopover-miner/lib/tenant-client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const CONTROL_PLANE_FLAG: string;
export const CONTROL_PLANE_URL_FLAG: string;
export const CONTROL_PLANE_ADMIN_TOKEN_FLAG: string;

export type TenantClientOptions = {
env?: Record<string, string | undefined>;
/** Always called as `fetchImpl(url, init)` with a plain string URL -- narrower than `typeof fetch` on
* purpose, since that's the only shape this module ever actually calls it with. */
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
requestTimeoutMs?: number;
};

export type CreateTenantOptions = TenantClientOptions & {
product?: string;
};

/** A tenant record as reported by the control plane. Lifecycle `state` is passed through verbatim (the API owns
* the vocabulary, e.g. `provisioning` / `active` / `suspended` / `torn down`); other fields vary by product. */
export type TenantRecord = Record<string, unknown>;

export function isControlPlaneEnabled(env?: Record<string, string | undefined>): boolean;

export function createTenant(name: string, options?: CreateTenantOptions): Promise<TenantRecord>;

export function listTenants(options?: TenantClientOptions): Promise<TenantRecord[]>;

export function destroyTenant(name: string, options?: TenantClientOptions): Promise<TenantRecord>;
Loading
Loading