cloudmason2 1.0.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.
@@ -0,0 +1 @@
1
+ []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # cloudmason2
2
+
3
+
4
+
5
+ CI/CD Tool for AWS Marketplace Deployments
@@ -0,0 +1,108 @@
1
+ const { CloudFormationClient, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation');
2
+ const { EC2Client, DescribeRegionsCommand } = require('@aws-sdk/client-ec2');
3
+ const { S3Client } = require("@aws-sdk/client-s3");
4
+ const OrgConfig = require('./helpers/org_config');
5
+ const Apps = require('./helpers/apps');
6
+
7
+ // app-details: print the manifest and every launched stack across all regions
8
+ exports.main = async function(args){
9
+ const cfg = OrgConfig.read();
10
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
11
+ const { bucket, prefix } = parseCdn(cfg.cdn);
12
+ const s3 = new S3Client({ region: cfg.region });
13
+
14
+ const appName = args.app.toLowerCase();
15
+
16
+ const app = await Apps.resolveApp(s3, bucket, prefix, appName);
17
+ if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
18
+ const manifest = app.manifest;
19
+
20
+ // --- I MANIFEST ---
21
+ printManifest(manifest);
22
+
23
+ // --- II STACKS ---
24
+ // Stacks are found by the mason=<app> tag applied on launch, in every region
25
+ console.log('\nSTACKS');
26
+ console.log('-'.repeat(50));
27
+ const ec2 = new EC2Client({ region: cfg.region });
28
+ const regionResp = await ec2.send(new DescribeRegionsCommand({}));
29
+ const regions = regionResp.Regions.map(r=>{ return r.RegionName }).sort();
30
+
31
+ const results = await Promise.all(regions.map(region=>{
32
+ return appStacks(appName, region).catch(e=>{
33
+ console.log(`${region}: ERR ${e.message}`);
34
+ return [];
35
+ });
36
+ }));
37
+
38
+ let found = 0;
39
+ regions.forEach((region, i)=>{
40
+ results[i].forEach(s=>{
41
+ found++;
42
+ console.log(`${region.padEnd(16)} ${s.name.padEnd(30)} v${(s.version || '?').padEnd(22)} ${s.status}`);
43
+ });
44
+ });
45
+ if (found === 0){ console.log('No stacks found') }
46
+ return true;
47
+ }
48
+
49
+
50
+ /////////////////////////////////////////
51
+ ////////////// FUNCS ////////////////////
52
+ ////////////////////////////////////////
53
+
54
+ function parseCdn(cdn){
55
+ const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
56
+ const parts = p.split('/');
57
+ return { bucket: parts[0], prefix: parts.slice(1).join('/') };
58
+ }
59
+
60
+ function printManifest(m){
61
+ console.log('='.repeat(50));
62
+ console.log(m.name);
63
+ console.log('='.repeat(50));
64
+ Object.keys(m).forEach(k=>{
65
+ if (k === 'versions' || k === 'name'){ return }
66
+ let v = m[k];
67
+ if (v && typeof v === 'object'){ v = JSON.stringify(v) }
68
+ v = String(v ?? '').replace(/\s+/g, ' ').trim();
69
+ if (v.length > 70){ v = v.slice(0, 67) + '...' }
70
+ console.log(`${k.padEnd(26)} ${v || '-'}`);
71
+ });
72
+
73
+ console.log('\nVERSIONS');
74
+ console.log('-'.repeat(50));
75
+ if (!m.versions || m.versions.length === 0){
76
+ console.log('No versions. Run update-app');
77
+ return;
78
+ }
79
+ m.versions.forEach(v=>{
80
+ const published = v.published ? `published ${v.publish_date || ''}`.trim() : 'unpublished';
81
+ console.log(`${v.version.padEnd(24)} ${(v.ami_id || 'no-ami').padEnd(24)} ${published}`);
82
+ console.log(`${''.padEnd(24)} updated ${v.updated_on}`);
83
+ });
84
+ }
85
+
86
+ // All stacks in a region tagged mason=<app> on launch
87
+ async function appStacks(appName, region){
88
+ const client = new CloudFormationClient({ region });
89
+ const stacks = [];
90
+ let token;
91
+ do {
92
+ const r = await client.send(new DescribeStacksCommand({ NextToken: token }));
93
+ (r.Stacks || []).forEach(s=>{
94
+ const tags = s.Tags || [];
95
+ const isApp = tags.some(t=>{ return t.Key === 'mason' && t.Value === appName });
96
+ if (!isApp){ return }
97
+ const versionTag = tags.find(t=>{ return t.Key === 'version' });
98
+ stacks.push({
99
+ name: s.StackName,
100
+ status: s.StackStatus,
101
+ version: versionTag ? versionTag.Value : null
102
+ });
103
+ });
104
+ token = r.NextToken;
105
+ } while (token);
106
+ return stacks;
107
+ }
108
+
@@ -0,0 +1,130 @@
1
+ const { S3Client, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand, PutPublicAccessBlockCommand } = require("@aws-sdk/client-s3");
2
+ const { IAMClient, GetRoleCommand, CreateRoleCommand, AttachRolePolicyCommand, PutRolePolicyCommand } = require("@aws-sdk/client-iam");
3
+ const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
4
+
5
+ // All CDN resources are created in us-east-1
6
+ const REGION = 'us-east-1';
7
+
8
+ exports.main = async function(args){
9
+ const bucket = args.name;
10
+ const s3 = new S3Client({ region: REGION });
11
+
12
+ // --- I CREATE BUCKET ---
13
+ const exists = await bucketExists(s3, bucket);
14
+ if (exists){ throw new Error(`Bucket ${bucket} already exists`) }
15
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
16
+ console.log(`Created bucket ${bucket} in ${REGION}`);
17
+
18
+ // --- II PUBLIC ACCESS POLICY ---
19
+ // New buckets block public policies by default; allow them (ACLs stay blocked)
20
+ await s3.send(new PutPublicAccessBlockCommand({
21
+ Bucket: bucket,
22
+ PublicAccessBlockConfiguration: {
23
+ BlockPublicAcls: true,
24
+ IgnorePublicAcls: true,
25
+ BlockPublicPolicy: false,
26
+ RestrictPublicBuckets: false
27
+ }
28
+ }));
29
+ // Everything is publicly readable except files beginning with "_", at any depth
30
+ const policy = {
31
+ Version: "2012-10-17",
32
+ Statement: [
33
+ {
34
+ Sid: "PublicReadExceptUnderscore",
35
+ Effect: "Allow",
36
+ Principal: "*",
37
+ Action: "s3:GetObject",
38
+ NotResource: [
39
+ `arn:aws:s3:::${bucket}/_*`,
40
+ `arn:aws:s3:::${bucket}/*/_*`
41
+ ]
42
+ }
43
+ ]
44
+ };
45
+ await s3.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
46
+ console.log('Set public access policy (keys starting with "_" are private)');
47
+
48
+ // --- III MARKETPLACE ROLE ---
49
+ const roleArn = await createMarketplaceRole(bucket);
50
+ const ssm = new SSMClient({ region: REGION });
51
+ await ssm.send(new PutParameterCommand({
52
+ Name: `mason-cdn-${bucket}`,
53
+ Value: roleArn,
54
+ Type: 'String',
55
+ Overwrite: true
56
+ }));
57
+ console.log(`Saved marketplace role to SSM parameter mason-cdn-${bucket}`);
58
+ return true;
59
+ }
60
+
61
+
62
+ /////////////////////////////////////////
63
+ ////////////// FUNCS ////////////////////
64
+ ////////////////////////////////////////
65
+
66
+ async function bucketExists(s3, bucket){
67
+ try {
68
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
69
+ return true;
70
+ } catch (e){
71
+ if (/NotFound|NoSuchBucket/.test(e)){ return false }
72
+ if (/Forbidden|403/.test(e)){ return true } // exists in another account
73
+ throw e;
74
+ }
75
+ }
76
+
77
+ async function createMarketplaceRole(bucket){
78
+ const roleName = `mason-cdn-${bucket}`;
79
+ const iam = new IAMClient({ region: REGION });
80
+
81
+ try {
82
+ const existing = await iam.send(new GetRoleCommand({ RoleName: roleName }));
83
+ console.log(`Role ${roleName} already exists`);
84
+ return existing.Role.Arn;
85
+ } catch (e){
86
+ if (!/NoSuchEntity/.test(e)){ throw e }
87
+ }
88
+
89
+ const trustPolicy = {
90
+ Version: "2012-10-17",
91
+ Statement: [
92
+ {
93
+ Effect: "Allow",
94
+ Principal: { Service: "assets.marketplace.amazonaws.com" },
95
+ Action: "sts:AssumeRole"
96
+ }
97
+ ]
98
+ };
99
+ const created = await iam.send(new CreateRoleCommand({
100
+ RoleName: roleName,
101
+ AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
102
+ Description: 'Allows AWS Marketplace to access AMIs and the CDN bucket'
103
+ }));
104
+
105
+ // AMI ingestion permissions
106
+ await iam.send(new AttachRolePolicyCommand({
107
+ RoleName: roleName,
108
+ PolicyArn: 'arn:aws:iam::aws:policy/AWSMarketplaceAmiIngestion'
109
+ }));
110
+
111
+ // Full read access to the CDN bucket, including private "_" keys
112
+ const cdnPolicy = {
113
+ Version: "2012-10-17",
114
+ Statement: [
115
+ {
116
+ Effect: "Allow",
117
+ Action: ["s3:GetObject", "s3:ListBucket"],
118
+ Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`]
119
+ }
120
+ ]
121
+ };
122
+ await iam.send(new PutRolePolicyCommand({
123
+ RoleName: roleName,
124
+ PolicyName: 'mason-cdn-access',
125
+ PolicyDocument: JSON.stringify(cdnPolicy)
126
+ }));
127
+
128
+ console.log(`Created role ${roleName} (${created.Role.Arn})`);
129
+ return created.Role.Arn;
130
+ }
@@ -0,0 +1,194 @@
1
+ const { CloudFormationClient, DescribeStacksCommand, DeleteStackCommand } = require('@aws-sdk/client-cloudformation');
2
+ const { EC2Client, DescribeRegionsCommand, DescribeImagesCommand, DeregisterImageCommand, DeleteSnapshotCommand } = require('@aws-sdk/client-ec2');
3
+ const { S3Client, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
4
+ const { IAMClient, DeleteRoleCommand, DeleteRolePolicyCommand, ListRolePoliciesCommand, ListAttachedRolePoliciesCommand, DetachRolePolicyCommand } = require('@aws-sdk/client-iam');
5
+ const OrgConfig = require('./helpers/org_config');
6
+ const Apps = require('./helpers/apps');
7
+
8
+ // delete-instance: roll back (delete) the stack of the specified instance
9
+ exports.delete_instance = async function(args){
10
+ const cfg = OrgConfig.read();
11
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
12
+
13
+ const appName = args.app.toLowerCase();
14
+ const stackName = `${appName}-${args.title}`;
15
+
16
+ // Find the stack - in the given region, or by searching all regions
17
+ let regions;
18
+ if (args.region){
19
+ regions = [args.region];
20
+ } else {
21
+ const ec2 = new EC2Client({ region: cfg.region });
22
+ const r = await ec2.send(new DescribeRegionsCommand({}));
23
+ regions = r.Regions.map(rg=>{ return rg.RegionName });
24
+ }
25
+
26
+ const found = [];
27
+ await Promise.all(regions.map(region=>{
28
+ return findStack(stackName, appName, region).then(s=>{
29
+ if (s){ found.push({ region: region, status: s.status }) }
30
+ }).catch(e=>{ console.log(`${region}: ERR ${e.message}`) });
31
+ }));
32
+
33
+ if (found.length === 0){
34
+ throw new Error(`No stack named ${stackName} found`);
35
+ }
36
+ if (found.length > 1){
37
+ found.forEach(f=>{ console.log(` ${f.region} ${stackName} ${f.status}`) });
38
+ throw new Error(`Stack ${stackName} exists in multiple regions. Pass -region`);
39
+ }
40
+
41
+ const target = found[0];
42
+ const client = new CloudFormationClient({ region: target.region });
43
+ await client.send(new DeleteStackCommand({ StackName: stackName }));
44
+ console.log(`Rolling back stack ${stackName} in ${target.region}`);
45
+ return true;
46
+ }
47
+
48
+ // delete-app: remove the app's s3 folder, all its AMIs in every region,
49
+ // and its IAM role. Refuses while launched instance stacks still exist.
50
+ // The CDN role/param and the OIDC provider are shared and are not touched
51
+ exports.delete_app = async function(args){
52
+ const cfg = OrgConfig.read();
53
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
54
+ const { bucket, prefix } = parseCdn(cfg.cdn);
55
+ const s3 = new S3Client({ region: cfg.region });
56
+
57
+ const appName = args.app.toLowerCase();
58
+
59
+ const app = await Apps.resolveApp(s3, bucket, prefix, appName);
60
+ if (!app){ throw new Error('No app named ' + args.app) }
61
+ const appPrefix = app.appPrefix;
62
+
63
+ const ec2 = new EC2Client({ region: cfg.region });
64
+ const regionResp = await ec2.send(new DescribeRegionsCommand({}));
65
+ const regions = regionResp.Regions.map(r=>{ return r.RegionName });
66
+
67
+ // --- I REFUSE IF INSTANCES STILL EXIST ---
68
+ const live = [];
69
+ await Promise.all(regions.map(region=>{
70
+ return regionStacks(appName, region).then(list=>{
71
+ list.forEach(s=>{ live.push(`${region} ${s.name} ${s.status}`) });
72
+ }).catch(e=>{ console.log(`${region}: ERR ${e.message}`) });
73
+ }));
74
+ if (live.length){
75
+ live.forEach(l=>{ console.log(' ' + l) });
76
+ throw new Error(`${live.length} instance stack(s) still exist. Run delete-instance first`);
77
+ }
78
+
79
+ // --- II DELETE S3 APP FOLDER ---
80
+ const deleted = await deleteFolder(s3, bucket, `${appPrefix}/`);
81
+ console.log(`Deleted ${deleted} objects from s3://${bucket}/${appPrefix}/`);
82
+
83
+ // --- III DELETE AMIS IN ALL REGIONS ---
84
+ for (const region of regions){
85
+ try {
86
+ await deleteAppAmis(appName, region);
87
+ } catch (e){
88
+ console.log(`AMI cleanup failed in ${region}: ${e.message}`);
89
+ }
90
+ }
91
+
92
+ // --- IV DELETE IAM ROLE ---
93
+ await deleteGithubRole(appName);
94
+
95
+ console.log(`Deleted app ${args.app}`);
96
+ return true;
97
+ }
98
+
99
+
100
+ /////////////////////////////////////////
101
+ ////////////// FUNCS ////////////////////
102
+ ////////////////////////////////////////
103
+
104
+ function parseCdn(cdn){
105
+ const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
106
+ const parts = p.split('/');
107
+ return { bucket: parts[0], prefix: parts.slice(1).join('/') };
108
+ }
109
+
110
+ // All stacks in a region tagged mason=<app> on launch
111
+ async function regionStacks(appName, region){
112
+ const client = new CloudFormationClient({ region });
113
+ const stacks = [];
114
+ let token;
115
+ do {
116
+ const r = await client.send(new DescribeStacksCommand({ NextToken: token }));
117
+ (r.Stacks || []).forEach(s=>{
118
+ const isApp = (s.Tags || []).some(t=>{ return t.Key === 'mason' && t.Value === appName });
119
+ if (isApp){ stacks.push({ name: s.StackName, status: s.StackStatus }) }
120
+ });
121
+ token = r.NextToken;
122
+ } while (token);
123
+ return stacks;
124
+ }
125
+
126
+ // Delete every object under the prefix, in batches
127
+ async function deleteFolder(s3, bucket, prefix){
128
+ let count = 0;
129
+ let truncated = true;
130
+ while (truncated){
131
+ const r = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }));
132
+ const keys = (r.Contents || []).map(o=>{ return { Key: o.Key } });
133
+ if (keys.length === 0){ break }
134
+ await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: keys } }));
135
+ count += keys.length;
136
+ truncated = r.IsTruncated;
137
+ }
138
+ return count;
139
+ }
140
+
141
+ // Deregister every build of the app in the region, along with its snapshots
142
+ async function deleteAppAmis(appName, region){
143
+ const client = new EC2Client({ region });
144
+ const r = await client.send(new DescribeImagesCommand({
145
+ Owners: ['self'],
146
+ Filters: [{ Name: 'name', Values: [`${appName}-v*`] }]
147
+ }));
148
+ for (const img of (r.Images || [])){
149
+ console.log(`Deleting AMI ${img.Name} (${img.ImageId}) in ${region}`);
150
+ await client.send(new DeregisterImageCommand({ ImageId: img.ImageId }));
151
+ for (const bdm of (img.BlockDeviceMappings || [])){
152
+ if (bdm.Ebs && bdm.Ebs.SnapshotId){
153
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: bdm.Ebs.SnapshotId }));
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // Delete the app's GitHub Actions role: inline policies, attachments, then the role
160
+ async function deleteGithubRole(appName){
161
+ const roleName = `mason-gha-${appName}`;
162
+ const iam = new IAMClient({ region: 'us-east-1' });
163
+ try {
164
+ const inline = await iam.send(new ListRolePoliciesCommand({ RoleName: roleName }));
165
+ for (const p of (inline.PolicyNames || [])){
166
+ await iam.send(new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: p }));
167
+ }
168
+ const attached = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName: roleName }));
169
+ for (const p of (attached.AttachedPolicies || [])){
170
+ await iam.send(new DetachRolePolicyCommand({ RoleName: roleName, PolicyArn: p.PolicyArn }));
171
+ }
172
+ await iam.send(new DeleteRoleCommand({ RoleName: roleName }));
173
+ console.log(`Deleted role ${roleName}`);
174
+ } catch (e){
175
+ if (/NoSuchEntity/.test(e)){ console.log(`No role ${roleName} to delete`); return }
176
+ throw e;
177
+ }
178
+ }
179
+
180
+ // Returns the stack only if it carries the mason=<app> tag set on launch,
181
+ // so an unrelated stack with a matching name can never be deleted
182
+ async function findStack(stackName, appName, region){
183
+ const client = new CloudFormationClient({ region });
184
+ try {
185
+ const r = await client.send(new DescribeStacksCommand({ StackName: stackName }));
186
+ const stack = r.Stacks[0];
187
+ const isApp = (stack.Tags || []).some(t=>{ return t.Key === 'mason' && t.Value === appName });
188
+ if (!isApp){ return null }
189
+ return { status: stack.StackStatus };
190
+ } catch (e){
191
+ if (e.name === 'ValidationError'){ return null } // does not exist
192
+ throw e;
193
+ }
194
+ }
@@ -0,0 +1,20 @@
1
+ const { GetObjectCommand } = require("@aws-sdk/client-s3");
2
+
3
+ // Resolve an app name to its s3 folder and manifest. Private apps live under
4
+ // a _ prefixed folder, so both locations are probed - callers always use the
5
+ // clean app name and never need to know where the app is stored
6
+ exports.resolveApp = async function(s3, bucket, orgPrefix, appName){
7
+ for (const folder of [appName, `_${appName}`]){
8
+ const appPrefix = orgPrefix ? `${orgPrefix}/${folder}` : folder;
9
+ try {
10
+ const r = await s3.send(new GetObjectCommand({
11
+ Bucket: bucket,
12
+ Key: `${appPrefix}/manifest.json`
13
+ }));
14
+ return { appPrefix: appPrefix, manifest: JSON.parse(await r.Body.transformToString()) };
15
+ } catch (e){
16
+ if (!/NoSuchKey|NotFound/.test(e)){ throw e }
17
+ }
18
+ }
19
+ return null;
20
+ }
@@ -0,0 +1,20 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Stored in the user's home dir so config survives upgrades/reinstalls of the utility
6
+ const CONFIG_DIR = path.join(os.homedir(), '.cloudmason2');
7
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
8
+
9
+ exports.read = function () {
10
+ if (fs.existsSync(CONFIG_PATH)) {
11
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
12
+ }
13
+ return null;
14
+ };
15
+
16
+ exports.write = function (cfg) {
17
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
18
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
19
+ return CONFIG_PATH;
20
+ };
@@ -0,0 +1,10 @@
1
+ const OrgConfig = require('./helpers/org_config');
2
+
3
+ // set-org: persist the CDN bucket name locally. All core resources live in us-east-1,
4
+ // so consumers construct s3 urls from the bucket name + fixed region
5
+ exports.main = async function(args){
6
+ const configPath = OrgConfig.write({ cdn: args.cdn, region: 'us-east-1' });
7
+ console.log(`Set org CDN ${args.cdn} (us-east-1)`);
8
+ console.log(`Config saved to ${configPath}`);
9
+ return true;
10
+ }
@@ -0,0 +1,155 @@
1
+ const { CloudFormationClient, CreateStackCommand, UpdateStackCommand, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation');
2
+ const { EC2Client, DescribeImagesCommand, CopyImageCommand } = require('@aws-sdk/client-ec2');
3
+ const { S3Client } = require("@aws-sdk/client-s3");
4
+ const OrgConfig = require('./helpers/org_config');
5
+ const Apps = require('./helpers/apps');
6
+
7
+ // CLI args consumed by the command itself; everything else is passed through as a CF stack parameter
8
+ const CLI_ARGS = ['app', 'v', 'title', 'region'];
9
+
10
+ exports.main = async function(args){
11
+ const cfg = OrgConfig.read();
12
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
13
+ const { bucket, prefix } = parseCdn(cfg.cdn);
14
+ const s3 = new S3Client({ region: cfg.region });
15
+
16
+ const appName = args.app.toLowerCase();
17
+
18
+ // Get App
19
+ const app = await Apps.resolveApp(s3, bucket, prefix, appName);
20
+ if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
21
+ const manifest = app.manifest;
22
+
23
+ // Find matching version (args.v is major.minor; manifest versions are major.minor.timestamp)
24
+ const targetVersion = manifest.versions.find(v=>{ return v.version === args.v || v.version.startsWith(args.v + '.') });
25
+ if (!targetVersion){ throw new Error(`No version ${args.v} of ${args.app}. Run update-app`) }
26
+
27
+ const stackName = `${appName}-${args.title}`;
28
+ console.log(`Launching ${args.app} v${targetVersion.version} as ${stackName} in ${args.region}`);
29
+
30
+ // --- I GET AMI ---
31
+ // AMI ids are region-scoped: outside the org region, find the AMI by name or copy it over.
32
+ // The regional copy is looked up by name on later launches, so it is not recorded in the manifest
33
+ let amiId = targetVersion.ami_id;
34
+ if (!manifest.noami && args.region !== cfg.region){
35
+ const amiName = `${appName}-v${targetVersion.version}`;
36
+ amiId = await findAMI(amiName, args.region);
37
+ if (amiId){
38
+ console.log(`Found existing image ${amiId} in ${args.region}`);
39
+ } else {
40
+ console.log(`Copying ${amiName} from ${cfg.region} to ${args.region}`);
41
+ amiId = await copyAMI(amiName, targetVersion.ami_id, cfg.region, args.region);
42
+ }
43
+ await waitForAMI(amiId, args.region);
44
+ }
45
+
46
+ // --- II DEPLOY CF STACK ---
47
+ // CF params: AmiId from the manifest version (unless noami) + any extra CLI args.
48
+ // No previous values are reused
49
+ const cfParams = manifest.noami ? [] : [{ ParameterKey: 'AmiId', ParameterValue: amiId }];
50
+ Object.keys(args).forEach(k=>{
51
+ if (CLI_ARGS.includes(k) || k === 'AmiId'){ return }
52
+ cfParams.push({ ParameterKey: k, ParameterValue: args[k] });
53
+ });
54
+
55
+ const input = {
56
+ StackName: stackName,
57
+ TemplateURL: targetVersion.stack_url,
58
+ Parameters: cfParams,
59
+ Capabilities: ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"],
60
+ // Stack-level tags: CloudFormation propagates these to every created resource
61
+ // that supports tagging - no per-resource tags needed in the template
62
+ Tags: [
63
+ { Key: 'mason', Value: appName },
64
+ { Key: 'version', Value: targetVersion.version },
65
+ { Key: `mason-${appName}`, Value: appName }
66
+ ]
67
+ };
68
+
69
+ // Create if the stack doesn't exist, update if it does
70
+ const client = new CloudFormationClient({ region: args.region });
71
+ const status = await stackStatus(client, stackName);
72
+ let stackId;
73
+ if (status === null){
74
+ console.log(`Creating stack ${stackName}`);
75
+ input.OnFailure = 'DELETE';
76
+ const r = await client.send(new CreateStackCommand(input));
77
+ stackId = r.StackId;
78
+ } else {
79
+ if (status.endsWith('_IN_PROGRESS')){
80
+ console.log(`Stack ${stackName} is ${status}. Wait for completion before relaunching`);
81
+ throw new Error('Stack operation in progress');
82
+ }
83
+ console.log(`Updating stack ${stackName}`);
84
+ try {
85
+ const r = await client.send(new UpdateStackCommand(input));
86
+ stackId = r.StackId;
87
+ } catch (e){
88
+ if (/No updates are to be performed/.test(e)){
89
+ console.log('Stack is already up to date');
90
+ return true;
91
+ }
92
+ throw e;
93
+ }
94
+ }
95
+ console.log('Stack:', stackId);
96
+ return true;
97
+ }
98
+
99
+
100
+ ///////////////////////////////////////////////
101
+ ///////////////////////////////////////////////
102
+ ///////////////////////////////////////////////
103
+
104
+ function parseCdn(cdn){
105
+ const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
106
+ const parts = p.split('/');
107
+ return { bucket: parts[0], prefix: parts.slice(1).join('/') };
108
+ }
109
+
110
+ async function findAMI(amiName, region){
111
+ const client = new EC2Client({ region });
112
+ const r = await client.send(new DescribeImagesCommand({
113
+ Filters: [{ Name: "name", Values: [amiName] }],
114
+ Owners: ['self'],
115
+ IncludeDeprecated: true
116
+ }));
117
+ if (!r.Images || !r.Images[0]){ return null }
118
+ return r.Images[0].ImageId;
119
+ }
120
+
121
+ async function copyAMI(amiName, srcAmiId, srcRegion, destRegion){
122
+ const client = new EC2Client({ region: destRegion });
123
+ const r = await client.send(new CopyImageCommand({
124
+ SourceRegion: srcRegion,
125
+ SourceImageId: srcAmiId,
126
+ Name: amiName
127
+ }));
128
+ return r.ImageId;
129
+ }
130
+
131
+ async function waitForAMI(amiId, region){
132
+ console.log(`Waiting for AMI ${amiId} to be available in ${region}`);
133
+ const client = new EC2Client({ region });
134
+ for (let i=0; i<40; i++){
135
+ const r = await client.send(new DescribeImagesCommand({ ImageIds: [amiId] }));
136
+ if (r.Images && r.Images[0] && r.Images[0].State.toLowerCase() === 'available'){
137
+ console.log(`AMI ${amiId} available after ${i*30}s`);
138
+ return;
139
+ }
140
+ console.log(`\tAMI Status Check ${i} @${i*30}s : Not Available`);
141
+ await new Promise(res=>{ setTimeout(res, 30000) });
142
+ }
143
+ throw new Error('AMI not available after 20 minutes. Try again in a few minutes.');
144
+ }
145
+
146
+ async function stackStatus(client, stackName){
147
+ try {
148
+ const r = await client.send(new DescribeStacksCommand({ StackName: stackName }));
149
+ return r.Stacks[0].StackStatus;
150
+ } catch (e){
151
+ if (e.name === 'ValidationError'){ return null }
152
+ throw e;
153
+ }
154
+ }
155
+