@metaplay/metaplay-auth 1.2.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2024-04-24
4
+
5
+ ### Added
6
+
7
+ * New command `deploy-server` which deploys a given docker image to the target environment, including support for auto-detecting default Helm chart repository and version from the built image's labels.
8
+ * New command `push-docker-image` which pushes the built docker image into the target environment's image repository.
9
+ * New command `check-server-status [gameserver]` which replaces the old `check-deployment [namespace]`
10
+ * Allow writing the kubeconfig from `get-kubeconfig` command directly into a file with `metaplay-auth get-kubeconfig --output /path/to/kubeconfig`.
11
+
12
+ ### Changed
13
+
14
+ * The `get-kubeconfig` command now defaults to generating a dynamic `kubeconfig` for human users. This means that the credentials are resolved on each time the `kubeconfig` is used. Thus, the `kubeconfig` stays valid as long as the `metaplay-auth` session is valid. Machine users still default to a static `kubeconfig` with the secret embedded.
15
+ * The `check-deployment` command is now considered deprecated. Use the `check-server-status [gameserver]` instead.
16
+
17
+ ### Fixed
18
+
19
+ * Many improvements to the game server status check logic: support multi-pod deployments, fix multiple bugs, make progress reporting less noisy and improve error messages.
20
+
21
+ ## [1.3.0] - 2024-04-18
22
+
23
+ ### Added
24
+
25
+ * Introduce new option `-t dynamic` for `metaplay-auth get-kubeconfig` that generates a `kubeconfig` which invokes the `metaplay-auth` itself to get the credentials. This way, the `kubeconfig` is longer-lived and no longer contains the sensitive access token.
26
+ * Support specifying target environment/gameserver address using the format '\<organization\>-\<project\>-\<environment\>', e.g., `metaplay-auth get-environment metaplay-idler-develop`.
27
+
28
+ ### Changed
29
+
30
+ * The `kubeconfig` files generated default to the environment's namespace so it doesn't need to be specified manually. The cluster, user, and context names were changed to be more meaningful.
31
+
3
32
  ## [1.2.1] - 2024-04-05
4
33
 
5
34
  ### Changed
package/README.md CHANGED
@@ -20,48 +20,6 @@ npx @metaplay/metaplay-auth@latest show-tokens
20
20
  npx @metaplay/metaplay-auth@latest logout
21
21
  ```
22
22
 
23
- ## Running locally
24
-
25
- When making changes to the `metaplay-auth`, it's easiest to test by running it locally:
26
-
27
- ```bash
28
- AuthCLI$ pnpm dev login
29
- AuthCLI$ pnpm dev show-tokens
30
- ```
31
-
32
- ## Building from sources
33
-
34
- The tool is also provided as part of the Metaplay SDK package (available via the [Metaplay Portal](https://portal.metaplay.dev/) under `AuthCLI/` and utilizes the same TypeScript toolchains as other components in the Metaplay SDK.
35
-
36
- You can build the tool using [Moon](https://www.npmjs.com/package/@moonrepo/cli):
37
-
38
- ```bash
39
- AuthCLI$ moon run build
40
- ```
41
-
42
- Now, you can run the application with node:
43
-
44
- ```bash
45
- AuthCLI$ node dist/index.js
46
- Usage: metaplay-auth [options] [command]
47
-
48
- Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.
49
-
50
- Options:
51
- -V, --version output the version number
52
- -d, --debug enable debug output
53
- -h, --help display help for command
54
-
55
- Commands:
56
- login login to your Metaplay account
57
- logout log out of your Metaplay account
58
- show-tokens show loaded tokens
59
- get-kubeconfig [options] [gameserver] get kubeconfig for deployment
60
- get-aws-credentials [options] [gameserver] get AWS credentials for deployment
61
- get-environment [options] [gameserver] get environment details for deployment
62
- help [command] display help for command
63
- ```
64
-
65
23
  ## License
66
24
 
67
25
  See the LICENSE file.
package/dist/index.js CHANGED
@@ -1,16 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import Docker from 'dockerode';
3
4
  import { loginAndSaveTokens, machineLoginAndSaveTokens, extendCurrentSession, loadTokens, removeTokens } from './src/auth.js';
4
5
  import { StackAPI } from './src/stackapi.js';
5
6
  import { checkGameServerDeployment } from './src/deployment.js';
6
7
  import { logger, setLogLevel } from './src/logging.js';
8
+ import { isValidFQDN, executeCommand } from './src/utils.js';
7
9
  import { exit } from 'process';
8
10
  import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
11
+ import { tmpdir } from 'os';
12
+ import { join } from 'path';
13
+ import { randomBytes } from 'crypto';
14
+ import { writeFile, unlink } from 'fs/promises';
15
+ import { existsSync } from 'fs';
16
+ import { KubeConfig } from '@kubernetes/client-node';
17
+ import * as semver from 'semver';
18
+ /**
19
+ * Helper for parsing the GameserverId type from the command line arguments. Accepts either the gameserver address
20
+ * (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
21
+ * tuple from options.
22
+ */
23
+ function resolveGameserverId(address, options) {
24
+ // If address is specified, use it, otherwise assume options has organization, project, and environment
25
+ if (address) {
26
+ if (isValidFQDN(address)) {
27
+ return { gameserver: address };
28
+ }
29
+ else {
30
+ const parts = address.split('-');
31
+ if (parts.length !== 3) {
32
+ throw new Error('Invalid gameserver address syntax: specify either <organization>-<project>-<environment> or a fully-qualified domain name (eg, idler-develop.p1.metaplay.io)');
33
+ }
34
+ return { organization: parts[0], project: parts[1], environment: parts[2] };
35
+ }
36
+ }
37
+ else if (options.organization && options.project && options.environment) {
38
+ return { organization: options.organization, project: options.project, environment: options.environment };
39
+ }
40
+ else {
41
+ 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.');
42
+ }
43
+ }
9
44
  const program = new Command();
10
45
  program
11
46
  .name('metaplay-auth')
12
47
  .description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
13
- .version('1.2.1')
48
+ .version('1.4.1')
14
49
  .option('-d, --debug', 'enable debug output')
15
50
  .hook('preAction', (thisCommand) => {
16
51
  // Handle debug flag for all commands.
@@ -88,39 +123,91 @@ program.command('show-tokens')
88
123
  }
89
124
  });
90
125
  program.command('get-kubeconfig')
91
- .description('get kubeconfig for deployment')
92
- .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
126
+ .description('get kubeconfig for target environment')
127
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
93
128
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
94
129
  .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
95
130
  .option('-p, --project <project>', 'project name (e.g. idler)')
96
131
  .option('-e, --environment <environment>', 'environment name (e.g. develop)')
132
+ .option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
133
+ .option('--output <kubeconfig-path>', 'path of the output file where to write kubeconfig (written to stdout if not specified)')
97
134
  .hook('preAction', async () => {
98
135
  await extendCurrentSession();
99
136
  })
100
137
  .action(async (gameserver, options) => {
101
138
  try {
102
139
  const tokens = await loadTokens();
103
- if (!gameserver && !(options.organization && options.project && options.environment)) {
104
- throw new Error('Could not determine a deployment to fetch the KubeConfigs from. You need to specify either a gameserver or an organization, project, and environment. Run this command with --help flag for more information.');
140
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
141
+ const gameserverId = resolveGameserverId(gameserver, options);
142
+ // Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
143
+ const isHumanUser = !!tokens.refresh_token;
144
+ const credentialsType = options.credentialsType ?? (isHumanUser ? 'dynamic' : 'static');
145
+ // Generate kubeconfig
146
+ let kubeconfigPayload;
147
+ if (credentialsType === 'dynamic') {
148
+ logger.debug('Fetching kubeconfig with execcredential');
149
+ kubeconfigPayload = await stackApi.getKubeConfigExecCredential(gameserverId);
150
+ }
151
+ else if (credentialsType === 'static') {
152
+ logger.debug('Fetching kubeconfig with embedded secret');
153
+ kubeconfigPayload = await stackApi.getKubeConfig(gameserverId);
105
154
  }
106
- const stackApi = new StackAPI(tokens.access_token);
107
- if (options.stackApi) {
108
- stackApi.stack_api_base_uri = options.stackApi;
155
+ else {
156
+ throw new Error('Invalid credentials type; must be either "static" or "dynamic"');
157
+ }
158
+ // Write kubeconfig to output (file or stdout)
159
+ if (options.output) {
160
+ logger.debug(`Writing kubeconfig to file ${options.output}`);
161
+ await writeFile(options.output, kubeconfigPayload, { mode: 0o600 });
162
+ console.log(`Wrote kubeconfig to ${options.output}`);
163
+ }
164
+ else {
165
+ console.log(kubeconfigPayload);
166
+ }
167
+ }
168
+ catch (error) {
169
+ if (error instanceof Error) {
170
+ console.error('Error getting KubeConfig:', error);
109
171
  }
110
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment };
111
- const credentials = await stackApi.getKubeConfig(payload);
172
+ exit(1);
173
+ }
174
+ });
175
+ /**
176
+ * Get the Kubernetes credentials in the execcredential format which can be used within the `kubeconfig` file:
177
+ * The kubeconfig can invoke this command to fetch the Kubernetes credentials just-in-time which allows us to
178
+ * generate kubeconfig files that don't contain access tokens and are longer-lived (the authentication is the
179
+ * same as that of metaplay-auth itself). Use `metaplay-auth get-kubeconfig -t dynamic ...` to create a
180
+ * kubeconfig that uses this command.
181
+ */
182
+ // todo: maybe this should be a hidden command as it's not very useful for end users and clutters help?
183
+ program.command('get-kubernetes-execcredential')
184
+ .description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
185
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
186
+ .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
187
+ .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
188
+ .option('-p, --project <project>', 'project name (e.g. idler)')
189
+ .option('-e, --environment <environment>', 'environment name (e.g. develop)')
190
+ .hook('preAction', async () => {
191
+ await extendCurrentSession();
192
+ })
193
+ .action(async (gameserver, options) => {
194
+ try {
195
+ const tokens = await loadTokens();
196
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
197
+ const gameserverId = resolveGameserverId(gameserver, options);
198
+ const credentials = await stackApi.getKubeExecCredential(gameserverId);
112
199
  console.log(credentials);
113
200
  }
114
201
  catch (error) {
115
202
  if (error instanceof Error) {
116
- console.error(`Error getting KubeConfig: ${error.message}`);
203
+ console.error(`Error getting Kubernetes ExecCredential: ${error.message}`);
117
204
  }
118
205
  exit(1);
119
206
  }
120
207
  });
121
208
  program.command('get-aws-credentials')
122
- .description('get AWS credentials for deployment')
123
- .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
209
+ .description('get AWS credentials for target environment')
210
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
124
211
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
125
212
  .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
126
213
  .option('-p, --project <project>', 'project name (e.g. idler)')
@@ -135,15 +222,9 @@ program.command('get-aws-credentials')
135
222
  throw new Error('Invalid format; must be one of json or env');
136
223
  }
137
224
  const tokens = await loadTokens();
138
- if (!gameserver && !(options.organization && options.project && options.environment)) {
139
- throw new Error('Could not determine a deployment to fetch the AWS credentials from. You need to specify either a gameserver or an organization, project, and environment');
140
- }
141
- const stackApi = new StackAPI(tokens.access_token);
142
- if (options.stackApi) {
143
- stackApi.stack_api_base_uri = options.stackApi;
144
- }
145
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment };
146
- const credentials = await stackApi.getAwsCredentials(payload);
225
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
226
+ const gameserverId = resolveGameserverId(gameserver, options);
227
+ const credentials = await stackApi.getAwsCredentials(gameserverId);
147
228
  if (options.format === 'env') {
148
229
  console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`);
149
230
  console.log(`export AWS_SECRET_ACCESS_KEY=${credentials.SecretAccessKey}`);
@@ -164,8 +245,8 @@ program.command('get-aws-credentials')
164
245
  }
165
246
  });
166
247
  program.command('get-docker-login')
167
- .description('get docker login credentials for pushing the server image')
168
- .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
248
+ .description('get docker login credentials for pushing the server image to target environment')
249
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
169
250
  .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
170
251
  .option('-p, --project <project>', 'project name (e.g. idler)')
171
252
  .option('-e, --environment <environment>', 'environment name (e.g. develop)')
@@ -179,20 +260,14 @@ program.command('get-docker-login')
179
260
  throw new Error('Invalid format; must be one of json or env');
180
261
  }
181
262
  const tokens = await loadTokens();
182
- if (!gameserver && !(options.organization && options.project && options.environment)) {
183
- throw new Error('Could not determine a game server deployment. You need to specify either a gameserver or an organization, project, and environment');
184
- }
185
- const stackApi = new StackAPI(tokens.access_token);
186
- if (options.stackApi) {
187
- stackApi.stack_api_base_uri = options.stackApi;
188
- }
263
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
264
+ const gameserverId = resolveGameserverId(gameserver, options);
189
265
  // Fetch AWS credentials from Metaplay cloud
190
266
  logger.debug('Get AWS credentials from Metaplay');
191
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment };
192
- const credentials = await stackApi.getAwsCredentials(payload);
267
+ const credentials = await stackApi.getAwsCredentials(gameserverId);
193
268
  // Get environment info (region is needed for ECR)
194
269
  logger.debug('Get environment info');
195
- const environment = await stackApi.getEnvironmentDetails(payload);
270
+ const environment = await stackApi.getEnvironmentDetails(gameserverId);
196
271
  const awsRegion = environment.deployment.aws_region;
197
272
  const dockerRepo = environment.deployment.ecr_repo;
198
273
  // Create ECR client with credentials
@@ -239,8 +314,8 @@ program.command('get-docker-login')
239
314
  }
240
315
  });
241
316
  program.command('get-environment')
242
- .description('get environment details for deployment')
243
- .argument('[gameserver]', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
317
+ .description('get details of an environment')
318
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
244
319
  .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
245
320
  .option('-o, --organization <organization>', 'organization name (e.g. metaplay)')
246
321
  .option('-p, --project <project>', 'project name (e.g. idler)')
@@ -251,15 +326,9 @@ program.command('get-environment')
251
326
  .action(async (gameserver, options) => {
252
327
  try {
253
328
  const tokens = await loadTokens();
254
- if (!gameserver && !(options.organization && options.project && options.environment)) {
255
- throw new Error('Could not determine a deployment to fetch environment details from. You need to specify either a gameserver or an organization, project, and environment');
256
- }
257
- const stackApi = new StackAPI(tokens.access_token);
258
- if (options.stackApi) {
259
- stackApi.stack_api_base_uri = options.stackApi;
260
- }
261
- const payload = gameserver ? { gameserver } : { organization: options.organization, project: options.project, environment: options.environment };
262
- const environment = await stackApi.getEnvironmentDetails(payload);
329
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
330
+ const gameserverId = resolveGameserverId(gameserver, options);
331
+ const environment = await stackApi.getEnvironmentDetails(gameserverId);
263
332
  console.log(JSON.stringify(environment));
264
333
  }
265
334
  catch (error) {
@@ -269,17 +338,295 @@ program.command('get-environment')
269
338
  exit(1);
270
339
  }
271
340
  });
341
+ program.command('push-docker-image')
342
+ .description('push docker image into the target environment image registry')
343
+ .argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
344
+ .argument('image-name', 'full name of the docker image to push (eg, the gameserver:<sha>)')
345
+ .hook('preAction', async () => {
346
+ await extendCurrentSession();
347
+ })
348
+ .action(async (gameserver, imageName, options) => {
349
+ try {
350
+ console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`);
351
+ const tokens = await loadTokens();
352
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
353
+ const gameserverId = resolveGameserverId(gameserver, options);
354
+ // Fetch AWS credentials from Metaplay cloud
355
+ logger.debug('Get AWS credentials from Metaplay');
356
+ const credentials = await stackApi.getAwsCredentials(gameserverId);
357
+ // Get environment info (region is needed for ECR)
358
+ logger.debug('Get environment info');
359
+ const envInfo = await stackApi.getEnvironmentDetails(gameserverId);
360
+ const awsRegion = envInfo.deployment.aws_region;
361
+ // Create ECR client with credentials
362
+ logger.debug('Create ECR client');
363
+ const client = new ECRClient({
364
+ credentials: {
365
+ accessKeyId: credentials.AccessKeyId,
366
+ secretAccessKey: credentials.SecretAccessKey,
367
+ sessionToken: credentials.SessionToken
368
+ },
369
+ region: awsRegion
370
+ });
371
+ // Fetch the ECR docker authentication token
372
+ logger.debug('Fetch ECR login credentials from AWS');
373
+ const command = new GetAuthorizationTokenCommand({});
374
+ const response = await client.send(command);
375
+ if (!response.authorizationData || response.authorizationData.length === 0 || !response.authorizationData[0].authorizationToken) {
376
+ throw new Error('Received an empty authorization token response for ECR repository');
377
+ }
378
+ // Parse username and password from the response (separated by a ':')
379
+ logger.debug('Parse ECR response');
380
+ const authorization64 = response.authorizationData[0].authorizationToken;
381
+ const authorization = Buffer.from(authorization64, 'base64').toString();
382
+ const [username, password] = authorization.split(':');
383
+ const authConfig = { username, password, serveraddress: '' }; // \todo serveraddress value? https://stackoverflow.com/questions/54252777/how-to-push-image-with-dockerode-image-not-pushed-but-no-error
384
+ // Resolve tag from src image
385
+ if (!imageName) {
386
+ throw new Error('Must specify a valid docker image name as the image-name argument');
387
+ }
388
+ const srcImageParts = imageName.split(':');
389
+ if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
390
+ throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`);
391
+ }
392
+ const imageTag = srcImageParts[1];
393
+ // Resolve source image
394
+ const srcImageName = imageName;
395
+ const dstRepoName = envInfo.deployment.ecr_repo;
396
+ const dstImageName = `${dstRepoName}:${imageTag}`;
397
+ const dockerApi = new Docker();
398
+ const srcDockerImage = dockerApi.getImage(srcImageName);
399
+ // If names don't match, tag the src image as dst
400
+ if (srcImageName !== dstImageName) {
401
+ logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`);
402
+ await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag });
403
+ }
404
+ // Push the image
405
+ logger.debug(`Push image ${dstImageName}`);
406
+ const dstDockerImage = dockerApi.getImage(dstImageName);
407
+ const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag });
408
+ // Follow push progress & wait until completed
409
+ logger.debug('Following push stream...');
410
+ await new Promise((resolve, reject) => {
411
+ dockerApi.modem.followProgress(pushStream, (error, result) => {
412
+ if (error) {
413
+ logger.debug('Failed to push image:', error);
414
+ reject(error);
415
+ }
416
+ else {
417
+ // result contains an array of all the progress objects
418
+ logger.debug('Succesfully finished pushing image');
419
+ resolve(result);
420
+ }
421
+ }, (obj) => {
422
+ // console.log('Progress:', obj)
423
+ // { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
424
+ // { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
425
+ });
426
+ });
427
+ console.log(`Successfully pushed docker image to ${dstImageName}!`);
428
+ }
429
+ catch (error) {
430
+ if (error instanceof Error) {
431
+ console.error(`Failed to push docker image: ${error.message}`);
432
+ }
433
+ exit(1);
434
+ }
435
+ });
436
+ program.command('deploy-server')
437
+ .description('deploy a game server image to target environment')
438
+ .argument('gameserver', 'address of gameserver (e.g. idler-develop.p1.metaplay.io)')
439
+ .argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
440
+ .option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
441
+ .option('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
442
+ .option('--local-chart-path <path-to-chart-directory>', 'path to local Helm chart directory (to use a chart from local disk)')
443
+ .option('--helm-chart-repo <url>', 'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)')
444
+ .option('--helm-chart-version <version>', 'override the Helm chart version (eg, 0.6.0)')
445
+ .option('--deployment-name', 'Helm deployment name to use', 'gameserver')
446
+ .hook('preAction', async () => {
447
+ await extendCurrentSession();
448
+ })
449
+ .action(async (gameserver, imageTag, options) => {
450
+ try {
451
+ const tokens = await loadTokens();
452
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
453
+ const gameserverId = resolveGameserverId(gameserver, options);
454
+ console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`);
455
+ // Fetch target environment details
456
+ const envInfo = await stackApi.getEnvironmentDetails(gameserverId);
457
+ if (!imageTag) {
458
+ throw new Error('Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build');
459
+ }
460
+ if (!options.deploymentName) {
461
+ throw new Error(`Invalid Helm deployment name '${options.deploymentName}'`);
462
+ }
463
+ // Resolve information about docker image
464
+ const dockerRepo = envInfo.deployment.ecr_repo;
465
+ const imageName = `${dockerRepo}:${imageTag}`;
466
+ logger.debug(`Fetch docker image information for ${imageName}`);
467
+ let imageLabels;
468
+ try {
469
+ const dockerApi = new Docker();
470
+ const dockerImage = dockerApi.getImage(imageName);
471
+ imageLabels = (await dockerImage.inspect()).Config.Labels || {};
472
+ }
473
+ catch (err) {
474
+ throw new Error(`Unable to fetch metadata for docker image ${imageName}: ${err}`);
475
+ }
476
+ // Try to resolve SDK version and Helm repo and chart version from the docker labels
477
+ // \note These only exist for SDK R28 and newer images
478
+ logger.debug('Docker image labels: ', JSON.stringify(imageLabels));
479
+ const sdkVersion = imageLabels['io.metaplay.sdk_version'];
480
+ // Resolve helmChartRepo, in order of precedence:
481
+ // - Specified in the cli options
482
+ // - Specified in the docker image label
483
+ // - Fall back to 'https://charts.metaplay.dev'
484
+ const helmChartRepo = options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev';
485
+ // Resolve helmChartVersion, in order of precedence:
486
+ // - Specified in the cli options
487
+ // - Specified in the docker image label
488
+ // - Unknown, error out!
489
+ const helmChartVersion = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version'];
490
+ if (!helmChartVersion) {
491
+ 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.');
492
+ }
493
+ // A values file is required (at least for now it makes no sense to deploy without one)
494
+ if (!options.values) {
495
+ throw new Error('Path to a Helm values file must be specified with --values');
496
+ }
497
+ // \If Helm chart >= 0.7.0, check that sdkVersion is defined in docker image labels (the labels were added in R28)
498
+ const helmChartSemver = semver.parse(helmChartVersion);
499
+ if (!helmChartSemver) {
500
+ throw new Error(`Resolve Helm chart version '${helmChartVersion}' is not a valid SemVer!`);
501
+ }
502
+ if (semver.gte(helmChartSemver, new semver.SemVer('0.7.0'), true) && !sdkVersion) {
503
+ throw new Error('Helm chart versions >=0.7.0 are only compatible with SDK versions 28.0.0 and above.');
504
+ }
505
+ // Fetch kubeconfig and write it to a temporary file
506
+ // \todo allow passing a custom kubeconfig file?
507
+ const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId);
508
+ const kubeconfigPath = join(tmpdir(), randomBytes(20).toString('hex'));
509
+ logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`);
510
+ await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 });
511
+ try {
512
+ // Construct Helm invocation
513
+ const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver';
514
+ 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
515
+ .concat(['--kubeconfig', kubeconfigPath])
516
+ .concat(['-n', envInfo.deployment.kubernetes_namespace])
517
+ .concat(['--values', options.values])
518
+ .concat(['--set-string', `image.tag=${imageTag}`])
519
+ .concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
520
+ .concat(!options.chartPath ? ['--repo', helmChartRepo, '--version', helmChartVersion] : [])
521
+ .concat([options.deploymentName])
522
+ .concat([chartNameOrPath]);
523
+ logger.info(`Execute: helm ${helmArgs.join(' ')}`);
524
+ // Execute Helm
525
+ let helmResult;
526
+ try {
527
+ helmResult = await executeCommand('helm', helmArgs);
528
+ // \todo output something from Helm result?
529
+ }
530
+ catch (err) {
531
+ throw new Error(`Failed to execute 'helm': ${err}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`);
532
+ }
533
+ // Throw on Helm non-success exit code
534
+ if (helmResult.code !== 0) {
535
+ throw new Error(`Helm deploy failed with exit code ${helmResult.code}: ${helmResult.stderr}`);
536
+ }
537
+ const testingRepoSuffix = (!options.chartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : '';
538
+ console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`);
539
+ }
540
+ finally {
541
+ // Remove temporary kubeconfig file
542
+ await unlink(kubeconfigPath);
543
+ }
544
+ // Check the status of the game server deployment
545
+ try {
546
+ const kubeconfig = new KubeConfig();
547
+ kubeconfig.loadFromString(kubeconfigPayload);
548
+ console.log('Validating game server deployment...');
549
+ const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig);
550
+ exit(exitCode);
551
+ }
552
+ catch (error) {
553
+ console.error(`Failed to resolve game server deployment status: ${error}`);
554
+ exit(2);
555
+ }
556
+ }
557
+ catch (error) {
558
+ if (error instanceof Error) {
559
+ console.error(`Error deploying game server into target environment: ${error.message}`);
560
+ }
561
+ exit(1);
562
+ }
563
+ });
564
+ program.command('check-server-status')
565
+ .description('check the status of a deployed server and print out information that is helpful in debugging failed deployments')
566
+ .argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
567
+ .hook('preAction', async () => {
568
+ await extendCurrentSession();
569
+ })
570
+ .action(async (gameserver, options) => {
571
+ const tokens = await loadTokens();
572
+ const stackApi = new StackAPI(tokens.access_token, options.stackApi);
573
+ const gameserverId = resolveGameserverId(gameserver, options);
574
+ try {
575
+ logger.debug('Get environment info');
576
+ const envInfo = await stackApi.getEnvironmentDetails(gameserverId);
577
+ const kubernetesNamespace = envInfo.deployment.kubernetes_namespace;
578
+ // Load kubeconfig from file and throw error if validation fails.
579
+ logger.debug('Get kubeconfig');
580
+ const kubeconfig = new KubeConfig();
581
+ try {
582
+ // Fetch kubeconfig and write it to a temporary file
583
+ // \todo allow passing a custom kubeconfig file?
584
+ const kubeconfigPayload = await stackApi.getKubeConfig(gameserverId);
585
+ kubeconfig.loadFromString(kubeconfigPayload);
586
+ }
587
+ catch (error) {
588
+ throw new Error(`Failed to load or validate kubeconfig. ${error}`);
589
+ }
590
+ // Run the checks and exit with success/failure exitCode depending on result
591
+ console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`);
592
+ const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig);
593
+ exit(exitCode);
594
+ }
595
+ catch (error) {
596
+ console.error(`Failed to check deployment status: ${error.message}`);
597
+ exit(1);
598
+ }
599
+ });
272
600
  program.command('check-deployment')
273
- .description('[experimental] check that a game server was successfully deployed, or print out useful error messages in case of failure')
601
+ .description('[deprecated] check that a game server was successfully deployed, or print out useful error messages in case of failure')
274
602
  .argument('[namespace]', 'kubernetes namespace of the deployment')
275
603
  .action(async (namespace) => {
276
- setLogLevel(0);
604
+ console.error('DEPRECATED! Use the "metaplay-auth check-server-status [gameserver]" command instead! This command will be removed soon.');
277
605
  try {
278
606
  if (!namespace) {
279
607
  throw new Error('Must specify value for argument "namespace"');
280
608
  }
609
+ // Check that the KUBECONFIG environment variable exists
610
+ const kubeconfigPath = process.env.KUBECONFIG;
611
+ if (!kubeconfigPath) {
612
+ throw new Error('The KUBECONFIG environment variable must be specified');
613
+ }
614
+ // Check that the kubeconfig file exists
615
+ if (!existsSync(kubeconfigPath)) {
616
+ throw new Error(`The environment variable KUBECONFIG points to a file '${kubeconfigPath}' that doesn't exist`);
617
+ }
618
+ // Create Kubernetes API instance (with default kubeconfig)
619
+ const kubeconfig = new KubeConfig();
620
+ // Load kubeconfig from file and throw error if validation fails.
621
+ try {
622
+ kubeconfig.loadFromFile(kubeconfigPath);
623
+ }
624
+ catch (error) {
625
+ throw new Error(`Failed to load or validate kubeconfig. ${error}`);
626
+ }
281
627
  // Run the checks and exit with success/failure exitCode depending on result
282
- const exitCode = await checkGameServerDeployment(namespace);
628
+ console.log(`Validating game server deployment in namespace ${namespace}`);
629
+ const exitCode = await checkGameServerDeployment(namespace, kubeconfig);
283
630
  exit(exitCode);
284
631
  }
285
632
  catch (error) {