@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/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.2.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 deployment')
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
- if (!gameserver && !(options.organization && options.project && options.environment)) {
111
- throw new Error('Could not determine a deployment to fetch the KubeConfigs from. You need to specify either a gameserver or an organization, project, and environment. Run this command with --help flag for more information.')
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
- const stackApi = new StackAPI(tokens.access_token)
115
- if (options.stackApi) {
116
- stackApi.stack_api_base_uri = options.stackApi
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
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment }
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.getKubeConfig(payload)
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 KubeConfig: ${error.message}`)
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 deployment')
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
- if (!gameserver && !(options.organization && options.project && options.environment)) {
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
- if (!gameserver && !(options.organization && options.project && options.environment)) {
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 payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment }
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(payload)
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 environment details for deployment')
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
- if (!gameserver && !(options.organization && options.project && options.environment)) {
279
- throw new Error('Could not determine a deployment to fetch environment details from. You need to specify either a gameserver or an organization, project, and environment')
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
- const stackApi = new StackAPI(tokens.access_token)
283
- if (options.stackApi) {
284
- stackApi.stack_api_base_uri = options.stackApi
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
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment }
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
- const environment = await stackApi.getEnvironmentDetails(payload)
290
- console.log(JSON.stringify(environment))
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(`Error getting environment details: ${error.message}`)
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('[experimental] check that a game server was successfully deployed, or print out useful error messages in case of failure')
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
- setLogLevel(0)
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
- const exitCode = await checkGameServerDeployment(namespace)
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.2.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": "dist/index.js",
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.11.30",
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.540.0",
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
- "h3": "^1.10.2",
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
  }