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.
- package/.marketplace/AdditionalResources.json +1 -0
- package/.marketplace/ArchitectureDiagram.png +0 -0
- package/.marketplace/Logo.png +0 -0
- package/.marketplace/LongDescription.txt +0 -0
- package/.marketplace/ShortDescription.txt +0 -0
- package/.marketplace/SupportDescription.txt +0 -0
- package/.marketplace/UsageInstructions.txt +0 -0
- package/.marketplace/VideoUrls.txt +0 -0
- package/README.md +5 -0
- package/commands/app_details.js +108 -0
- package/commands/create_cdn.js +130 -0
- package/commands/delete.js +194 -0
- package/commands/helpers/apps.js +20 -0
- package/commands/helpers/org_config.js +20 -0
- package/commands/init_org.js +10 -0
- package/commands/launch_app.js +155 -0
- package/commands/list_apps.js +48 -0
- package/commands/new_app.js +227 -0
- package/commands/publish.js +254 -0
- package/commands/ssh_build.js +622 -0
- package/commands/update_app.js +279 -0
- package/commands/update_listing.js +124 -0
- package/main.js +202 -0
- package/package.json +38 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { S3Client, ListObjectsV2Command } = require("@aws-sdk/client-s3");
|
|
2
|
+
const OrgConfig = require('./helpers/org_config');
|
|
3
|
+
|
|
4
|
+
// list-apps: every folder in the cdn bucket holding a manifest.json is an app
|
|
5
|
+
exports.main = async function(){
|
|
6
|
+
const cfg = OrgConfig.read();
|
|
7
|
+
if (!cfg){ throw new Error('No org config found. Run set-org') }
|
|
8
|
+
const { bucket, prefix } = parseCdn(cfg.cdn);
|
|
9
|
+
const s3 = new S3Client({ region: cfg.region });
|
|
10
|
+
|
|
11
|
+
const base = prefix ? `${prefix}/` : '';
|
|
12
|
+
const apps = [];
|
|
13
|
+
let token;
|
|
14
|
+
do {
|
|
15
|
+
const r = await s3.send(new ListObjectsV2Command({
|
|
16
|
+
Bucket: bucket,
|
|
17
|
+
Prefix: base,
|
|
18
|
+
ContinuationToken: token
|
|
19
|
+
}));
|
|
20
|
+
(r.Contents || []).forEach(o=>{
|
|
21
|
+
const rel = o.Key.slice(base.length).split('/');
|
|
22
|
+
if (rel.length === 2 && rel[1] === 'manifest.json'){
|
|
23
|
+
const folder = rel[0];
|
|
24
|
+
apps.push(folder.startsWith('_') ? `${folder.slice(1)} (private)` : folder);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
token = r.NextContinuationToken;
|
|
28
|
+
} while (token);
|
|
29
|
+
|
|
30
|
+
if (apps.length === 0){
|
|
31
|
+
console.log('No apps found in ' + bucket);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
apps.sort().forEach(a=>{ console.log(a) });
|
|
35
|
+
console.log(`\n${apps.length} app${apps.length === 1 ? '' : 's'}`);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
/////////////////////////////////////////
|
|
41
|
+
////////////// FUNCS ////////////////////
|
|
42
|
+
////////////////////////////////////////
|
|
43
|
+
|
|
44
|
+
function parseCdn(cdn){
|
|
45
|
+
const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
|
|
46
|
+
const parts = p.split('/');
|
|
47
|
+
return { bucket: parts[0], prefix: parts.slice(1).join('/') };
|
|
48
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } = require("@aws-sdk/client-s3");
|
|
4
|
+
const { IAMClient, GetRoleCommand, CreateRoleCommand, PutRolePolicyCommand, GetOpenIDConnectProviderCommand, CreateOpenIDConnectProviderCommand } = require("@aws-sdk/client-iam");
|
|
5
|
+
const { STSClient, GetCallerIdentityCommand } = require("@aws-sdk/client-sts");
|
|
6
|
+
const OrgConfig = require('./helpers/org_config');
|
|
7
|
+
|
|
8
|
+
const TEMPLATE_DIR = path.resolve(__dirname, '..', '.marketplace');
|
|
9
|
+
|
|
10
|
+
// Base manifest for a new app
|
|
11
|
+
const MANIFEST_TEMPLATE = {
|
|
12
|
+
name: "",
|
|
13
|
+
productId: "",
|
|
14
|
+
RecommendedInstanceType: "r8g.medium",
|
|
15
|
+
versions: []
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// new-app: create the app's core assets in s3, the local template folder,
|
|
19
|
+
// and (if -repo) the GitHub Actions CI role. Listing content is set by update-listing
|
|
20
|
+
exports.main = async function(args){
|
|
21
|
+
const cfg = OrgConfig.read();
|
|
22
|
+
if (!cfg){ throw new Error('No org config found. Run set-org') }
|
|
23
|
+
const { bucket, prefix } = parseCdn(cfg.cdn);
|
|
24
|
+
const s3 = new S3Client({ region: cfg.region });
|
|
25
|
+
|
|
26
|
+
const appName = args.name.toLowerCase();
|
|
27
|
+
// -private: leading _ keeps the folder out of the CDN bucket's public policy
|
|
28
|
+
const folderName = (args.private !== undefined ? '_' : '') + appName;
|
|
29
|
+
const appPrefix = prefix ? `${prefix}/${folderName}` : folderName;
|
|
30
|
+
|
|
31
|
+
// --- I CORE ASSETS ---
|
|
32
|
+
const exists = await appFolderExists(s3, bucket, appPrefix);
|
|
33
|
+
if (exists){
|
|
34
|
+
console.log(`App folder exists: s3://${bucket}/${appPrefix}`);
|
|
35
|
+
} else {
|
|
36
|
+
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: `${appPrefix}/` }));
|
|
37
|
+
console.log(`Created app folder s3://${bucket}/${appPrefix}`);
|
|
38
|
+
await createManifest(s3, bucket, appPrefix, args.name, args.noami !== undefined, args.private !== undefined);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- II LOCAL TEMPLATES ---
|
|
42
|
+
createLocalMarketplace();
|
|
43
|
+
|
|
44
|
+
// --- III REPO ROLE ---
|
|
45
|
+
if (args.repo){
|
|
46
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(args.repo)){ throw new Error('Invalid repo. Use org/repo format') }
|
|
47
|
+
const manifest = await getManifest(s3, bucket, appPrefix);
|
|
48
|
+
await ensureGithubRole(args.repo, bucket, appPrefix, appName, manifest.productId);
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/////////////////////////////////////////
|
|
55
|
+
////////////// FUNCS ////////////////////
|
|
56
|
+
////////////////////////////////////////
|
|
57
|
+
|
|
58
|
+
function parseCdn(cdn){
|
|
59
|
+
const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
|
|
60
|
+
const parts = p.split('/');
|
|
61
|
+
return { bucket: parts[0], prefix: parts.slice(1).join('/') };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function appFolderExists(s3, bucket, appPrefix){
|
|
65
|
+
const r = await s3.send(new ListObjectsV2Command({
|
|
66
|
+
Bucket: bucket,
|
|
67
|
+
Prefix: `${appPrefix}/`,
|
|
68
|
+
MaxKeys: 1
|
|
69
|
+
}));
|
|
70
|
+
return r.KeyCount > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function createManifest(s3, bucket, appPrefix, appName, noami, isPrivate){
|
|
74
|
+
const manifest = JSON.parse(JSON.stringify(MANIFEST_TEMPLATE));
|
|
75
|
+
manifest.name = appName;
|
|
76
|
+
manifest.versions = [];
|
|
77
|
+
if (noami){ manifest.noami = true }
|
|
78
|
+
if (isPrivate){ manifest.private = true }
|
|
79
|
+
await s3.send(new PutObjectCommand({
|
|
80
|
+
Bucket: bucket,
|
|
81
|
+
Key: `${appPrefix}/manifest.json`,
|
|
82
|
+
Body: JSON.stringify(manifest, null, 4),
|
|
83
|
+
ContentType: 'application/json'
|
|
84
|
+
}));
|
|
85
|
+
console.log(`Created manifest s3://${bucket}/${appPrefix}/manifest.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createLocalMarketplace(){
|
|
89
|
+
const destDir = path.join(process.cwd(), '.marketplace');
|
|
90
|
+
if (!fs.existsSync(destDir)){ fs.mkdirSync(destDir, { recursive: true }) }
|
|
91
|
+
|
|
92
|
+
const srcDir = TEMPLATE_DIR;
|
|
93
|
+
if (destDir === srcDir){ return } // running from the cloudmason repo itself
|
|
94
|
+
fs.readdirSync(srcDir).forEach(f=>{
|
|
95
|
+
if (f.startsWith('.')){ return } // skip .DS_Store etc
|
|
96
|
+
const destFile = path.join(destDir, f);
|
|
97
|
+
if (fs.existsSync(destFile)){ return } // never overwrite edited assets
|
|
98
|
+
fs.copyFileSync(path.join(srcDir, f), destFile);
|
|
99
|
+
});
|
|
100
|
+
console.log(`Created ${destDir}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Create a role GitHub Actions can assume via OIDC from the given repo.
|
|
104
|
+
// Permissions cover update-listing, update-app, launch, and publish - not new-app
|
|
105
|
+
async function ensureGithubRole(repo, bucket, appPrefix, appName, productId){
|
|
106
|
+
const roleName = `mason-gha-${appName}`;
|
|
107
|
+
const iam = new IAMClient({ region: 'us-east-1' });
|
|
108
|
+
const sts = new STSClient({ region: 'us-east-1' });
|
|
109
|
+
const accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
110
|
+
|
|
111
|
+
const oidcArn = await ensureOidcProvider(iam, accountId);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const existing = await iam.send(new GetRoleCommand({ RoleName: roleName }));
|
|
115
|
+
console.log(`Role ${roleName} already exists (${existing.Role.Arn})`);
|
|
116
|
+
return existing.Role.Arn;
|
|
117
|
+
} catch (e){
|
|
118
|
+
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Trust: only GitHub Actions workflows from this repo
|
|
122
|
+
const trustPolicy = {
|
|
123
|
+
Version: "2012-10-17",
|
|
124
|
+
Statement: [
|
|
125
|
+
{
|
|
126
|
+
Effect: "Allow",
|
|
127
|
+
Principal: { Federated: oidcArn },
|
|
128
|
+
Action: "sts:AssumeRoleWithWebIdentity",
|
|
129
|
+
Condition: {
|
|
130
|
+
StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
|
|
131
|
+
StringLike: { "token.actions.githubusercontent.com:sub": `repo:${repo}:*` }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
};
|
|
136
|
+
const created = await iam.send(new CreateRoleCommand({
|
|
137
|
+
RoleName: roleName,
|
|
138
|
+
AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
|
|
139
|
+
Description: `Mason CI role for ${appName} (github.com/${repo})`
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// Scoped to the app prefix, product id, and app stack names where the
|
|
143
|
+
// services support it. EC2 build permissions cannot be scoped
|
|
144
|
+
if (!productId){ console.log('WARNING: No productId in manifest. Marketplace access is scoped to all products. Re-create the role after update-listing -pid to scope it') }
|
|
145
|
+
const marketplaceEntity = productId
|
|
146
|
+
? `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/${productId}`
|
|
147
|
+
: `arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/AmiProduct/*`;
|
|
148
|
+
const policy = {
|
|
149
|
+
Version: "2012-10-17",
|
|
150
|
+
Statement: [
|
|
151
|
+
{
|
|
152
|
+
Sid: "AppPrefixWrite",
|
|
153
|
+
Effect: "Allow",
|
|
154
|
+
Action: ["s3:GetObject", "s3:PutObject"],
|
|
155
|
+
Resource: `arn:aws:s3:::${bucket}/${appPrefix}/*`
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
Sid: "AppPrefixList",
|
|
159
|
+
Effect: "Allow",
|
|
160
|
+
Action: "s3:ListBucket",
|
|
161
|
+
Resource: `arn:aws:s3:::${bucket}`,
|
|
162
|
+
Condition: { StringLike: { "s3:prefix": `${appPrefix}/*` } }
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
Sid: "MarketplacePublish",
|
|
166
|
+
Effect: "Allow",
|
|
167
|
+
Action: ["aws-marketplace:StartChangeSet", "aws-marketplace:DescribeChangeSet", "aws-marketplace:DescribeEntity"],
|
|
168
|
+
Resource: [
|
|
169
|
+
marketplaceEntity,
|
|
170
|
+
`arn:aws:aws-marketplace:us-east-1:${accountId}:AWSMarketplace/ChangeSet/*`
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
Sid: "AppStacks",
|
|
175
|
+
Effect: "Allow",
|
|
176
|
+
Action: ["cloudformation:CreateStack", "cloudformation:UpdateStack", "cloudformation:DescribeStacks"],
|
|
177
|
+
Resource: `arn:aws:cloudformation:*:${accountId}:stack/${appName}-*/*`
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
Sid: "ReadRoleParam",
|
|
181
|
+
Effect: "Allow",
|
|
182
|
+
Action: "ssm:GetParameter",
|
|
183
|
+
Resource: `arn:aws:ssm:us-east-1:${accountId}:parameter/mason-cdn-${bucket}`
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
Sid: "AmiBuildAndLaunch",
|
|
187
|
+
Effect: "Allow",
|
|
188
|
+
Action: ["ec2:*", "autoscaling:*", "elasticloadbalancing:*"],
|
|
189
|
+
Resource: "*"
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
};
|
|
193
|
+
await iam.send(new PutRolePolicyCommand({
|
|
194
|
+
RoleName: roleName,
|
|
195
|
+
PolicyName: 'mason-app-access',
|
|
196
|
+
PolicyDocument: JSON.stringify(policy)
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
console.log(`Created role ${roleName} (${created.Role.Arn})`);
|
|
200
|
+
console.log(`Use in GitHub Actions with: role-to-assume: ${created.Role.Arn}`);
|
|
201
|
+
return created.Role.Arn;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ensureOidcProvider(iam, accountId){
|
|
205
|
+
const arn = `arn:aws:iam::${accountId}:oidc-provider/token.actions.githubusercontent.com`;
|
|
206
|
+
try {
|
|
207
|
+
await iam.send(new GetOpenIDConnectProviderCommand({ OpenIDConnectProviderArn: arn }));
|
|
208
|
+
return arn;
|
|
209
|
+
} catch (e){
|
|
210
|
+
if (!/NoSuchEntity/.test(e)){ throw e }
|
|
211
|
+
}
|
|
212
|
+
await iam.send(new CreateOpenIDConnectProviderCommand({
|
|
213
|
+
Url: 'https://token.actions.githubusercontent.com',
|
|
214
|
+
ClientIDList: ['sts.amazonaws.com'],
|
|
215
|
+
ThumbprintList: ['6938fd4d98bab03faadb97b34396831e3780aea1']
|
|
216
|
+
}));
|
|
217
|
+
console.log('Created GitHub OIDC identity provider');
|
|
218
|
+
return arn;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getManifest(s3, bucket, appPrefix){
|
|
222
|
+
const r = await s3.send(new GetObjectCommand({
|
|
223
|
+
Bucket: bucket,
|
|
224
|
+
Key: `${appPrefix}/manifest.json`
|
|
225
|
+
}));
|
|
226
|
+
return JSON.parse(await r.Body.transformToString());
|
|
227
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const { MarketplaceCatalogClient, StartChangeSetCommand, DescribeChangeSetCommand } = require("@aws-sdk/client-marketplace-catalog");
|
|
2
|
+
const { S3Client, PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
|
|
3
|
+
const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");
|
|
4
|
+
const OrgConfig = require('./helpers/org_config');
|
|
5
|
+
const Apps = require('./helpers/apps');
|
|
6
|
+
|
|
7
|
+
// The Marketplace Catalog API is only available in us-east-1
|
|
8
|
+
const CATALOG_REGION = 'us-east-1';
|
|
9
|
+
const DELIVERY_OPTION_TITLE = 'AMI with CloudFormation Template';
|
|
10
|
+
|
|
11
|
+
// Manifest fields required by the AddDeliveryOptions change request
|
|
12
|
+
const REQUIRED_MANIFEST = ['productId', 'ShortDescription', 'LongDescription', 'UsageInstructions', 'RecommendedInstanceType', 'ArchitectureDiagram'];
|
|
13
|
+
|
|
14
|
+
exports.main = async function(args){
|
|
15
|
+
const cfg = OrgConfig.read();
|
|
16
|
+
if (!cfg){ throw new Error('No org config found. Run set-org') }
|
|
17
|
+
const { bucket, prefix } = parseCdn(cfg.cdn);
|
|
18
|
+
const s3 = new S3Client({ region: cfg.region });
|
|
19
|
+
|
|
20
|
+
const appName = args.app.toLowerCase();
|
|
21
|
+
|
|
22
|
+
// Get App + target version
|
|
23
|
+
const app = await Apps.resolveApp(s3, bucket, prefix, appName);
|
|
24
|
+
if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
|
|
25
|
+
const { appPrefix, manifest } = app;
|
|
26
|
+
if (manifest.private){ throw new Error('Private apps cannot be published to marketplace') }
|
|
27
|
+
// -v accepts the major.minor or the full version name; the full name is used throughout
|
|
28
|
+
const version = manifest.versions.find(v=>{ return v.version === args.v || v.version.startsWith(args.v + '.') });
|
|
29
|
+
if (!version){ throw new Error(`No version ${args.v} of ${args.app}. Run update-app`) }
|
|
30
|
+
const fullVersion = version.version;
|
|
31
|
+
const vFolder = fullVersion.split('.').slice(0, 2).join('.'); // storage folder is major.minor
|
|
32
|
+
|
|
33
|
+
// --- I CHECK REQUIRED FIELDS ---
|
|
34
|
+
const role = await getMarketplaceRole(bucket);
|
|
35
|
+
if (!role){ throw new Error('No marketplace access role found for this CDN. Run create-cdn first') }
|
|
36
|
+
const missing = REQUIRED_MANIFEST.filter(k=>{ return !manifest[k] });
|
|
37
|
+
if (!version.ami_id){ missing.push('versions[].ami_id') }
|
|
38
|
+
if (!version.stack_url){ missing.push('versions[].stack_url') }
|
|
39
|
+
if (missing.length){
|
|
40
|
+
throw new Error('Missing required manifest fields: ' + missing.join(', ') + '. Run update-listing to set them');
|
|
41
|
+
}
|
|
42
|
+
const arch = /[0-9]g/.test(manifest.RecommendedInstanceType.split('.')[0]) ? 'arm' : 'x86_64';
|
|
43
|
+
|
|
44
|
+
console.log(`Publishing ${args.app} v${version.version} to product ${manifest.productId}`);
|
|
45
|
+
|
|
46
|
+
// --- II CREATE UPGRADE TEMPLATE ---
|
|
47
|
+
// Copy of the stack with the AmiId parameter stripped and refs hardcoded to the
|
|
48
|
+
// region-resolving marketplace SSM alias, for buyers upgrading an existing stack
|
|
49
|
+
const stackKey = `${appPrefix}/versions/${vFolder}/stack.yaml`;
|
|
50
|
+
const stackText = await getFileText(s3, bucket, stackKey);
|
|
51
|
+
if (stackText === null){ throw new Error('No stack found for v' + fullVersion) }
|
|
52
|
+
|
|
53
|
+
const amiAlias = `resolve:ssm:/aws/service/marketplace/${manifest.productId}/${fullVersion}`;
|
|
54
|
+
const upgradeText = buildUpgradeTemplate(stackText, amiAlias);
|
|
55
|
+
const upgradeKey = `${appPrefix}/versions/${vFolder}/upgrade.yaml`;
|
|
56
|
+
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: upgradeKey, Body: upgradeText }));
|
|
57
|
+
console.log(`Uploaded upgrade template s3://${bucket}/${upgradeKey}`);
|
|
58
|
+
|
|
59
|
+
// --- III START CHANGE SET ---
|
|
60
|
+
const client = new MarketplaceCatalogClient({ region: CATALOG_REGION });
|
|
61
|
+
const changeSet = {
|
|
62
|
+
Catalog: "AWSMarketplace",
|
|
63
|
+
Intent: "APPLY",
|
|
64
|
+
ChangeSet: [
|
|
65
|
+
{
|
|
66
|
+
ChangeType: "AddDeliveryOptions",
|
|
67
|
+
Entity: {
|
|
68
|
+
Type: "AmiProduct@1.0",
|
|
69
|
+
Identifier: manifest.productId
|
|
70
|
+
},
|
|
71
|
+
Details: JSON.stringify({
|
|
72
|
+
Version: {
|
|
73
|
+
VersionTitle: fullVersion,
|
|
74
|
+
ReleaseNotes: args.desc
|
|
75
|
+
},
|
|
76
|
+
DeliveryOptions: [
|
|
77
|
+
{
|
|
78
|
+
DeliveryOptionTitle: DELIVERY_OPTION_TITLE,
|
|
79
|
+
Details: {
|
|
80
|
+
"DeploymentTemplateDeliveryOptionDetails": {
|
|
81
|
+
"ShortDescription": manifest.ShortDescription,
|
|
82
|
+
"LongDescription": manifest.LongDescription,
|
|
83
|
+
"UsageInstructions": manifest.UsageInstructions,
|
|
84
|
+
"RecommendedInstanceType": manifest.RecommendedInstanceType,
|
|
85
|
+
"ArchitectureDiagram": manifest.ArchitectureDiagram,
|
|
86
|
+
"Template": version.stack_url,
|
|
87
|
+
"TemplateSources": [
|
|
88
|
+
{
|
|
89
|
+
"ParameterName": "AmiId",
|
|
90
|
+
"AmiSource": {
|
|
91
|
+
"AmiId": version.ami_id,
|
|
92
|
+
"AccessRoleArn": role,
|
|
93
|
+
"UserName": "ec2-user",
|
|
94
|
+
"OperatingSystemName": "AMAZONLINUX",
|
|
95
|
+
"OperatingSystemVersion": arch === 'arm'
|
|
96
|
+
? "Amazon Linux 2023 arm64 HVM"
|
|
97
|
+
: "Amazon Linux 2023 x86_64 HVM"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
};
|
|
109
|
+
const startResponse = await client.send(new StartChangeSetCommand(changeSet));
|
|
110
|
+
const changeSetId = startResponse.ChangeSetId;
|
|
111
|
+
console.log('Change set started:', changeSetId);
|
|
112
|
+
|
|
113
|
+
// --- IV UPDATE MANIFEST ---
|
|
114
|
+
if (version.published !== true){
|
|
115
|
+
version.published = true;
|
|
116
|
+
version.publish_date = new Date().toISOString();
|
|
117
|
+
await putManifest(s3, bucket, appPrefix, manifest);
|
|
118
|
+
console.log(`Marked v${version.version} published in manifest`);
|
|
119
|
+
} else {
|
|
120
|
+
console.log(`v${version.version} already marked published (${version.publish_date})`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- V AWAIT ---
|
|
124
|
+
if (args.await !== undefined){
|
|
125
|
+
const status = await awaitChangeSet(client, changeSetId);
|
|
126
|
+
if (status === 'FAILED'){
|
|
127
|
+
version.published = false;
|
|
128
|
+
await putManifest(s3, bucket, appPrefix, manifest);
|
|
129
|
+
throw new Error('Publish change set failed');
|
|
130
|
+
}
|
|
131
|
+
if (status === 'TIMEOUT'){
|
|
132
|
+
console.log('Change set still in progress after 90 minutes. Check the marketplace portal for final status');
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log('Publish requested. Run with -await to wait for marketplace processing');
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
///////////////////////////////////////////////
|
|
142
|
+
///////////////////////////////////////////////
|
|
143
|
+
///////////////////////////////////////////////
|
|
144
|
+
|
|
145
|
+
function parseCdn(cdn){
|
|
146
|
+
const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
|
|
147
|
+
const parts = p.split('/');
|
|
148
|
+
return { bucket: parts[0], prefix: parts.slice(1).join('/') };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Poll the change set every 30s for up to 90 minutes
|
|
152
|
+
async function awaitChangeSet(client, changeSetId){
|
|
153
|
+
for (let i=0; i<180; i++){
|
|
154
|
+
await new Promise(res=>{ setTimeout(res, 30000) });
|
|
155
|
+
const r = await client.send(new DescribeChangeSetCommand({
|
|
156
|
+
Catalog: "AWSMarketplace",
|
|
157
|
+
ChangeSetId: changeSetId
|
|
158
|
+
}));
|
|
159
|
+
console.log(`Change set status @${Math.round((i+1)*0.5)}m: ${r.Status}`);
|
|
160
|
+
if (r.Status === 'SUCCEEDED'){ return 'SUCCEEDED' }
|
|
161
|
+
if (r.Status === 'FAILED' || r.Status === 'CANCELLED'){
|
|
162
|
+
(r.ChangeSet || []).forEach(c=>{
|
|
163
|
+
(c.ErrorDetailList || []).forEach(e=>{ console.log(`\t${e.ErrorCode}: ${e.ErrorMessage}`) });
|
|
164
|
+
});
|
|
165
|
+
return 'FAILED';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return 'TIMEOUT';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Strip the AmiId parameter and hardcode all references to the marketplace SSM alias
|
|
172
|
+
function buildUpgradeTemplate(stackText, amiAlias){
|
|
173
|
+
if (stackText.trim().startsWith('{')){
|
|
174
|
+
const tpl = JSON.parse(stackText);
|
|
175
|
+
if (!tpl.Parameters || !tpl.Parameters.AmiId){ throw new Error('Stack has no AmiId parameter') }
|
|
176
|
+
delete tpl.Parameters.AmiId;
|
|
177
|
+
if (Object.keys(tpl.Parameters).length === 0){ delete tpl.Parameters }
|
|
178
|
+
return JSON.stringify(replaceAmiRefs(tpl, amiAlias), null, 4);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let lines = stackText.split(/\r?\n/);
|
|
182
|
+
const pStart = lines.findIndex(l=>{ return /^Parameters:\s*(#.*)?$/.test(l) });
|
|
183
|
+
let amiLine = -1;
|
|
184
|
+
let amiIndent = 0;
|
|
185
|
+
if (pStart > -1){
|
|
186
|
+
for (let i=pStart+1; i<lines.length; i++){
|
|
187
|
+
if (/^\S/.test(lines[i])){ break }
|
|
188
|
+
const m = lines[i].match(/^(\s+)AmiId:\s*(#.*)?$/);
|
|
189
|
+
if (m){ amiLine = i; amiIndent = m[1].length; break }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (amiLine === -1){ throw new Error('Stack has no AmiId parameter') }
|
|
193
|
+
|
|
194
|
+
// Remove the AmiId parameter block
|
|
195
|
+
let blockEnd = lines.length;
|
|
196
|
+
for (let i=amiLine+1; i<lines.length; i++){
|
|
197
|
+
if (lines[i].trim() === ''){ continue }
|
|
198
|
+
if (lines[i].match(/^\s*/)[0].length <= amiIndent){ blockEnd = i; break }
|
|
199
|
+
}
|
|
200
|
+
lines.splice(amiLine, blockEnd - amiLine);
|
|
201
|
+
|
|
202
|
+
// Drop the Parameters section entirely if AmiId was its only entry
|
|
203
|
+
const nextLine = lines[pStart+1];
|
|
204
|
+
if (nextLine === undefined || /^\S/.test(nextLine)){ lines.splice(pStart, 1) }
|
|
205
|
+
|
|
206
|
+
// Hardcode references to the region-resolving alias
|
|
207
|
+
return lines.join('\n')
|
|
208
|
+
.replace(/!Ref\s+AmiId\b/g, `'${amiAlias}'`)
|
|
209
|
+
.replace(/Ref:\s*AmiId\b/g, `'${amiAlias}'`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function replaceAmiRefs(obj, amiAlias){
|
|
213
|
+
if (Array.isArray(obj)){ return obj.map(o=>{ return replaceAmiRefs(o, amiAlias) }) }
|
|
214
|
+
if (obj && typeof obj === 'object'){
|
|
215
|
+
const keys = Object.keys(obj);
|
|
216
|
+
if (keys.length === 1 && keys[0] === 'Ref' && obj.Ref === 'AmiId'){ return amiAlias }
|
|
217
|
+
const out = {};
|
|
218
|
+
keys.forEach(k=>{ out[k] = replaceAmiRefs(obj[k], amiAlias) });
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
return obj;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The role param is named per CDN bucket so one account can hold multiple cdns/roles.
|
|
225
|
+
// create-cdn stores it in us-east-1
|
|
226
|
+
async function getMarketplaceRole(bucket){
|
|
227
|
+
const ssm = new SSMClient({ region: 'us-east-1' });
|
|
228
|
+
try {
|
|
229
|
+
const r = await ssm.send(new GetParameterCommand({ Name: `mason-cdn-${bucket}` }));
|
|
230
|
+
return r.Parameter.Value;
|
|
231
|
+
} catch (e){
|
|
232
|
+
if (/ParameterNotFound/.test(e)){ return null }
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function getFileText(s3, bucket, key){
|
|
238
|
+
try {
|
|
239
|
+
const r = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
240
|
+
return await r.Body.transformToString();
|
|
241
|
+
} catch (e){
|
|
242
|
+
if (/NoSuchKey|NotFound/.test(e)){ return null }
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function putManifest(s3, bucket, appPrefix, manifest){
|
|
248
|
+
await s3.send(new PutObjectCommand({
|
|
249
|
+
Bucket: bucket,
|
|
250
|
+
Key: `${appPrefix}/manifest.json`,
|
|
251
|
+
Body: JSON.stringify(manifest, null, 4),
|
|
252
|
+
ContentType: 'application/json'
|
|
253
|
+
}));
|
|
254
|
+
}
|