Skip to content

Commit 3885cbe

Browse files
rSnapkoOpenOpsCopilotMarceloRGonc
authored
Add aws generate runbook link action (#1563)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Part of OPS-2931. --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Marcelo Gonçalves <[email protected]>
1 parent fb48ce0 commit 3885cbe

File tree

7 files changed

+376
-1
lines changed

7 files changed

+376
-1
lines changed

packages/blocks/aws/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { rdsDeleteInstanceAction } from './lib/actions/rds/rds-delete-instance-a
2323
import { rdsDeleteSnapshotAction } from './lib/actions/rds/rds-delete-snapshot-action';
2424
import { rdsGetInstancesAction } from './lib/actions/rds/rds-describe-instances-action';
2525
import { rdsGetSnapshotsAction } from './lib/actions/rds/rds-describe-snapshots-action';
26+
import { ssmGenerateRunbookLinkAction } from './lib/actions/ssm/ssm-generate-runbook-link-action';
2627

2728
export const aws = createBlock({
2829
displayName: 'AWS',
@@ -53,6 +54,7 @@ export const aws = createBlock({
5354
rdsCreateSnapshotAction,
5455
rdsDeleteSnapshotAction,
5556
addTagsAction,
57+
ssmGenerateRunbookLinkAction,
5658
awsCliAction,
5759
],
5860
triggers: [],
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createAction, Property } from '@openops/blocks-framework';
2+
import {
3+
amazonAuth,
4+
DocumentOwner,
5+
generateBaseSSMRunbookExecutionLink,
6+
generateSSMRunbookExecutionParams,
7+
runbookNameProperty,
8+
runbookParametersProperty,
9+
runbookVersionProperty,
10+
} from '@openops/common';
11+
import { RiskLevel } from '@openops/shared';
12+
13+
export const ssmGenerateRunbookLinkAction = createAction({
14+
auth: amazonAuth,
15+
name: 'ssm_generate_runbook_execution_link',
16+
description:
17+
'Generate an AWS Console link to execute an SSM Automation Runbook',
18+
displayName: 'Generate Runbook Execution Link',
19+
isWriteAction: false,
20+
riskLevel: RiskLevel.LOW,
21+
props: {
22+
region: Property.ShortText({
23+
displayName: 'Region',
24+
description: 'AWS region (defaults to the region from authentication).',
25+
required: false,
26+
}),
27+
owner: Property.StaticDropdown({
28+
displayName: 'Owner',
29+
description: 'Source/owner of the runbook (Automation document).',
30+
required: true,
31+
options: {
32+
options: [
33+
{ label: 'Owned by Amazon', value: DocumentOwner.Amazon },
34+
{ label: 'Owned by me', value: DocumentOwner.Self },
35+
{ label: 'Shared with me', value: DocumentOwner.Private },
36+
{ label: 'Public', value: DocumentOwner.Public },
37+
{ label: 'Third Party', value: DocumentOwner.ThirdParty },
38+
{ label: 'All runbooks', value: DocumentOwner.All },
39+
],
40+
},
41+
defaultValue: DocumentOwner.Private,
42+
}),
43+
runbook: runbookNameProperty,
44+
version: runbookVersionProperty,
45+
parameters: runbookParametersProperty,
46+
},
47+
async run({ propsValue, auth }) {
48+
const { runbook, version, parameters, region } = propsValue;
49+
const awsRegion = region || auth.defaultRegion;
50+
51+
if (!runbook) {
52+
throw new Error('Runbook is required');
53+
}
54+
55+
const base = generateBaseSSMRunbookExecutionLink(
56+
awsRegion,
57+
runbook,
58+
version,
59+
);
60+
61+
const fragment = generateSSMRunbookExecutionParams(
62+
(parameters as Record<string, unknown>) || {},
63+
);
64+
65+
return {
66+
link: `${base}${fragment}`,
67+
};
68+
},
69+
});

packages/blocks/aws/test/index.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('block declaration tests', () => {
2121
});
2222

2323
test('should return block with correct number of actions', () => {
24-
expect(Object.keys(aws.actions()).length).toBe(22);
24+
expect(Object.keys(aws.actions()).length).toBe(23);
2525
expect(aws.actions()).toMatchObject({
2626
ebs_get_volumes: {
2727
name: 'ebs_get_volumes',
@@ -103,6 +103,10 @@ describe('block declaration tests', () => {
103103
name: 'ec2_get_instances',
104104
requireAuth: true,
105105
},
106+
ssm_generate_runbook_execution_link: {
107+
name: 'ssm_generate_runbook_execution_link',
108+
requireAuth: true,
109+
},
106110
aws_cli: {
107111
name: 'aws_cli',
108112
requireAuth: true,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { ssmGenerateRunbookLinkAction } from '../../src/lib/actions/ssm/ssm-generate-runbook-link-action';
2+
3+
type RunResult = { link: string };
4+
5+
describe('ssmGenerateRunbookLinkAction.run', () => {
6+
const auth = {
7+
...jest.requireActual('@openops/blocks-framework'),
8+
defaultRegion: 'us-west-2',
9+
};
10+
11+
test('generates base link using auth.defaultRegion when region is not provided', async () => {
12+
const ctx = {
13+
...jest.requireActual('@openops/blocks-framework'),
14+
auth,
15+
propsValue: {
16+
runbook: 'AWS-RestartEC2Instance',
17+
},
18+
};
19+
20+
const result = (await ssmGenerateRunbookLinkAction.run(ctx)) as RunResult;
21+
22+
expect(result.link).toBe(
23+
'https://us-west-2.console.aws.amazon.com/systems-manager/automation/execute/AWS-RestartEC2Instance?region=us-west-2',
24+
);
25+
});
26+
27+
test('honors explicit region override', async () => {
28+
const ctx = {
29+
...jest.requireActual('@openops/blocks-framework'),
30+
auth: { defaultRegion: 'us-east-1' },
31+
propsValue: {
32+
runbook: 'My-Runbook',
33+
region: 'eu-central-1',
34+
},
35+
};
36+
37+
const result = (await ssmGenerateRunbookLinkAction.run(ctx)) as RunResult;
38+
39+
expect(result.link).toBe(
40+
'https://eu-central-1.console.aws.amazon.com/systems-manager/automation/execute/My-Runbook?region=eu-central-1',
41+
);
42+
});
43+
44+
test('includes documentVersion when provided', async () => {
45+
const ctx = {
46+
...jest.requireActual('@openops/blocks-framework'),
47+
auth: { defaultRegion: 'ap-south-1' },
48+
propsValue: {
49+
runbook: 'My-Runbook',
50+
version: '3',
51+
},
52+
};
53+
54+
const result = (await ssmGenerateRunbookLinkAction.run(ctx)) as RunResult;
55+
56+
expect(result.link).toBe(
57+
'https://ap-south-1.console.aws.amazon.com/systems-manager/automation/execute/My-Runbook?region=ap-south-1&documentVersion=3',
58+
);
59+
});
60+
61+
test('encodes parameters of various types and omits empty values', async () => {
62+
const ctx = {
63+
...jest.requireActual('@openops/blocks-framework'),
64+
auth: { defaultRegion: 'us-west-1' },
65+
propsValue: {
66+
runbook: 'Param-Runbook',
67+
parameters: {
68+
s: 'hello world',
69+
n: 42,
70+
b: true,
71+
arrPrimitives: [1, 'a', true, null, undefined],
72+
arrObjects: [{ a: 1 }, { b: 2 }],
73+
obj: { key: 'value' },
74+
emptyStr: '',
75+
nothing: undefined,
76+
nothing2: null,
77+
},
78+
},
79+
};
80+
81+
const result = (await ssmGenerateRunbookLinkAction.run(ctx)) as RunResult;
82+
83+
expect(
84+
result.link.startsWith(
85+
'https://us-west-1.console.aws.amazon.com/systems-manager/automation/execute/Param-Runbook?region=us-west-1#',
86+
),
87+
).toBe(true);
88+
89+
const fragment = result.link.split('#')[1] as string;
90+
expect(fragment).toBeDefined();
91+
92+
const expectedParts = [
93+
's=hello%20world',
94+
'n=42',
95+
'b=true',
96+
'arrPrimitives=%5B1%2C%22a%22%2Ctrue%2Cnull%2Cnull%5D',
97+
'arrObjects=%5B%7B%22a%22%3A1%7D%2C%7B%22b%22%3A2%7D%5D',
98+
'obj=%7B%22key%22%3A%22value%22%7D',
99+
];
100+
101+
const actualParts = fragment.split('&');
102+
103+
for (const forbidden of ['emptyStr', 'nothing', 'nothing2']) {
104+
expect(
105+
actualParts.some((part) =>
106+
part.startsWith(`${encodeURIComponent(forbidden)}=`),
107+
),
108+
).toBe(false);
109+
}
110+
111+
for (const part of expectedParts) {
112+
expect(actualParts).toContain(part);
113+
}
114+
});
115+
116+
test('encodes special characters in param keys and values', async () => {
117+
const ctx = {
118+
...jest.requireActual('@openops/blocks-framework'),
119+
auth: { defaultRegion: 'eu-west-3' },
120+
propsValue: {
121+
runbook: 'RB',
122+
parameters: {
123+
'key with space': 'value/with?special&chars',
124+
},
125+
},
126+
};
127+
128+
const result = (await ssmGenerateRunbookLinkAction.run(ctx)) as RunResult;
129+
130+
expect(result.link).toBe(
131+
'https://eu-west-3.console.aws.amazon.com/systems-manager/automation/execute/RB?region=eu-west-3#key%20with%20space=value%2Fwith%3Fspecial%26chars',
132+
);
133+
});
134+
135+
test('throws when runbook is missing', async () => {
136+
const ctx = {
137+
...jest.requireActual('@openops/blocks-framework'),
138+
auth: { defaultRegion: 'us-east-2' },
139+
propsValue: {},
140+
};
141+
142+
await expect(ssmGenerateRunbookLinkAction.run(ctx)).rejects.toThrow(
143+
'Runbook is required',
144+
);
145+
});
146+
});

packages/openops/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export * from './lib/aws/rds/rds-delete-instance';
2222
export * from './lib/aws/rds/rds-delete-snapshot';
2323
export * from './lib/aws/rds/rds-describe';
2424
export * from './lib/aws/regions';
25+
export * from './lib/aws/ssm/document-owner';
26+
export * from './lib/aws/ssm/generate-ssm-runbook-execution-link';
27+
export * from './lib/aws/ssm/runbook-name-property';
28+
export * from './lib/aws/ssm/runbook-parameters-property';
29+
export * from './lib/aws/ssm/runbook-version-property';
2530
export * from './lib/aws/sts-common';
2631
export * from './lib/aws/tags/tag-resources';
2732
export * from './lib/aws/tags/tags';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const encodeParamValue = (value: unknown): string | undefined => {
2+
if (value === undefined || value === null) return undefined;
3+
4+
if (typeof value === 'string') {
5+
return encodeURIComponent(value);
6+
}
7+
8+
if (Array.isArray(value)) {
9+
const allPrimitives = value.every(
10+
(v) =>
11+
typeof v === 'string' ||
12+
typeof v === 'number' ||
13+
typeof v === 'boolean',
14+
);
15+
16+
if (allPrimitives) {
17+
const joined = value.map((v) => String(v)).join(', ');
18+
return encodeURIComponent(joined);
19+
}
20+
21+
return encodeURIComponent(JSON.stringify(value));
22+
}
23+
24+
return encodeURIComponent(JSON.stringify(value));
25+
};
26+
27+
export const generateBaseSSMRunbookExecutionLink = (
28+
region: string,
29+
runbookName: string,
30+
version?: string,
31+
) => {
32+
return `https://${region}.console.aws.amazon.com/systems-manager/automation/execute/${encodeURIComponent(
33+
runbookName,
34+
)}?region=${encodeURIComponent(region)}${
35+
version ? `&documentVersion=${encodeURIComponent(version)}` : ''
36+
}`;
37+
};
38+
39+
export const generateSSMRunbookExecutionParams = (
40+
parameters: Record<string, unknown>,
41+
) => {
42+
const entries = Object.entries(parameters);
43+
44+
const hashParts: string[] = [];
45+
for (const [key, value] of entries) {
46+
const encodedValue = encodeParamValue(value);
47+
if (encodedValue) {
48+
hashParts.push(`${encodeURIComponent(key)}=${encodedValue}`);
49+
}
50+
}
51+
52+
return hashParts.length ? `#${hashParts.join('&')}` : '';
53+
};

0 commit comments

Comments
 (0)