mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments give each agent run an execution target: the local host, SSH, or a sandbox provider > - A managed deployment can provision one platform-managed sandbox environment through the `PAPERCLIP_MANAGED_CONFIG` `environments` section > - That row is fully locked today. A tenant cannot add environment variables for their agents. There is also no way to hide local execution — run selection falls back to the local row > - A platform that manages the sandbox for its tenants needs both: the tenant adds env vars (and nothing else), and local execution is neither visible nor reachable > - This pull request opens exactly one tenant edit (env vars) on the managed sandbox row, and adds an `enableManagedSandboxOnly` mode that hides local and makes run selection fail closed > - The benefit is a complete managed-sandbox experience with no change for self-hosted instances ## Linked Issues or Issue Description **Subsystem affected** Environments (managed sandbox provisioning, environment routes, run environment selection) and the environments UI. **Problem or motivation** Platform-provisioned sandbox environments (`metadata.managedByPaperclip`) reject every write on cloud-managed instances. Agents often need environment variables inside their sandbox. The tenant has no way to set them on the managed row. Separately, an operator cannot remove local execution: the environment list always shows the local row, and run selection falls back to it when no default is set. **Proposed solution** Allow an envVars-only PATCH on the managed sandbox row, and echo those env vars back for editing. Add a managed-tier feature (`enableManagedSandboxOnly`) that hides the local environment from all read surfaces and redirects local-landing run selection to the managed sandbox environment, failing closed when it is unavailable. **Alternatives considered** UI-only hiding of the local row. This was rejected: it does not stop a run from resolving to local, so it is presentation without enforcement. Full unlock of the managed row was also rejected: name, driver, and config stay platform-owned so boot reconciliation cannot fight tenant edits. ## What Changed - `server/src/routes/environments.ts`: the platform-provisioned write floor admits an envVars-only PATCH on the generalized managed sandbox row (sandbox driver, `managedByPaperclip`, not legacy kubernetes-marker rows). Name, driver, config, status, metadata, and DELETE stay rejected. The read floor stops blanking env vars on that row; credential-shaped config keys stay redacted for every actor. Legacy kubernetes-marker rows keep the full floor. - Same file: under `enableManagedSandboxOnly`, the environments list and the by-id read omit the local row for every actor, including instance admins. - `server/src/services/execution-workspace-policy.ts`: `resolveExecutionWorkspaceEnvironmentId` gains the managed-sandbox-only inputs. A selection that lands on the local environment is redirected to the managed sandbox environment. With no active managed row it throws `ManagedSandboxUnavailableError` — never local. Non-local selections (ssh, user-created sandboxes) are untouched. - `server/src/services/heartbeat.ts`: the run path reads the flag, looks up the managed row (`findManagedSandboxEnvironment`, new read-only finder in `environments.ts`), and passes both to the resolver. Mirrors the forced-kubernetes precedent, which keeps precedence when both regimes are on. - `server/src/services/managed-environments.ts`: after a successful reconcile, the instance default environment moves to the managed sandbox row when the current default is unset, local, or dangling. A tenant-chosen custom environment is never overridden. - `packages/shared`: new `enableManagedSandboxOnly` key (schema default false, catalog tier `managed`, cloudDefault false, selfHostedDefault false) and the matching interface field. - UI: managed rows show a "Managed by Paperclip" lock badge; editing one opens a dedicated env-vars-only editor that sends the one PATCH shape the server admits (the old full form failed with a 403 on save). New `ui/src/lib/managed-sandbox-environment.ts` mirrors the local filter for cached lists (applied in the project picker; the agent picker already excluded local). The experimental settings page gains the toggle at its alphabetical card position. `environmentsApi.update` now declares the `envVars` field it already sent. - Tests: environment route floor coverage (envVars-only accepted, mixed bodies rejected, legacy rows still blanked and locked, local hidden and 404 under the flag, self-hosted unchanged), an embedded-postgres service test pinning that boot reconciliation never touches tenant env vars, resolver redirect/fail-closed cases, managed-environments default-stamping cases, and UI lib/settings tests. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/environment-routes.test.ts src/__tests__/environment-service.test.ts src/__tests__/execution-workspace-policy.test.ts src/services/managed-environments.test.ts` — all pass. - `pnpm --filter @paperclipai/shared exec vitest run` — 425 pass (catalog/schema default parity is pinned by an existing test). - `pnpm --filter @paperclipai/ui exec tsc --noEmit` and the affected UI suites (CompanyEnvironments, InstanceExperimentalSettings incl. card-order test, new lib test) — all pass. - Full workspace `pnpm test`: 3,414 passed. 17 files report failures on this machine; the identical 17 fail on a clean `origin/master` worktree in the same environment (git-worktree/skills/embedded-postgres environment dependencies and plugin-SDK zero-test collections). One additional file (`issue-monitor-scheduler.test.ts`) failed one timing-sensitive test in one of two full-suite runs and passes 7/7 in isolation on this branch — a flake in a domain this diff does not touch. The branch introduces no new failures. - Self-hosted zero-delta: every new behavior is gated on the cloud-managed instance check or the new flag, which defaults to false in schema and catalog; pinned by the "does not floor platform-marked rows on self-hosted instances" and flag-off tests. ## Risks - Behavior is opt-in twice over: the write-floor exception applies only to rows the managed-config provisioner stamps, and the hiding/forcing applies only when `enableManagedSandboxOnly` is on (default false everywhere). Self-hosted instances see no change. - The env-vars echo is scoped to the generalized managed sandbox row; legacy kubernetes-marker rows keep the blanket floor because pre-generalization builds may have written platform values there. - Fail-closed run selection means a managed instance with the flag on and an archived managed row (provider plugin down) refuses runs with a precise error instead of running locally. That is the intended posture. ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use via Claude Code CLI. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
420 lines
19 KiB
TypeScript
420 lines
19 KiB
TypeScript
/**
|
|
* Managed-environment bootstrap (harness → app contract).
|
|
*
|
|
* Managed-cloud instances may declare sandbox environments in the
|
|
* `environments` section of `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed in
|
|
* `managed-config.ts`). On boot, each declared environment is idempotently
|
|
* ensured as the instance-level Paperclip-managed sandbox row via the
|
|
* provider-agnostic `ensureManagedSandboxEnvironment` — the control plane
|
|
* provisions, tenants use, for any bundled sandbox provider plugin.
|
|
*
|
|
* The failure posture mirrors bundled plugin provisioning
|
|
* (`bundled-plugins.ts`), deliberately split:
|
|
*
|
|
* 1. **Validation fails closed at parse time** (`managed-config.ts`): a
|
|
* malformed section refuses startup with a precise error.
|
|
* 2. **The DB ensure step is fail-safe per entry**: an ensure failure is
|
|
* logged and boot continues degraded (environment unavailable) rather
|
|
* than crash-looping a fleet.
|
|
*
|
|
* Ensuring is additionally synchronized with provider availability: the
|
|
* caller's `pluginsReady` promise (the bundled-plugin install/load pass) is
|
|
* awaited first, and an entry whose provider plugin is not installed, `ready`,
|
|
* AND running a live worker (`workerManager.isRunning`; a `ready` record whose
|
|
* activation failed has no worker and cannot serve leases) afterwards is
|
|
* skipped (counted failed) instead of being written as an active row —
|
|
* otherwise the heartbeat would resume queued runs against an environment
|
|
* whose lease acquisition cannot succeed yet. When such an entry was
|
|
* provisioned by an earlier boot, its still-active row is archived
|
|
* (`archiveManagedSandboxEnvironment`) for the same reason. Re-activation
|
|
* happens on the next healthy boot's ensure, or earlier: when the plugin
|
|
* record is `ready` and only the worker is down (a crash in restart-backoff),
|
|
* a one-shot `ready` listener on the worker handle re-runs the ensure as soon
|
|
* as the worker manager respawns the worker, so a transient crash does not
|
|
* leave the environment archived until someone restarts the server.
|
|
*
|
|
* Removing an entry from the document stops future refreshes but never
|
|
* deletes or archives the row — there is intentionally no unprovision path
|
|
* here, matching `plugins.autoInstall` semantics (leases may still reference
|
|
* the row; withdrawal is an explicit operator action). Archiving above is
|
|
* scoped to a declared-but-unavailable provider, not to document removal.
|
|
*
|
|
* Provider credentials are never part of the declared config: every bundled
|
|
* sandbox provider falls back to its documented process environment variable
|
|
* (e.g. `DAYTONA_API_KEY`) when `config` omits the key, so the deployment
|
|
* delivers secrets as env vars and the managed document stays secret-free.
|
|
*/
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { environmentService } from "./environments.js";
|
|
import type { ManagedSandboxEnvironmentReconcileAction } from "./environments.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import type { ManagedInstanceConfig } from "./managed-config.js";
|
|
import type { ManagedResourceStockStatus } from "./managed-resource-drift.js";
|
|
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
|
import { resolvePluginSandboxProviderDriverByKey } from "./plugin-environment-driver.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
|
|
export interface ApplyManagedEnvironmentsOptions {
|
|
env?: Record<string, string | undefined>;
|
|
/**
|
|
* Resolves when the bundled-plugin startup pass (install + load; the
|
|
* `ensureBundledPlugins` chain in `createApp`) has finished. Awaited before
|
|
* any environment is ensured so an active row never precedes its provider
|
|
* driver. The promise never rejects (the chain catches internally).
|
|
*/
|
|
pluginsReady?: Promise<unknown>;
|
|
/**
|
|
* The app's plugin worker manager, consulted per entry so an environment is
|
|
* only ensured while its provider plugin has a live worker (a `ready`
|
|
* registry record whose activation failed cannot serve leases). Fails
|
|
* closed: without a worker manager every entry is treated as unavailable.
|
|
*
|
|
* `getWorker` is `PluginWorkerManager.getWorker` narrowed to the `ready`
|
|
* subscription used for post-archive recovery: a worker in crash-restart
|
|
* backoff re-emits `ready` when the manager respawns it, and the listener
|
|
* re-ensures the archived environment (see `applyManagedEnvironments`).
|
|
*/
|
|
workerManager?: Pick<PluginWorkerManager, "isRunning"> & {
|
|
getWorker(pluginId: string):
|
|
| {
|
|
on(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
off(event: "ready", listener: (payload: { pluginId: string }) => void): void;
|
|
}
|
|
| undefined;
|
|
};
|
|
/** Test seam: overrides the environment service built from `db`. */
|
|
environments?: Pick<
|
|
ReturnType<typeof environmentService>,
|
|
| "ensureManagedSandboxEnvironment"
|
|
| "archiveManagedSandboxEnvironment"
|
|
| "getById"
|
|
| "update"
|
|
| "findManagedSandboxEnvironment"
|
|
>;
|
|
/** Test seam: overrides the instance-settings service built from `db`. */
|
|
instanceSettings?: Pick<ReturnType<typeof instanceSettingsService>, "get" | "update">;
|
|
/** Test seam: overrides the sandbox-provider plugin driver lookup. */
|
|
resolveSandboxProviderDriver?: (input: {
|
|
db: Db;
|
|
driverKey: string;
|
|
}) => Promise<{ plugin: { id: string; pluginKey: string; status: string } } | null>;
|
|
}
|
|
|
|
export interface ManagedEnvironmentReconciliationOutcome {
|
|
environmentId: string;
|
|
name: string;
|
|
provider: string;
|
|
action: ManagedSandboxEnvironmentReconcileAction;
|
|
stockStatus: ManagedResourceStockStatus;
|
|
updateAvailable: boolean;
|
|
}
|
|
|
|
export interface ApplyManagedEnvironmentsResult {
|
|
ensured: number;
|
|
failed: number;
|
|
added: number;
|
|
updated: number;
|
|
unchanged: number;
|
|
skipped: number;
|
|
/** Managed reconciliation never removes rows in preserve-only mode. */
|
|
removed: number;
|
|
/** Preserve-only reconciliation never creates replacement backups. */
|
|
backedUp: number;
|
|
outcomes: ManagedEnvironmentReconciliationOutcome[];
|
|
}
|
|
|
|
/**
|
|
* Ensure every environment declared in the managed-config document. Returns
|
|
* null when there is nothing to do (self-hosted, or no `environments`
|
|
* section); otherwise the ensured/failed counts. Idempotent; safe to call on
|
|
* every boot.
|
|
*/
|
|
export async function applyManagedEnvironments(
|
|
db: Db,
|
|
managedConfig: ManagedInstanceConfig | null,
|
|
opts: ApplyManagedEnvironmentsOptions = {},
|
|
): Promise<ApplyManagedEnvironmentsResult | null> {
|
|
if (!managedConfig) return null;
|
|
|
|
const settings = opts.instanceSettings ?? instanceSettingsService(db);
|
|
const environments = opts.environments ?? environmentService(db);
|
|
const managedSandboxOnlyDeclared = managedConfig.features.enableManagedSandboxOnly === true;
|
|
|
|
// Mode-off default cleanup runs BEFORE any ensure, and regardless of
|
|
// whether the document still declares environments or the provider is
|
|
// available: a reconciliation-stamped default must not outlive the mode
|
|
// through the paths that skip per-entry reconciliation entirely — the
|
|
// declaration was removed, the provider is down, or the row was
|
|
// archived. Only a default that points at the managed row AND carries
|
|
// the `managedDefaultStamped` marker reverts; tenant-chosen defaults
|
|
// (no marker) are untouched.
|
|
if (!managedSandboxOnlyDeclared) {
|
|
try {
|
|
const managedRow = await environments.findManagedSandboxEnvironment(undefined, {
|
|
includeArchived: true,
|
|
});
|
|
if (
|
|
managedRow &&
|
|
managedRow.metadata?.managedDefaultStamped === true &&
|
|
((await settings.get()).defaultEnvironmentId ?? null) === managedRow.id
|
|
) {
|
|
await settings.update({ defaultEnvironmentId: null });
|
|
const { managedDefaultStamped: _cleared, ...remainingMetadata } = managedRow.metadata ?? {};
|
|
await environments.update(managedRow.id, { metadata: remainingMetadata });
|
|
logger.info(
|
|
{ environmentId: managedRow.id },
|
|
"instance default environment reverted from the managed sandbox environment (managed-sandbox-only is not declared)",
|
|
);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err },
|
|
"failed to revert the stamped managed sandbox instance default (degraded: stale default until the next boot)",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (managedConfig.environments.length === 0) return null;
|
|
|
|
// The forced-execution-mode bootstrap (`PAPERCLIP_EXECUTION_MODE=kubernetes`)
|
|
// and this one both own the single Paperclip-managed sandbox row
|
|
// (`environments_managed_sandbox_idx`). Configuring both is contradictory;
|
|
// refuse startup rather than let bootstrap ordering pick a winner.
|
|
const env = opts.env ?? process.env;
|
|
if (parseExecutionPolicyBootstrapEnv(env)) {
|
|
throw new Error(
|
|
`PAPERCLIP_EXECUTION_MODE and the PAPERCLIP_MANAGED_CONFIG "environments" section are mutually exclusive: both manage the single instance sandbox environment`,
|
|
);
|
|
}
|
|
|
|
// The heartbeat resumes queued runs right after this bootstrap step, and
|
|
// lease acquisition fails hard on a provider whose plugin is missing or not
|
|
// ready. Wait for the bundled-plugin startup pass to finish, then refuse to
|
|
// ensure (and in particular to re-activate) a row whose provider driver did
|
|
// not come up — a degraded boot without the row beats an active environment
|
|
// that fails every lease until the plugin recovers.
|
|
await opts.pluginsReady;
|
|
|
|
const resolveDriver = opts.resolveSandboxProviderDriver ?? resolvePluginSandboxProviderDriverByKey;
|
|
|
|
/**
|
|
* Point the instance default at the managed sandbox row when no
|
|
* deliberate choice stands in the way: an unset default, a default on
|
|
* the (hidden-under-this-mode) local row, or a dangling reference all
|
|
* move to the managed environment, so pickers and run selection agree
|
|
* that "the default" is the platform sandbox. A tenant-chosen custom
|
|
* environment (ssh, their own sandbox) is never overridden.
|
|
*
|
|
* Gated symmetrically on the document declaring
|
|
* `enableManagedSandboxOnly`:
|
|
*
|
|
* - Declared: stamp the managed row as the default, so pickers and run
|
|
* selection agree the platform sandbox is "the default". The stamp is
|
|
* recorded as `managedDefaultStamped` in the row's platform-owned
|
|
* metadata (which boot reconciliation preserves and the client write
|
|
* floor protects), so a stamped default is distinguishable from one
|
|
* the tenant chose deliberately.
|
|
* - Not declared: local execution is a legitimate default again, so a
|
|
* default THIS reconciliation stamped earlier (points at the managed
|
|
* row AND carries the stamp marker) reverts to unset — otherwise
|
|
* turning the mode off would keep routing default-following runs
|
|
* into the sandbox off a stale stamp. A default the tenant selected
|
|
* themselves — the managed row without the marker, or any custom
|
|
* environment — is untouched in both directions.
|
|
*
|
|
* Idempotent and best-effort — a failure degrades to the run-time
|
|
* policy, which refuses local under managed-sandbox-only regardless.
|
|
*/
|
|
const ensureManagedInstanceDefault = async (managedEnvironment: {
|
|
id: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}): Promise<void> => {
|
|
if (!managedSandboxOnlyDeclared) return;
|
|
try {
|
|
const current = (await settings.get()).defaultEnvironmentId ?? null;
|
|
if (current === managedEnvironment.id) return;
|
|
if (current !== null) {
|
|
const currentEnvironment = await environments.getById(current);
|
|
if (currentEnvironment && currentEnvironment.driver !== "local") return;
|
|
}
|
|
// Marker first: a crash between the two writes must never leave a
|
|
// stamped default that the revert path cannot attribute.
|
|
await environments.update(managedEnvironment.id, {
|
|
metadata: { ...(managedEnvironment.metadata ?? {}), managedDefaultStamped: true },
|
|
});
|
|
await settings.update({ defaultEnvironmentId: managedEnvironment.id });
|
|
logger.info(
|
|
{ environmentId: managedEnvironment.id, previousDefaultEnvironmentId: current },
|
|
"instance default environment set to the managed sandbox environment",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, environmentId: managedEnvironment.id },
|
|
"failed to reconcile the instance default environment with the managed sandbox environment",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Recovery path for a `ready` plugin whose worker was down at check time:
|
|
// that shape is usually a crash in restart-backoff, and the manager's
|
|
// respawn re-emits `ready` on the same handle. A one-shot listener re-runs
|
|
// the idempotent ensure so the recovered provider becomes selectable again
|
|
// without waiting for the next boot (this pass has already archived the
|
|
// row). The post-subscribe `isRunning` re-check closes the race where the
|
|
// worker recovered between the gate check and the subscription. No handle
|
|
// means no respawn is coming (activation never started a worker), so the
|
|
// degraded-until-next-boot posture stands.
|
|
const scheduleRecoveryReactivation = (
|
|
spec: ManagedInstanceConfig["environments"][number],
|
|
pluginId: string,
|
|
): void => {
|
|
const manager = opts.workerManager;
|
|
const handle = manager?.getWorker(pluginId);
|
|
if (!handle) return;
|
|
let reactivated = false;
|
|
const reactivate = (): void => {
|
|
if (reactivated) return;
|
|
reactivated = true;
|
|
handle.off("ready", reactivate);
|
|
void environments
|
|
.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
})
|
|
.then((result) => {
|
|
logger.info(
|
|
{
|
|
environmentId: result.environment.id,
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
action: result.action,
|
|
stockStatus: result.stockStatus,
|
|
updateAvailable: result.updateAvailable,
|
|
},
|
|
"managed sandbox environment reactivated after provider worker recovery",
|
|
);
|
|
})
|
|
.catch((err: unknown) => {
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to reactivate managed sandbox environment after provider worker recovery (degraded: environment unavailable until next boot)",
|
|
);
|
|
});
|
|
};
|
|
handle.on("ready", reactivate);
|
|
if (manager?.isRunning(pluginId)) reactivate();
|
|
};
|
|
|
|
let ensured = 0;
|
|
let failed = 0;
|
|
let added = 0;
|
|
let updated = 0;
|
|
let unchanged = 0;
|
|
let skipped = 0;
|
|
const outcomes: ManagedEnvironmentReconciliationOutcome[] = [];
|
|
for (const spec of managedConfig.environments) {
|
|
try {
|
|
const resolved = await resolveDriver({ db, driverKey: spec.provider });
|
|
// `ready` in the registry is necessary but not sufficient: activation
|
|
// can fail after install leaves the record `ready`, in which case no
|
|
// worker is running and the driver cannot serve leases.
|
|
const workerRunning =
|
|
resolved != null && (opts.workerManager?.isRunning(resolved.plugin.id) ?? false);
|
|
if (!resolved || resolved.plugin.status !== "ready" || !workerRunning) {
|
|
failed += 1;
|
|
// A row provisioned by an earlier boot must not stay active either —
|
|
// archive it so run scheduling stops selecting it. Best-effort: an
|
|
// archive failure is logged, and the entry is already counted failed.
|
|
const archived = await environments
|
|
.archiveManagedSandboxEnvironment({ provider: spec.provider })
|
|
.catch((archiveErr: unknown) => {
|
|
logger.error(
|
|
{ err: archiveErr, name: spec.name, provider: spec.provider },
|
|
"failed to archive the managed sandbox environment of an unavailable provider",
|
|
);
|
|
return null;
|
|
});
|
|
logger.error(
|
|
{
|
|
name: spec.name,
|
|
provider: spec.provider,
|
|
pluginKey: resolved?.plugin.pluginKey ?? null,
|
|
pluginStatus: resolved?.plugin.status ?? null,
|
|
workerRunning,
|
|
archivedEnvironmentId: archived?.id ?? null,
|
|
},
|
|
"managed sandbox environment provider plugin is not installed, ready, and running; skipping ensure and archiving any previously provisioned row (degraded: environment unavailable)",
|
|
);
|
|
// Only a `ready` record can recover without another boot: the worker
|
|
// manager restarts crashed workers, but nothing (re)installs a missing
|
|
// or non-ready plugin at runtime.
|
|
if (resolved && resolved.plugin.status === "ready") {
|
|
scheduleRecoveryReactivation(spec, resolved.plugin.id);
|
|
}
|
|
continue;
|
|
}
|
|
const reconciliation = await environments.ensureManagedSandboxEnvironment({
|
|
name: spec.name,
|
|
description: spec.description,
|
|
provider: spec.provider,
|
|
config: { ...spec.config },
|
|
stockVersion: managedConfig.catalogVersion,
|
|
});
|
|
if (reconciliation.action === "skipped") skipped += 1;
|
|
else {
|
|
ensured += 1;
|
|
if (reconciliation.action === "added") added += 1;
|
|
else if (reconciliation.action === "updated") updated += 1;
|
|
else unchanged += 1;
|
|
}
|
|
outcomes.push({
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
});
|
|
await ensureManagedInstanceDefault(reconciliation.environment);
|
|
const logContext = {
|
|
environmentId: reconciliation.environment.id,
|
|
name: reconciliation.environment.name,
|
|
provider: spec.provider,
|
|
action: reconciliation.action,
|
|
stockStatus: reconciliation.stockStatus,
|
|
updateAvailable: reconciliation.updateAvailable,
|
|
};
|
|
if (reconciliation.action === "skipped") {
|
|
logger.warn(
|
|
logContext,
|
|
"managed sandbox environment has operator modifications; preserving row and skipping stock update",
|
|
);
|
|
} else {
|
|
logger.info(logContext, "managed sandbox environment reconciled");
|
|
}
|
|
} catch (err) {
|
|
failed += 1;
|
|
logger.error(
|
|
{ err, name: spec.name, provider: spec.provider },
|
|
"failed to ensure managed sandbox environment; continuing boot (degraded: environment unavailable)",
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
ensured,
|
|
failed,
|
|
added,
|
|
updated,
|
|
unchanged,
|
|
skipped,
|
|
removed: 0,
|
|
backedUp: 0,
|
|
outcomes,
|
|
};
|
|
}
|