@ingram-tech/pulumi-ingram-cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @ingram-tech/pulumi-ingram-cloud
2
+
3
+ Pulumi dynamic resources for the [Ingram Cloud](https://cloud.ingram.tech) `/v1`
4
+ API. Declare your tenant's IC configuration — blueprints, MCP servers, channels,
5
+ webhooks, BYOK model keys — as Pulumi resources instead of imperative
6
+ `register-*.ts` / `ensure-blueprint.ts` scripts, so it lives in state and a change
7
+ is a `pulumi up`.
8
+
9
+ The library is published from the `pulumi/` folder of the
10
+ [`cloud.ingram.tech`](https://github.com/ingram-technologies/cloud.ingram.tech)
11
+ repo so the wrapper tracks the `/v1` surface it wraps. The API version it targets
12
+ is pinned per library version (`IC_API_VERSION`).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ bun add @ingram-tech/pulumi-ingram-cloud @pulumi/pulumi
18
+ ```
19
+
20
+ ## How the repos interact
21
+
22
+ ```
23
+ cloud.ingram.tech/pulumi/ THIS LIBRARY — knows nothing about any consumer
24
+ (Blueprint, McpServer, TelegramBot, WhatsAppConfig,
25
+ Webhook, ModelKey, Secret)
26
+
27
+ app repos (thornhill, …) declare their BLUEPRINTS — the `instructions` are
28
+ app-owned content (prompts), so they stay in the app
29
+ repo, sourced from the app's own spec module. The app's
30
+ Pulumi program imports this library + those specs.
31
+
32
+ infra repo (~/src/infra) declares tenant CHANNEL config (MCP, Telegram, WhatsApp,
33
+ webhook, model keys) alongside DNS/DB/Vercel, and can
34
+ read an app stack's outputs via pulumi.StackReference to
35
+ push IC-derived values (blueprint ids, webhook secret)
36
+ into Vercel env. No prompts ever live here.
37
+ ```
38
+
39
+ The hard rule: **prompts never leave the app repo.** This library carries no
40
+ content; it only carries the machinery.
41
+
42
+ ## Connection
43
+
44
+ Every resource takes `baseUrl` + `token` (the tenant-admin bearer). Resolve them
45
+ once with `connectionFromConfig()` and spread:
46
+
47
+ ```ts
48
+ import * as ic from "@ingram-tech/pulumi-ingram-cloud";
49
+
50
+ const conn = ic.connectionFromConfig(); // ingram-cloud:token (secret) / :baseUrl
51
+ // or env INGRAM_CLOUD_TOKEN|CLOUD_API_KEY
52
+ ```
53
+
54
+ Set the token as a stack secret:
55
+
56
+ ```bash
57
+ pulumi config set --secret ingram-cloud:token tha_live_…
58
+ ```
59
+
60
+ ## Resources
61
+
62
+ ### IcBlueprint (declare in the APP repo)
63
+
64
+ Create-or-adopt by name → publish a new immutable version **only when the content
65
+ changed** → roll it out. Mirrors the old `ensure-blueprint.ts` idempotency, so the
66
+ first `pulumi up` after switching from a script **adopts** the existing blueprint
67
+ (matched by name) rather than recreating it.
68
+
69
+ ```ts
70
+ import { BLUEPRINT_SPECS } from "../src/lib/cloud/blueprint-spec"; // app-owned
71
+
72
+ const curator = new ic.IcBlueprint("curator", {
73
+ ...conn,
74
+ name: BLUEPRINT_SPECS.curator.name,
75
+ instructions: BLUEPRINT_SPECS.curator.instructions,
76
+ model: BLUEPRINT_SPECS.curator.model,
77
+ autoMemory: BLUEPRINT_SPECS.curator.auto_memory,
78
+ // variables: [...], enabledHostedTools: [...], rolloutPercent: 100,
79
+ });
80
+
81
+ export const curatorBlueprintId = curator.blueprintId;
82
+ ```
83
+
84
+ > Attaching **existing** principals to a blueprint is a one-time fleet backfill —
85
+ > keep it as the app's migration script. New principals attach at birth in app code.
86
+ > Pulumi does not own per-user runtime state.
87
+
88
+ ### IcMcpServer, IcTelegramBot, IcWhatsAppConfig, IcWebhook, IcModelKey (declare in INFRA)
89
+
90
+ ```ts
91
+ new ic.IcMcpServer("thornhill-mcp", {
92
+ ...conn, serverName: "thornhill", url: `${APP_URL}/api/mcp`,
93
+ authKind: "static", secret: mcpSecret,
94
+ });
95
+
96
+ new ic.IcTelegramBot("thornhill-telegram", { ...conn, botToken });
97
+
98
+ const hook = new ic.IcWebhook("thornhill-events", {
99
+ ...conn, url: `${APP_URL}/api/ic/events`,
100
+ events: ["run.completed", "approval.required", /* … */],
101
+ });
102
+ export const webhookSigningSecret = hook.secret; // whsec_… (create-only secret output)
103
+
104
+ new ic.IcModelKey("anthropic", { ...conn, provider: "anthropic", apiKey });
105
+ ```
106
+
107
+ `IcWebhook.secret` is the `whsec_…` signing secret IC returns exactly once — it
108
+ becomes a secret Pulumi output, so the infra stack can flow it straight into Vercel
109
+ env with no copy-paste.
110
+
111
+ ### IcSecret (IC-hosted apps only)
112
+
113
+ `PUT /v1/apps/{appId}/env` — one resource per secret name. Only relevant if your
114
+ tenant runs an IC-hosted `app`.
115
+
116
+ ## Importing resources a script already created
117
+
118
+ Blueprints and webhooks implement `read`, so you can adopt pre-existing ones:
119
+
120
+ ```bash
121
+ pulumi import 'ingram-cloud:index:IcBlueprint' curator bp_123…
122
+ ```
123
+
124
+ (Blueprints also self-adopt by name on first `create`, so an explicit import is
125
+ usually unnecessary — a plain `pulumi up` will reconcile the live blueprint.)
126
+
127
+ ## Notes
128
+
129
+ - Built as CommonJS to match the Pulumi Node.js runtime and the infra stack's
130
+ `module: commonjs` tsconfig.
131
+ - `token` and every credential input are marked `additionalSecretOutputs`, so they
132
+ are encrypted in Pulumi state.
133
+ - CRUD runs inline in the Pulumi process over the global `fetch` (Node ≥18).
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Connection helper — resolves the IC `{ baseUrl, token }` pair once so a Pulumi
3
+ * program can spread it into every resource:
4
+ *
5
+ * const conn = connectionFromConfig();
6
+ * new ic.IcBlueprint("assistant", { ...conn, name: "…", instructions: "…" });
7
+ *
8
+ * Resolution order (first hit wins):
9
+ * baseUrl: `ingram-cloud:baseUrl` config → IC_BASE_URL → CLOUD_BASE_URL → prod
10
+ * token: `ingram-cloud:token` secret config → INGRAM_CLOUD_TOKEN → CLOUD_API_KEY
11
+ *
12
+ * The env fallbacks bridge the two naming conventions already in the fleet
13
+ * (thornhill's INGRAM_CLOUD_TOKEN, integrain's CLOUD_API_KEY). The returned
14
+ * token is always a secret Output so it stays encrypted in state.
15
+ */
16
+ import * as pulumi from "@pulumi/pulumi";
17
+ export declare const DEFAULT_BASE_URL = "https://api.cloud.ingram.tech";
18
+ export interface IcConnection {
19
+ baseUrl: pulumi.Input<string>;
20
+ token: pulumi.Input<string>;
21
+ }
22
+ export declare function connectionFromConfig(cfg?: pulumi.Config): IcConnection;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_BASE_URL = void 0;
37
+ exports.connectionFromConfig = connectionFromConfig;
38
+ /**
39
+ * Connection helper — resolves the IC `{ baseUrl, token }` pair once so a Pulumi
40
+ * program can spread it into every resource:
41
+ *
42
+ * const conn = connectionFromConfig();
43
+ * new ic.IcBlueprint("assistant", { ...conn, name: "…", instructions: "…" });
44
+ *
45
+ * Resolution order (first hit wins):
46
+ * baseUrl: `ingram-cloud:baseUrl` config → IC_BASE_URL → CLOUD_BASE_URL → prod
47
+ * token: `ingram-cloud:token` secret config → INGRAM_CLOUD_TOKEN → CLOUD_API_KEY
48
+ *
49
+ * The env fallbacks bridge the two naming conventions already in the fleet
50
+ * (thornhill's INGRAM_CLOUD_TOKEN, integrain's CLOUD_API_KEY). The returned
51
+ * token is always a secret Output so it stays encrypted in state.
52
+ */
53
+ const pulumi = __importStar(require("@pulumi/pulumi"));
54
+ exports.DEFAULT_BASE_URL = "https://api.cloud.ingram.tech";
55
+ function connectionFromConfig(cfg) {
56
+ const c = cfg ?? new pulumi.Config("ingram-cloud");
57
+ const baseUrl = c.get("baseUrl") ??
58
+ process.env.IC_BASE_URL ??
59
+ process.env.CLOUD_BASE_URL ??
60
+ exports.DEFAULT_BASE_URL;
61
+ const tokenFromCfg = c.getSecret("token");
62
+ const token = tokenFromCfg ??
63
+ pulumi.secret(process.env.INGRAM_CLOUD_TOKEN ?? process.env.CLOUD_API_KEY ?? "");
64
+ return { baseUrl, token };
65
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @ingram-tech/pulumi-ingram-cloud
3
+ *
4
+ * Pulumi dynamic resources for the Ingram Cloud /v1 API. See README.md for the
5
+ * repo-interaction model (library here, blueprints declared in app repos,
6
+ * channel/tenant config in the infra repo).
7
+ */
8
+ export * from "./resources";
9
+ export * from "./connection";
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * @ingram-tech/pulumi-ingram-cloud
19
+ *
20
+ * Pulumi dynamic resources for the Ingram Cloud /v1 API. See README.md for the
21
+ * repo-interaction model (library here, blueprints declared in app repos,
22
+ * channel/tenant config in the infra repo).
23
+ */
24
+ __exportStar(require("./resources"), exports);
25
+ __exportStar(require("./connection"), exports);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
3
+ *
4
+ * Replaces the per-app `scripts/register-*.ts` / `ensure-blueprint.ts` one-shots
5
+ * with declarative resources, so IC wiring is a `pulumi up` and lives in state
6
+ * instead of drifting in someone's shell history.
7
+ *
8
+ * Each resource talks to the IC REST API. `token` is the tenant-admin bearer;
9
+ * keep it (and any credential input) a stack secret — it's marked as an
10
+ * additional secret output so it's encrypted in state.
11
+ *
12
+ * Where each resource is meant to be declared:
13
+ * - **Blueprints** (`IcBlueprint`) — the `instructions` are app-owned *content*,
14
+ * so declare these in the APP repo's Pulumi program, sourced from the app's
15
+ * own spec module. Never copy prompts into the infra repo.
16
+ * - **Channels / tenant config** (`IcMcpServer`, `IcTelegramBot`,
17
+ * `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) — tenant-level wiring;
18
+ * declare in the infra repo alongside DNS/DB/Vercel.
19
+ * - **App env secrets** (`IcSecret`) — only relevant to IC-hosted `apps`.
20
+ *
21
+ * Not modelled here, on purpose:
22
+ * - **Per-end-user actions** — creating users, binding channels, pushing
23
+ * per-user connection tokens, minting user tokens — happen at app runtime.
24
+ * - **Attaching existing principals to a blueprint** — a one-time backfill that
25
+ * mutates the live fleet; keep it as the app's migration script. New
26
+ * principals attach at birth in app code.
27
+ * - **Slack app factory** — config-token rotation is stateful and app-driven.
28
+ */
29
+ import * as pulumi from "@pulumi/pulumi";
30
+ /**
31
+ * The /v1 API version this library targets. Bumping the IC API contract is a
32
+ * library version bump — consumers pin behaviour by pinning this package.
33
+ */
34
+ export declare const IC_API_VERSION = "2026-05-01";
35
+ export interface BlueprintVariable {
36
+ name: string;
37
+ default?: string | null;
38
+ description?: string | null;
39
+ required?: boolean;
40
+ }
41
+ export interface IcBlueprintArgs {
42
+ baseUrl: pulumi.Input<string>;
43
+ token: pulumi.Input<string>;
44
+ /** Tenant-unique blueprint name (the create/adopt key). */
45
+ name: pulumi.Input<string>;
46
+ /** The persona/instruction template (may contain `{{ variables }}`). */
47
+ instructions?: pulumi.Input<string>;
48
+ model?: pulumi.Input<string>;
49
+ enabledHostedTools?: pulumi.Input<string[]>;
50
+ autoMemory?: pulumi.Input<boolean>;
51
+ variables?: pulumi.Input<BlueprintVariable[]>;
52
+ /** Release note recorded when a new version is published. */
53
+ publishNote?: pulumi.Input<string>;
54
+ /** Rollout percentage for the published version (default 100). */
55
+ rolloutPercent?: pulumi.Input<number>;
56
+ }
57
+ export declare class IcBlueprint extends pulumi.dynamic.Resource {
58
+ /** The `bp_…` id (same as `.id`, exposed for convenience). */
59
+ readonly blueprintId: pulumi.Output<string>;
60
+ /** The version number currently rolled out. */
61
+ readonly activeVersion: pulumi.Output<number>;
62
+ constructor(name: string, args: IcBlueprintArgs, opts?: pulumi.CustomResourceOptions);
63
+ }
64
+ export interface IcMcpServerArgs {
65
+ baseUrl: pulumi.Input<string>;
66
+ token: pulumi.Input<string>;
67
+ /** Registry name — the `{name}` in `/v1/tenant/mcp/{name}`. */
68
+ serverName: pulumi.Input<string>;
69
+ url: pulumi.Input<string>;
70
+ authKind?: pulumi.Input<string>;
71
+ authProvider?: pulumi.Input<string>;
72
+ secret?: pulumi.Input<string>;
73
+ }
74
+ export declare class IcMcpServer extends pulumi.dynamic.Resource {
75
+ readonly toolsDiscovered: pulumi.Output<number>;
76
+ readonly discoveryError: pulumi.Output<string | undefined>;
77
+ constructor(name: string, args: IcMcpServerArgs, opts?: pulumi.CustomResourceOptions);
78
+ }
79
+ export interface IcTelegramBotArgs {
80
+ baseUrl: pulumi.Input<string>;
81
+ token: pulumi.Input<string>;
82
+ botToken: pulumi.Input<string>;
83
+ }
84
+ export declare class IcTelegramBot extends pulumi.dynamic.Resource {
85
+ readonly botUsername: pulumi.Output<string>;
86
+ readonly webhookUrl: pulumi.Output<string>;
87
+ constructor(name: string, args: IcTelegramBotArgs, opts?: pulumi.CustomResourceOptions);
88
+ }
89
+ export interface IcWhatsAppConfigArgs {
90
+ baseUrl: pulumi.Input<string>;
91
+ token: pulumi.Input<string>;
92
+ phoneNumberId: pulumi.Input<string>;
93
+ accessToken: pulumi.Input<string>;
94
+ appSecret?: pulumi.Input<string>;
95
+ verifyToken?: pulumi.Input<string>;
96
+ businessId?: pulumi.Input<string>;
97
+ }
98
+ export declare class IcWhatsAppConfig extends pulumi.dynamic.Resource {
99
+ readonly displayPhoneNumber: pulumi.Output<string>;
100
+ readonly webhookUrl: pulumi.Output<string>;
101
+ readonly verifyToken: pulumi.Output<string>;
102
+ constructor(name: string, args: IcWhatsAppConfigArgs, opts?: pulumi.CustomResourceOptions);
103
+ }
104
+ export interface IcWebhookArgs {
105
+ baseUrl: pulumi.Input<string>;
106
+ token: pulumi.Input<string>;
107
+ url: pulumi.Input<string>;
108
+ events: pulumi.Input<string[]>;
109
+ }
110
+ export declare class IcWebhook extends pulumi.dynamic.Resource {
111
+ /** The signing secret — present only when Pulumi created the webhook. */
112
+ readonly secret: pulumi.Output<string | undefined>;
113
+ constructor(name: string, args: IcWebhookArgs, opts?: pulumi.CustomResourceOptions);
114
+ }
115
+ export interface IcModelKeyArgs {
116
+ baseUrl: pulumi.Input<string>;
117
+ token: pulumi.Input<string>;
118
+ /** Provider id, e.g. `openai`, `anthropic`. */
119
+ provider: pulumi.Input<string>;
120
+ apiKey: pulumi.Input<string>;
121
+ /** Optional override base URL (OpenAI-compatible backends). */
122
+ providerBaseUrl?: pulumi.Input<string>;
123
+ }
124
+ export declare class IcModelKey extends pulumi.dynamic.Resource {
125
+ constructor(name: string, args: IcModelKeyArgs, opts?: pulumi.CustomResourceOptions);
126
+ }
127
+ export interface IcSecretArgs {
128
+ baseUrl: pulumi.Input<string>;
129
+ token: pulumi.Input<string>;
130
+ /** The IC-hosted app id (`app_…`) this secret belongs to. */
131
+ appId: pulumi.Input<string>;
132
+ name: pulumi.Input<string>;
133
+ value: pulumi.Input<string>;
134
+ }
135
+ export declare class IcSecret extends pulumi.dynamic.Resource {
136
+ constructor(name: string, args: IcSecretArgs, opts?: pulumi.CustomResourceOptions);
137
+ }
@@ -0,0 +1,478 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IcSecret = exports.IcModelKey = exports.IcWebhook = exports.IcWhatsAppConfig = exports.IcTelegramBot = exports.IcMcpServer = exports.IcBlueprint = exports.IC_API_VERSION = void 0;
37
+ /**
38
+ * Ingram Cloud /v1 tenant configuration as Pulumi dynamic resources.
39
+ *
40
+ * Replaces the per-app `scripts/register-*.ts` / `ensure-blueprint.ts` one-shots
41
+ * with declarative resources, so IC wiring is a `pulumi up` and lives in state
42
+ * instead of drifting in someone's shell history.
43
+ *
44
+ * Each resource talks to the IC REST API. `token` is the tenant-admin bearer;
45
+ * keep it (and any credential input) a stack secret — it's marked as an
46
+ * additional secret output so it's encrypted in state.
47
+ *
48
+ * Where each resource is meant to be declared:
49
+ * - **Blueprints** (`IcBlueprint`) — the `instructions` are app-owned *content*,
50
+ * so declare these in the APP repo's Pulumi program, sourced from the app's
51
+ * own spec module. Never copy prompts into the infra repo.
52
+ * - **Channels / tenant config** (`IcMcpServer`, `IcTelegramBot`,
53
+ * `IcWhatsAppConfig`, `IcWebhook`, `IcModelKey`) — tenant-level wiring;
54
+ * declare in the infra repo alongside DNS/DB/Vercel.
55
+ * - **App env secrets** (`IcSecret`) — only relevant to IC-hosted `apps`.
56
+ *
57
+ * Not modelled here, on purpose:
58
+ * - **Per-end-user actions** — creating users, binding channels, pushing
59
+ * per-user connection tokens, minting user tokens — happen at app runtime.
60
+ * - **Attaching existing principals to a blueprint** — a one-time backfill that
61
+ * mutates the live fleet; keep it as the app's migration script. New
62
+ * principals attach at birth in app code.
63
+ * - **Slack app factory** — config-token rotation is stateful and app-driven.
64
+ */
65
+ const pulumi = __importStar(require("@pulumi/pulumi"));
66
+ /**
67
+ * The /v1 API version this library targets. Bumping the IC API contract is a
68
+ * library version bump — consumers pin behaviour by pinning this package.
69
+ */
70
+ exports.IC_API_VERSION = "2026-05-01";
71
+ // Shared IC REST call. Module-scope so Pulumi's closure serialization captures
72
+ // it into each provider's methods; uses only the global `fetch` (Node ≥18).
73
+ async function icRequest(baseUrl, token, method, path, body, idempotencyKey) {
74
+ const headers = {
75
+ Authorization: `Bearer ${token}`,
76
+ "IC-Api-Version": exports.IC_API_VERSION,
77
+ };
78
+ if (body !== undefined)
79
+ headers["Content-Type"] = "application/json";
80
+ if (idempotencyKey)
81
+ headers["Idempotency-Key"] = idempotencyKey;
82
+ const res = await fetch(`${baseUrl}${path}`, {
83
+ method,
84
+ headers,
85
+ body: body !== undefined ? JSON.stringify(body) : undefined,
86
+ });
87
+ const text = await res.text();
88
+ if (!res.ok) {
89
+ throw new Error(`IC ${method} ${path} → ${res.status}: ${text.slice(0, 400)}`);
90
+ }
91
+ return text ? JSON.parse(text) : {};
92
+ }
93
+ // The draft body IC's create/patch expect (snake_case wire shape).
94
+ function blueprintBody(i) {
95
+ return {
96
+ name: i.name,
97
+ instructions: i.instructions ?? null,
98
+ model: i.model ?? null,
99
+ enabled_hosted_tools: i.enabledHostedTools ?? [],
100
+ auto_memory: i.autoMemory ?? null,
101
+ variables: i.variables ?? [],
102
+ };
103
+ }
104
+ // Stable signature of the *content* that warrants a new published version
105
+ // (name and rollout% deliberately excluded — a rename/restage is not new content).
106
+ function blueprintContentSig(i) {
107
+ return JSON.stringify({
108
+ instructions: i.instructions ?? null,
109
+ model: i.model ?? null,
110
+ tools: [...(i.enabledHostedTools ?? [])].sort(),
111
+ auto_memory: i.autoMemory ?? null,
112
+ variables: i.variables ?? [],
113
+ });
114
+ }
115
+ async function findBlueprintByName(baseUrl, token, name) {
116
+ const res = await icRequest(baseUrl, token, "GET", "/v1/blueprints");
117
+ return (res.data ?? []).find((b) => b.name === name) ?? null;
118
+ }
119
+ // Signature of the snapshot currently published as the active version, or null
120
+ // if nothing is published yet.
121
+ async function activeSnapshotSig(baseUrl, token, id, activeVersion) {
122
+ if (!activeVersion)
123
+ return null;
124
+ const res = await icRequest(baseUrl, token, "GET", `/v1/blueprints/${id}/versions`);
125
+ const v = (res.data ?? []).find((x) => x.version === activeVersion);
126
+ if (!v)
127
+ return null;
128
+ const s = v.snapshot ?? {};
129
+ return blueprintContentSig({
130
+ instructions: s.instructions,
131
+ model: s.model,
132
+ enabledHostedTools: s.enabled_hosted_tools,
133
+ autoMemory: s.auto_memory,
134
+ variables: s.variables,
135
+ });
136
+ }
137
+ // Ensure the live blueprint matches desired: adopt-or-create, publish iff the
138
+ // content drifted from the active version, then roll out at `percent`.
139
+ async function reconcileBlueprint(i, knownId, priorSig) {
140
+ let id;
141
+ if (!knownId) {
142
+ const existing = await findBlueprintByName(i.baseUrl, i.token, i.name);
143
+ if (existing) {
144
+ id = existing.id;
145
+ await icRequest(i.baseUrl, i.token, "PATCH", `/v1/blueprints/${id}`, blueprintBody(i));
146
+ }
147
+ else {
148
+ // No idempotency key: Pulumi calls create once and tracks the id in
149
+ // state, and the adopt-by-name branch above covers re-runs and the
150
+ // script→Pulumi migration. Keying on the name would replay a stale
151
+ // (possibly archived) create after a destroy+recreate within the 24h
152
+ // idempotency window.
153
+ const bp = await icRequest(i.baseUrl, i.token, "POST", "/v1/blueprints", blueprintBody(i));
154
+ id = bp.id;
155
+ }
156
+ }
157
+ else {
158
+ // Known id (update path): push the draft to the desired shape.
159
+ id = knownId;
160
+ await icRequest(i.baseUrl, i.token, "PATCH", `/v1/blueprints/${id}`, blueprintBody(i));
161
+ }
162
+ const bp = await icRequest(i.baseUrl, i.token, "GET", `/v1/blueprints/${id}`);
163
+ const desiredSig = blueprintContentSig(i);
164
+ // On the create path we don't know the prior sig — compare to what's actually
165
+ // published so adopting a script-made blueprint doesn't churn a needless version.
166
+ const liveSig = priorSig ?? (await activeSnapshotSig(i.baseUrl, i.token, id, bp.active_version));
167
+ let version = bp.active_version ?? 0;
168
+ if (!bp.active_version || liveSig !== desiredSig) {
169
+ // Publishing snapshots the draft as the next immutable version and returns
170
+ // its number. Only the *first* publish auto-activates; later ones go live
171
+ // only via the rollout below — so take the version from the publish reply,
172
+ // not from active_version (which hasn't advanced yet).
173
+ const pub = await icRequest(i.baseUrl, i.token, "POST", `/v1/blueprints/${id}/versions`, {
174
+ note: i.publishNote ?? null,
175
+ });
176
+ version = pub.version;
177
+ }
178
+ // Always assert the rollout: it activates a freshly published version and is
179
+ // an idempotent no-op when `version` is already the active one at `percent`.
180
+ const percent = i.rolloutPercent ?? 100;
181
+ await icRequest(i.baseUrl, i.token, "POST", `/v1/blueprints/${id}/rollout`, { version, percent });
182
+ return {
183
+ ...i,
184
+ blueprintId: id,
185
+ contentSig: desiredSig,
186
+ activeVersion: version,
187
+ rolloutPercent: percent,
188
+ };
189
+ }
190
+ const blueprintProvider = {
191
+ async create(i) {
192
+ const outs = await reconcileBlueprint(i);
193
+ return { id: outs.blueprintId, outs };
194
+ },
195
+ async update(id, olds, news) {
196
+ const outs = await reconcileBlueprint(news, id, olds.contentSig);
197
+ return { outs };
198
+ },
199
+ async delete(id, props) {
200
+ try {
201
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/blueprints/${id}`);
202
+ }
203
+ catch (e) {
204
+ throw new Error(`Blueprint ${id} could not be archived (it likely still has users attached). ` +
205
+ `Detach its users first, or set { retainOnDelete: true } on the resource. ` +
206
+ `Original error: ${e}`);
207
+ }
208
+ },
209
+ async diff(_id, olds, news) {
210
+ const contentChanged = blueprintContentSig(olds) !== blueprintContentSig(news);
211
+ const nameChanged = olds.name !== news.name;
212
+ const rolloutChanged = (olds.rolloutPercent ?? 100) !== (news.rolloutPercent ?? 100);
213
+ return { changes: contentChanged || nameChanged || rolloutChanged };
214
+ },
215
+ // Adopt a blueprint a script already created (`pulumi import <id>`).
216
+ async read(id, props) {
217
+ const bp = await icRequest(props.baseUrl, props.token, "GET", `/v1/blueprints/${id}`);
218
+ if (!bp || !bp.id)
219
+ return { id, props: props };
220
+ const d = bp.draft ?? {};
221
+ return {
222
+ id,
223
+ props: {
224
+ ...props,
225
+ name: bp.name,
226
+ instructions: d.instructions ?? undefined,
227
+ model: d.model ?? undefined,
228
+ enabledHostedTools: d.enabled_hosted_tools ?? [],
229
+ autoMemory: d.auto_memory ?? undefined,
230
+ variables: d.variables ?? [],
231
+ },
232
+ };
233
+ },
234
+ };
235
+ class IcBlueprint extends pulumi.dynamic.Resource {
236
+ constructor(name, args, opts) {
237
+ super(blueprintProvider, name, {
238
+ instructions: undefined,
239
+ model: undefined,
240
+ enabledHostedTools: undefined,
241
+ autoMemory: undefined,
242
+ variables: undefined,
243
+ publishNote: undefined,
244
+ rolloutPercent: undefined,
245
+ blueprintId: undefined,
246
+ activeVersion: undefined,
247
+ contentSig: undefined,
248
+ ...args,
249
+ }, { ...opts, additionalSecretOutputs: ["token"] });
250
+ }
251
+ }
252
+ exports.IcBlueprint = IcBlueprint;
253
+ function mcpBody(i) {
254
+ const auth = { kind: i.authKind };
255
+ if (i.authProvider)
256
+ auth.provider = i.authProvider;
257
+ if (i.secret)
258
+ auth.secret = i.secret;
259
+ return { url: i.url, auth };
260
+ }
261
+ async function mcpPut(i) {
262
+ const res = await icRequest(i.baseUrl, i.token, "PUT", `/v1/tenant/mcp/${encodeURIComponent(i.serverName)}`, mcpBody(i));
263
+ return {
264
+ ...i,
265
+ toolsDiscovered: res.tools_discovered ?? 0,
266
+ discoveryError: res.discovery_error ?? null,
267
+ };
268
+ }
269
+ const mcpProvider = {
270
+ async create(i) {
271
+ return { id: i.serverName, outs: await mcpPut(i) };
272
+ },
273
+ async update(_id, _olds, news) {
274
+ return { outs: await mcpPut(news) };
275
+ },
276
+ async delete(id, props) {
277
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/tenant/mcp/${encodeURIComponent(id)}`);
278
+ },
279
+ async diff(_id, olds, news) {
280
+ const replaces = olds.serverName !== news.serverName ? ["serverName"] : [];
281
+ const changed = replaces.length > 0 ||
282
+ ["url", "authKind", "authProvider", "secret", "token", "baseUrl"].some((k) => olds[k] !== news[k]);
283
+ return { changes: changed, replaces, deleteBeforeReplace: true };
284
+ },
285
+ };
286
+ class IcMcpServer extends pulumi.dynamic.Resource {
287
+ constructor(name, args, opts) {
288
+ super(mcpProvider, name, {
289
+ authKind: "static",
290
+ authProvider: undefined,
291
+ secret: undefined,
292
+ toolsDiscovered: undefined,
293
+ discoveryError: undefined,
294
+ ...args,
295
+ }, { ...opts, additionalSecretOutputs: ["token", "secret"] });
296
+ }
297
+ }
298
+ exports.IcMcpServer = IcMcpServer;
299
+ async function telegramPut(i) {
300
+ const res = await icRequest(i.baseUrl, i.token, "PUT", "/v1/tenant/telegram", {
301
+ bot_token: i.botToken,
302
+ });
303
+ return { ...i, botUsername: res.bot_username, botId: res.bot_id, webhookUrl: res.webhook_url };
304
+ }
305
+ const telegramProvider = {
306
+ async create(i) {
307
+ return { id: "telegram", outs: await telegramPut(i) };
308
+ },
309
+ async update(_id, _olds, news) {
310
+ return { outs: await telegramPut(news) };
311
+ },
312
+ async delete(_id, props) {
313
+ await icRequest(props.baseUrl, props.token, "DELETE", "/v1/tenant/telegram");
314
+ },
315
+ async diff(_id, olds, news) {
316
+ return {
317
+ changes: ["botToken", "token", "baseUrl"].some((k) => olds[k] !== news[k]),
318
+ };
319
+ },
320
+ };
321
+ class IcTelegramBot extends pulumi.dynamic.Resource {
322
+ constructor(name, args, opts) {
323
+ super(telegramProvider, name, { botUsername: undefined, botId: undefined, webhookUrl: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token", "botToken"] });
324
+ }
325
+ }
326
+ exports.IcTelegramBot = IcTelegramBot;
327
+ async function whatsappPut(i) {
328
+ const res = await icRequest(i.baseUrl, i.token, "PUT", "/v1/tenant/whatsapp", {
329
+ phone_number_id: i.phoneNumberId,
330
+ access_token: i.accessToken,
331
+ app_secret: i.appSecret,
332
+ verify_token: i.verifyToken,
333
+ business_id: i.businessId,
334
+ });
335
+ return {
336
+ ...i,
337
+ // IC generates verify_token if we didn't supply one — capture it so it's
338
+ // stable in state (and so the operator can paste it into Meta).
339
+ verifyToken: res.verify_token ?? i.verifyToken,
340
+ displayPhoneNumber: res.display_phone_number,
341
+ webhookUrl: res.webhook_url,
342
+ };
343
+ }
344
+ const whatsappProvider = {
345
+ async create(i) {
346
+ return { id: "whatsapp", outs: await whatsappPut(i) };
347
+ },
348
+ async update(_id, _olds, news) {
349
+ return { outs: await whatsappPut(news) };
350
+ },
351
+ async delete(_id, props) {
352
+ await icRequest(props.baseUrl, props.token, "DELETE", "/v1/tenant/whatsapp");
353
+ },
354
+ async diff(_id, olds, news) {
355
+ return {
356
+ changes: ["phoneNumberId", "accessToken", "appSecret", "businessId", "token", "baseUrl"].some((k) => olds[k] !== news[k]),
357
+ };
358
+ },
359
+ };
360
+ class IcWhatsAppConfig extends pulumi.dynamic.Resource {
361
+ constructor(name, args, opts) {
362
+ super(whatsappProvider, name, {
363
+ appSecret: undefined,
364
+ verifyToken: undefined,
365
+ businessId: undefined,
366
+ displayPhoneNumber: undefined,
367
+ webhookUrl: undefined,
368
+ ...args,
369
+ }, { ...opts, additionalSecretOutputs: ["token", "accessToken", "appSecret", "verifyToken"] });
370
+ }
371
+ }
372
+ exports.IcWhatsAppConfig = IcWhatsAppConfig;
373
+ const webhookProvider = {
374
+ async create(i) {
375
+ const res = await icRequest(i.baseUrl, i.token, "POST", "/v1/tenant/webhooks", {
376
+ url: i.url,
377
+ events: i.events,
378
+ active: true,
379
+ });
380
+ return { id: res.id, outs: { ...i, secret: res.secret } };
381
+ },
382
+ async update(id, _olds, news) {
383
+ await icRequest(news.baseUrl, news.token, "PATCH", `/v1/tenant/webhooks/${id}`, {
384
+ events: news.events,
385
+ });
386
+ return { outs: { ...news } };
387
+ },
388
+ async delete(id, props) {
389
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/tenant/webhooks/${id}`);
390
+ },
391
+ async diff(_id, olds, news) {
392
+ const replaces = olds.url !== news.url ? ["url"] : [];
393
+ const eventsChanged = JSON.stringify([...(olds.events ?? [])].sort()) !==
394
+ JSON.stringify([...(news.events ?? [])].sort());
395
+ return { changes: replaces.length > 0 || eventsChanged, replaces };
396
+ },
397
+ // Enables `pulumi import` of a webhook a script already created (id is the
398
+ // import id). The signing secret is intentionally not recoverable.
399
+ async read(id, props) {
400
+ const list = await icRequest(props.baseUrl, props.token, "GET", "/v1/tenant/webhooks");
401
+ const hit = (list.data ?? []).find((w) => w.id === id);
402
+ if (!hit)
403
+ return { id, props: props };
404
+ return { id, props: { ...props, url: hit.url, events: hit.events } };
405
+ },
406
+ };
407
+ class IcWebhook extends pulumi.dynamic.Resource {
408
+ constructor(name, args, opts) {
409
+ super(webhookProvider, name, { secret: undefined, ...args }, {
410
+ ...opts,
411
+ additionalSecretOutputs: ["token", "secret"],
412
+ });
413
+ }
414
+ }
415
+ exports.IcWebhook = IcWebhook;
416
+ async function modelKeyPut(i) {
417
+ await icRequest(i.baseUrl, i.token, "PUT", `/v1/tenant/model_keys/${encodeURIComponent(i.provider)}`, { api_key: i.apiKey, base_url: i.providerBaseUrl ?? null });
418
+ return { ...i };
419
+ }
420
+ const modelKeyProvider = {
421
+ async create(i) {
422
+ return { id: i.provider, outs: await modelKeyPut(i) };
423
+ },
424
+ async update(_id, _olds, news) {
425
+ return { outs: await modelKeyPut(news) };
426
+ },
427
+ async delete(id, props) {
428
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/tenant/model_keys/${encodeURIComponent(id)}`);
429
+ },
430
+ async diff(_id, olds, news) {
431
+ const replaces = olds.provider !== news.provider ? ["provider"] : [];
432
+ const changed = replaces.length > 0 ||
433
+ ["apiKey", "providerBaseUrl", "token", "baseUrl"].some((k) => olds[k] !== news[k]);
434
+ return { changes: changed, replaces, deleteBeforeReplace: true };
435
+ },
436
+ };
437
+ class IcModelKey extends pulumi.dynamic.Resource {
438
+ constructor(name, args, opts) {
439
+ super(modelKeyProvider, name, { providerBaseUrl: undefined, ...args }, { ...opts, additionalSecretOutputs: ["token", "apiKey"] });
440
+ }
441
+ }
442
+ exports.IcModelKey = IcModelKey;
443
+ async function secretPut(i) {
444
+ await icRequest(i.baseUrl, i.token, "PUT", `/v1/apps/${encodeURIComponent(i.appId)}/env`, {
445
+ values: { [i.name]: i.value },
446
+ });
447
+ return { ...i };
448
+ }
449
+ const secretProvider = {
450
+ async create(i) {
451
+ return { id: `${i.appId}:${i.name}`, outs: await secretPut(i) };
452
+ },
453
+ async update(_id, _olds, news) {
454
+ return { outs: await secretPut(news) };
455
+ },
456
+ async delete(_id, props) {
457
+ await icRequest(props.baseUrl, props.token, "DELETE", `/v1/apps/${encodeURIComponent(props.appId)}/env/${encodeURIComponent(props.name)}`);
458
+ },
459
+ async diff(_id, olds, news) {
460
+ const replaces = [];
461
+ if (olds.appId !== news.appId)
462
+ replaces.push("appId");
463
+ if (olds.name !== news.name)
464
+ replaces.push("name");
465
+ const changed = replaces.length > 0 ||
466
+ ["value", "token", "baseUrl"].some((k) => olds[k] !== news[k]);
467
+ return { changes: changed, replaces, deleteBeforeReplace: true };
468
+ },
469
+ };
470
+ class IcSecret extends pulumi.dynamic.Resource {
471
+ constructor(name, args, opts) {
472
+ super(secretProvider, name, { ...args }, {
473
+ ...opts,
474
+ additionalSecretOutputs: ["token", "value"],
475
+ });
476
+ }
477
+ }
478
+ exports.IcSecret = IcSecret;
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@ingram-tech/pulumi-ingram-cloud",
3
+ "version": "0.1.0",
4
+ "description": "Pulumi dynamic resources for the Ingram Cloud /v1 API — blueprints, MCP servers, channels, webhooks, model keys.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ingram-technologies/cloud.ingram.tech.git",
9
+ "directory": "pulumi"
10
+ },
11
+ "homepage": "https://github.com/ingram-technologies/cloud.ingram.tech/tree/main/pulumi#readme",
12
+ "keywords": ["pulumi", "ingram-cloud", "ingram"],
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "files": ["dist"],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "type-check": "tsc -p tsconfig.json --noEmit",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "peerDependencies": {
25
+ "@pulumi/pulumi": "^3.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@pulumi/pulumi": "^3.0.0",
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }