@metaplay/metaplay-auth 1.5.0 → 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,49 +1,151 @@
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, TokenSet } from './src/auth.js'
5
- import { StackAPI, defaultStackApiBaseUrl } from './src/stackapi.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'
6
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, getGameserverAdminUrl } from './src/utils.js'
9
23
  import { TargetEnvironment } from './src/targetenvironment.js'
10
24
  import { exit } from 'process'
11
25
  import { tmpdir } from 'os'
12
- import { join } from 'path'
13
26
  import { randomBytes } from 'crypto'
14
27
  import { writeFile, unlink } from 'fs/promises'
15
28
  import { existsSync } from 'fs'
16
29
  import { KubeConfig } from '@kubernetes/client-node'
17
30
  import * as semver from 'semver'
18
31
  import { PACKAGE_VERSION } from './src/version.js'
32
+ import { registerBuildCommand } from './src/buildCommand.js'
19
33
 
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`
34
+ /**
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
+ }
24
119
 
25
- return new TargetEnvironment(
26
- tokens.access_token,
27
- environmentDomain,
28
- defaultStackApiBaseUrl,
29
- environmentApiBaseUrl)
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)
30
143
  }
31
144
 
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)
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)
47
149
  }
48
150
 
49
151
  /**
@@ -51,30 +153,44 @@ async function resolveTargetEnvironmentFromTuple (tokens: TokenSet, organization
51
153
  * (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
52
154
  * tuple from options.
53
155
  */
54
- async function resolveTargetEnvironment (address: string | undefined, options: { organization?: string, project?: string, environment?: string, stackApi?: string }): Promise<TargetEnvironment> {
156
+ async function resolveTargetEnvironment(
157
+ address: string | undefined,
158
+ options: { organization?: string; project?: string; environment?: string }
159
+ ): Promise<TargetEnvironment> {
55
160
  const tokens = await loadTokens()
56
161
 
57
162
  // If address is specified, use it, otherwise assume options has organization, project, and environment
58
163
  if (address) {
59
- // Address is either FQDN or 'organization-project-environment' tuple
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'
60
168
  if (isValidFQDN(address)) {
61
- if (options.stackApi) {
62
- throw new Error('--stack-api override only supported with organization-project-environment naming, not with FQDN')
63
- }
64
169
  return resolveTargetEnvironmentFromFQDN(tokens, address)
65
170
  } else {
66
171
  const parts = address.split('-')
67
- if (parts.length !== 3) {
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)')
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
+ )
69
182
  }
70
- return await resolveTargetEnvironmentFromTuple(tokens, parts[0], parts[1], parts[2], options.stackApi)
71
183
  }
72
184
  } else if (options.organization && options.project && options.environment) {
73
185
  // 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)
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)
76
190
  } else {
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.')
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
+ )
78
194
  }
79
195
  }
80
196
 
@@ -85,6 +201,11 @@ program
85
201
  .description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
86
202
  .version(PACKAGE_VERSION)
87
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
+ )
88
209
  .hook('preAction', (thisCommand) => {
89
210
  // Handle debug flag for all commands.
90
211
  const opts = thisCommand.opts()
@@ -93,17 +214,34 @@ program
93
214
  } else {
94
215
  setLogLevel(10)
95
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
+ }
96
227
  })
97
228
 
98
- program.command('login')
229
+ program
230
+ .command('login')
99
231
  .description('login to your Metaplay account')
100
232
  .action(async () => {
101
233
  await loginAndSaveTokens()
102
234
  })
103
235
 
104
- program.command('machine-login')
105
- .description('login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)')
106
- .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
+ )
107
245
  .action(async (options) => {
108
246
  // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
109
247
  let credentials: string
@@ -121,7 +259,9 @@ program.command('machine-login')
121
259
  // \note We can't be certain that the secret does not contain pluses so split at the first occurrence
122
260
  const splitOffset = credentials.indexOf('+')
123
261
  if (splitOffset === -1) {
124
- 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
+ )
125
265
  }
126
266
  const clientId = credentials.substring(0, splitOffset)
127
267
  const clientSecret = credentials.substring(splitOffset + 1)
@@ -130,7 +270,8 @@ program.command('machine-login')
130
270
  await machineLoginAndSaveTokens(clientId, clientSecret)
131
271
  })
132
272
 
133
- program.command('logout')
273
+ program
274
+ .command('logout')
134
275
  .description('log out of your Metaplay account')
135
276
  .action(async () => {
136
277
  console.log('Logging out by removing locally stored tokens...')
@@ -147,12 +288,13 @@ program.command('logout')
147
288
  }
148
289
  })
149
290
 
150
- program.command('show-tokens')
291
+ program
292
+ .command('show-tokens')
151
293
  .description('show loaded tokens')
152
294
  .hook('preAction', async () => {
153
295
  await extendCurrentSession()
154
296
  })
155
- .action(async (options) => {
297
+ .action(async () => {
156
298
  try {
157
299
  // TODO: Could detect if not logged in and fail more gracefully?
158
300
  const tokens = await loadTokens()
@@ -165,54 +307,62 @@ program.command('show-tokens')
165
307
  }
166
308
  })
167
309
 
168
- program.command('get-kubeconfig')
310
+ program
311
+ .command('get-kubeconfig')
169
312
  .description('get kubeconfig for target environment')
170
313
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
171
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
172
314
  .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
173
315
  .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
174
316
  .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
175
317
  .option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
176
- .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
+ )
177
322
  .hook('preAction', async () => {
178
323
  await extendCurrentSession()
179
324
  })
180
- .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, type?: string, output?: string }) => {
181
- try {
182
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
183
-
184
- // Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
185
- const tokens = await loadTokens()
186
- const isHumanUser = !!tokens.refresh_token
187
- const credentialsType = options.type ?? (isHumanUser ? 'dynamic' : 'static')
188
-
189
- // Generate kubeconfig
190
- let kubeconfigPayload
191
- if (credentialsType === 'dynamic') {
192
- logger.debug('Fetching kubeconfig with execcredential')
193
- kubeconfigPayload = await targetEnv.getKubeConfigExecCredential()
194
- } else if (credentialsType === 'static') {
195
- logger.debug('Fetching kubeconfig with embedded secret')
196
- kubeconfigPayload = await targetEnv.getKubeConfig()
197
- } else {
198
- throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
199
- }
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
+ }
200
349
 
201
- // Write kubeconfig to output (file or stdout)
202
- if (options.output) {
203
- logger.debug(`Writing kubeconfig to file ${options.output}`)
204
- await writeFile(options.output, kubeconfigPayload, { mode: 0o600 })
205
- console.log(`Wrote kubeconfig to ${options.output}`)
206
- } else {
207
- console.log(kubeconfigPayload)
208
- }
209
- } catch (error) {
210
- if (error instanceof Error) {
211
- 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)
212
363
  }
213
- exit(1)
214
364
  }
215
- })
365
+ )
216
366
 
217
367
  /**
218
368
  * Get the Kubernetes credentials in the execcredential format which can be used within the `kubeconfig` file:
@@ -222,33 +372,38 @@ program.command('get-kubeconfig')
222
372
  * kubeconfig that uses this command.
223
373
  */
224
374
  // todo: maybe this should be a hidden command as it's not very useful for end users and clutters help?
225
- program.command('get-kubernetes-execcredential')
375
+ program
376
+ .command('get-kubernetes-execcredential')
226
377
  .description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
227
378
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
228
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
229
379
  .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
230
380
  .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
231
381
  .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
232
382
  .hook('preAction', async () => {
233
383
  await extendCurrentSession()
234
384
  })
235
- .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string }) => {
236
- try {
237
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
238
- const credentials = await targetEnv.getKubeExecCredential()
239
- console.log(credentials)
240
- } catch (error) {
241
- if (error instanceof Error) {
242
- 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)
243
399
  }
244
- exit(1)
245
400
  }
246
- })
401
+ )
247
402
 
248
- program.command('get-aws-credentials')
403
+ program
404
+ .command('get-aws-credentials')
249
405
  .description('get AWS credentials for target environment')
250
406
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
251
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
252
407
  .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
253
408
  .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
254
409
  .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
@@ -256,39 +411,46 @@ program.command('get-aws-credentials')
256
411
  .hook('preAction', async () => {
257
412
  await extendCurrentSession()
258
413
  })
259
- .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, format?: string }) => {
260
- try {
261
- if (options.format !== 'json' && options.format !== 'env') {
262
- throw new Error('Invalid format; must be one of json or env')
263
- }
264
-
265
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
266
-
267
- // Get the AWS credentials
268
- const credentials = await targetEnv.getAwsCredentials()
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
+ }
269
423
 
270
- if (options.format === 'env') {
271
- console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
272
- console.log(`export AWS_SECRET_ACCESS_KEY=${credentials.SecretAccessKey}`)
273
- console.log(`export AWS_SESSION_TOKEN=${credentials.SessionToken}`)
274
- } else {
275
- console.log(JSON.stringify({
276
- ...credentials,
277
- Version: 1 // this is needed to comply with `aws` format for external credential providers
278
- }))
279
- }
280
- } catch (error) {
281
- if (error instanceof Error) {
282
- 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)
283
446
  }
284
- exit(1)
285
447
  }
286
- })
448
+ )
287
449
 
288
- program.command('get-docker-login')
450
+ program
451
+ .command('get-docker-login')
289
452
  .description('[deprecated] get docker login credentials for pushing the server image to target environment')
290
453
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
291
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
292
454
  .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
293
455
  .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
294
456
  .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
@@ -296,392 +458,497 @@ program.command('get-docker-login')
296
458
  .hook('preAction', async () => {
297
459
  await extendCurrentSession()
298
460
  })
299
- .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string, format?: string }) => {
300
- try {
301
- if (options.format !== 'json' && options.format !== 'env') {
302
- throw new Error('Invalid format; must be one of json or env')
303
- }
304
-
305
- console.warn('The get-docker-login command is deprecated! Use the push-docker-image command instead.')
306
-
307
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
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
+ }
308
470
 
309
- // Get environment info (region is needed for ECR)
310
- logger.debug('Get environment info')
311
- const environment = await targetEnv.getEnvironmentDetails()
312
- const dockerRepo = environment.deployment.ecr_repo
313
-
314
- // Resolve docker credentials for remote registry
315
- logger.debug('Get docker credentials')
316
- const dockerCredentials = await targetEnv.getDockerCredentials()
317
- const { username, password } = dockerCredentials
318
-
319
- // Output the docker repo & credentials
320
- if (options.format === 'env') {
321
- console.log(`export DOCKER_REPO=${dockerRepo}`)
322
- console.log(`export DOCKER_USERNAME=${username}`)
323
- console.log(`export DOCKER_PASSWORD=${password}`)
324
- } else {
325
- console.log(JSON.stringify({
326
- dockerRepo,
327
- username,
328
- password
329
- }))
330
- }
331
- } catch (error) {
332
- if (error instanceof Error) {
333
- 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)
334
504
  }
335
- exit(1)
336
505
  }
337
- })
506
+ )
338
507
 
339
- program.command('get-environment')
508
+ program
509
+ .command('get-environment')
340
510
  .description('get details of an environment')
341
511
  .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
342
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
343
512
  .option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
344
513
  .option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
345
514
  .option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
346
515
  .hook('preAction', async () => {
347
516
  await extendCurrentSession()
348
517
  })
349
- .action(async (gameserver: string | undefined, options: { stackApi?: string, organization?: string, project?: string, environment?: string }) => {
350
- try {
351
- const targetEnv = await resolveTargetEnvironment(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)
352
525
 
353
- const environment = await targetEnv.getEnvironmentDetails()
354
- console.log(JSON.stringify(environment, undefined, 2))
355
- } catch (error) {
356
- if (error instanceof Error) {
357
- 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)
358
533
  }
359
- exit(1)
360
534
  }
361
- })
535
+ )
362
536
 
363
- program.command('push-docker-image')
537
+ program
538
+ .command('push-docker-image')
364
539
  .description('push docker image into the target environment image registry')
365
540
  .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
366
541
  .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/)')
368
542
  .hook('preAction', async () => {
369
543
  await extendCurrentSession()
370
544
  })
371
- .action(async (gameserver: string | undefined, imageName: string | undefined, options: { stackApi?: string, imageTag?: string }) => {
372
- try {
373
- 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}...`)
374
553
 
375
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
554
+ const targetEnv = await resolveTargetEnvironment(gameserver, options)
376
555
 
377
- // Get environment info (region is needed for ECR)
378
- logger.debug('Get environment info')
379
- const envInfo = await targetEnv.getEnvironmentDetails()
556
+ // Get environment info (region is needed for ECR)
557
+ logger.debug('Get environment info')
558
+ const envInfo = await targetEnv.getEnvironmentDetails()
380
559
 
381
- // Resolve docker credentials for remote registry
382
- logger.debug('Get docker credentials')
383
- const dockerCredentials = await targetEnv.getDockerCredentials()
560
+ // Resolve docker credentials for remote registry
561
+ logger.debug('Get docker credentials')
562
+ const dockerCredentials = await targetEnv.getDockerCredentials()
384
563
 
385
- // Resolve tag from src image
386
- if (!imageName) {
387
- throw new Error('Must specify a valid docker image name as the image-name argument')
388
- }
389
- const srcImageParts = imageName.split(':')
390
- if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
391
- throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`)
392
- }
393
- const imageTag = srcImageParts[1]
394
-
395
- // Resolve source image
396
- const srcImageName = imageName
397
- const dstRepoName = envInfo.deployment.ecr_repo
398
- const dstImageName = `${dstRepoName}:${imageTag}`
399
- const dockerApi = new Docker()
400
- const srcDockerImage = dockerApi.getImage(srcImageName)
401
-
402
- // If names don't match, tag the src image as dst
403
- if (srcImageName !== dstImageName) {
404
- logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`)
405
- await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag })
406
- }
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
+ }
407
586
 
408
- // Push the image
409
- logger.debug(`Push image ${dstImageName}`)
410
- const dstDockerImage = dockerApi.getImage(dstImageName)
411
- const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
412
- const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
413
-
414
- // Follow push progress & wait until completed
415
- logger.debug('Following image push stream...')
416
- await new Promise((resolve, reject) => {
417
- dockerApi.modem.followProgress(
418
- pushStream,
419
- (error: Error | null, result: any[]) => {
420
- if (error) {
421
- logger.debug('Failed to push docker image to target repository:', error)
422
- reject(error)
423
- } else {
424
- // result contains an array of all the progress objects
425
- logger.debug('Succesfully finished pushing docker image')
426
- 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' }
427
616
  }
428
- },
429
- (obj: any) => {
430
- // console.log('Progress:', obj)
431
- // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
432
- // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
433
- })
434
- })
435
-
436
- console.log(`Successfully pushed docker image to ${dstImageName}!`)
437
- } catch (error) {
438
- if (error instanceof Error) {
439
- 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)
440
626
  }
441
- exit(1)
442
627
  }
443
- })
628
+ )
444
629
 
445
- program.command('deploy-server')
630
+ program
631
+ .command('deploy-server')
446
632
  .description('deploy a game server image to target environment')
447
633
  .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
448
634
  .argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
449
635
  .requiredOption('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
450
- .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
451
- .option('--local-chart-path <path-to-chart-directory>', 'path to local Helm chart directory (to use a chart from local disk)')
452
- .option('--helm-chart-repo <url>', 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)')
453
- .option('--helm-chart-version <version>', 'override the Helm chart version (eg, 0.6.0)')
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)')
454
645
  .option('--deployment-name', 'Helm deployment name to use', 'gameserver')
455
646
  .hook('preAction', async () => {
456
647
  await extendCurrentSession()
457
648
  })
458
- .action(async (gameserver: string | undefined, imageTag: string | undefined, options: { values: string, stackApi?: string, localChartPath?: string, helmChartRepo?: string, helmChartVersion?: string, deploymentName?: string }) => {
459
- try {
460
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
461
-
462
- 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)
463
666
 
464
- // Fetch target environment details
465
- const envInfo = await targetEnv.getEnvironmentDetails()
667
+ console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
466
668
 
467
- if (!imageTag) {
468
- throw new Error('Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build')
469
- }
470
- // \todo validate that imageTag is just the version part (i.e, contains no ':')
669
+ // Fetch target environment details
670
+ const envInfo = await targetEnv.getEnvironmentDetails()
471
671
 
472
- if (!options.deploymentName) {
473
- throw new Error(`Invalid Helm deployment name '${options.deploymentName}'; specify one with --deployment-name or use the default`)
474
- }
475
-
476
- // Fetch Docker credentials for target environment registry
477
- const dockerCredentials = await targetEnv.getDockerCredentials()
478
-
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()
491
- const dockerRepo = envInfo.deployment.ecr_repo
492
- const imageName = `${dockerRepo}:${imageTag}`
493
- logger.debug(`Fetch docker image labels for ${imageName}`)
494
- let imageLabels
495
- try {
496
- const localDockerImage = dockerApi.getImage(imageName)
497
- imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
498
- } catch (err) {
499
- logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${err}`)
500
- }
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 ':')
501
678
 
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}`)
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
+ )
533
683
  }
534
684
 
535
- // 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
536
704
  try {
537
- logger.debug('Get docker labels again (with pulled image)')
538
705
  const localDockerImage = dockerApi.getImage(imageName)
539
706
  imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
540
- } catch (err) {
541
- 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}`)
542
710
  }
543
- }
544
711
 
545
- // Try to resolve SDK version and Helm repo and chart version from the docker labels
546
- // \note These only exist for SDK R28 and newer images
547
- logger.debug('Docker image labels: ', JSON.stringify(imageLabels))
548
- const sdkVersion = imageLabels['io.metaplay.sdk_version']
549
-
550
- // Resolve helmChartRepo, in order of precedence:
551
- // - Specified in the cli options
552
- // - Specified in the docker image label
553
- // - Fall back to 'https://charts.metaplay.dev'
554
- const helmChartRepo = options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
555
-
556
- // Resolve helmChartVersion, in order of precedence:
557
- // - Specified in the cli options
558
- // - Specified in the docker image label
559
- // - Unknown, error out!
560
- const helmChartVersion = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
561
- if (!helmChartVersion) {
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>.')
563
- }
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
+ }
564
764
 
565
- // A values file is required (at least for now it makes no sense to deploy without one)
566
- if (!options.values) {
567
- throw new Error('Path to a Helm values file must be specified with --values')
568
- }
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
+ }
569
791
 
570
- // \If Helm chart >= 0.7.0, check that sdkVersion is defined in docker image labels (the labels were added in R28)
571
- const helmChartSemver = semver.parse(helmChartVersion)
572
- if (!helmChartSemver) {
573
- throw new Error(`Resolve Helm chart version '${helmChartVersion}' is not a valid SemVer!`)
574
- }
575
- if (semver.gte(helmChartSemver, new semver.SemVer('0.7.0'), true) && !sdkVersion) {
576
- throw new Error('Helm chart versions >=0.7.0 are only compatible with SDK versions 28.0.0 and above.')
577
- }
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
+ }
578
801
 
579
- // Fetch kubeconfig and write it to a temporary file
580
- // \todo allow passing a custom kubeconfig file?
581
- const kubeconfigPayload = await targetEnv.getKubeConfig()
582
- const kubeconfigPath = join(tmpdir(), randomBytes(20).toString('hex'))
583
- logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
584
- await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
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
+ }
585
806
 
586
- try {
587
- // Construct Helm invocation
588
- const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver'
589
- const helmArgs =
590
- ['upgrade', '--install', '--wait'] // \note wait for the pods to stabilize -- otherwise status check can read state before any changes to pods are applied
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(', ')}`)
810
+
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
591
831
  .concat(['--kubeconfig', kubeconfigPath])
592
832
  .concat(['-n', envInfo.deployment.kubernetes_namespace])
593
833
  .concat(['--values', options.values])
594
834
  .concat(['--set-string', `image.tag=${imageTag}`])
595
835
  .concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
596
- .concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
836
+ .concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version', resolvedHelmChartVersion] : [])
597
837
  .concat([options.deploymentName])
598
838
  .concat([chartNameOrPath])
599
- logger.info(`Execute: helm ${helmArgs.join(' ')}`)
600
-
601
- // Execute Helm
602
- let helmResult
603
- try {
604
- helmResult = await executeCommand('helm', helmArgs, false)
605
- // \todo output something from Helm result?
606
- } catch (err) {
607
- 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)
608
868
  }
609
869
 
610
- // Throw on Helm non-success exit code
611
- if (helmResult.exitCode !== 0) {
612
- throw new Error(`Helm deploy failed with exit code ${helmResult.exitCode}: ${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)
613
886
  }
614
-
615
- const testingRepoSuffix = (!options.localChartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
616
- console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`)
617
- } finally {
618
- // Remove temporary kubeconfig file
619
- await unlink(kubeconfigPath)
620
- }
621
-
622
- // Check the status of the game server deployment
623
- try {
624
- const kubeconfig = new KubeConfig()
625
- kubeconfig.loadFromString(kubeconfigPayload)
626
-
627
- console.log('Validating game server deployment...')
628
- const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig, imageTag)
629
- exit(exitCode)
630
887
  } catch (error) {
631
- console.error(`Failed to resolve game server deployment status: ${error}`)
632
- exit(2)
633
- }
634
- } catch (error) {
635
- if (error instanceof Error) {
636
- 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)
637
892
  }
638
- exit(1)
639
893
  }
640
- })
894
+ )
641
895
 
642
- program.command('check-server-status')
643
- .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
+ )
644
901
  .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/)')
646
902
  .hook('preAction', async () => {
647
903
  await extendCurrentSession()
648
904
  })
649
- .action(async (gameserver: string | undefined, options: { stackApi?: string }) => {
650
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
651
-
652
- try {
653
- logger.debug('Get environment info')
654
- const envInfo = await targetEnv.getEnvironmentDetails()
655
- 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)
656
911
 
657
- // Load kubeconfig from file and throw error if validation fails.
658
- logger.debug('Get kubeconfig')
659
- const kubeconfig = new KubeConfig()
660
912
  try {
661
- // Initialize kubeconfig with the payload fetched from the cloud
662
- // \todo allow passing a custom kubeconfig file?
663
- const kubeconfigPayload = await targetEnv.getKubeConfig()
664
- kubeconfig.loadFromString(kubeconfigPayload)
665
- } catch (error) {
666
- throw new Error(`Failed to load or validate kubeconfig. ${error}`)
667
- }
913
+ logger.debug('Get environment info')
914
+ const envInfo = await targetEnv.getEnvironmentDetails()
915
+ const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
668
916
 
669
- // Run the checks and exit with success/failure exitCode depending on result
670
- console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
671
- // \todo Get requiredImageTag from the Helm chart
672
- const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig, /* requiredImageTag: */ null)
673
- exit(exitCode)
674
- } catch (error: any) {
675
- console.error(`Failed to check deployment status: ${error.message}`)
676
- 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
+ }
677
939
  }
678
- })
940
+ )
679
941
 
680
- program.command('check-deployment')
681
- .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
+ )
682
947
  .argument('[namespace]', 'kubernetes namespace of the deployment')
683
948
  .action(async (namespace: string) => {
684
- 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
+ )
685
952
 
686
953
  try {
687
954
  if (!namespace) {
@@ -705,7 +972,8 @@ program.command('check-deployment')
705
972
  try {
706
973
  kubeconfig.loadFromFile(kubeconfigPath)
707
974
  } catch (error) {
708
- 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}`)
709
977
  }
710
978
 
711
979
  // Run the checks and exit with success/failure exitCode depending on result
@@ -719,19 +987,29 @@ program.command('check-deployment')
719
987
  }
720
988
  })
721
989
 
722
- program.command('debug-server')
990
+ program
991
+ .command('debug-server')
723
992
  .description('run an ephemeral debug container against a game server pod running in the cloud')
724
993
  .argument('gameserver', 'address of gameserver (e.g., metaplay-idler-develop or idler-develop.p1.metaplay.io)')
725
994
  .argument('[pod-name]', 'name of the pod to debug (must be specified if deployment has multiple pods)')
726
995
  .hook('preAction', async () => {
727
996
  await extendCurrentSession()
728
997
  })
729
- .action(async (gameserver: string, podName: string | undefined, options: { stackApi?: string }) => {
730
- // Resolve target environment
731
- const targetEnv = await resolveTargetEnvironment(gameserver, options)
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)
732
1006
 
733
- // Exec 'kubectl debug ...'
734
- await debugGameServer(targetEnv, podName)
735
- })
1007
+ // Exec 'kubectl debug ...'
1008
+ await debugGameServer(targetEnv, podName)
1009
+ }
1010
+ )
1011
+
1012
+ // Register docker build command
1013
+ registerBuildCommand(program)
736
1014
 
737
1015
  void program.parseAsync()