Skip to content

Commit 7ebe9f3

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci): add Resource Manager integration
1 parent 3fa59e7 commit 7ebe9f3

64 files changed

Lines changed: 3691 additions & 8 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
482482
notion: NotionIcon,
483483
notion_v2: NotionIcon,
484484
obsidian: ObsidianIcon,
485+
oci_resource_manager: NetSuiteIcon,
485486
okta: OktaIcon,
486487
onedrive: MicrosoftOneDriveIcon,
487488
onepassword: OnePasswordIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
"notion",
186186
"notion-service-account",
187187
"obsidian",
188+
"oci_resource_manager",
188189
"okta",
189190
"onedrive",
190191
"onepassword",

apps/docs/content/docs/integrations/oci_resource_manager.mdx

Lines changed: 956 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oci_resource_manager.ts

Lines changed: 371 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { OciResourceManagerBlock, OciResourceManagerBlockMeta } from '@/blocks/blocks/oci_resource_manager'
12
import { A2ABlock } from '@/blocks/blocks/a2a'
23
import { AffinityBlock, AffinityBlockMeta } from '@/blocks/blocks/affinity'
34
import { AgentBlock } from '@/blocks/blocks/agent'
@@ -590,6 +591,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
590591
mysql: MySQLBlock,
591592
neo4j: Neo4jBlock,
592593
netsuite: NetSuiteBlock,
594+
oci_resource_manager: OciResourceManagerBlock,
593595
new_relic: NewRelicBlock,
594596
note: NoteBlock,
595597
notion: NotionBlock,
@@ -915,6 +917,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
915917
mysql: MySQLBlockMeta,
916918
neo4j: Neo4jBlockMeta,
917919
netsuite: NetSuiteBlockMeta,
920+
oci_resource_manager: OciResourceManagerBlockMeta,
918921
neverbounce: NeverBounceBlockMeta,
919922
new_relic: NewRelicBlockMeta,
920923
notion: NotionBlockMeta,

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DOCS_MANIFEST: readonly string[] = [
243243
'integrations/notion-service-account.mdx',
244244
'integrations/notion.mdx',
245245
'integrations/obsidian.mdx',
246+
'integrations/oci_resource_manager.mdx',
246247
'integrations/okta.mdx',
247248
'integrations/onedrive.mdx',
248249
'integrations/onepassword.mdx',

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
465465
notion: NotionIcon,
466466
notion_v2: NotionIcon,
467467
obsidian: ObsidianIcon,
468+
oci_resource_manager: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
const mocks = vi.hoisted(() => ({ create: vi.fn(), prepare: vi.fn(), request: vi.fn() }))
4+
vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.create }))
5+
import { OCI_RESOURCE_MANAGER_FILE_LIMIT, OCI_RESOURCE_MANAGER_POLICY, prepareOciResourceManagerClient, requestResourceManager, resourcePath } from '@/lib/internal/oci-resource-manager/client'
6+
7+
beforeEach(() => {
8+
vi.resetAllMocks()
9+
mocks.create.mockResolvedValue({ prepareStaticEndpoint: mocks.prepare, request: mocks.request })
10+
mocks.prepare.mockResolvedValue({ origin: 'https://resourcemanager.us-ashburn-1.oraclecloud.com' })
11+
mocks.request.mockResolvedValue({ status: 200, headers: {}, body: new Uint8Array() })
12+
})
13+
async function prepared() {
14+
return prepareOciResourceManagerClient({ credentialId: 'resolved', workspaceId: 'workspace' })
15+
}
16+
describe('Resource Manager foundation adapter', () => {
17+
it('uses the existing credential client and a static Resource Manager endpoint policy', async () => {
18+
await prepared()
19+
expect(mocks.create).toHaveBeenCalledWith({ credentialId: 'resolved', workspaceId: 'workspace', serviceId: 'oci-resource-manager' })
20+
expect(mocks.prepare).toHaveBeenCalledWith(OCI_RESOURCE_MANAGER_POLICY)
21+
expect(resourcePath('jobs', 'a/b+c')).toBe('/20180917/jobs/a%2Fb%2Bc')
22+
})
23+
it('preserves repeated query values and work-request/pagination headers', async () => {
24+
await requestResourceManager(await prepared(), { method: 'GET', path: '/20180917/jobs', query: [['type', 'a'], ['type', 'b']] })
25+
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ queryPairs: [['type', 'a'], ['type', 'b']], responseHeaders: ['opc-next-page', 'opc-work-request-id'], retry: { kind: 'safe', maxAttempts: 2 } }))
26+
})
27+
it('sends bodyless read POSTs as empty bytes without assigning safe POST retries', async () => {
28+
await requestResourceManager(await prepared(), { method: 'POST', path: '/20180917/stacks/s/actions/listResourceDriftDetails' })
29+
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ body: new Uint8Array(), contentType: 'application/json', retry: undefined }))
30+
})
31+
it('passes a stable explicit token only through the foundation retry policy', async () => {
32+
await requestResourceManager(await prepared(), { method: 'POST', path: '/20180917/jobs', body: { stackId: 'stack' }, retryToken: 'same-logical-request' })
33+
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ retry: { kind: 'tokenized', maxAttempts: 2, retryToken: 'same-logical-request' } }))
34+
expect(new TextDecoder().decode(mocks.request.mock.calls[0][0].body)).toBe('{"stackId":"stack"}')
35+
})
36+
it('accepts empty cancellation responses and caps binary reads without JSON parsing', async () => {
37+
mocks.request.mockResolvedValueOnce({ status: 202, headers: {}, body: new Uint8Array() })
38+
await requestResourceManager(await prepared(), { method: 'DELETE', path: resourcePath('jobs', 'job'), expectedStatus: 202, ifMatch: 'etag' })
39+
expect(mocks.request).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'DELETE', headers: { 'if-match': 'etag' } }))
40+
expect(mocks.request.mock.calls[0][0]).not.toHaveProperty('body')
41+
await requestResourceManager(await prepared(), { method: 'GET', path: resourcePath('jobs', 'job', '/tfState'), binary: true })
42+
expect(mocks.request).toHaveBeenLastCalledWith(expect.objectContaining({ maxResponseBytes: OCI_RESOURCE_MANAGER_FILE_LIMIT }))
43+
})
44+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { z } from 'zod'
2+
import { createOciClient, type OciAuthenticatedResponse, type OciClient } from '@/lib/internal/oci/client.server'
3+
import { createOciStaticEndpointPolicy, type OciPreparedEndpoint } from '@/lib/internal/oci/endpoints'
4+
5+
export const OCI_RESOURCE_MANAGER_SERVICE_ID = 'oci-resource-manager'
6+
export const OCI_RESOURCE_MANAGER_POLICY = createOciStaticEndpointPolicy({ serviceId: OCI_RESOURCE_MANAGER_SERVICE_ID, serviceName: 'resourcemanager', hostnameTemplate: 'regional' })
7+
export const OCI_RESOURCE_MANAGER_JSON_LIMIT = 6_000_000
8+
export const OCI_RESOURCE_MANAGER_FILE_LIMIT = 100 * 1024 * 1024
9+
export const OCI_RESOURCE_MANAGER_ZIP_LIMIT = 11_000_000
10+
export interface PreparedOciResourceManagerClient { client: OciClient; endpoint: OciPreparedEndpoint }
11+
export class OciResourceManagerError extends Error {
12+
constructor(message: string, readonly status = 400) { super(message) }
13+
}
14+
export async function prepareOciResourceManagerClient(binding: { credentialId: string; workspaceId: string; region?: string }): Promise<PreparedOciResourceManagerClient> {
15+
const client = await createOciClient({ ...binding, serviceId: OCI_RESOURCE_MANAGER_SERVICE_ID })
16+
return { client, endpoint: await client.prepareStaticEndpoint(OCI_RESOURCE_MANAGER_POLICY) }
17+
}
18+
export function resourcePath(kind: 'stacks' | 'jobs' | 'workRequests', id?: string, suffix = '') {
19+
return `/20180917/${kind}${id === undefined ? '' : `/${encodeURIComponent(id)}`}${suffix}`
20+
}
21+
export interface ResourceManagerRequest {
22+
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
23+
path: string
24+
body?: unknown
25+
query?: readonly (readonly [string, string])[]
26+
ifMatch?: string
27+
retryToken?: string
28+
binary?: boolean
29+
expectedStatus?: number
30+
}
31+
export async function requestResourceManager(prepared: PreparedOciResourceManagerClient, request: ResourceManagerRequest, signal?: AbortSignal) {
32+
const common = {
33+
endpoint: prepared.endpoint, encodedPath: request.path, queryPairs: request.query,
34+
headers: request.ifMatch ? { 'if-match': request.ifMatch } : undefined,
35+
responseHeaders: ['opc-next-page', 'opc-work-request-id'],
36+
timeoutMs: 60_000, maxResponseBytes: request.binary ? OCI_RESOURCE_MANAGER_FILE_LIMIT : OCI_RESOURCE_MANAGER_JSON_LIMIT, signal,
37+
}
38+
const retry = request.retryToken ? { kind: 'tokenized' as const, maxAttempts: 2, retryToken: request.retryToken } : undefined
39+
const response = await prepared.client.request(request.method === 'POST' || request.method === 'PUT'
40+
? { ...common, method: request.method, contentType: 'application/json', body: request.body === undefined ? new Uint8Array() : new TextEncoder().encode(JSON.stringify(request.body)), retry }
41+
: request.method === 'GET'
42+
? { ...common, method: 'GET', retry: { kind: 'safe', maxAttempts: 2 } }
43+
: { ...common, method: 'DELETE' })
44+
if (response.status !== (request.expectedStatus ?? 200)) throw new OciResourceManagerError('Unexpected OCI Resource Manager response status', 502)
45+
return response
46+
}
47+
export function responseJson(response: OciAuthenticatedResponse): unknown {
48+
try { return JSON.parse(new TextDecoder().decode(response.body)) } catch { throw new OciResourceManagerError('Invalid OCI Resource Manager JSON response', 502) }
49+
}
50+
const string = z.string().nullish()
51+
const stringMap = z.record(z.string(), z.string()).nullish()
52+
const common = { id: z.string().min(1), compartmentId: string, displayName: string, lifecycleState: string, timeCreated: string }
53+
export const stackResponseSchema = z.object({ ...common, terraformVersion: string, stackDriftStatus: string, timeDriftLastChecked: string, variables: stringMap, configSource: z.object({ configSourceType: string, workingDirectory: string, compartmentId: string, servicesToDiscover: z.array(z.string()).nullish(), configurationSourceProviderId: string, repositoryUrl: string, branchName: string, region: string, namespace: string, bucketName: string, projectId: string, repositoryId: string, workspaceId: string }).nullish() })
54+
export const jobResponseSchema = z.object({ ...common, stackId: z.string().min(1), operation: string, timeFinished: string, failureDetails: z.object({ code: string }).nullish(), variables: stringMap, jobOperationDetails: z.object({ operation: string, executionPlanStrategy: string, executionPlanJobId: string, executionPlanRollbackStrategy: string, executionPlanRollbackJobId: string, targetRollbackJobId: string }).nullish(), cancellationDetails: z.object({ isForced: z.boolean().nullish() }).nullish(), configSource: z.object({ configSourceRecordType: string, commitId: string }).nullish() })
55+
export const workResponseSchema = z.object({ id: z.string().min(1), compartmentId: string, operationType: string, status: string, percentComplete: z.number().nullish(), timeAccepted: string, timeStarted: string, timeFinished: string, resources: z.array(z.object({ actionType: string, entityType: string, identifier: string, entityUri: string })).nullish() })
56+
export const logResponseSchema = z.object({ type: string, level: string, timestamp: string, message: string, code: string })
57+
export const outputResponseSchema = z.object({ outputName: string, outputType: string, isSensitive: z.boolean().nullish(), outputValue: string })
58+
export const associatedResponseSchema = z.object({ resourceId: string, resourceName: string, resourceType: string, resourceAddress: string, region: string, timeCreated: string, attributes: stringMap })
59+
export const driftResponseSchema = z.object({ stackId: string, compartmentId: string, resourceId: string, resourceName: string, resourceType: string, resourceDriftStatus: string, timeDriftChecked: string, actualProperties: stringMap, expectedProperties: stringMap })
60+
export const providerResponseSchema = z.object({ ...common, configSourceProviderType: string })
61+
export const templateResponseSchema = z.object({ ...common, isFreeTier: z.boolean().nullish() })
62+
export const versionResponseSchema = z.object({ name: z.string(), isDefault: z.boolean().nullish() })
63+
export const discoveryResponseSchema = z.object({ name: z.string(), discoveryScope: string })
64+
export function parseResponse<T>(schema: z.ZodType<T>, value: unknown): T {
65+
const result = schema.safeParse(value)
66+
if (!result.success) throw new OciResourceManagerError('Unexpected OCI Resource Manager response shape', 502)
67+
return result.data
68+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
const mocks = vi.hoisted(() => ({ authorize: vi.fn(), prepare: vi.fn(), execute: vi.fn() }))
4+
vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mocks.authorize }))
5+
vi.mock('@/lib/internal/oci-resource-manager/client', () => ({ prepareOciResourceManagerClient: mocks.prepare, OciResourceManagerError: class extends Error {} }))
6+
vi.mock('@/lib/internal/oci-resource-manager/operations', () => ({ executeOciResourceManagerOperation: mocks.execute, OCI_RESOURCE_MANAGER_MUTATIONS: new Set(['plan', 'apply', 'destroy', 'import_state', 'plan_rollback', 'apply_rollback', 'cancel_job', 'create_stack', 'update_stack', 'delete_stack', 'change_stack_compartment', 'update_job', 'detect_drift']) }))
7+
import { executeOciResourceManagerTool } from '@/lib/internal/oci-resource-manager/execute-tool'
8+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
9+
function request(overrides: Partial<InternalToolOperationCall> = {}): InternalToolOperationCall {
10+
return { toolId: 'oci_resource_manager_plan', input: { oauthCredential: 'supplied', stackId: 'stack' }, context: { userId: 'actor', workspaceId: 'workspace', workflowId: 'workflow', executionId: 'execution' }, headers: new Headers(), requestId: 'request', ...overrides }
11+
}
12+
beforeEach(() => {
13+
vi.resetAllMocks()
14+
mocks.authorize.mockResolvedValue({ ok: true, resolvedCredentialId: 'resolved', credentialType: 'service_account', workspaceId: 'workspace' })
15+
mocks.prepare.mockResolvedValue({ client: 'client' })
16+
mocks.execute.mockResolvedValue({ success: true, output: { status: 200, job: { id: 'job', lifecycleState: 'ACCEPTED' } } })
17+
})
18+
describe('Resource Manager execution authorization', () => {
19+
it('uses only the authorized credential ID and trusted workspace/actor', async () => {
20+
expect((await executeOciResourceManagerTool(request())).status).toBe(200)
21+
expect(mocks.authorize).toHaveBeenCalledWith(expect.objectContaining({ userId: 'actor' }), { credentialId: 'supplied', workflowId: 'workflow', workspaceId: 'workspace', callerUserId: 'actor' })
22+
expect(mocks.prepare).toHaveBeenCalledWith({ credentialId: 'resolved', workspaceId: 'workspace', region: undefined })
23+
expect(mocks.execute).toHaveBeenCalledWith('plan', expect.anything(), expect.objectContaining({ userId: 'actor', workspaceId: 'workspace', workflowId: 'workflow', executionId: 'execution' }))
24+
})
25+
it.each([{ ok: false }, { ok: true, resolvedCredentialId: 'resolved', credentialType: 'service_account', workspaceId: 'other' }, { ok: true, resolvedCredentialId: 'resolved', credentialType: 'oauth', workspaceId: 'workspace' }])('rejects invalid credential access before preparing a client', async (access) => {
26+
mocks.authorize.mockResolvedValue(access)
27+
expect((await executeOciResourceManagerTool(request())).status).toBe(403)
28+
expect(mocks.prepare).not.toHaveBeenCalled()
29+
})
30+
it('ignores injected or forged authority and rejects missing trusted actors', async () => {
31+
expect((await executeOciResourceManagerTool(request({ input: { oauthCredential: 'supplied', stackId: 'stack', userId: 'forged', accessToken: 'opaque', credential: 'forged' } }))).status).toBe(200)
32+
expect(mocks.execute).toHaveBeenLastCalledWith('plan', { oauthCredential: 'supplied', stackId: 'stack' }, expect.objectContaining({ userId: 'actor', workspaceId: 'workspace' }))
33+
mocks.prepare.mockClear()
34+
expect((await executeOciResourceManagerTool(request({ context: { workspaceId: 'workspace', workflowId: 'workflow' } }))).status).toBe(403)
35+
expect(mocks.prepare).not.toHaveBeenCalled()
36+
})
37+
it('preserves nonretryable mutation failure without exposing arbitrary error contents', async () => {
38+
mocks.execute.mockRejectedValue(new Error('secret-canary'))
39+
const result = await executeOciResourceManagerTool(request())
40+
expect(await result.json()).toMatchObject({ success: false, retryable: false })
41+
const second = await executeOciResourceManagerTool(request())
42+
expect(await second.text()).not.toContain('secret-canary')
43+
})
44+
})

0 commit comments

Comments
 (0)