Skip to content
Open
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
32 changes: 25 additions & 7 deletions src/cli/cloudformation/__tests__/outputs-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ describe('buildDeployedState', () => {
},
};

const state = buildDeployedState('default', 'MyStack', agents, {});
const state = buildDeployedState({ targetName: 'default', stackName: 'MyStack', agents, gateways: {} });
expect(state.targets.default).toBeDefined();
expect(state.targets.default!.resources?.agents).toEqual(agents);
expect(state.targets.default!.resources?.stackName).toBe('MyStack');
Expand All @@ -181,7 +181,13 @@ describe('buildDeployedState', () => {
DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' },
};

const state = buildDeployedState('dev', 'DevStack', devAgents, {}, existing);
const state = buildDeployedState({
targetName: 'dev',
stackName: 'DevStack',
agents: devAgents,
gateways: {},
existingState: existing,
});
expect(state.targets.prod).toBeDefined();
expect(state.targets.dev).toBeDefined();
expect(state.targets.prod!.resources?.stackName).toBe('ProdStack');
Expand All @@ -197,22 +203,34 @@ describe('buildDeployedState', () => {
},
};

const state = buildDeployedState('default', 'NewStack', {}, {}, existing);
const state = buildDeployedState({
targetName: 'default',
stackName: 'NewStack',
agents: {},
gateways: {},
existingState: existing,
});
expect(state.targets.default!.resources?.stackName).toBe('NewStack');
});

it('includes identityKmsKeyArn when provided', () => {
const state = buildDeployedState('default', 'Stack', {}, {}, undefined, 'arn:aws:kms:key');
const state = buildDeployedState({
targetName: 'default',
stackName: 'Stack',
agents: {},
gateways: {},
identityKmsKeyArn: 'arn:aws:kms:key',
});
expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key');
});

it('omits identityKmsKeyArn when undefined', () => {
const state = buildDeployedState('default', 'Stack', {}, {});
const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} });
expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined();
});

it('handles empty agents record', () => {
const state = buildDeployedState('default', 'Stack', {}, {});
expect(state.targets.default!.resources?.agents).toEqual({});
const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} });
expect(state.targets.default!.resources?.agents).toBeUndefined();
});
});
140 changes: 121 additions & 19 deletions src/cli/cloudformation/__tests__/outputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildDeployedState, parseGatewayOutputs } from '../outputs';
import { buildDeployedState, parseGatewayOutputs, parseMemoryOutputs } from '../outputs';
import { describe, expect, it } from 'vitest';

describe('buildDeployedState', () => {
Expand All @@ -11,14 +11,13 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState(
'default',
'TestStack',
const result = buildDeployedState({
targetName: 'default',
stackName: 'TestStack',
agents,
{},
undefined,
'arn:aws:kms:us-east-1:123456789012:key/abc-123'
);
gateways: {},
identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
});

expect(result.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/abc-123');
});
Expand All @@ -32,7 +31,7 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState('default', 'TestStack', agents, {});
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} });

expect(result.targets.default!.resources?.identityKmsKeyArn).toBeUndefined();
});
Expand All @@ -49,14 +48,14 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState(
'dev',
'DevStack',
{},
{},
const result = buildDeployedState({
targetName: 'dev',
stackName: 'DevStack',
agents: {},
gateways: {},
existingState,
'arn:aws:kms:us-east-1:123456789012:key/dev-key'
);
identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/dev-key',
});

expect(result.targets.prod!.resources?.stackName).toBe('ProdStack');
expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key');
Expand All @@ -77,7 +76,13 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials);
const result = buildDeployedState({
targetName: 'default',
stackName: 'TestStack',
agents,
gateways: {},
credentials,
});

expect(result.targets.default!.resources?.credentials).toEqual(credentials);
});
Expand All @@ -91,7 +96,7 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState('default', 'TestStack', agents, {});
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} });

expect(result.targets.default!.resources?.credentials).toBeUndefined();
});
Expand All @@ -105,10 +110,53 @@ describe('buildDeployedState', () => {
},
};

const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {});
const result = buildDeployedState({
targetName: 'default',
stackName: 'TestStack',
agents,
gateways: {},
credentials: {},
});

expect(result.targets.default!.resources?.credentials).toBeUndefined();
});

it('includes memories in deployed state when provided', () => {
const memories = {
'my-memory': {
memoryId: 'mem-123',
memoryArn: 'arn:aws:bedrock:us-east-1:123456789012:memory/mem-123',
},
};

const result = buildDeployedState({
targetName: 'default',
stackName: 'TestStack',
agents: {},
gateways: {},
memories,
});

expect(result.targets.default!.resources?.memories).toEqual(memories);
});

it('omits memories field when memories is empty object', () => {
const result = buildDeployedState({
targetName: 'default',
stackName: 'TestStack',
agents: {},
gateways: {},
memories: {},
});

expect(result.targets.default!.resources?.memories).toBeUndefined();
});

it('omits agents field when agents is empty object', () => {
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents: {}, gateways: {} });

expect(result.targets.default!.resources?.agents).toBeUndefined();
});
});

describe('parseGatewayOutputs', () => {
Expand Down Expand Up @@ -183,3 +231,57 @@ describe('parseGatewayOutputs', () => {
expect(result['third-gateway']?.gatewayUrl).toBe('https://third.url');
});
});

describe('parseMemoryOutputs', () => {
it('extracts memory outputs matching pattern', () => {
const outputs = {
ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123',
ApplicationMemoryMyMemoryArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123:memory/mem-123',
UnrelatedOutput: 'some-value',
};

const result = parseMemoryOutputs(outputs, ['my-memory']);

expect(result).toEqual({
'my-memory': {
memoryId: 'mem-123',
memoryArn: 'arn:aws:bedrock:us-east-1:123:memory/mem-123',
},
});
});

it('handles multiple memories', () => {
const outputs = {
ApplicationMemoryFirstMemoryIdOutput123: 'mem-1',
ApplicationMemoryFirstMemoryArnOutput123: 'arn:mem-1',
ApplicationMemorySecondMemoryIdOutput456: 'mem-2',
ApplicationMemorySecondMemoryArnOutput456: 'arn:mem-2',
};

const result = parseMemoryOutputs(outputs, ['first-memory', 'second-memory']);

expect(Object.keys(result)).toHaveLength(2);
expect(result['first-memory']?.memoryId).toBe('mem-1');
expect(result['second-memory']?.memoryId).toBe('mem-2');
});

it('returns empty record when no memory outputs found', () => {
const outputs = {
UnrelatedOutput: 'some-value',
};

const result = parseMemoryOutputs(outputs, ['my-memory']);

expect(result).toEqual({});
});

it('skips incomplete memory outputs (missing ARN)', () => {
const outputs = {
ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123',
};

const result = parseMemoryOutputs(outputs, ['my-memory']);

expect(result).toEqual({});
});
});
57 changes: 46 additions & 11 deletions src/cli/cloudformation/outputs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AgentCoreDeployedState, DeployedState, TargetDeployedState } from '../../schema';
import type { AgentCoreDeployedState, DeployedState, MemoryDeployedState, TargetDeployedState } from '../../schema';
import { getCredentialProvider } from '../aws';
import { toPascalId } from './logical-ids';
import { getStackName } from './stack-discovery';
Expand Down Expand Up @@ -172,21 +172,56 @@ export function parseAgentOutputs(
return agents;
}

/**
* Parse stack outputs into deployed state for memories.
*
* Looks up outputs by constructing the expected key prefix from known memory names
*
* Output key pattern: ApplicationMemory{PascalName}(Id|Arn)Output{Hash}
*/
export function parseMemoryOutputs(outputs: StackOutputs, memoryNames: string[]): Record<string, MemoryDeployedState> {
const memories: Record<string, MemoryDeployedState> = {};
const outputKeys = Object.keys(outputs);

for (const memoryName of memoryNames) {
const pascal = toPascalId(memoryName);
const idPrefix = `ApplicationMemory${pascal}IdOutput`;
const arnPrefix = `ApplicationMemory${pascal}ArnOutput`;

const idKey = outputKeys.find(k => k.startsWith(idPrefix));
const arnKey = outputKeys.find(k => k.startsWith(arnPrefix));

if (idKey && arnKey) {
memories[memoryName] = {
memoryId: outputs[idKey]!,
memoryArn: outputs[arnKey]!,
};
}
}

return memories;
}

export interface BuildDeployedStateOptions {
targetName: string;
stackName: string;
agents: Record<string, AgentCoreDeployedState>;
gateways: Record<string, { gatewayId: string; gatewayArn: string; gatewayUrl?: string }>;
existingState?: DeployedState;
identityKmsKeyArn?: string;
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>;
memories?: Record<string, MemoryDeployedState>;
}

/**
* Build deployed state from stack outputs.
*/
export function buildDeployedState(
targetName: string,
stackName: string,
agents: Record<string, AgentCoreDeployedState>,
gateways: Record<string, { gatewayId: string; gatewayArn: string; gatewayUrl?: string }>,
existingState?: DeployedState,
identityKmsKeyArn?: string,
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>
): DeployedState {
export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedState {
const { targetName, stackName, agents, gateways, existingState, identityKmsKeyArn, credentials, memories } = opts;
const targetState: TargetDeployedState = {
resources: {
agents,
agents: Object.keys(agents).length > 0 ? agents : undefined,
memories: memories && Object.keys(memories).length > 0 ? memories : undefined,
stackName,
identityKmsKeyArn,
},
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/deploy/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ describe('deploy without agents', () => {
await rm(noAgentTestDir, { recursive: true, force: true });
});

it('rejects deploy when no agents are defined', async () => {
it('rejects deploy when no resources are defined', async () => {
const result = await runCLI(['deploy', '--json'], noAgentProjectDir);
expect(result.exitCode).toBe(1);
const json = JSON.parse(result.stdout);
expect(json.success).toBe(false);
expect(json.error).toBeDefined();
expect(json.error.toLowerCase()).toContain('no agents');
expect(json.error.toLowerCase()).toContain('no resources defined');
});
});
Loading
Loading