@metaplay/metaplay-auth 1.4.1 → 1.5.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/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander'
3
3
  import Docker from 'dockerode'
4
- import { loginAndSaveTokens, machineLoginAndSaveTokens, extendCurrentSession, loadTokens, removeTokens } from './src/auth.js'
5
- import { StackAPI, type GameserverId } from './src/stackapi.js'
6
- import { checkGameServerDeployment } from './src/deployment.js'
4
+ import { loginAndSaveTokens, machineLoginAndSaveTokens, extendCurrentSession, loadTokens, removeTokens, TokenSet } from './src/auth.js'
5
+ import { StackAPI, defaultStackApiBaseUrl } from './src/stackapi.js'
6
+ import { checkGameServerDeployment, debugGameServer } from './src/deployment.js'
7
7
  import { logger, setLogLevel } from './src/logging.js'
8
- import { isValidFQDN, executeCommand } from './src/utils.js'
8
+ import { isValidFQDN, executeCommand, getGameserverAdminUrl } from './src/utils.js'
9
+ import { TargetEnvironment } from './src/targetenvironment.js'
9
10
  import { exit } from 'process'
10
- import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr'
11
11
  import { tmpdir } from 'os'
12
12
  import { join } from 'path'
13
13
  import { randomBytes } from 'crypto'
@@ -15,28 +15,66 @@ import { writeFile, unlink } from 'fs/promises'
15
15
  import { existsSync } from 'fs'
16
16
  import { KubeConfig } from '@kubernetes/client-node'
17
17
  import * as semver from 'semver'
18
+ import { PACKAGE_VERSION } from './src/version.js'
19
+
20
+ function resolveTargetEnvironmentFromFQDN (tokens: TokenSet, environmentDomain: string): TargetEnvironment {
21
+ const adminApiDomain = getGameserverAdminUrl(environmentDomain)
22
+ // \todo using the old <environment>/.infra path as v1 API needs organization, project, environment which we don't have with FQDN environments
23
+ const environmentApiBaseUrl = `https://${adminApiDomain}/.infra`
24
+
25
+ return new TargetEnvironment(
26
+ tokens.access_token,
27
+ environmentDomain,
28
+ defaultStackApiBaseUrl,
29
+ environmentApiBaseUrl)
30
+ }
31
+
32
+ async function resolveTargetEnvironmentFromTuple (tokens: TokenSet, organization: string, project: string, environment: string, stackApiUrl?: string): Promise<TargetEnvironment> {
33
+ // \todo Later on, fetch the environment information from the portal (not StackAPI), including the stack and its stackApiBaseUrl
34
+ const stackApiBaseUrl = stackApiUrl ?? defaultStackApiBaseUrl
35
+ const stackApi = new StackAPI(tokens.access_token, stackApiBaseUrl)
36
+ const environmentDomain = await stackApi.resolveManagedEnvironmentFQDN(organization, project, environment)
37
+ const environmentApiBaseUrl = `${stackApiBaseUrl}/v1/servers/${organization}/${project}/${environment}`
38
+
39
+ return new TargetEnvironment(
40
+ tokens.access_token,
41
+ environmentDomain,
42
+ stackApiBaseUrl,
43
+ environmentApiBaseUrl,
44
+ organization,
45
+ project,
46
+ environment)
47
+ }
18
48
 
19
49
  /**
20
- * Helper for parsing the GameserverId type from the command line arguments. Accepts either the gameserver address
50
+ * Helper for parsing the TargetEnvironment type from the command line arguments. Accepts either the gameserver address
21
51
  * (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
22
52
  * tuple from options.
23
53
  */
24
- function resolveGameserverId (address: string | undefined, options: any): GameserverId {
54
+ async function resolveTargetEnvironment (address: string | undefined, options: { organization?: string, project?: string, environment?: string, stackApi?: string }): Promise<TargetEnvironment> {
55
+ const tokens = await loadTokens()
56
+
25
57
  // If address is specified, use it, otherwise assume options has organization, project, and environment
26
58
  if (address) {
59
+ // Address is either FQDN or 'organization-project-environment' tuple
27
60
  if (isValidFQDN(address)) {
28
- return { gameserver: address }
61
+ if (options.stackApi) {
62
+ throw new Error('--stack-api override only supported with organization-project-environment naming, not with FQDN')
63
+ }
64
+ return resolveTargetEnvironmentFromFQDN(tokens, address)
29
65
  } else {
30
66
  const parts = address.split('-')
31
67
  if (parts.length !== 3) {
32
68
  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
69
  }
34
- return { organization: parts[0], project: parts[1], environment: parts[2] }
70
+ return await resolveTargetEnvironmentFromTuple(tokens, parts[0], parts[1], parts[2], options.stackApi)
35
71
  }
36
72
  } else if (options.organization && options.project && options.environment) {
37
- return { organization: options.organization, project: options.project, environment: options.environment }
73
+ // Parse tuple from command-line options (output to stderr to avoid messing up '$(eval metaplay-auth ... --format env)' invocations
74
+ console.warn(`Warning: Specifying the target environment with -o (--organization), -p (--project), and -e (--environment) is deprecated! Use the '${options.organization}-${options.project}-${options.environment}' syntax instead.`)
75
+ return await resolveTargetEnvironmentFromTuple(tokens, options.organization, options.project, options.environment, options.stackApi)
38
76
  } 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.')
77
+ throw new Error('Could not determine target environment from arguments: You need to specify either an environment FQDN or an organization, project, and environment. Run this command with --help flag for more information.')
40
78
  }
41
79
  }
42
80
 
@@ -45,7 +83,7 @@ const program = new Command()
45
83
  program
46
84
  .name('metaplay-auth')
47
85
  .description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
48
- .version('1.4.1')
86
+ .version(PACKAGE_VERSION)
49
87
  .option('-d, --debug', 'enable debug output')
50
88
  .hook('preAction', (thisCommand) => {
51
89
  // Handle debug flag for all commands.
@@ -68,11 +106,12 @@ program.command('machine-login')
68
106
  .option('--dev-credentials', 'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!')
69
107
  .action(async (options) => {
70
108
  // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
71
- let credentials
109
+ let credentials: string
72
110
  if (options.devCredentials) {
73
111
  credentials = options.devCredentials
74
112
  } else {
75
- credentials = process.env.METAPLAY_CREDENTIALS
113
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
114
+ credentials = process.env.METAPLAY_CREDENTIALS!
76
115
  if (!credentials || credentials === '') {
77
116
  throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
78
117
  }
@@ -117,7 +156,7 @@ program.command('show-tokens')
117
156
  try {
118
157
  // TODO: Could detect if not logged in and fail more gracefully?
119
158
  const tokens = await loadTokens()
120
- console.log(tokens)
159
+ console.log(JSON.stringify(tokens, undefined, 2))
121
160
  } catch (error) {
122
161
  if (error instanceof Error) {
123
162
  console.error(`Error showing tokens: ${error.message}`)
@@ -130,32 +169,31 @@ program.command('get-kubeconfig')
130
169
  .description('get kubeconfig for target environment')
131
170
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
132
171
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
133
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
134
- .option('-p, --project <project>', 'project name (e.g. idler)')
135
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
172
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
173
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
174
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
136
175
  .option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
137
176
  .option('--output <kubeconfig-path>', 'path of the output file where to write kubeconfig (written to stdout if not specified)')
138
177
  .hook('preAction', async () => {
139
178
  await extendCurrentSession()
140
179
  })
141
- .action(async (gameserver, options) => {
180
+ .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, type?: string, output?: string }) => {
142
181
  try {
143
- const tokens = await loadTokens()
144
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
145
- const gameserverId = resolveGameserverId(gameserver, options)
182
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
146
183
 
147
184
  // Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
185
+ const tokens = await loadTokens()
148
186
  const isHumanUser = !!tokens.refresh_token
149
- const credentialsType = options.credentialsType ?? (isHumanUser ? 'dynamic' : 'static')
187
+ const credentialsType = options.type ?? (isHumanUser ? 'dynamic' : 'static')
150
188
 
151
189
  // Generate kubeconfig
152
190
  let kubeconfigPayload
153
191
  if (credentialsType === 'dynamic') {
154
192
  logger.debug('Fetching kubeconfig with execcredential')
155
- kubeconfigPayload = await stackApi.getKubeConfigExecCredential(gameserverId)
193
+ kubeconfigPayload = await targetEnv.getKubeConfigExecCredential()
156
194
  } else if (credentialsType === 'static') {
157
195
  logger.debug('Fetching kubeconfig with embedded secret')
158
- kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
196
+ kubeconfigPayload = await targetEnv.getKubeConfig()
159
197
  } else {
160
198
  throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
161
199
  }
@@ -188,19 +226,16 @@ program.command('get-kubernetes-execcredential')
188
226
  .description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
189
227
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
190
228
  .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)')
229
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
230
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
231
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
194
232
  .hook('preAction', async () => {
195
233
  await extendCurrentSession()
196
234
  })
197
- .action(async (gameserver, options) => {
235
+ .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string }) => {
198
236
  try {
199
- const tokens = await loadTokens()
200
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
201
- const gameserverId = resolveGameserverId(gameserver, options)
202
-
203
- const credentials = await stackApi.getKubeExecCredential(gameserverId)
237
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
238
+ const credentials = await targetEnv.getKubeExecCredential()
204
239
  console.log(credentials)
205
240
  } catch (error) {
206
241
  if (error instanceof Error) {
@@ -214,24 +249,23 @@ program.command('get-aws-credentials')
214
249
  .description('get AWS credentials for target environment')
215
250
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
216
251
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
217
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
218
- .option('-p, --project <project>', 'project name (e.g. idler)')
219
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
252
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
253
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
254
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
220
255
  .option('-f, --format <format>', 'output format (json or env)', 'json')
221
256
  .hook('preAction', async () => {
222
257
  await extendCurrentSession()
223
258
  })
224
- .action(async (gameserver, options) => {
259
+ .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, format?: string }) => {
225
260
  try {
226
261
  if (options.format !== 'json' && options.format !== 'env') {
227
262
  throw new Error('Invalid format; must be one of json or env')
228
263
  }
229
264
 
230
- const tokens = await loadTokens()
231
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
232
- const gameserverId = resolveGameserverId(gameserver, options)
265
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
233
266
 
234
- const credentials = await stackApi.getAwsCredentials(gameserverId)
267
+ // Get the AWS credentials
268
+ const credentials = await targetEnv.getAwsCredentials()
235
269
 
236
270
  if (options.format === 'env') {
237
271
  console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
@@ -252,59 +286,35 @@ program.command('get-aws-credentials')
252
286
  })
253
287
 
254
288
  program.command('get-docker-login')
255
- .description('get docker login credentials for pushing the server image to target environment')
289
+ .description('[deprecated] get docker login credentials for pushing the server image to target environment')
256
290
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
257
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
258
- .option('-p, --project <project>', 'project name (e.g. idler)')
259
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
291
+ .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
292
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
293
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
294
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
260
295
  .option('-f, --format <format>', 'output format (json or env)', 'json')
261
296
  .hook('preAction', async () => {
262
297
  await extendCurrentSession()
263
298
  })
264
- .action(async (gameserver, options) => {
299
+ .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, format?: string }) => {
265
300
  try {
266
301
  if (options.format !== 'json' && options.format !== 'env') {
267
302
  throw new Error('Invalid format; must be one of json or env')
268
303
  }
269
304
 
270
- const tokens = await loadTokens()
271
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
272
- const gameserverId = resolveGameserverId(gameserver, options)
305
+ console.warn('The get-docker-login command is deprecated! Use the push-docker-image command instead.')
273
306
 
274
- // Fetch AWS credentials from Metaplay cloud
275
- logger.debug('Get AWS credentials from Metaplay')
276
- const credentials = await stackApi.getAwsCredentials(gameserverId)
307
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
277
308
 
278
309
  // Get environment info (region is needed for ECR)
279
310
  logger.debug('Get environment info')
280
- const environment = await stackApi.getEnvironmentDetails(gameserverId)
281
- const awsRegion = environment.deployment.aws_region
311
+ const environment = await targetEnv.getEnvironmentDetails()
282
312
  const dockerRepo = environment.deployment.ecr_repo
283
313
 
284
- // Create ECR client with credentials
285
- logger.debug('Create ECR client')
286
- const client = new ECRClient({
287
- credentials: {
288
- accessKeyId: credentials.AccessKeyId,
289
- secretAccessKey: credentials.SecretAccessKey,
290
- sessionToken: credentials.SessionToken
291
- },
292
- region: awsRegion
293
- })
294
-
295
- // Fetch the ECR docker authentication token
296
- logger.debug('Fetch ECR login credentials from AWS')
297
- const command = new GetAuthorizationTokenCommand({})
298
- const response = await client.send(command)
299
- if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken) {
300
- throw new Error('Received an empty authorization token response for ECR repository')
301
- }
302
-
303
- // Parse username and password from the response (separated by a ':')
304
- logger.debug('Parse ECR response')
305
- const authorization64 = response.authorizationData[0].authorizationToken
306
- const authorization = Buffer.from(authorization64, 'base64').toString()
307
- const [username, password] = authorization.split(':')
314
+ // Resolve docker credentials for remote registry
315
+ logger.debug('Get docker credentials')
316
+ const dockerCredentials = await targetEnv.getDockerCredentials()
317
+ const { username, password } = dockerCredentials
308
318
 
309
319
  // Output the docker repo & credentials
310
320
  if (options.format === 'env') {
@@ -330,20 +340,18 @@ program.command('get-environment')
330
340
  .description('get details of an environment')
331
341
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
332
342
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
333
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
334
- .option('-p, --project <project>', 'project name (e.g. idler)')
335
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
343
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
344
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
345
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
336
346
  .hook('preAction', async () => {
337
347
  await extendCurrentSession()
338
348
  })
339
- .action(async (gameserver, options) => {
349
+ .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string }) => {
340
350
  try {
341
- const tokens = await loadTokens()
342
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
343
- const gameserverId = resolveGameserverId(gameserver, options)
351
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
344
352
 
345
- const environment = await stackApi.getEnvironmentDetails(gameserverId)
346
- console.log(JSON.stringify(environment))
353
+ const environment = await targetEnv.getEnvironmentDetails()
354
+ console.log(JSON.stringify(environment, undefined, 2))
347
355
  } catch (error) {
348
356
  if (error instanceof Error) {
349
357
  console.error(`Error getting environment details: ${error.message}`)
@@ -356,51 +364,23 @@ program.command('push-docker-image')
356
364
  .description('push docker image into the target environment image registry')
357
365
  .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
358
366
  .argument('image-name', 'full name of the docker image to push (eg, the gameserver:<sha>)')
367
+ .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
359
368
  .hook('preAction', async () => {
360
369
  await extendCurrentSession()
361
370
  })
362
- .action(async (gameserver, imageName, options) => {
371
+ .action(async (gameserver: string | undefined, imageName: string | undefined, options: { stackApi?: string, imageTag?: string }) => {
363
372
  try {
364
373
  console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`)
365
374
 
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)
375
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
373
376
 
374
377
  // Get environment info (region is needed for ECR)
375
378
  logger.debug('Get environment info')
376
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
377
- const awsRegion = envInfo.deployment.aws_region
378
-
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')
396
- }
379
+ const envInfo = await targetEnv.getEnvironmentDetails()
397
380
 
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
381
+ // Resolve docker credentials for remote registry
382
+ logger.debug('Get docker credentials')
383
+ const dockerCredentials = await targetEnv.getDockerCredentials()
404
384
 
405
385
  // Resolve tag from src image
406
386
  if (!imageName) {
@@ -428,20 +408,21 @@ program.command('push-docker-image')
428
408
  // Push the image
429
409
  logger.debug(`Push image ${dstImageName}`)
430
410
  const dstDockerImage = dockerApi.getImage(dstImageName)
411
+ const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
431
412
  const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
432
413
 
433
414
  // Follow push progress & wait until completed
434
- logger.debug('Following push stream...')
415
+ logger.debug('Following image push stream...')
435
416
  await new Promise((resolve, reject) => {
436
417
  dockerApi.modem.followProgress(
437
418
  pushStream,
438
419
  (error: Error | null, result: any[]) => {
439
420
  if (error) {
440
- logger.debug('Failed to push image:', error)
421
+ logger.debug('Failed to push docker image to target repository:', error)
441
422
  reject(error)
442
423
  } else {
443
424
  // result contains an array of all the progress objects
444
- logger.debug('Succesfully finished pushing image')
425
+ logger.debug('Succesfully finished pushing docker image')
445
426
  resolve(result)
446
427
  }
447
428
  },
@@ -463,10 +444,10 @@ program.command('push-docker-image')
463
444
 
464
445
  program.command('deploy-server')
465
446
  .description('deploy a game server image to target environment')
466
- .argument('gameserver', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
447
+ .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
467
448
  .argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
449
+ .requiredOption('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
468
450
  .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
451
  .option('--local-chart-path <path-to-chart-directory>', 'path to local Helm chart directory (to use a chart from local disk)')
471
452
  .option('--helm-chart-repo <url>', 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)')
472
453
  .option('--helm-chart-version <version>', 'override the Helm chart version (eg, 0.6.0)')
@@ -474,36 +455,91 @@ program.command('deploy-server')
474
455
  .hook('preAction', async () => {
475
456
  await extendCurrentSession()
476
457
  })
477
- .action(async (gameserver, imageTag, options) => {
458
+ .action(async (gameserver: string | undefined, imageTag: string | undefined, options: { values: string, stackApi?: string, localChartPath?: string, helmChartRepo?: string, helmChartVersion?: string, deploymentName?: string }) => {
478
459
  try {
479
- const tokens = await loadTokens()
480
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
481
- const gameserverId = resolveGameserverId(gameserver, options)
460
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
482
461
 
483
462
  console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
484
463
 
485
464
  // Fetch target environment details
486
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
465
+ const envInfo = await targetEnv.getEnvironmentDetails()
487
466
 
488
467
  if (!imageTag) {
489
468
  throw new Error('Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build')
490
469
  }
470
+ // \todo validate that imageTag is just the version part (i.e, contains no ':')
491
471
 
492
472
  if (!options.deploymentName) {
493
- throw new Error(`Invalid Helm deployment name '${options.deploymentName}'`)
473
+ throw new Error(`Invalid Helm deployment name '${options.deploymentName}'; specify one with --deployment-name or use the default`)
494
474
  }
495
475
 
476
+ // Fetch Docker credentials for target environment registry
477
+ const dockerCredentials = await targetEnv.getDockerCredentials()
478
+
496
479
  // Resolve information about docker image
480
+ // const dockerApi = new Docker({
481
+ // host: dockerCredentials.registryUrl,
482
+ // port: 443,
483
+ // protocol: 'https',
484
+ // username: dockerCredentials.username,
485
+ // headers: {
486
+ // Authorization: `Bearer ${dockerCredentials.password}`,
487
+ // Host: dockerCredentials.registryUrl.replace('https://', ''),
488
+ // }
489
+ // })
490
+ const dockerApi = new Docker()
497
491
  const dockerRepo = envInfo.deployment.ecr_repo
498
492
  const imageName = `${dockerRepo}:${imageTag}`
499
- logger.debug(`Fetch docker image information for ${imageName}`)
493
+ logger.debug(`Fetch docker image labels for ${imageName}`)
500
494
  let imageLabels
501
495
  try {
502
- const dockerApi = new Docker()
503
- const dockerImage = dockerApi.getImage(imageName)
504
- imageLabels = (await dockerImage.inspect()).Config.Labels || {}
496
+ const localDockerImage = dockerApi.getImage(imageName)
497
+ imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
505
498
  } catch (err) {
506
- throw new Error(`Unable to fetch metadata for docker image ${imageName}: ${err}`)
499
+ logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${err}`)
500
+ }
501
+
502
+ // If wasn't able to resolve the images, pull image from the target environment registry & resolve labels
503
+ if (imageLabels === undefined) {
504
+ // Pull the image from remote registry
505
+ try {
506
+ console.log(`Image ${imageName} not found locally -- pulling docker image from target environment registry...`)
507
+ const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
508
+ const pullStream = (await dockerApi.pull(imageName, { authconfig: authConfig }))
509
+
510
+ // Follow pull progress & wait until completed
511
+ logger.debug('Following image pull stream...')
512
+ await new Promise((resolve, reject) => {
513
+ dockerApi.modem.followProgress(
514
+ pullStream,
515
+ (error: Error | null, result: any[]) => {
516
+ if (error) {
517
+ logger.debug('Failed to pull image:', error)
518
+ reject(error)
519
+ } else {
520
+ // result contains an array of all the progress objects
521
+ logger.debug('Succesfully finished pulling image')
522
+ resolve(result)
523
+ }
524
+ },
525
+ (obj: any) => {
526
+ // console.log('Progress:', obj)
527
+ // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
528
+ // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
529
+ })
530
+ })
531
+ } catch (err) {
532
+ throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${err}`)
533
+ }
534
+
535
+ // Resolve the labels
536
+ try {
537
+ logger.debug('Get docker labels again (with pulled image)')
538
+ const localDockerImage = dockerApi.getImage(imageName)
539
+ imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
540
+ } catch (err) {
541
+ throw new Error(`Failed to resolve docker image metadata from pulled image ${imageName}: ${err}`)
542
+ }
507
543
  }
508
544
 
509
545
  // Try to resolve SDK version and Helm repo and chart version from the docker labels
@@ -523,7 +559,7 @@ program.command('deploy-server')
523
559
  // - Unknown, error out!
524
560
  const helmChartVersion = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
525
561
  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.')
562
+ throw new Error('No Helm chart version defined. With pre-R28 SDK versions, you must specify the Helm chart version explicitly with --helm-chart-version=<version>.')
527
563
  }
528
564
 
529
565
  // A values file is required (at least for now it makes no sense to deploy without one)
@@ -542,7 +578,7 @@ program.command('deploy-server')
542
578
 
543
579
  // Fetch kubeconfig and write it to a temporary file
544
580
  // \todo allow passing a custom kubeconfig file?
545
- const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
581
+ const kubeconfigPayload = await targetEnv.getKubeConfig()
546
582
  const kubeconfigPath = join(tmpdir(), randomBytes(20).toString('hex'))
547
583
  logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
548
584
  await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
@@ -557,7 +593,7 @@ program.command('deploy-server')
557
593
  .concat(['--values', options.values])
558
594
  .concat(['--set-string', `image.tag=${imageTag}`])
559
595
  .concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
560
- .concat(!options.chartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
596
+ .concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
561
597
  .concat([options.deploymentName])
562
598
  .concat([chartNameOrPath])
563
599
  logger.info(`Execute: helm ${helmArgs.join(' ')}`)
@@ -565,18 +601,18 @@ program.command('deploy-server')
565
601
  // Execute Helm
566
602
  let helmResult
567
603
  try {
568
- helmResult = await executeCommand('helm', helmArgs)
604
+ helmResult = await executeCommand('helm', helmArgs, false)
569
605
  // \todo output something from Helm result?
570
606
  } catch (err) {
571
607
  throw new Error(`Failed to execute 'helm': ${err}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`)
572
608
  }
573
609
 
574
610
  // 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}`)
611
+ if (helmResult.exitCode !== 0) {
612
+ throw new Error(`Helm deploy failed with exit code ${helmResult.exitCode}: ${helmResult.stderr}`)
577
613
  }
578
614
 
579
- const testingRepoSuffix = (!options.chartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
615
+ const testingRepoSuffix = (!options.localChartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
580
616
  console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`)
581
617
  } finally {
582
618
  // Remove temporary kubeconfig file
@@ -589,7 +625,7 @@ program.command('deploy-server')
589
625
  kubeconfig.loadFromString(kubeconfigPayload)
590
626
 
591
627
  console.log('Validating game server deployment...')
592
- const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig)
628
+ const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig, imageTag)
593
629
  exit(exitCode)
594
630
  } catch (error) {
595
631
  console.error(`Failed to resolve game server deployment status: ${error}`)
@@ -606,26 +642,25 @@ program.command('deploy-server')
606
642
  program.command('check-server-status')
607
643
  .description('check the status of a deployed server and print out information that is helpful in debugging failed deployments')
608
644
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
645
+ .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
609
646
  .hook('preAction', async () => {
610
647
  await extendCurrentSession()
611
648
  })
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)
649
+ .action(async (gameserver: string | undefined, options: { stackApi?: string }) => {
650
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
616
651
 
617
652
  try {
618
653
  logger.debug('Get environment info')
619
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
654
+ const envInfo = await targetEnv.getEnvironmentDetails()
620
655
  const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
621
656
 
622
657
  // Load kubeconfig from file and throw error if validation fails.
623
658
  logger.debug('Get kubeconfig')
624
659
  const kubeconfig = new KubeConfig()
625
660
  try {
626
- // Fetch kubeconfig and write it to a temporary file
661
+ // Initialize kubeconfig with the payload fetched from the cloud
627
662
  // \todo allow passing a custom kubeconfig file?
628
- const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
663
+ const kubeconfigPayload = await targetEnv.getKubeConfig()
629
664
  kubeconfig.loadFromString(kubeconfigPayload)
630
665
  } catch (error) {
631
666
  throw new Error(`Failed to load or validate kubeconfig. ${error}`)
@@ -633,7 +668,8 @@ program.command('check-server-status')
633
668
 
634
669
  // Run the checks and exit with success/failure exitCode depending on result
635
670
  console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
636
- const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig)
671
+ // \todo Get requiredImageTag from the Helm chart
672
+ const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig, /* requiredImageTag: */ null)
637
673
  exit(exitCode)
638
674
  } catch (error: any) {
639
675
  console.error(`Failed to check deployment status: ${error.message}`)
@@ -674,7 +710,8 @@ program.command('check-deployment')
674
710
 
675
711
  // Run the checks and exit with success/failure exitCode depending on result
676
712
  console.log(`Validating game server deployment in namespace ${namespace}`)
677
- const exitCode = await checkGameServerDeployment(namespace, kubeconfig)
713
+ // \todo Get requiredImageTag from the Helm chart
714
+ const exitCode = await checkGameServerDeployment(namespace, kubeconfig, /* requiredImageTag: */ null)
678
715
  exit(exitCode)
679
716
  } catch (error: any) {
680
717
  console.error(`Failed to check deployment status: ${error.message}`)
@@ -682,4 +719,19 @@ program.command('check-deployment')
682
719
  }
683
720
  })
684
721
 
722
+ program.command('debug-server')
723
+ .description('run an ephemeral debug container against a game server pod running in the cloud')
724
+ .argument('gameserver', 'address of gameserver (e.g., metaplay-idler-develop or idler-develop.p1.metaplay.io)')
725
+ .argument('[pod-name]', 'name of the pod to debug (must be specified if deployment has multiple pods)')
726
+ .hook('preAction', async () => {
727
+ await extendCurrentSession()
728
+ })
729
+ .action(async (gameserver: string, podName: string | undefined, options: { stackApi?: string }) => {
730
+ // Resolve target environment
731
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
732
+
733
+ // Exec 'kubectl debug ...'
734
+ await debugGameServer(targetEnv, podName)
735
+ })
736
+
685
737
  void program.parseAsync()