@metaplay/metaplay-auth 1.2.1 → 1.4.1
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/CHANGELOG.md +29 -0
- package/README.md +0 -42
- package/dist/index.js +395 -48
- package/dist/index.js.map +1 -1
- package/dist/src/auth.js +46 -8
- package/dist/src/auth.js.map +1 -1
- package/dist/src/deployment.js +72 -53
- package/dist/src/deployment.js.map +1 -1
- package/dist/src/stackapi.js +170 -17
- package/dist/src/stackapi.js.map +1 -1
- package/dist/src/utils.js +21 -0
- package/dist/src/utils.js.map +1 -1
- package/index.ts +421 -55
- package/package.json +13 -5
- package/src/auth.ts +50 -8
- package/src/deployment.ts +70 -57
- package/src/stackapi.ts +230 -19
- package/src/utils.ts +37 -0
package/index.ts
CHANGED
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander'
|
|
3
|
+
import Docker from 'dockerode'
|
|
3
4
|
import { loginAndSaveTokens, machineLoginAndSaveTokens, extendCurrentSession, loadTokens, removeTokens } from './src/auth.js'
|
|
4
|
-
import { StackAPI } from './src/stackapi.js'
|
|
5
|
+
import { StackAPI, type GameserverId } from './src/stackapi.js'
|
|
5
6
|
import { checkGameServerDeployment } from './src/deployment.js'
|
|
6
7
|
import { logger, setLogLevel } from './src/logging.js'
|
|
8
|
+
import { isValidFQDN, executeCommand } from './src/utils.js'
|
|
7
9
|
import { exit } from 'process'
|
|
8
10
|
import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'
|
|
11
|
+
import { tmpdir } from 'os'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { randomBytes } from 'crypto'
|
|
14
|
+
import { writeFile, unlink } from 'fs/promises'
|
|
15
|
+
import { existsSync } from 'fs'
|
|
16
|
+
import { KubeConfig } from '@kubernetes/client-node'
|
|
17
|
+
import * as semver from 'semver'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Helper for parsing the GameserverId type from the command line arguments. Accepts either the gameserver address
|
|
21
|
+
* (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
|
|
22
|
+
* tuple from options.
|
|
23
|
+
*/
|
|
24
|
+
function resolveGameserverId (address: string | undefined, options: any): GameserverId {
|
|
25
|
+
// If address is specified, use it, otherwise assume options has organization, project, and environment
|
|
26
|
+
if (address) {
|
|
27
|
+
if (isValidFQDN(address)) {
|
|
28
|
+
return { gameserver: address }
|
|
29
|
+
} else {
|
|
30
|
+
const parts = address.split('-')
|
|
31
|
+
if (parts.length !== 3) {
|
|
32
|
+
throw new Error('Invalid gameserver address syntax: specify either <organization>-<project>-<environment> or a fully-qualified domain name (eg, idler-develop.p1.metaplay.io)')
|
|
33
|
+
}
|
|
34
|
+
return { organization: parts[0], project: parts[1], environment: parts[2] }
|
|
35
|
+
}
|
|
36
|
+
} else if (options.organization && options.project && options.environment) {
|
|
37
|
+
return { organization: options.organization, project: options.project, environment: options.environment }
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error('Could not determine target environment from arguments: You need to specify either a gameserver address or an organization, project, and environment. Run this command with --help flag for more information.')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
9
42
|
|
|
10
43
|
const program = new Command()
|
|
11
44
|
|
|
12
45
|
program
|
|
13
46
|
.name('metaplay-auth')
|
|
14
47
|
.description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
|
|
15
|
-
.version('1.
|
|
48
|
+
.version('1.4.1')
|
|
16
49
|
.option('-d, --debug', 'enable debug output')
|
|
17
50
|
.hook('preAction', (thisCommand) => {
|
|
18
51
|
// Handle debug flag for all commands.
|
|
@@ -94,43 +127,92 @@ program.command('show-tokens')
|
|
|
94
127
|
})
|
|
95
128
|
|
|
96
129
|
program.command('get-kubeconfig')
|
|
97
|
-
.description('get kubeconfig for
|
|
98
|
-
.argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
|
|
130
|
+
.description('get kubeconfig for target environment')
|
|
131
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
99
132
|
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
100
133
|
.option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
|
|
101
134
|
.option('-p, --project <project>', 'project name (e.g. idler)')
|
|
102
135
|
.option('-e, --environment <environment>', 'environment name (e.g. develop)')
|
|
136
|
+
.option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
|
|
137
|
+
.option('--output <kubeconfig-path>', 'path of the output file where to write kubeconfig (written to stdout if not specified)')
|
|
103
138
|
.hook('preAction', async () => {
|
|
104
139
|
await extendCurrentSession()
|
|
105
140
|
})
|
|
106
141
|
.action(async (gameserver, options) => {
|
|
107
142
|
try {
|
|
108
143
|
const tokens = await loadTokens()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
145
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
146
|
+
|
|
147
|
+
// Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
|
|
148
|
+
const isHumanUser = !!tokens.refresh_token
|
|
149
|
+
const credentialsType = options.credentialsType ?? (isHumanUser ? 'dynamic' : 'static')
|
|
150
|
+
|
|
151
|
+
// Generate kubeconfig
|
|
152
|
+
let kubeconfigPayload
|
|
153
|
+
if (credentialsType === 'dynamic') {
|
|
154
|
+
logger.debug('Fetching kubeconfig with execcredential')
|
|
155
|
+
kubeconfigPayload = await stackApi.getKubeConfigExecCredential(gameserverId)
|
|
156
|
+
} else if (credentialsType === 'static') {
|
|
157
|
+
logger.debug('Fetching kubeconfig with embedded secret')
|
|
158
|
+
kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
|
|
159
|
+
} else {
|
|
160
|
+
throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
|
|
112
161
|
}
|
|
113
162
|
|
|
114
|
-
|
|
115
|
-
if (options.
|
|
116
|
-
|
|
163
|
+
// Write kubeconfig to output (file or stdout)
|
|
164
|
+
if (options.output) {
|
|
165
|
+
logger.debug(`Writing kubeconfig to file ${options.output}`)
|
|
166
|
+
await writeFile(options.output, kubeconfigPayload, { mode: 0o600 })
|
|
167
|
+
console.log(`Wrote kubeconfig to ${options.output}`)
|
|
168
|
+
} else {
|
|
169
|
+
console.log(kubeconfigPayload)
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof Error) {
|
|
173
|
+
console.error('Error getting KubeConfig:', error)
|
|
117
174
|
}
|
|
175
|
+
exit(1)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
118
178
|
|
|
119
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Get the Kubernetes credentials in the execcredential format which can be used within the `kubeconfig` file:
|
|
181
|
+
* The kubeconfig can invoke this command to fetch the Kubernetes credentials just-in-time which allows us to
|
|
182
|
+
* generate kubeconfig files that don't contain access tokens and are longer-lived (the authentication is the
|
|
183
|
+
* same as that of metaplay-auth itself). Use `metaplay-auth get-kubeconfig -t dynamic ...` to create a
|
|
184
|
+
* kubeconfig that uses this command.
|
|
185
|
+
*/
|
|
186
|
+
// todo: maybe this should be a hidden command as it's not very useful for end users and clutters help?
|
|
187
|
+
program.command('get-kubernetes-execcredential')
|
|
188
|
+
.description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
|
|
189
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
190
|
+
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
191
|
+
.option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
|
|
192
|
+
.option('-p, --project <project>', 'project name (e.g. idler)')
|
|
193
|
+
.option('-e, --environment <environment>', 'environment name (e.g. develop)')
|
|
194
|
+
.hook('preAction', async () => {
|
|
195
|
+
await extendCurrentSession()
|
|
196
|
+
})
|
|
197
|
+
.action(async (gameserver, options) => {
|
|
198
|
+
try {
|
|
199
|
+
const tokens = await loadTokens()
|
|
200
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
201
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
120
202
|
|
|
121
|
-
const credentials = await stackApi.
|
|
203
|
+
const credentials = await stackApi.getKubeExecCredential(gameserverId)
|
|
122
204
|
console.log(credentials)
|
|
123
205
|
} catch (error) {
|
|
124
206
|
if (error instanceof Error) {
|
|
125
|
-
console.error(`Error getting
|
|
207
|
+
console.error(`Error getting Kubernetes ExecCredential: ${error.message}`)
|
|
126
208
|
}
|
|
127
209
|
exit(1)
|
|
128
210
|
}
|
|
129
211
|
})
|
|
130
212
|
|
|
131
213
|
program.command('get-aws-credentials')
|
|
132
|
-
.description('get AWS credentials for
|
|
133
|
-
.argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
|
|
214
|
+
.description('get AWS credentials for target environment')
|
|
215
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
134
216
|
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
135
217
|
.option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
|
|
136
218
|
.option('-p, --project <project>', 'project name (e.g. idler)')
|
|
@@ -146,19 +228,10 @@ program.command('get-aws-credentials')
|
|
|
146
228
|
}
|
|
147
229
|
|
|
148
230
|
const tokens = await loadTokens()
|
|
231
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
232
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
149
233
|
|
|
150
|
-
|
|
151
|
-
throw new Error('Could not determine a deployment to fetch the AWS credentials from. You need to specify either a gameserver or an organization, project, and environment')
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const stackApi = new StackAPI(tokens.access_token)
|
|
155
|
-
if (options.stackApi) {
|
|
156
|
-
stackApi.stack_api_base_uri = options.stackApi
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment }
|
|
160
|
-
|
|
161
|
-
const credentials = await stackApi.getAwsCredentials(payload)
|
|
234
|
+
const credentials = await stackApi.getAwsCredentials(gameserverId)
|
|
162
235
|
|
|
163
236
|
if (options.format === 'env') {
|
|
164
237
|
console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
|
|
@@ -179,8 +252,8 @@ program.command('get-aws-credentials')
|
|
|
179
252
|
})
|
|
180
253
|
|
|
181
254
|
program.command('get-docker-login')
|
|
182
|
-
.description('get docker login credentials for pushing the server image')
|
|
183
|
-
.argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
|
|
255
|
+
.description('get docker login credentials for pushing the server image to target environment')
|
|
256
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
184
257
|
.option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
|
|
185
258
|
.option('-p, --project <project>', 'project name (e.g. idler)')
|
|
186
259
|
.option('-e, --environment <environment>', 'environment name (e.g. develop)')
|
|
@@ -195,24 +268,16 @@ program.command('get-docker-login')
|
|
|
195
268
|
}
|
|
196
269
|
|
|
197
270
|
const tokens = await loadTokens()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
throw new Error('Could not determine a game server deployment. You need to specify either a gameserver or an organization, project, and environment')
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const stackApi = new StackAPI(tokens.access_token)
|
|
204
|
-
if (options.stackApi) {
|
|
205
|
-
stackApi.stack_api_base_uri = options.stackApi
|
|
206
|
-
}
|
|
271
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
272
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
207
273
|
|
|
208
274
|
// Fetch AWS credentials from Metaplay cloud
|
|
209
275
|
logger.debug('Get AWS credentials from Metaplay')
|
|
210
|
-
const
|
|
211
|
-
const credentials = await stackApi.getAwsCredentials(payload)
|
|
276
|
+
const credentials = await stackApi.getAwsCredentials(gameserverId)
|
|
212
277
|
|
|
213
278
|
// Get environment info (region is needed for ECR)
|
|
214
279
|
logger.debug('Get environment info')
|
|
215
|
-
const environment = await stackApi.getEnvironmentDetails(
|
|
280
|
+
const environment = await stackApi.getEnvironmentDetails(gameserverId)
|
|
216
281
|
const awsRegion = environment.deployment.aws_region
|
|
217
282
|
const dockerRepo = environment.deployment.ecr_repo
|
|
218
283
|
|
|
@@ -262,8 +327,8 @@ program.command('get-docker-login')
|
|
|
262
327
|
})
|
|
263
328
|
|
|
264
329
|
program.command('get-environment')
|
|
265
|
-
.description('get
|
|
266
|
-
.argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
|
|
330
|
+
.description('get details of an environment')
|
|
331
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
267
332
|
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
268
333
|
.option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
|
|
269
334
|
.option('-p, --project <project>', 'project name (e.g. idler)')
|
|
@@ -274,41 +339,342 @@ program.command('get-environment')
|
|
|
274
339
|
.action(async (gameserver, options) => {
|
|
275
340
|
try {
|
|
276
341
|
const tokens = await loadTokens()
|
|
342
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
343
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
277
344
|
|
|
278
|
-
|
|
279
|
-
|
|
345
|
+
const environment = await stackApi.getEnvironmentDetails(gameserverId)
|
|
346
|
+
console.log(JSON.stringify(environment))
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof Error) {
|
|
349
|
+
console.error(`Error getting environment details: ${error.message}`)
|
|
280
350
|
}
|
|
351
|
+
exit(1)
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
program.command('push-docker-image')
|
|
356
|
+
.description('push docker image into the target environment image registry')
|
|
357
|
+
.argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
358
|
+
.argument('image-name', 'full name of the docker image to push (eg, the gameserver:<sha>)')
|
|
359
|
+
.hook('preAction', async () => {
|
|
360
|
+
await extendCurrentSession()
|
|
361
|
+
})
|
|
362
|
+
.action(async (gameserver, imageName, options) => {
|
|
363
|
+
try {
|
|
364
|
+
console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`)
|
|
365
|
+
|
|
366
|
+
const tokens = await loadTokens()
|
|
367
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
368
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
369
|
+
|
|
370
|
+
// Fetch AWS credentials from Metaplay cloud
|
|
371
|
+
logger.debug('Get AWS credentials from Metaplay')
|
|
372
|
+
const credentials = await stackApi.getAwsCredentials(gameserverId)
|
|
373
|
+
|
|
374
|
+
// Get environment info (region is needed for ECR)
|
|
375
|
+
logger.debug('Get environment info')
|
|
376
|
+
const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
|
|
377
|
+
const awsRegion = envInfo.deployment.aws_region
|
|
281
378
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
379
|
+
// Create ECR client with credentials
|
|
380
|
+
logger.debug('Create ECR client')
|
|
381
|
+
const client = new ECRClient({
|
|
382
|
+
credentials: {
|
|
383
|
+
accessKeyId: credentials.AccessKeyId,
|
|
384
|
+
secretAccessKey: credentials.SecretAccessKey,
|
|
385
|
+
sessionToken: credentials.SessionToken
|
|
386
|
+
},
|
|
387
|
+
region: awsRegion
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// Fetch the ECR docker authentication token
|
|
391
|
+
logger.debug('Fetch ECR login credentials from AWS')
|
|
392
|
+
const command = new GetAuthorizationTokenCommand({})
|
|
393
|
+
const response = await client.send(command)
|
|
394
|
+
if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken) {
|
|
395
|
+
throw new Error('Received an empty authorization token response for ECR repository')
|
|
285
396
|
}
|
|
286
397
|
|
|
287
|
-
|
|
398
|
+
// Parse username and password from the response (separated by a ':')
|
|
399
|
+
logger.debug('Parse ECR response')
|
|
400
|
+
const authorization64 = response.authorizationData[0].authorizationToken
|
|
401
|
+
const authorization = Buffer.from(authorization64, 'base64').toString()
|
|
402
|
+
const [username, password] = authorization.split(':')
|
|
403
|
+
const authConfig = { username, password, serveraddress: '' } // \todo serveraddress value? https://stackoverflow.com/questions/54252777/how-to-push-image-with-dockerode-image-not-pushed-but-no-error
|
|
288
404
|
|
|
289
|
-
|
|
290
|
-
|
|
405
|
+
// Resolve tag from src image
|
|
406
|
+
if (!imageName) {
|
|
407
|
+
throw new Error('Must specify a valid docker image name as the image-name argument')
|
|
408
|
+
}
|
|
409
|
+
const srcImageParts = imageName.split(':')
|
|
410
|
+
if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
|
|
411
|
+
throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`)
|
|
412
|
+
}
|
|
413
|
+
const imageTag = srcImageParts[1]
|
|
414
|
+
|
|
415
|
+
// Resolve source image
|
|
416
|
+
const srcImageName = imageName
|
|
417
|
+
const dstRepoName = envInfo.deployment.ecr_repo
|
|
418
|
+
const dstImageName = `${dstRepoName}:${imageTag}`
|
|
419
|
+
const dockerApi = new Docker()
|
|
420
|
+
const srcDockerImage = dockerApi.getImage(srcImageName)
|
|
421
|
+
|
|
422
|
+
// If names don't match, tag the src image as dst
|
|
423
|
+
if (srcImageName !== dstImageName) {
|
|
424
|
+
logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`)
|
|
425
|
+
await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Push the image
|
|
429
|
+
logger.debug(`Push image ${dstImageName}`)
|
|
430
|
+
const dstDockerImage = dockerApi.getImage(dstImageName)
|
|
431
|
+
const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
|
|
432
|
+
|
|
433
|
+
// Follow push progress & wait until completed
|
|
434
|
+
logger.debug('Following push stream...')
|
|
435
|
+
await new Promise((resolve, reject) => {
|
|
436
|
+
dockerApi.modem.followProgress(
|
|
437
|
+
pushStream,
|
|
438
|
+
(error: Error | null, result: any[]) => {
|
|
439
|
+
if (error) {
|
|
440
|
+
logger.debug('Failed to push image:', error)
|
|
441
|
+
reject(error)
|
|
442
|
+
} else {
|
|
443
|
+
// result contains an array of all the progress objects
|
|
444
|
+
logger.debug('Succesfully finished pushing image')
|
|
445
|
+
resolve(result)
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
(obj: any) => {
|
|
449
|
+
// console.log('Progress:', obj)
|
|
450
|
+
// { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
|
|
451
|
+
// { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
console.log(`Successfully pushed docker image to ${dstImageName}!`)
|
|
291
456
|
} catch (error) {
|
|
292
457
|
if (error instanceof Error) {
|
|
293
|
-
console.error(`
|
|
458
|
+
console.error(`Failed to push docker image: ${error.message}`)
|
|
294
459
|
}
|
|
295
460
|
exit(1)
|
|
296
461
|
}
|
|
297
462
|
})
|
|
298
463
|
|
|
464
|
+
program.command('deploy-server')
|
|
465
|
+
.description('deploy a game server image to target environment')
|
|
466
|
+
.argument('gameserver', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
|
|
467
|
+
.argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
|
|
468
|
+
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
469
|
+
.option('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
|
|
470
|
+
.option('--local-chart-path <path-to-chart-directory>', 'path to local Helm chart directory (to use a chart from local disk)')
|
|
471
|
+
.option('--helm-chart-repo <url>', 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)')
|
|
472
|
+
.option('--helm-chart-version <version>', 'override the Helm chart version (eg, 0.6.0)')
|
|
473
|
+
.option('--deployment-name', 'Helm deployment name to use', 'gameserver')
|
|
474
|
+
.hook('preAction', async () => {
|
|
475
|
+
await extendCurrentSession()
|
|
476
|
+
})
|
|
477
|
+
.action(async (gameserver, imageTag, options) => {
|
|
478
|
+
try {
|
|
479
|
+
const tokens = await loadTokens()
|
|
480
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
481
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
482
|
+
|
|
483
|
+
console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
|
|
484
|
+
|
|
485
|
+
// Fetch target environment details
|
|
486
|
+
const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
|
|
487
|
+
|
|
488
|
+
if (!imageTag) {
|
|
489
|
+
throw new Error('Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!options.deploymentName) {
|
|
493
|
+
throw new Error(`Invalid Helm deployment name '${options.deploymentName}'`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Resolve information about docker image
|
|
497
|
+
const dockerRepo = envInfo.deployment.ecr_repo
|
|
498
|
+
const imageName = `${dockerRepo}:${imageTag}`
|
|
499
|
+
logger.debug(`Fetch docker image information for ${imageName}`)
|
|
500
|
+
let imageLabels
|
|
501
|
+
try {
|
|
502
|
+
const dockerApi = new Docker()
|
|
503
|
+
const dockerImage = dockerApi.getImage(imageName)
|
|
504
|
+
imageLabels = (await dockerImage.inspect()).Config.Labels || {}
|
|
505
|
+
} catch (err) {
|
|
506
|
+
throw new Error(`Unable to fetch metadata for docker image ${imageName}: ${err}`)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Try to resolve SDK version and Helm repo and chart version from the docker labels
|
|
510
|
+
// \note These only exist for SDK R28 and newer images
|
|
511
|
+
logger.debug('Docker image labels: ', JSON.stringify(imageLabels))
|
|
512
|
+
const sdkVersion = imageLabels['io.metaplay.sdk_version']
|
|
513
|
+
|
|
514
|
+
// Resolve helmChartRepo, in order of precedence:
|
|
515
|
+
// - Specified in the cli options
|
|
516
|
+
// - Specified in the docker image label
|
|
517
|
+
// - Fall back to 'https://charts.metaplay.dev'
|
|
518
|
+
const helmChartRepo = options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
|
|
519
|
+
|
|
520
|
+
// Resolve helmChartVersion, in order of precedence:
|
|
521
|
+
// - Specified in the cli options
|
|
522
|
+
// - Specified in the docker image label
|
|
523
|
+
// - Unknown, error out!
|
|
524
|
+
const helmChartVersion = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
|
|
525
|
+
if (!helmChartVersion) {
|
|
526
|
+
throw new Error('No Helm chart version defined. With pre-R28 SDK versions, you must specify the Helm chart repository explicitly with --helm-chart-version.')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A values file is required (at least for now it makes no sense to deploy without one)
|
|
530
|
+
if (!options.values) {
|
|
531
|
+
throw new Error('Path to a Helm values file must be specified with --values')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// \If Helm chart >= 0.7.0, check that sdkVersion is defined in docker image labels (the labels were added in R28)
|
|
535
|
+
const helmChartSemver = semver.parse(helmChartVersion)
|
|
536
|
+
if (!helmChartSemver) {
|
|
537
|
+
throw new Error(`Resolve Helm chart version '${helmChartVersion}' is not a valid SemVer!`)
|
|
538
|
+
}
|
|
539
|
+
if (semver.gte(helmChartSemver, new semver.SemVer('0.7.0'), true) && !sdkVersion) {
|
|
540
|
+
throw new Error('Helm chart versions >=0.7.0 are only compatible with SDK versions 28.0.0 and above.')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Fetch kubeconfig and write it to a temporary file
|
|
544
|
+
// \todo allow passing a custom kubeconfig file?
|
|
545
|
+
const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
|
|
546
|
+
const kubeconfigPath = join(tmpdir(), randomBytes(20).toString('hex'))
|
|
547
|
+
logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
|
|
548
|
+
await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
// Construct Helm invocation
|
|
552
|
+
const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver'
|
|
553
|
+
const helmArgs =
|
|
554
|
+
['upgrade', '--install', '--wait'] // \note wait for the pods to stabilize -- otherwise status check can read state before any changes to pods are applied
|
|
555
|
+
.concat(['--kubeconfig', kubeconfigPath])
|
|
556
|
+
.concat(['-n', envInfo.deployment.kubernetes_namespace])
|
|
557
|
+
.concat(['--values', options.values])
|
|
558
|
+
.concat(['--set-string', `image.tag=${imageTag}`])
|
|
559
|
+
.concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
|
|
560
|
+
.concat(!options.chartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
|
|
561
|
+
.concat([options.deploymentName])
|
|
562
|
+
.concat([chartNameOrPath])
|
|
563
|
+
logger.info(`Execute: helm ${helmArgs.join(' ')}`)
|
|
564
|
+
|
|
565
|
+
// Execute Helm
|
|
566
|
+
let helmResult
|
|
567
|
+
try {
|
|
568
|
+
helmResult = await executeCommand('helm', helmArgs)
|
|
569
|
+
// \todo output something from Helm result?
|
|
570
|
+
} catch (err) {
|
|
571
|
+
throw new Error(`Failed to execute 'helm': ${err}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Throw on Helm non-success exit code
|
|
575
|
+
if (helmResult.code !== 0) {
|
|
576
|
+
throw new Error(`Helm deploy failed with exit code ${helmResult.code}: ${helmResult.stderr}`)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const testingRepoSuffix = (!options.chartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
|
|
580
|
+
console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`)
|
|
581
|
+
} finally {
|
|
582
|
+
// Remove temporary kubeconfig file
|
|
583
|
+
await unlink(kubeconfigPath)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Check the status of the game server deployment
|
|
587
|
+
try {
|
|
588
|
+
const kubeconfig = new KubeConfig()
|
|
589
|
+
kubeconfig.loadFromString(kubeconfigPayload)
|
|
590
|
+
|
|
591
|
+
console.log('Validating game server deployment...')
|
|
592
|
+
const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig)
|
|
593
|
+
exit(exitCode)
|
|
594
|
+
} catch (error) {
|
|
595
|
+
console.error(`Failed to resolve game server deployment status: ${error}`)
|
|
596
|
+
exit(2)
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (error instanceof Error) {
|
|
600
|
+
console.error(`Error deploying game server into target environment: ${error.message}`)
|
|
601
|
+
}
|
|
602
|
+
exit(1)
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
program.command('check-server-status')
|
|
607
|
+
.description('check the status of a deployed server and print out information that is helpful in debugging failed deployments')
|
|
608
|
+
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
609
|
+
.hook('preAction', async () => {
|
|
610
|
+
await extendCurrentSession()
|
|
611
|
+
})
|
|
612
|
+
.action(async (gameserver: string | undefined, options) => {
|
|
613
|
+
const tokens = await loadTokens()
|
|
614
|
+
const stackApi = new StackAPI(tokens.access_token, options.stackApi)
|
|
615
|
+
const gameserverId = resolveGameserverId(gameserver, options)
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
logger.debug('Get environment info')
|
|
619
|
+
const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
|
|
620
|
+
const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
|
|
621
|
+
|
|
622
|
+
// Load kubeconfig from file and throw error if validation fails.
|
|
623
|
+
logger.debug('Get kubeconfig')
|
|
624
|
+
const kubeconfig = new KubeConfig()
|
|
625
|
+
try {
|
|
626
|
+
// Fetch kubeconfig and write it to a temporary file
|
|
627
|
+
// \todo allow passing a custom kubeconfig file?
|
|
628
|
+
const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
|
|
629
|
+
kubeconfig.loadFromString(kubeconfigPayload)
|
|
630
|
+
} catch (error) {
|
|
631
|
+
throw new Error(`Failed to load or validate kubeconfig. ${error}`)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Run the checks and exit with success/failure exitCode depending on result
|
|
635
|
+
console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
|
|
636
|
+
const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig)
|
|
637
|
+
exit(exitCode)
|
|
638
|
+
} catch (error: any) {
|
|
639
|
+
console.error(`Failed to check deployment status: ${error.message}`)
|
|
640
|
+
exit(1)
|
|
641
|
+
}
|
|
642
|
+
})
|
|
643
|
+
|
|
299
644
|
program.command('check-deployment')
|
|
300
|
-
.description('[
|
|
645
|
+
.description('[deprecated] check that a game server was successfully deployed, or print out useful error messages in case of failure')
|
|
301
646
|
.argument('[namespace]', 'kubernetes namespace of the deployment')
|
|
302
647
|
.action(async (namespace: string) => {
|
|
303
|
-
|
|
648
|
+
console.error('DEPRECATED! Use the "metaplay-auth check-server-status [gameserver]" command instead! This command will be removed soon.')
|
|
304
649
|
|
|
305
650
|
try {
|
|
306
651
|
if (!namespace) {
|
|
307
652
|
throw new Error('Must specify value for argument "namespace"')
|
|
308
653
|
}
|
|
309
654
|
|
|
655
|
+
// Check that the KUBECONFIG environment variable exists
|
|
656
|
+
const kubeconfigPath = process.env.KUBECONFIG
|
|
657
|
+
if (!kubeconfigPath) {
|
|
658
|
+
throw new Error('The KUBECONFIG environment variable must be specified')
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Check that the kubeconfig file exists
|
|
662
|
+
if (!existsSync(kubeconfigPath)) {
|
|
663
|
+
throw new Error(`The environment variable KUBECONFIG points to a file '${kubeconfigPath}' that doesn't exist`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Create Kubernetes API instance (with default kubeconfig)
|
|
667
|
+
const kubeconfig = new KubeConfig()
|
|
668
|
+
// Load kubeconfig from file and throw error if validation fails.
|
|
669
|
+
try {
|
|
670
|
+
kubeconfig.loadFromFile(kubeconfigPath)
|
|
671
|
+
} catch (error) {
|
|
672
|
+
throw new Error(`Failed to load or validate kubeconfig. ${error}`)
|
|
673
|
+
}
|
|
674
|
+
|
|
310
675
|
// Run the checks and exit with success/failure exitCode depending on result
|
|
311
|
-
|
|
676
|
+
console.log(`Validating game server deployment in namespace ${namespace}`)
|
|
677
|
+
const exitCode = await checkGameServerDeployment(namespace, kubeconfig)
|
|
312
678
|
exit(exitCode)
|
|
313
679
|
} catch (error: any) {
|
|
314
680
|
console.error(`Failed to check deployment status: ${error.message}`)
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metaplay/metaplay-auth",
|
|
3
3
|
"description": "Utility CLI for authenticating with the Metaplay Auth and making authenticated calls to infrastructure endpoints.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"homepage": "https://metaplay.io",
|
|
8
|
-
"bin":
|
|
8
|
+
"bin": {
|
|
9
|
+
"metaplay-auth": "dist/index.js"
|
|
10
|
+
},
|
|
9
11
|
"scripts": {
|
|
10
12
|
"dev": "tsx index.ts",
|
|
11
13
|
"prepublish": "tsc"
|
|
@@ -16,23 +18,29 @@
|
|
|
16
18
|
"devDependencies": {
|
|
17
19
|
"@metaplay/eslint-config": "workspace:*",
|
|
18
20
|
"@metaplay/typescript-config": "workspace:*",
|
|
21
|
+
"@types/dockerode": "^3.3.28",
|
|
19
22
|
"@types/express": "^4.17.21",
|
|
23
|
+
"@types/js-yaml": "^4.0.9",
|
|
20
24
|
"@types/jsonwebtoken": "^9.0.5",
|
|
21
25
|
"@types/jwk-to-pem": "^2.0.3",
|
|
22
|
-
"@types/node": "^20.
|
|
26
|
+
"@types/node": "^20.12.5",
|
|
23
27
|
"tsx": "^4.7.1",
|
|
24
28
|
"typescript": "^5.1.6",
|
|
25
29
|
"vitest": "^1.4.0"
|
|
26
30
|
},
|
|
27
31
|
"dependencies": {
|
|
28
|
-
"@aws-sdk/client-ecr": "^3.
|
|
32
|
+
"@aws-sdk/client-ecr": "^3.549.0",
|
|
29
33
|
"@kubernetes/client-node": "^1.0.0-rc4",
|
|
30
34
|
"@ory/client": "^1.9.0",
|
|
35
|
+
"@types/semver": "^7.5.8",
|
|
31
36
|
"commander": "^12.0.0",
|
|
32
|
-
"
|
|
37
|
+
"dockerode": "^4.0.2",
|
|
38
|
+
"h3": "^1.11.1",
|
|
39
|
+
"js-yaml": "^4.1.0",
|
|
33
40
|
"jsonwebtoken": "^9.0.2",
|
|
34
41
|
"jwk-to-pem": "^2.0.5",
|
|
35
42
|
"open": "^10.1.0",
|
|
43
|
+
"semver": "^7.6.0",
|
|
36
44
|
"tslog": "^4.9.2"
|
|
37
45
|
}
|
|
38
46
|
}
|