@metaplay/metaplay-auth 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,45 +1,43 @@
1
1
  {
2
2
  "name": "@metaplay/metaplay-auth",
3
3
  "description": "Utility CLI for authenticating with the Metaplay Auth and making authenticated calls to infrastructure endpoints.",
4
- "version": "1.4.1",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://metaplay.io",
8
8
  "bin": {
9
- "metaplay-auth": "dist/index.js"
9
+ "metaplay-auth": "dist/index.cjs"
10
10
  },
11
11
  "scripts": {
12
12
  "dev": "tsx index.ts",
13
- "prepublish": "tsc"
13
+ "bake-version": "node -p \"'export const PACKAGE_VERSION = ' + JSON.stringify(require('./package.json').version)\" > src/version.ts"
14
14
  },
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@metaplay/eslint-config": "workspace:*",
20
- "@metaplay/typescript-config": "workspace:*",
21
- "@types/dockerode": "^3.3.28",
20
+ "@types/dockerode": "^3.3.29",
22
21
  "@types/express": "^4.17.21",
23
22
  "@types/js-yaml": "^4.0.9",
24
23
  "@types/jsonwebtoken": "^9.0.5",
25
24
  "@types/jwk-to-pem": "^2.0.3",
26
- "@types/node": "^20.12.5",
25
+ "@types/node": "^20.14.8",
26
+ "@types/semver": "^7.5.8",
27
+ "esbuild": "^0.23.0",
27
28
  "tsx": "^4.7.1",
28
- "typescript": "^5.1.6",
29
- "vitest": "^1.4.0"
30
- },
31
- "dependencies": {
32
- "@aws-sdk/client-ecr": "^3.549.0",
33
- "@kubernetes/client-node": "^1.0.0-rc4",
29
+ "typescript": "5.4.x",
30
+ "vitest": "^1.4.0",
31
+ "@aws-sdk/client-ecr": "^3.620.0",
32
+ "@kubernetes/client-node": "^1.0.0-rc6",
34
33
  "@ory/client": "^1.9.0",
35
- "@types/semver": "^7.5.8",
36
34
  "commander": "^12.0.0",
37
35
  "dockerode": "^4.0.2",
38
36
  "h3": "^1.11.1",
39
37
  "js-yaml": "^4.1.0",
40
38
  "jsonwebtoken": "^9.0.2",
41
39
  "jwk-to-pem": "^2.0.5",
42
- "open": "^10.1.0",
40
+ "open": "^8.4.2",
43
41
  "semver": "^7.6.0",
44
42
  "tslog": "^4.9.2"
45
43
  }
package/src/auth.ts CHANGED
@@ -12,6 +12,12 @@ import { setSecret, getSecret, removeSecret } from './secret_store.js'
12
12
 
13
13
  import { logger } from './logging.js'
14
14
 
15
+ export interface TokenSet {
16
+ id_token?: string
17
+ access_token: string
18
+ refresh_token?: string
19
+ }
20
+
15
21
  // oauth2 client details (maybe move these to be discovered from some online location to make changes easier to manage?)
16
22
  const clientId = 'c16ea663-ced3-46c6-8f85-38c9681fe1f0'
17
23
  const baseURL = 'https://auth.metaplay.dev'
@@ -275,7 +281,7 @@ async function extendCurrentSessionWithRefreshToken (refreshToken: string): Prom
275
281
 
276
282
  // Check if the response is OK
277
283
  if (!response.ok) {
278
- const responseJSON = await response.json()
284
+ const responseJSON = await response.json() as { error: string, error_description: string }
279
285
 
280
286
  logger.error('Failed to refresh tokens.')
281
287
  logger.error(`Error Type: ${responseJSON.error}`)
@@ -288,7 +294,7 @@ async function extendCurrentSessionWithRefreshToken (refreshToken: string): Prom
288
294
  throw new Error('Failed extending current session, exiting. Please log in again.')
289
295
  }
290
296
 
291
- return await response.json()
297
+ return await response.json() as { id_token: string, access_token: string, refresh_token: string }
292
298
  }
293
299
 
294
300
  /**
@@ -310,7 +316,7 @@ async function getTokensWithAuthorizationCode (state: string, redirectUri: strin
310
316
  body: `grant_type=authorization_code&code=${code}&redirect_uri=${encodeURIComponent(redirectUri)}&client_id=${clientId}&code_verifier=${verifier}&state=${encodeURIComponent(state)}`
311
317
  })
312
318
 
313
- return await response.json()
319
+ return await response.json() as { id_token: string, access_token: string, refresh_token: string }
314
320
  } catch (error) {
315
321
  if (error instanceof Error) {
316
322
  logger.error(`Error exchanging code for tokens: ${error.message}`)
@@ -322,9 +328,9 @@ async function getTokensWithAuthorizationCode (state: string, redirectUri: strin
322
328
  /**
323
329
  * Load tokens from the local secret store.
324
330
  */
325
- export async function loadTokens (): Promise<{ id_token?: string, access_token: string, refresh_token?: string }> {
331
+ export async function loadTokens (): Promise<TokenSet> {
326
332
  try {
327
- const tokens = await getSecret('tokens') as { id_token?: string, access_token: string, refresh_token?: string }
333
+ const tokens = await getSecret('tokens') as TokenSet
328
334
 
329
335
  if (!tokens) {
330
336
  throw new Error('Unable to load tokens. You need to login first.')
package/src/deployment.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { KubeConfig, CoreV1Api, V1Pod } from '@kubernetes/client-node'
1
+ import { KubeConfig, CoreV1Api, type V1Pod, type CoreV1ApiListNamespacedPodRequest, type CoreV1ApiReadNamespacedPodLogRequest } from '@kubernetes/client-node'
2
2
  import { logger } from './logging.js'
3
- import type { CoreV1ApiListNamespacedPodRequest, CoreV1ApiReadNamespacedPodLogRequest } from '@kubernetes/client-node'
3
+ import { TargetEnvironment } from 'targetenvironment.js'
4
+ import { exit } from 'process'
5
+ import { unlink, writeFile } from 'fs/promises'
6
+ import { executeCommand, type ExecuteCommandResult } from './utils.js'
7
+ import os from 'os'
8
+ import path from 'path'
4
9
 
5
10
  enum GameServerPodPhase {
6
11
  Ready = 'Ready', // Successfully started and ready to accept traffic
@@ -16,7 +21,7 @@ interface GameServerPodStatus {
16
21
  details?: any
17
22
  }
18
23
 
19
- async function fetchGameServerPods (k8sApi: CoreV1Api, namespace: string) {
24
+ async function fetchGameServerPods (k8sApi: CoreV1Api, namespace: string): Promise<V1Pod[]> {
20
25
  // Define label selector for gameserver
21
26
  logger.debug(`Fetching game server pods from Kubernetes: namespace=${namespace}`)
22
27
  const param: CoreV1ApiListNamespacedPodRequest = {
@@ -32,7 +37,7 @@ async function fetchGameServerPods (k8sApi: CoreV1Api, namespace: string) {
32
37
  return response.items
33
38
  } catch (error) {
34
39
  // \todo Better error handling ..
35
- console.log('Failed to fetch pods from Kubernetes:', error)
40
+ console.error('Failed to fetch pods from Kubernetes:', error)
36
41
  throw new Error('Failed to fetch pods from Kubernetes')
37
42
  }
38
43
  }
@@ -77,9 +82,9 @@ function resolvePodContainersConditions (pod: V1Pod): GameServerPodStatus {
77
82
  // Try to detecth why the previous launch failed
78
83
  if (containerState.waiting) {
79
84
  const reason = containerState.waiting.reason
80
- if (knownContainerFailureReasons.includes(reason as string)) {
85
+ if (reason && knownContainerFailureReasons.includes(reason)) {
81
86
  return { phase: GameServerPodPhase.Failed, message: `Container ${containerName} is in waiting state, reason=${reason}`, details: containerState.waiting }
82
- } else if (knownContainerPendingReasons.includes(reason as string)) {
87
+ } else if (reason && knownContainerPendingReasons.includes(reason)) {
83
88
  return { phase: GameServerPodPhase.Pending, message: `Container ${containerName} is in waiting state, reason=${reason}`, details: containerState.waiting }
84
89
  } else {
85
90
  return { phase: GameServerPodPhase.Unknown, message: `Container ${containerName} is in waiting state, reason=${reason}`, details: containerState.waiting }
@@ -141,7 +146,20 @@ function resolvePodStatusConditions (pod: V1Pod): GameServerPodStatus {
141
146
  return { phase: GameServerPodPhase.Ready, message: 'Pod is ready to serve traffic' }
142
147
  }
143
148
 
144
- function resolvePodStatus (pod: V1Pod): GameServerPodStatus {
149
+ function resolvePodGameServerImageTag (pod: V1Pod): string | null {
150
+ // Find the shard-server spec from the pod and extract image tag from spec
151
+ const containerSpec = pod.spec?.containers?.find(containerSpec => containerSpec.name === 'shard-server')
152
+ if (!containerSpec) {
153
+ return null
154
+ }
155
+ if (!containerSpec.image) {
156
+ return null
157
+ }
158
+ const [_, imageTag] = containerSpec.image.split(':')
159
+ return imageTag
160
+ }
161
+
162
+ function resolvePodStatus (pod: V1Pod, requiredImageTag: string | null): GameServerPodStatus {
145
163
  // logger.debug('resolvePodStatus(): pod =', JSON.stringify(pod, undefined, 2))
146
164
 
147
165
  if (!pod) {
@@ -152,6 +170,14 @@ function resolvePodStatus (pod: V1Pod): GameServerPodStatus {
152
170
  return { phase: GameServerPodPhase.Unknown, message: 'Unable to access pod.status from Kubernetes' }
153
171
  }
154
172
 
173
+ // Check the pod has the expected image.
174
+ if (requiredImageTag !== null) {
175
+ const podImageTag = resolvePodGameServerImageTag(pod)
176
+ if (podImageTag !== requiredImageTag) {
177
+ return { phase: GameServerPodPhase.Unknown, message: `Image tag is not (yet?) updated. Pod image is ${podImageTag ?? 'unknown'}, expecting ${requiredImageTag}.` }
178
+ }
179
+ }
180
+
155
181
  // Handle status.phase
156
182
  const podPhase = pod.status?.phase
157
183
  switch (podPhase) {
@@ -163,8 +189,14 @@ function resolvePodStatus (pod: V1Pod): GameServerPodStatus {
163
189
  // Pod has been scheduled and start -- note that the containers may have failed!
164
190
  return resolvePodStatusConditions(pod)
165
191
 
166
- case 'Succeeded': // Should not happen, the game server pods should never stop
167
- case 'Failed': // Should not happen, the game server pods should never stop
192
+ case 'Succeeded':
193
+ // Should not happen, the game server pods should never stop
194
+ return { phase: GameServerPodPhase.Unknown, message: 'Pod has unexpectedly terminated (with a clean exit status)' }
195
+
196
+ case 'Failed':
197
+ // Should not happen, the game server pods should never stop
198
+ return { phase: GameServerPodPhase.Unknown, message: 'Pod has unexpectedly terminated (with a failure exit status)' }
199
+
168
200
  case 'Unknown':
169
201
  default:
170
202
  return { phase: GameServerPodPhase.Unknown, message: `Invalid pod.status.phase: ${podPhase}` }
@@ -200,11 +232,11 @@ async function fetchPodLogs (k8sApi: CoreV1Api, pod: V1Pod) {
200
232
  }
201
233
  }
202
234
 
203
- async function checkGameServerPod (k8sApi: CoreV1Api, pod: V1Pod) {
235
+ async function checkGameServerPod (k8sApi: CoreV1Api, pod: V1Pod, requiredImageTag: string | null) {
204
236
  // console.log('Pod:', JSON.stringify(pod, undefined, 2))
205
237
 
206
238
  // Classify game server status
207
- const podStatus = resolvePodStatus(pod)
239
+ const podStatus = resolvePodStatus(pod, requiredImageTag)
208
240
  const podName = pod.metadata?.name ?? '<unable to resolve name>'
209
241
 
210
242
  // If game server launch failed, get the error logs
@@ -218,7 +250,7 @@ async function checkGameServerPod (k8sApi: CoreV1Api, pod: V1Pod) {
218
250
  }
219
251
 
220
252
  async function delay (ms: number): Promise<void> {
221
- return await new Promise<void>(resolve => setTimeout(resolve, ms))
253
+ await new Promise<void>(resolve => setTimeout(resolve, ms))
222
254
  }
223
255
 
224
256
  function anyPodsInPhase (podStatuses: GameServerPodStatus[], phase: GameServerPodPhase): boolean {
@@ -229,7 +261,7 @@ function allPodsInPhase (podStatuses: GameServerPodStatus[], phase: GameServerPo
229
261
  return podStatuses.every(status => status.phase === phase)
230
262
  }
231
263
 
232
- export async function checkGameServerDeployment (namespace: string, kubeconfig: KubeConfig): Promise<number> {
264
+ export async function checkGameServerDeployment (namespace: string, kubeconfig: KubeConfig, requiredImageTag: string | null): Promise<number> {
233
265
  const k8sApi = kubeconfig.makeApiClient(CoreV1Api)
234
266
 
235
267
  // Figure out when to stop
@@ -242,7 +274,7 @@ export async function checkGameServerDeployment (namespace: string, kubeconfig:
242
274
  logger.debug(`Found ${pods?.length} pod(s) deployed in Kubernetes`)
243
275
  if (pods.length > 0) {
244
276
  // Resolve status for all pods
245
- const podStatuses = await Promise.all(pods.map(async pod => await checkGameServerPod(k8sApi, pod)))
277
+ const podStatuses = await Promise.all(pods.map(async pod => await checkGameServerPod(k8sApi, pod, requiredImageTag)))
246
278
  logger.debug(`Pod phases: ${podStatuses.map(status => status.phase)}`)
247
279
 
248
280
  // Handle state of the deployment
@@ -285,3 +317,81 @@ export async function checkGameServerDeployment (namespace: string, kubeconfig:
285
317
  await delay(2000)
286
318
  }
287
319
  }
320
+
321
+ export async function debugGameServer (targetEnv: TargetEnvironment, targetPodName?: string) {
322
+ // Initialize kubeconfig for target environment
323
+ logger.debug('Get kubeconfig')
324
+ let kubeconfigPayload
325
+ const kubeconfig = new KubeConfig()
326
+ try {
327
+ kubeconfigPayload = await targetEnv.getKubeConfig()
328
+ kubeconfig.loadFromString(kubeconfigPayload)
329
+ } catch (error) {
330
+ console.error(`Failed to fetch kubeconfig for environment: ${error}`)
331
+ exit(1)
332
+ }
333
+
334
+ // Fetch environment details
335
+ logger.debug('Get environment details')
336
+ const envInfo = await targetEnv.getEnvironmentDetails()
337
+ const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
338
+
339
+ // Resolve which pods are running in the target environment
340
+ logger.debug('Get running game server pods')
341
+ const k8sApi = kubeconfig.makeApiClient(CoreV1Api)
342
+ const gameServerPods = await fetchGameServerPods(k8sApi, kubernetesNamespace)
343
+
344
+ // If no pod name is specified, automaticalyl target a singleton pod or fail if there are multiple
345
+ if (!targetPodName) {
346
+ if (gameServerPods.length === 1) {
347
+ const metadata = gameServerPods[0].metadata
348
+ if (!metadata?.name) {
349
+ throw new Error('Unable to resolve name for the Kubernetes pod!')
350
+ }
351
+ targetPodName = metadata.name
352
+ } else {
353
+ const podNames = gameServerPods.map(pod => pod.metadata?.name).join(', ')
354
+ console.error(`Multiple game server pods running: ${podNames}\nPlease specify which you want to debug.`)
355
+ exit(1)
356
+ }
357
+ }
358
+
359
+ // Execute 'kubectl debug ..'
360
+ const tmpKubeconfigPath = path.join(os.tmpdir(), `temp-kubeconfig-${+Date.now()}`)
361
+ try {
362
+ // Write kubeconfig to a file
363
+ logger.debug(`Write temporary kubeconfig to ${tmpKubeconfigPath}`)
364
+ await writeFile(tmpKubeconfigPath, kubeconfigPayload)
365
+
366
+ // Run kubctl
367
+ // const args = [`--kubeconfig=${tmpKubeconfigPath}`, `--namespace=${kubernetesNamespace}`, 'get', 'pods']
368
+ const args = [
369
+ `--kubeconfig=${tmpKubeconfigPath}`,
370
+ `--namespace=${kubernetesNamespace}`,
371
+ 'debug',
372
+ targetPodName,
373
+ '-it',
374
+ '--profile=general',
375
+ '--image=metaplay/diagnostics:latest',
376
+ '--target=shard-server'
377
+ ]
378
+ console.log(`Execute: kubectl ${args.join(' ')}`)
379
+ const execResult = await executeCommand('kubectl', args, true)
380
+
381
+ // Remove temporary kubeconfig
382
+ logger.debug('Delete temporary kubeconfig')
383
+ await unlink(tmpKubeconfigPath)
384
+
385
+ // Forward exit code
386
+ if (execResult?.exitCode !== 0) {
387
+ console.error(`kubectl failed failed with exit code ${execResult.exitCode}`)
388
+ exit(execResult.exitCode)
389
+ }
390
+ } catch (err) {
391
+ console.error(`Failed to execute 'kubectl': ${err}. You need to have kubectl installed to debug a game server with metaplay-auth.`)
392
+
393
+ // Remove temporary kubeconfig
394
+ logger.debug('Delete temporary kubeconfig')
395
+ await unlink(tmpKubeconfigPath)
396
+ }
397
+ }