@metaplay/metaplay-auth 1.4.2 → 1.6.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,41 +1,196 @@
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 { portalBaseUrl, setPortalBaseUrl } from './src/config.js'
5
+ import {
6
+ loginAndSaveTokens,
7
+ machineLoginAndSaveTokens,
8
+ extendCurrentSession,
9
+ loadTokens,
10
+ removeTokens,
11
+ TokenSet,
12
+ } from './src/auth.js'
13
+ import { checkGameServerDeployment, debugGameServer } from './src/deployment.js'
14
+ import {
15
+ pathJoin,
16
+ isValidFQDN,
17
+ executeCommand,
18
+ removeTrailingSlash,
19
+ fetchHelmChartVersions,
20
+ resolveBestMatchingVersion,
21
+ } from './src/utils.js'
7
22
  import { logger, setLogLevel } from './src/logging.js'
8
- import { isValidFQDN, executeCommand } from './src/utils.js'
23
+ import { TargetEnvironment } from './src/targetenvironment.js'
9
24
  import { exit } from 'process'
10
25
  import { tmpdir } from 'os'
11
- import { join } from 'path'
12
26
  import { randomBytes } from 'crypto'
13
27
  import { writeFile, unlink } from 'fs/promises'
14
28
  import { existsSync } from 'fs'
15
29
  import { KubeConfig } from '@kubernetes/client-node'
16
30
  import * as semver from 'semver'
31
+ import { PACKAGE_VERSION } from './src/version.js'
32
+ import { registerBuildCommand } from './src/buildCommand.js'
17
33
 
18
34
  /**
19
- * Helper for parsing the GameserverId type from the command line arguments. Accepts either the gameserver address
35
+ * Base URL of StackAPI infra to use -- defaults to p1.metaplay.io. Override with the global --stack-api flag.
36
+ * Note: The dynamic `kubeconfig`s generated by `metaplay-auth` override this with '--stack-api <url>' flag.
37
+ */
38
+ let defaultStackApiBaseUrl = 'https://infra.p1.metaplay.io/stackapi'
39
+
40
+ /**
41
+ * Resolve a TargetEnvironment from a fully-qualified domain name (eg, 'idler-develop.p1.metaplay.io').
42
+ * @param tokens Tokens to use for authenticating with the portal
43
+ * @param environmentDomain The environment domain name (eg, 'idler-develop.p1.metaplay.io')
44
+ * @returns The TargetEnvironment instance needed to operate with the environment.
45
+ */
46
+ function resolveTargetEnvironmentFromFQDN(tokens: TokenSet, environmentDomain: string): TargetEnvironment {
47
+ // Extract the humanId from the domain, eg: 'idler-develop.p1.metaplay.io' -> 'idler-develop'
48
+ const humanId = environmentDomain.split('.')[0]
49
+ return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl) // \todo We could probably infer the stack API URL from the domain?
50
+ }
51
+
52
+ /**
53
+ * Information about an environment received from the portal.
54
+ */
55
+ // \todo Should share this type with the portal endpoint code that returns
56
+ interface PortalEnvironmentInfo {
57
+ /** UUID of the environment */
58
+ id: string
59
+
60
+ /** UUID of the project */
61
+ project_id: string
62
+
63
+ /** User-provided name for the environment (can change) */
64
+ name: string
65
+
66
+ /** TODO: What is this URL? */
67
+ url: string
68
+
69
+ /** Slug for the environment (simplified version of name) */
70
+ slug: string
71
+
72
+ /** Creation time of the environment (eg, '2024-02-02T08:20:18.457748+00:00') */
73
+ created_at: string
74
+
75
+ /** Type of the environment (eg, 'development' or 'production') */
76
+ type: string
77
+
78
+ /** Immutable human-readable identifier for the environment (eg, 'delicious-pumpkin' .. can also be legacy name like 'idler-develop') */
79
+ // \todo Make field mandatory when portal returns valid values
80
+ human_id?: string
81
+
82
+ // \todo Add stackapi url that the portal should return?
83
+ }
84
+
85
+ /**
86
+ * Fetch information about a specific managed environment from the Metaplay portal using slugs.
87
+ * @param tokens Tokens to use to for authenticating with the portal.
88
+ * @param organization Organization slug.
89
+ * @param project Project slug.
90
+ * @param environment Environment slug.
91
+ * @returns The portal's information about the environment.
92
+ */
93
+ // eslint-disable-next-line @typescript-eslint/max-params
94
+ async function fetchManagedEnvironmentInfo(
95
+ tokens: TokenSet,
96
+ organization: string,
97
+ project: string,
98
+ environment: string
99
+ ): Promise<PortalEnvironmentInfo> {
100
+ const url = `${portalBaseUrl}/api/v1/environments/with-slugs?organization_slug=${organization}&project_slug=${project}&environment_slug=${environment}`
101
+ logger.debug(`Getting environment information from portal: ${url}...`)
102
+ const response = await fetch(url, {
103
+ method: 'GET',
104
+ headers: {
105
+ Authorization: `Bearer ${tokens.access_token}`,
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ })
109
+
110
+ // Throw on server errors (eg, forbidden)
111
+ if (!response.ok) {
112
+ const errorData = await response.json()
113
+ throw new Error(`Failed to fetch environment details with error ${response.status}: ${JSON.stringify(errorData)}`)
114
+ }
115
+
116
+ // \todo Validate response?
117
+ return (await response.json()) as PortalEnvironmentInfo
118
+ }
119
+
120
+ /**
121
+ * Resolve the target environment based on (org, proj, env) tuple. We fetch the information
122
+ * from the portal and then construct the `TargetEnvironment` class with the required information
123
+ * for downstream operations.
124
+ * @param tokens Tokens to use for authenticating with the portal
125
+ * @param organization Organization slug.
126
+ * @param project Project slug.
127
+ * @param environment Environment slug.
128
+ * @returns The TargetEnvironment instance needed to operate with the environment.
129
+ */
130
+ // eslint-disable-next-line @typescript-eslint/max-params
131
+ async function resolveTargetEnvironmentFromSlugs(
132
+ tokens: TokenSet,
133
+ organization: string,
134
+ project: string,
135
+ environment: string
136
+ ): Promise<TargetEnvironment> {
137
+ // Fetch the deployment information from the portal
138
+ const portalEnvInfo = await fetchManagedEnvironmentInfo(tokens, organization, project, environment)
139
+ // \todo Use legacy '<project>-<environment>' -- should be filled in by portal!
140
+ const humanId = portalEnvInfo.human_id ?? `${project}-${environment}`
141
+
142
+ return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl)
143
+ }
144
+
145
+ async function resolveTargetEnvironmentHumanId(tokens: TokenSet, humanId: string): Promise<TargetEnvironment> {
146
+ // \todo Validate that the target environment exists?
147
+
148
+ return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl)
149
+ }
150
+
151
+ /**
152
+ * Helper for parsing the TargetEnvironment type from the command line arguments. Accepts either the gameserver address
20
153
  * (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
21
154
  * tuple from options.
22
155
  */
23
- function resolveGameserverId (address: string | undefined, options: any): GameserverId {
156
+ async function resolveTargetEnvironment(
157
+ address: string | undefined,
158
+ options: { organization?: string; project?: string; environment?: string }
159
+ ): Promise<TargetEnvironment> {
160
+ const tokens = await loadTokens()
161
+
24
162
  // If address is specified, use it, otherwise assume options has organization, project, and environment
25
163
  if (address) {
164
+ // Address is one of:
165
+ // - FQDN of the target environment, eg, 'idler-develop.p1.metaplay.io'
166
+ // - Tuple of '<organization>-<project>-<environment>' slugs (eg, 'metaplay-idler-develop')
167
+ // - Stable humanId, eg, 'delicious-elephant'
26
168
  if (isValidFQDN(address)) {
27
- return { gameserver: address }
169
+ return resolveTargetEnvironmentFromFQDN(tokens, address)
28
170
  } else {
29
171
  const parts = address.split('-')
30
- if (parts.length !== 3) {
31
- throw new Error('Invalid gameserver address syntax: specify either <organization>-<project>-<environment> or a fully-qualified domain name (eg, idler-develop.p1.metaplay.io)')
172
+ if (parts.length === 2) {
173
+ // Two parts is humanId, eg, 'delicious-elephant'
174
+ return await resolveTargetEnvironmentHumanId(tokens, address)
175
+ } else if (parts.length === 3) {
176
+ // Three parts is tuple of slush '<organization>-<project>-<environment>'
177
+ return await resolveTargetEnvironmentFromSlugs(tokens, parts[0], parts[1], parts[2])
178
+ } else {
179
+ throw new Error(
180
+ `Invalid environment address syntax '${address}'. Specify either "<organization>-<project>-<environment>", a humanId (eg, "delicious-elephant"), or a fully-qualified domain name (eg, idler-develop.p1.metaplay.io)`
181
+ )
32
182
  }
33
- return { organization: parts[0], project: parts[1], environment: parts[2] }
34
183
  }
35
184
  } else if (options.organization && options.project && options.environment) {
36
- return { organization: options.organization, project: options.project, environment: options.environment }
185
+ // Parse tuple from command-line options (output to stderr to avoid messing up '$(eval metaplay-auth ... --format env)' invocations
186
+ console.warn(
187
+ `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.`
188
+ )
189
+ return await resolveTargetEnvironmentFromSlugs(tokens, options.organization, options.project, options.environment)
37
190
  } else {
38
- 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.')
191
+ throw new Error(
192
+ '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.'
193
+ )
39
194
  }
40
195
  }
41
196
 
@@ -44,8 +199,13 @@ const program = new Command()
44
199
  program
45
200
  .name('metaplay-auth')
46
201
  .description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
47
- .version('1.4.2')
202
+ .version(PACKAGE_VERSION)
48
203
  .option('-d, --debug', 'enable debug output')
204
+ .option('--portal-base-url <portal-base-url>', 'override the default portal base URL (e.g. http://localhost:3000)')
205
+ .option(
206
+ '--stack-api <stack-api-base-url>',
207
+ 'override the default stack API base URL (e.g. https://infra.p1.metaplay.io/stackapi/)'
208
+ )
49
209
  .hook('preAction', (thisCommand) => {
50
210
  // Handle debug flag for all commands.
51
211
  const opts = thisCommand.opts()
@@ -54,24 +214,42 @@ program
54
214
  } else {
55
215
  setLogLevel(10)
56
216
  }
217
+
218
+ // Store the portal base URL for accessing globally
219
+ if (opts.portalBaseUrl) {
220
+ setPortalBaseUrl(opts.portalBaseUrl as string)
221
+ }
222
+
223
+ // Store the stack API base URL for accessing globally
224
+ if (opts.stackApi) {
225
+ defaultStackApiBaseUrl = opts.stackApi as string
226
+ }
57
227
  })
58
228
 
59
- program.command('login')
229
+ program
230
+ .command('login')
60
231
  .description('login to your Metaplay account')
61
232
  .action(async () => {
62
233
  await loginAndSaveTokens()
63
234
  })
64
235
 
65
- program.command('machine-login')
66
- .description('login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)')
67
- .option('--dev-credentials', 'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!')
236
+ program
237
+ .command('machine-login')
238
+ .description(
239
+ 'login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)'
240
+ )
241
+ .option(
242
+ '--dev-credentials',
243
+ 'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!'
244
+ )
68
245
  .action(async (options) => {
69
246
  // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
70
- let credentials
247
+ let credentials: string
71
248
  if (options.devCredentials) {
72
249
  credentials = options.devCredentials
73
250
  } else {
74
- credentials = process.env.METAPLAY_CREDENTIALS
251
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
252
+ credentials = process.env.METAPLAY_CREDENTIALS!
75
253
  if (!credentials || credentials === '') {
76
254
  throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
77
255
  }
@@ -81,7 +259,9 @@ program.command('machine-login')
81
259
  // \note We can't be certain that the secret does not contain pluses so split at the first occurrence
82
260
  const splitOffset = credentials.indexOf('+')
83
261
  if (splitOffset === -1) {
84
- throw new Error('Invalid format for credentials, you should copy-paste the value from the developer portal verbatim')
262
+ throw new Error(
263
+ 'Invalid format for credentials, you should copy-paste the value from the developer portal verbatim'
264
+ )
85
265
  }
86
266
  const clientId = credentials.substring(0, splitOffset)
87
267
  const clientSecret = credentials.substring(splitOffset + 1)
@@ -90,7 +270,8 @@ program.command('machine-login')
90
270
  await machineLoginAndSaveTokens(clientId, clientSecret)
91
271
  })
92
272
 
93
- program.command('logout')
273
+ program
274
+ .command('logout')
94
275
  .description('log out of your Metaplay account')
95
276
  .action(async () => {
96
277
  console.log('Logging out by removing locally stored tokens...')
@@ -107,16 +288,17 @@ program.command('logout')
107
288
  }
108
289
  })
109
290
 
110
- program.command('show-tokens')
291
+ program
292
+ .command('show-tokens')
111
293
  .description('show loaded tokens')
112
294
  .hook('preAction', async () => {
113
295
  await extendCurrentSession()
114
296
  })
115
- .action(async (options) => {
297
+ .action(async () => {
116
298
  try {
117
299
  // TODO: Could detect if not logged in and fail more gracefully?
118
300
  const tokens = await loadTokens()
119
- console.log(tokens)
301
+ console.log(JSON.stringify(tokens, undefined, 2))
120
302
  } catch (error) {
121
303
  if (error instanceof Error) {
122
304
  console.error(`Error showing tokens: ${error.message}`)
@@ -125,55 +307,62 @@ program.command('show-tokens')
125
307
  }
126
308
  })
127
309
 
128
- program.command('get-kubeconfig')
310
+ program
311
+ .command('get-kubeconfig')
129
312
  .description('get kubeconfig for target environment')
130
313
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
131
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
132
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
133
- .option('-p, --project <project>', 'project name (e.g. idler)')
134
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
314
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
315
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
316
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
135
317
  .option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
136
- .option('--output <kubeconfig-path>', 'path of the output file where to write kubeconfig (written to stdout if not specified)')
318
+ .option(
319
+ '--output <kubeconfig-path>',
320
+ 'path of the output file where to write kubeconfig (written to stdout if not specified)'
321
+ )
137
322
  .hook('preAction', async () => {
138
323
  await extendCurrentSession()
139
324
  })
140
- .action(async (gameserver, options) => {
141
- try {
142
- const tokens = await loadTokens()
143
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
144
- const gameserverId = resolveGameserverId(gameserver, options)
145
-
146
- // Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
147
- const isHumanUser = !!tokens.refresh_token
148
- const credentialsType = options.credentialsType ?? (isHumanUser ? 'dynamic' : 'static')
149
-
150
- // Generate kubeconfig
151
- let kubeconfigPayload
152
- if (credentialsType === 'dynamic') {
153
- logger.debug('Fetching kubeconfig with execcredential')
154
- kubeconfigPayload = await stackApi.getKubeConfigExecCredential(gameserverId)
155
- } else if (credentialsType === 'static') {
156
- logger.debug('Fetching kubeconfig with embedded secret')
157
- kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
158
- } else {
159
- throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
160
- }
325
+ .action(
326
+ async (
327
+ gameserver: string | undefined,
328
+ options: { organization?: string; project?: string; environment?: string; type?: string; output?: string }
329
+ ) => {
330
+ try {
331
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
332
+
333
+ // Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
334
+ const tokens = await loadTokens()
335
+ const isHumanUser = !!tokens.refresh_token
336
+ const credentialsType = options.type ?? (isHumanUser ? 'dynamic' : 'static')
337
+
338
+ // Generate kubeconfig
339
+ let kubeconfigPayload
340
+ if (credentialsType === 'dynamic') {
341
+ logger.debug('Fetching kubeconfig with execcredential')
342
+ kubeconfigPayload = await targetEnv.getKubeConfigWithExecCredential()
343
+ } else if (credentialsType === 'static') {
344
+ logger.debug('Fetching kubeconfig with embedded secret')
345
+ kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
346
+ } else {
347
+ throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
348
+ }
161
349
 
162
- // Write kubeconfig to output (file or stdout)
163
- if (options.output) {
164
- logger.debug(`Writing kubeconfig to file ${options.output}`)
165
- await writeFile(options.output, kubeconfigPayload, { mode: 0o600 })
166
- console.log(`Wrote kubeconfig to ${options.output}`)
167
- } else {
168
- console.log(kubeconfigPayload)
169
- }
170
- } catch (error) {
171
- if (error instanceof Error) {
172
- console.error('Error getting KubeConfig:', error)
350
+ // Write kubeconfig to output (file or stdout)
351
+ if (options.output) {
352
+ logger.debug(`Writing kubeconfig to file ${options.output}`)
353
+ await writeFile(options.output, kubeconfigPayload, { mode: 0o600 })
354
+ console.log(`Wrote kubeconfig to ${options.output}`)
355
+ } else {
356
+ console.log(kubeconfigPayload)
357
+ }
358
+ } catch (error) {
359
+ if (error instanceof Error) {
360
+ console.error('Error getting KubeConfig:', error)
361
+ }
362
+ exit(1)
173
363
  }
174
- exit(1)
175
364
  }
176
- })
365
+ )
177
366
 
178
367
  /**
179
368
  * Get the Kubernetes credentials in the execcredential format which can be used within the `kubeconfig` file:
@@ -183,473 +372,583 @@ program.command('get-kubeconfig')
183
372
  * kubeconfig that uses this command.
184
373
  */
185
374
  // todo: maybe this should be a hidden command as it's not very useful for end users and clutters help?
186
- program.command('get-kubernetes-execcredential')
375
+ program
376
+ .command('get-kubernetes-execcredential')
187
377
  .description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
188
378
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
189
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
190
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
191
- .option('-p, --project <project>', 'project name (e.g. idler)')
192
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
379
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
380
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
381
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
193
382
  .hook('preAction', async () => {
194
383
  await extendCurrentSession()
195
384
  })
196
- .action(async (gameserver, options) => {
197
- try {
198
- const tokens = await loadTokens()
199
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
200
- const gameserverId = resolveGameserverId(gameserver, options)
201
-
202
- const credentials = await stackApi.getKubeExecCredential(gameserverId)
203
- console.log(credentials)
204
- } catch (error) {
205
- if (error instanceof Error) {
206
- console.error(`Error getting Kubernetes ExecCredential: ${error.message}`)
385
+ .action(
386
+ async (
387
+ gameserver: string | undefined,
388
+ options: { organization?: string; project?: string; environment?: string }
389
+ ) => {
390
+ try {
391
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
392
+ const credentials = await targetEnv.getKubeExecCredential()
393
+ console.log(credentials)
394
+ } catch (error) {
395
+ if (error instanceof Error) {
396
+ console.error(`Error getting Kubernetes ExecCredential: ${error.message}`)
397
+ }
398
+ exit(1)
207
399
  }
208
- exit(1)
209
400
  }
210
- })
401
+ )
211
402
 
212
- program.command('get-aws-credentials')
403
+ program
404
+ .command('get-aws-credentials')
213
405
  .description('get AWS credentials for target environment')
214
406
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
215
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
216
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
217
- .option('-p, --project <project>', 'project name (e.g. idler)')
218
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
407
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
408
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
409
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
219
410
  .option('-f, --format <format>', 'output format (json or env)', 'json')
220
411
  .hook('preAction', async () => {
221
412
  await extendCurrentSession()
222
413
  })
223
- .action(async (gameserver, options) => {
224
- try {
225
- if (options.format !== 'json' && options.format !== 'env') {
226
- throw new Error('Invalid format; must be one of json or env')
227
- }
228
-
229
- const tokens = await loadTokens()
230
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
231
- const gameserverId = resolveGameserverId(gameserver, options)
232
-
233
- const credentials = await stackApi.getAwsCredentials(gameserverId)
414
+ .action(
415
+ async (
416
+ gameserver: string | undefined,
417
+ options: { organization?: string; project?: string; environment?: string; format?: string }
418
+ ) => {
419
+ try {
420
+ if (options.format !== 'json' && options.format !== 'env') {
421
+ throw new Error('Invalid format; must be one of json or env')
422
+ }
234
423
 
235
- if (options.format === 'env') {
236
- console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
237
- console.log(`export AWS_SECRET_ACCESS_KEY=${credentials.SecretAccessKey}`)
238
- console.log(`export AWS_SESSION_TOKEN=${credentials.SessionToken}`)
239
- } else {
240
- console.log(JSON.stringify({
241
- ...credentials,
242
- Version: 1 // this is needed to comply with `aws` format for external credential providers
243
- }))
244
- }
245
- } catch (error) {
246
- if (error instanceof Error) {
247
- console.error(`Error getting AWS credentials: ${error.message}`)
424
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
425
+
426
+ // Get the AWS credentials
427
+ const credentials = await targetEnv.getAwsCredentials()
428
+
429
+ if (options.format === 'env') {
430
+ console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
431
+ console.log(`export AWS_SECRET_ACCESS_KEY=${credentials.SecretAccessKey}`)
432
+ console.log(`export AWS_SESSION_TOKEN=${credentials.SessionToken}`)
433
+ } else {
434
+ console.log(
435
+ JSON.stringify({
436
+ ...credentials,
437
+ Version: 1, // this is needed to comply with `aws` format for external credential providers
438
+ })
439
+ )
440
+ }
441
+ } catch (error) {
442
+ if (error instanceof Error) {
443
+ console.error(`Error getting AWS credentials: ${error.message}`)
444
+ }
445
+ exit(1)
248
446
  }
249
- exit(1)
250
447
  }
251
- })
448
+ )
252
449
 
253
- program.command('get-docker-login')
254
- .description('get docker login credentials for pushing the server image to target environment')
450
+ program
451
+ .command('get-docker-login')
452
+ .description('[deprecated] get docker login credentials for pushing the server image to target environment')
255
453
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
256
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
257
- .option('-p, --project <project>', 'project name (e.g. idler)')
258
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
454
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
455
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
456
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
259
457
  .option('-f, --format <format>', 'output format (json or env)', 'json')
260
458
  .hook('preAction', async () => {
261
459
  await extendCurrentSession()
262
460
  })
263
- .action(async (gameserver, options) => {
264
- try {
265
- if (options.format !== 'json' && options.format !== 'env') {
266
- throw new Error('Invalid format; must be one of json or env')
267
- }
461
+ .action(
462
+ async (
463
+ gameserver: string | undefined,
464
+ options: { organization?: string; project?: string; environment?: string; format?: string }
465
+ ) => {
466
+ try {
467
+ if (options.format !== 'json' && options.format !== 'env') {
468
+ throw new Error('Invalid format; must be one of json or env')
469
+ }
268
470
 
269
- const tokens = await loadTokens()
270
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
271
- const gameserverId = resolveGameserverId(gameserver, options)
272
-
273
- // Get environment info (region is needed for ECR)
274
- logger.debug('Get environment info')
275
- const environment = await stackApi.getEnvironmentDetails(gameserverId)
276
- const dockerRepo = environment.deployment.ecr_repo
277
-
278
- // Resolve docker credentials for remote registry
279
- logger.debug('Get docker credentials')
280
- const dockerCredentials = await stackApi.getDockerCredentials(gameserverId)
281
- const { username, password } = dockerCredentials
282
-
283
- // Output the docker repo & credentials
284
- if (options.format === 'env') {
285
- console.log(`export DOCKER_REPO=${dockerRepo}`)
286
- console.log(`export DOCKER_USERNAME=${username}`)
287
- console.log(`export DOCKER_PASSWORD=${password}`)
288
- } else {
289
- console.log(JSON.stringify({
290
- dockerRepo,
291
- username,
292
- password
293
- }))
294
- }
295
- } catch (error) {
296
- if (error instanceof Error) {
297
- console.error(`Error getting docker login credentials: ${error.message}`)
471
+ console.warn('The get-docker-login command is deprecated! Use the push-docker-image command instead.')
472
+
473
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
474
+
475
+ // Get environment info (region is needed for ECR)
476
+ logger.debug('Get environment info')
477
+ const environment = await targetEnv.getEnvironmentDetails()
478
+ const dockerRepo = environment.deployment.ecr_repo
479
+
480
+ // Resolve docker credentials for remote registry
481
+ logger.debug('Get docker credentials')
482
+ const dockerCredentials = await targetEnv.getDockerCredentials()
483
+ const { username, password } = dockerCredentials
484
+
485
+ // Output the docker repo & credentials
486
+ if (options.format === 'env') {
487
+ console.log(`export DOCKER_REPO=${dockerRepo}`)
488
+ console.log(`export DOCKER_USERNAME=${username}`)
489
+ console.log(`export DOCKER_PASSWORD=${password}`)
490
+ } else {
491
+ console.log(
492
+ JSON.stringify({
493
+ dockerRepo,
494
+ username,
495
+ password,
496
+ })
497
+ )
498
+ }
499
+ } catch (error) {
500
+ if (error instanceof Error) {
501
+ console.error(`Error getting docker login credentials: ${error.message}`)
502
+ }
503
+ exit(1)
298
504
  }
299
- exit(1)
300
505
  }
301
- })
506
+ )
302
507
 
303
- program.command('get-environment')
508
+ program
509
+ .command('get-environment')
304
510
  .description('get details of an environment')
305
511
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
306
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
307
- .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
308
- .option('-p, --project <project>', 'project name (e.g. idler)')
309
- .option('-e, --environment <environment>', 'environment name (e.g. develop)')
512
+ .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
513
+ .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
514
+ .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
310
515
  .hook('preAction', async () => {
311
516
  await extendCurrentSession()
312
517
  })
313
- .action(async (gameserver, options) => {
314
- try {
315
- const tokens = await loadTokens()
316
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
317
- const gameserverId = resolveGameserverId(gameserver, options)
518
+ .action(
519
+ async (
520
+ gameserver: string | undefined,
521
+ options: { organization?: string; project?: string; environment?: string }
522
+ ) => {
523
+ try {
524
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
318
525
 
319
- const environment = await stackApi.getEnvironmentDetails(gameserverId)
320
- console.log(JSON.stringify(environment))
321
- } catch (error) {
322
- if (error instanceof Error) {
323
- console.error(`Error getting environment details: ${error.message}`)
526
+ const environment = await targetEnv.getEnvironmentDetails()
527
+ console.log(JSON.stringify(environment, undefined, 2))
528
+ } catch (error) {
529
+ if (error instanceof Error) {
530
+ console.error(`Error getting environment details: ${error.message}`)
531
+ }
532
+ exit(1)
324
533
  }
325
- exit(1)
326
534
  }
327
- })
535
+ )
328
536
 
329
- program.command('push-docker-image')
537
+ program
538
+ .command('push-docker-image')
330
539
  .description('push docker image into the target environment image registry')
331
540
  .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
332
541
  .argument('image-name', 'full name of the docker image to push (eg, the gameserver:<sha>)')
333
542
  .hook('preAction', async () => {
334
543
  await extendCurrentSession()
335
544
  })
336
- .action(async (gameserver, imageName, options) => {
337
- try {
338
- console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`)
545
+ .action(
546
+ async (
547
+ gameserver: string | undefined,
548
+ imageName: string | undefined,
549
+ options: { organization?: string; project?: string; environment?: string; imageTag?: string }
550
+ ) => {
551
+ try {
552
+ console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`)
339
553
 
340
- const tokens = await loadTokens()
341
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
342
- const gameserverId = resolveGameserverId(gameserver, options)
554
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
343
555
 
344
- // Get environment info (region is needed for ECR)
345
- logger.debug('Get environment info')
346
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
556
+ // Get environment info (region is needed for ECR)
557
+ logger.debug('Get environment info')
558
+ const envInfo = await targetEnv.getEnvironmentDetails()
347
559
 
348
- // Resolve docker credentials for remote registry
349
- logger.debug('Get docker credentials')
350
- const dockerCredentials = await stackApi.getDockerCredentials(gameserverId)
560
+ // Resolve docker credentials for remote registry
561
+ logger.debug('Get docker credentials')
562
+ const dockerCredentials = await targetEnv.getDockerCredentials()
351
563
 
352
- // Resolve tag from src image
353
- if (!imageName) {
354
- throw new Error('Must specify a valid docker image name as the image-name argument')
355
- }
356
- const srcImageParts = imageName.split(':')
357
- if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
358
- throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`)
359
- }
360
- const imageTag = srcImageParts[1]
361
-
362
- // Resolve source image
363
- const srcImageName = imageName
364
- const dstRepoName = envInfo.deployment.ecr_repo
365
- const dstImageName = `${dstRepoName}:${imageTag}`
366
- const dockerApi = new Docker()
367
- const srcDockerImage = dockerApi.getImage(srcImageName)
368
-
369
- // If names don't match, tag the src image as dst
370
- if (srcImageName !== dstImageName) {
371
- logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`)
372
- await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag })
373
- }
564
+ // Resolve tag from src image
565
+ if (!imageName) {
566
+ throw new Error('Must specify a valid docker image name as the image-name argument')
567
+ }
568
+ const srcImageParts = imageName.split(':')
569
+ if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
570
+ throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`)
571
+ }
572
+ const imageTag = srcImageParts[1]
573
+
574
+ // Resolve source image
575
+ const srcImageName = imageName
576
+ const dstRepoName = envInfo.deployment.ecr_repo
577
+ const dstImageName = `${dstRepoName}:${imageTag}`
578
+ const dockerApi = new Docker()
579
+ const srcDockerImage = dockerApi.getImage(srcImageName)
580
+
581
+ // If names don't match, tag the src image as dst
582
+ if (srcImageName !== dstImageName) {
583
+ logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`)
584
+ await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag })
585
+ }
374
586
 
375
- // Push the image
376
- logger.debug(`Push image ${dstImageName}`)
377
- const dstDockerImage = dockerApi.getImage(dstImageName)
378
- const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
379
- const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
380
-
381
- // Follow push progress & wait until completed
382
- logger.debug('Following image push stream...')
383
- await new Promise((resolve, reject) => {
384
- dockerApi.modem.followProgress(
385
- pushStream,
386
- (error: Error | null, result: any[]) => {
387
- if (error) {
388
- logger.debug('Failed to push image:', error)
389
- reject(error)
390
- } else {
391
- // result contains an array of all the progress objects
392
- logger.debug('Succesfully finished pushing image')
393
- resolve(result)
587
+ // Push the image
588
+ logger.debug(`Push image ${dstImageName}`)
589
+ const dstDockerImage = dockerApi.getImage(dstImageName)
590
+ const authConfig = {
591
+ username: dockerCredentials.username,
592
+ password: dockerCredentials.password,
593
+ serveraddress: dockerCredentials.registryUrl,
594
+ }
595
+ const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
596
+
597
+ // Follow push progress & wait until completed
598
+ logger.debug('Following image push stream...')
599
+ await new Promise((resolve, reject) => {
600
+ dockerApi.modem.followProgress(
601
+ pushStream,
602
+ (error: Error | null, result: any[]) => {
603
+ if (error) {
604
+ logger.debug('Failed to push docker image to target repository:', error)
605
+ reject(error)
606
+ } else {
607
+ // result contains an array of all the progress objects
608
+ logger.debug('Succesfully finished pushing docker image')
609
+ resolve(result)
610
+ }
611
+ },
612
+ () => {
613
+ // console.log('Progress:', obj)
614
+ // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
615
+ // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
394
616
  }
395
- },
396
- (obj: any) => {
397
- // console.log('Progress:', obj)
398
- // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
399
- // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
400
- })
401
- })
402
-
403
- console.log(`Successfully pushed docker image to ${dstImageName}!`)
404
- } catch (error) {
405
- if (error instanceof Error) {
406
- console.error(`Failed to push docker image: ${error.message}`)
617
+ )
618
+ })
619
+
620
+ console.log(`Successfully pushed docker image to ${dstImageName}!`)
621
+ } catch (error) {
622
+ if (error instanceof Error) {
623
+ console.error(`Failed to push docker image: ${error.message}`)
624
+ }
625
+ exit(1)
407
626
  }
408
- exit(1)
409
627
  }
410
- })
628
+ )
411
629
 
412
- program.command('deploy-server')
630
+ program
631
+ .command('deploy-server')
413
632
  .description('deploy a game server image to target environment')
414
- .argument('gameserver', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
633
+ .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
415
634
  .argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
416
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
417
- .option('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
418
- .option('--local-chart-path <path-to-chart-directory>', 'path to local Helm chart directory (to use a chart from local disk)')
419
- .option('--helm-chart-repo <url>', 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)')
420
- .option('--helm-chart-version <version>', 'override the Helm chart version (eg, 0.6.0)')
635
+ .requiredOption('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
636
+ .option(
637
+ '--local-chart-path <path-to-chart-directory>',
638
+ 'path to local Helm chart directory (to use a chart from local disk)'
639
+ )
640
+ .option(
641
+ '--helm-chart-repo <url>',
642
+ 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)'
643
+ )
644
+ .option('--helm-chart-version <version>', 'the Helm chart version to use (eg, 0.6.0)')
421
645
  .option('--deployment-name', 'Helm deployment name to use', 'gameserver')
422
646
  .hook('preAction', async () => {
423
647
  await extendCurrentSession()
424
648
  })
425
- .action(async (gameserver, imageTag, options) => {
426
- try {
427
- const tokens = await loadTokens()
428
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
429
- const gameserverId = resolveGameserverId(gameserver, options)
430
-
431
- console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
649
+ .action(
650
+ async (
651
+ gameserver: string | undefined,
652
+ imageTag: string | undefined,
653
+ options: {
654
+ organization?: string
655
+ project?: string
656
+ environment?: string
657
+ values: string
658
+ localChartPath?: string
659
+ helmChartRepo?: string
660
+ helmChartVersion?: string
661
+ deploymentName?: string
662
+ }
663
+ ) => {
664
+ try {
665
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
432
666
 
433
- // Fetch target environment details
434
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
667
+ console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
435
668
 
436
- if (!imageTag) {
437
- throw new Error('Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build')
438
- }
669
+ // Fetch target environment details
670
+ const envInfo = await targetEnv.getEnvironmentDetails()
439
671
 
440
- if (!options.deploymentName) {
441
- throw new Error(`Invalid Helm deployment name '${options.deploymentName}'; specify one with --deployment-name or use the default`)
442
- }
443
-
444
- // Fetch Docker credentials for target environment registry
445
- const dockerCredentials = await stackApi.getDockerCredentials(gameserverId)
446
-
447
- // Resolve information about docker image
448
- // const dockerApi = new Docker({
449
- // host: dockerCredentials.registryUrl,
450
- // port: 443,
451
- // protocol: 'https',
452
- // username: dockerCredentials.username,
453
- // headers: {
454
- // Authorization: `Bearer ${dockerCredentials.password}`,
455
- // Host: dockerCredentials.registryUrl.replace('https://', ''),
456
- // }
457
- // })
458
- const dockerApi = new Docker()
459
- const dockerRepo = envInfo.deployment.ecr_repo
460
- const imageName = `${dockerRepo}:${imageTag}`
461
- logger.debug(`Fetch docker image labels for ${imageName}`)
462
- let imageLabels
463
- try {
464
- const localDockerImage = dockerApi.getImage(imageName)
465
- imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
466
- } catch (err) {
467
- logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${err}`)
468
- }
672
+ if (!imageTag) {
673
+ throw new Error(
674
+ 'Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build'
675
+ )
676
+ }
677
+ // \todo validate that imageTag is just the version part (i.e, contains no ':')
469
678
 
470
- // If wasn't able to resolve the images, pull image from the target environment registry & resolve labels
471
- if (imageLabels === undefined) {
472
- // Pull the image from remote registry
473
- try {
474
- console.log(`Image ${imageName} not found locally -- pulling docker image from target environment registry...`)
475
- const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
476
- const pullStream = await dockerApi.pull(imageName, { authconfig: authConfig })
477
-
478
- // Follow pull progress & wait until completed
479
- logger.debug('Following image pull stream...')
480
- await new Promise((resolve, reject) => {
481
- dockerApi.modem.followProgress(
482
- pullStream,
483
- (error: Error | null, result: any[]) => {
484
- if (error) {
485
- logger.debug('Failed to pull image:', error)
486
- reject(error)
487
- } else {
488
- // result contains an array of all the progress objects
489
- logger.debug('Succesfully finished pulling image')
490
- resolve(result)
491
- }
492
- },
493
- (obj: any) => {
494
- // console.log('Progress:', obj)
495
- // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
496
- // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
497
- })
498
- })
499
- } catch (err) {
500
- throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${err}`)
679
+ if (!options.deploymentName) {
680
+ throw new Error(
681
+ `Invalid Helm deployment name '${options.deploymentName}'; specify one with --deployment-name or use the default`
682
+ )
501
683
  }
502
684
 
503
- // Resolve the labels
685
+ // Fetch Docker credentials for target environment registry
686
+ const dockerCredentials = await targetEnv.getDockerCredentials()
687
+
688
+ // Resolve information about docker image
689
+ // const dockerApi = new Docker({
690
+ // host: dockerCredentials.registryUrl,
691
+ // port: 443,
692
+ // protocol: 'https',
693
+ // username: dockerCredentials.username,
694
+ // headers: {
695
+ // Authorization: `Bearer ${dockerCredentials.password}`,
696
+ // Host: dockerCredentials.registryUrl.replace('https://', ''),
697
+ // }
698
+ // })
699
+ const dockerApi = new Docker()
700
+ const dockerRepo = envInfo.deployment.ecr_repo
701
+ const imageName = `${dockerRepo}:${imageTag}`
702
+ logger.debug(`Fetch docker image labels for ${imageName}`)
703
+ let imageLabels
504
704
  try {
505
- logger.debug('Get docker labels again (with pulled image)')
506
705
  const localDockerImage = dockerApi.getImage(imageName)
507
706
  imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
508
- } catch (err) {
509
- throw new Error(`Failed to resolve docker image metadata from pulled image ${imageName}: ${err}`)
707
+ } catch (error) {
708
+ const errMessage = error instanceof Error ? error.message : String(error)
709
+ logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${errMessage}`)
510
710
  }
511
- }
512
711
 
513
- // Try to resolve SDK version and Helm repo and chart version from the docker labels
514
- // \note These only exist for SDK R28 and newer images
515
- logger.debug('Docker image labels: ', JSON.stringify(imageLabels))
516
- const sdkVersion = imageLabels['io.metaplay.sdk_version']
517
-
518
- // Resolve helmChartRepo, in order of precedence:
519
- // - Specified in the cli options
520
- // - Specified in the docker image label
521
- // - Fall back to 'https://charts.metaplay.dev'
522
- const helmChartRepo = options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
523
-
524
- // Resolve helmChartVersion, in order of precedence:
525
- // - Specified in the cli options
526
- // - Specified in the docker image label
527
- // - Unknown, error out!
528
- const helmChartVersion = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
529
- if (!helmChartVersion) {
530
- 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.')
531
- }
712
+ // If wasn't able to resolve the images, pull image from the target environment registry & resolve labels
713
+ if (imageLabels === undefined) {
714
+ // Pull the image from remote registry
715
+ try {
716
+ console.log(
717
+ `Image ${imageName} not found locally -- pulling docker image from target environment registry...`
718
+ )
719
+ const authConfig = {
720
+ username: dockerCredentials.username,
721
+ password: dockerCredentials.password,
722
+ serveraddress: dockerCredentials.registryUrl,
723
+ }
724
+ const pullStream = await dockerApi.pull(imageName, { authconfig: authConfig })
725
+
726
+ // Follow pull progress & wait until completed
727
+ logger.debug('Following image pull stream...')
728
+ await new Promise((resolve, reject) => {
729
+ dockerApi.modem.followProgress(
730
+ pullStream,
731
+ (error: Error | null, result: any[]) => {
732
+ if (error) {
733
+ logger.debug('Failed to pull image:', error)
734
+ reject(error)
735
+ } else {
736
+ // result contains an array of all the progress objects
737
+ logger.debug('Succesfully finished pulling image')
738
+ resolve(result)
739
+ }
740
+ },
741
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
742
+ (obj: any) => {
743
+ // console.log('Progress:', obj)
744
+ // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
745
+ // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
746
+ }
747
+ )
748
+ })
749
+ } catch (error) {
750
+ const errMessage = error instanceof Error ? error.message : String(error)
751
+ throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${errMessage}`)
752
+ }
753
+
754
+ // Resolve the labels
755
+ try {
756
+ logger.debug('Get docker labels again (with pulled image)')
757
+ const localDockerImage = dockerApi.getImage(imageName)
758
+ imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
759
+ } catch (error) {
760
+ const errMessage = error instanceof Error ? error.message : String(error)
761
+ throw new Error(`Failed to resolve docker image metadata from pulled image ${imageName}: ${errMessage}`)
762
+ }
763
+ }
532
764
 
533
- // A values file is required (at least for now it makes no sense to deploy without one)
534
- if (!options.values) {
535
- throw new Error('Path to a Helm values file must be specified with --values')
536
- }
765
+ // Try to resolve SDK version and Helm repo and chart version from the docker labels
766
+ // \note These only exist for SDK R28 and newer images
767
+ logger.debug('Docker image labels: ', JSON.stringify(imageLabels))
768
+ const sdkVersion = imageLabels['io.metaplay.sdk_version']
769
+
770
+ // Resolve helmChartRepo, in order of precedence:
771
+ // - Specified in the cli options
772
+ // - Specified in the docker image label
773
+ // - Fall back to 'https://charts.metaplay.dev'
774
+ const helmChartRepo = removeTrailingSlash(
775
+ options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
776
+ )
777
+
778
+ // Resolve helmChartVersion, in order of precedence:
779
+ // - Specified in the cli options
780
+ // - Specified in the docker image label
781
+ // - Unknown, error out!
782
+ const helmChartVersionSpec = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
783
+ if (!options.helmChartVersion) {
784
+ console.warn('You should specify the Helm chart version with --helm-chart-version=<version>!')
785
+ }
786
+ if (!helmChartVersionSpec) {
787
+ throw new Error(
788
+ 'No Helm chart version defined. With pre-R28 SDK versions, you must specify the Helm chart version explicitly with --helm-chart-version=<version>.'
789
+ )
790
+ }
537
791
 
538
- // \If Helm chart >= 0.7.0, check that sdkVersion is defined in docker image labels (the labels were added in R28)
539
- const helmChartSemver = semver.parse(helmChartVersion)
540
- if (!helmChartSemver) {
541
- throw new Error(`Resolve Helm chart version '${helmChartVersion}' is not a valid SemVer!`)
542
- }
543
- if (semver.gte(helmChartSemver, new semver.SemVer('0.7.0'), true) && !sdkVersion) {
544
- throw new Error('Helm chart versions >=0.7.0 are only compatible with SDK versions 28.0.0 and above.')
545
- }
792
+ // Parse the Helm chart version spec into a semver.Range, or null if 'latest' is specified
793
+ let helmChartRange: semver.Range | null = null
794
+ if (helmChartVersionSpec !== 'latest-prerelease') {
795
+ try {
796
+ helmChartRange = new semver.Range(helmChartVersionSpec)
797
+ } catch (error) {
798
+ throw new Error(`Helm chart version '${helmChartVersionSpec}' is not a valid SemVer range!`)
799
+ }
800
+ }
801
+
802
+ // A values file is required (at least for now it makes no sense to deploy without one)
803
+ if (!options.values) {
804
+ throw new Error('Path to a Helm values file must be specified with --values')
805
+ }
546
806
 
547
- // Fetch kubeconfig and write it to a temporary file
548
- // \todo allow passing a custom kubeconfig file?
549
- const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
550
- const kubeconfigPath = join(tmpdir(), randomBytes(20).toString('hex'))
551
- logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
552
- await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
807
+ // Resolve available metaplay-gameserver Helm chart versions
808
+ const availableHelmChartVersions = await fetchHelmChartVersions(helmChartRepo, 'metaplay-gameserver')
809
+ logger.debug(`Available Helm chart versions: ${availableHelmChartVersions.join(', ')}`)
553
810
 
554
- try {
555
- // Construct Helm invocation
556
- const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver'
557
- const helmArgs =
558
- ['upgrade', '--install', '--wait'] // \note wait for the pods to stabilize -- otherwise status check can read state before any changes to pods are applied
811
+ // Resolve the best matching Helm chart version
812
+ const resolvedHelmChartVersion = resolveBestMatchingVersion(availableHelmChartVersions, helmChartRange)
813
+ if (!resolvedHelmChartVersion) {
814
+ throw new Error(
815
+ `No Helm chart version found that satisfies '${helmChartVersionSpec}' in repository '${helmChartRepo}'`
816
+ )
817
+ }
818
+ logger.debug('Resolved Helm chart version: ', resolvedHelmChartVersion)
819
+
820
+ // Fetch kubeconfig and write it to a temporary file
821
+ // \todo allow passing a custom kubeconfig file?
822
+ const kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
823
+ const kubeconfigPath = pathJoin(tmpdir(), randomBytes(20).toString('hex'))
824
+ logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
825
+ await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
826
+
827
+ try {
828
+ // Construct Helm invocation
829
+ const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver'
830
+ const helmArgs = ['upgrade', '--install', '--wait'] // \note wait for the pods to stabilize -- otherwise status check can read state before any changes to pods are applied
559
831
  .concat(['--kubeconfig', kubeconfigPath])
560
832
  .concat(['-n', envInfo.deployment.kubernetes_namespace])
561
833
  .concat(['--values', options.values])
562
834
  .concat(['--set-string', `image.tag=${imageTag}`])
563
835
  .concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
564
- .concat(!options.chartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
836
+ .concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version', resolvedHelmChartVersion] : [])
565
837
  .concat([options.deploymentName])
566
838
  .concat([chartNameOrPath])
567
- logger.info(`Execute: helm ${helmArgs.join(' ')}`)
568
-
569
- // Execute Helm
570
- let helmResult
571
- try {
572
- helmResult = await executeCommand('helm', helmArgs)
573
- // \todo output something from Helm result?
574
- } catch (err) {
575
- throw new Error(`Failed to execute 'helm': ${err}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`)
839
+ logger.info(`Execute: helm ${helmArgs.join(' ')}`)
840
+
841
+ // Execute Helm
842
+ let helmResult
843
+ try {
844
+ helmResult = await executeCommand('helm', helmArgs)
845
+ // \todo output something from Helm result?
846
+ } catch (error) {
847
+ const errMessage = error instanceof Error ? error.message : String(error)
848
+ throw new Error(
849
+ `Failed to execute 'helm': ${errMessage}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`
850
+ )
851
+ }
852
+
853
+ // Throw on Helm non-success exit code
854
+ if (helmResult.exitCode !== 0) {
855
+ throw new Error(`Helm deploy failed with exit code ${helmResult.exitCode}: ${String(helmResult.stderr)}`)
856
+ }
857
+
858
+ const testingRepoSuffix =
859
+ !options.localChartPath && helmChartRepo !== 'https://charts.metaplay.dev'
860
+ ? ` from repo ${helmChartRepo}`
861
+ : ''
862
+ console.log(
863
+ `Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${resolvedHelmChartVersion}${testingRepoSuffix}!`
864
+ )
865
+ } finally {
866
+ // Remove temporary kubeconfig file
867
+ await unlink(kubeconfigPath)
576
868
  }
577
869
 
578
- // Throw on Helm non-success exit code
579
- if (helmResult.code !== 0) {
580
- throw new Error(`Helm deploy failed with exit code ${helmResult.code}: ${helmResult.stderr}`)
870
+ // Check the status of the game server deployment
871
+ try {
872
+ const kubeconfig = new KubeConfig()
873
+ kubeconfig.loadFromString(kubeconfigPayload)
874
+
875
+ console.log('Validating game server deployment...')
876
+ const exitCode = await checkGameServerDeployment(
877
+ envInfo.deployment.kubernetes_namespace,
878
+ kubeconfig,
879
+ imageTag
880
+ )
881
+ exit(exitCode)
882
+ } catch (error) {
883
+ const errMessage = error instanceof Error ? error.message : String(error)
884
+ console.error(`Failed to resolve game server deployment status: ${errMessage}`)
885
+ exit(2)
581
886
  }
582
-
583
- const testingRepoSuffix = (!options.chartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
584
- console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`)
585
- } finally {
586
- // Remove temporary kubeconfig file
587
- await unlink(kubeconfigPath)
588
- }
589
-
590
- // Check the status of the game server deployment
591
- try {
592
- const kubeconfig = new KubeConfig()
593
- kubeconfig.loadFromString(kubeconfigPayload)
594
-
595
- console.log('Validating game server deployment...')
596
- const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig)
597
- exit(exitCode)
598
887
  } catch (error) {
599
- console.error(`Failed to resolve game server deployment status: ${error}`)
600
- exit(2)
601
- }
602
- } catch (error) {
603
- if (error instanceof Error) {
604
- console.error(`Error deploying game server into target environment: ${error.message}`)
888
+ if (error instanceof Error) {
889
+ console.error(`Error deploying game server into target environment: ${error.message}`)
890
+ }
891
+ exit(1)
605
892
  }
606
- exit(1)
607
893
  }
608
- })
894
+ )
609
895
 
610
- program.command('check-server-status')
611
- .description('check the status of a deployed server and print out information that is helpful in debugging failed deployments')
896
+ program
897
+ .command('check-server-status')
898
+ .description(
899
+ 'check the status of a deployed server and print out information that is helpful in debugging failed deployments'
900
+ )
612
901
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
613
902
  .hook('preAction', async () => {
614
903
  await extendCurrentSession()
615
904
  })
616
- .action(async (gameserver: string | undefined, options) => {
617
- const tokens = await loadTokens()
618
- const stackApi = new StackAPI(tokens.access_token, options.stackApi)
619
- const gameserverId = resolveGameserverId(gameserver, options)
620
-
621
- try {
622
- logger.debug('Get environment info')
623
- const envInfo = await stackApi.getEnvironmentDetails(gameserverId)
624
- const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
905
+ .action(
906
+ async (
907
+ gameserver: string | undefined,
908
+ options: { organization?: string; project?: string; environment?: string }
909
+ ) => {
910
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
625
911
 
626
- // Load kubeconfig from file and throw error if validation fails.
627
- logger.debug('Get kubeconfig')
628
- const kubeconfig = new KubeConfig()
629
912
  try {
630
- // Fetch kubeconfig and write it to a temporary file
631
- // \todo allow passing a custom kubeconfig file?
632
- const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId)
633
- kubeconfig.loadFromString(kubeconfigPayload)
634
- } catch (error) {
635
- throw new Error(`Failed to load or validate kubeconfig. ${error}`)
636
- }
913
+ logger.debug('Get environment info')
914
+ const envInfo = await targetEnv.getEnvironmentDetails()
915
+ const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
637
916
 
638
- // Run the checks and exit with success/failure exitCode depending on result
639
- console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
640
- const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig)
641
- exit(exitCode)
642
- } catch (error: any) {
643
- console.error(`Failed to check deployment status: ${error.message}`)
644
- exit(1)
917
+ // Load kubeconfig from file and throw error if validation fails.
918
+ logger.debug('Get kubeconfig')
919
+ const kubeconfig = new KubeConfig()
920
+ try {
921
+ // Initialize kubeconfig with the payload fetched from the cloud
922
+ // \todo allow passing a custom kubeconfig file?
923
+ const kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
924
+ kubeconfig.loadFromString(kubeconfigPayload)
925
+ } catch (error) {
926
+ const errMessage = error instanceof Error ? error.message : String(error)
927
+ throw new Error(`Failed to load or validate kubeconfig. ${errMessage}`)
928
+ }
929
+
930
+ // Run the checks and exit with success/failure exitCode depending on result
931
+ console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
932
+ // \todo Get requiredImageTag from the Helm chart
933
+ const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig, /* requiredImageTag: */ null)
934
+ exit(exitCode)
935
+ } catch (error: any) {
936
+ console.error(`Failed to check deployment status: ${error.message}`)
937
+ exit(1)
938
+ }
645
939
  }
646
- })
940
+ )
647
941
 
648
- program.command('check-deployment')
649
- .description('[deprecated] check that a game server was successfully deployed, or print out useful error messages in case of failure')
942
+ program
943
+ .command('check-deployment')
944
+ .description(
945
+ '[deprecated] check that a game server was successfully deployed, or print out useful error messages in case of failure'
946
+ )
650
947
  .argument('[namespace]', 'kubernetes namespace of the deployment')
651
948
  .action(async (namespace: string) => {
652
- console.error('DEPRECATED! Use the "metaplay-auth check-server-status [gameserver]" command instead! This command will be removed soon.')
949
+ console.error(
950
+ 'DEPRECATED! Use the "metaplay-auth check-server-status [gameserver]" command instead! This command will be removed soon.'
951
+ )
653
952
 
654
953
  try {
655
954
  if (!namespace) {
@@ -673,12 +972,14 @@ program.command('check-deployment')
673
972
  try {
674
973
  kubeconfig.loadFromFile(kubeconfigPath)
675
974
  } catch (error) {
676
- throw new Error(`Failed to load or validate kubeconfig. ${error}`)
975
+ const errMessage = error instanceof Error ? error.message : String(error)
976
+ throw new Error(`Failed to load or validate kubeconfig: ${errMessage}`)
677
977
  }
678
978
 
679
979
  // Run the checks and exit with success/failure exitCode depending on result
680
980
  console.log(`Validating game server deployment in namespace ${namespace}`)
681
- const exitCode = await checkGameServerDeployment(namespace, kubeconfig)
981
+ // \todo Get requiredImageTag from the Helm chart
982
+ const exitCode = await checkGameServerDeployment(namespace, kubeconfig, /* requiredImageTag: */ null)
682
983
  exit(exitCode)
683
984
  } catch (error: any) {
684
985
  console.error(`Failed to check deployment status: ${error.message}`)
@@ -686,4 +987,29 @@ program.command('check-deployment')
686
987
  }
687
988
  })
688
989
 
990
+ program
991
+ .command('debug-server')
992
+ .description('run an ephemeral debug container against a game server pod running in the cloud')
993
+ .argument('gameserver', 'address of gameserver (e.g., metaplay-idler-develop or idler-develop.p1.metaplay.io)')
994
+ .argument('[pod-name]', 'name of the pod to debug (must be specified if deployment has multiple pods)')
995
+ .hook('preAction', async () => {
996
+ await extendCurrentSession()
997
+ })
998
+ .action(
999
+ async (
1000
+ gameserver: string,
1001
+ podName: string | undefined,
1002
+ options: { organization?: string; project?: string; environment?: string }
1003
+ ) => {
1004
+ // Resolve target environment
1005
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
1006
+
1007
+ // Exec 'kubectl debug ...'
1008
+ await debugGameServer(targetEnv, podName)
1009
+ }
1010
+ )
1011
+
1012
+ // Register docker build command
1013
+ registerBuildCommand(program)
1014
+
689
1015
  void program.parseAsync()