@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/src/auth.ts CHANGED
@@ -7,7 +7,7 @@ import open from 'open'
7
7
  import { randomBytes, createHash } from 'node:crypto'
8
8
  import jwt from 'jsonwebtoken'
9
9
  import jwkToPem from 'jwk-to-pem'
10
- import { Configuration, WellknownApi } from '@ory/client'
10
+ import { Configuration, WellknownApi, OidcApi } from '@ory/client'
11
11
  import { setSecret, getSecret, removeSecret } from './secret_store.js'
12
12
 
13
13
  import { logger } from './logging.js'
@@ -20,6 +20,9 @@ const tokenEndpoint = `${baseURL}/oauth2/token`
20
20
  const wellknownApi = new WellknownApi(new Configuration({
21
21
  basePath: baseURL,
22
22
  }))
23
+ const oidcApi = new OidcApi(new Configuration({
24
+ basePath: baseURL,
25
+ }))
23
26
 
24
27
  /**
25
28
  * A helper function which generates a code verifier and challenge for exchaning code from Ory server.
@@ -31,6 +34,34 @@ function generateCodeVerifierAndChallenge (): { verifier: string, challenge: str
31
34
  return { verifier, challenge }
32
35
  }
33
36
 
37
+ /**
38
+ * A helper function which fetches the user's info from an OIDC userinfo endpoint for the given token.
39
+ * @param token The token to fetch the userinfo for.
40
+ * @returns An object containing the user's info.
41
+ */
42
+ export async function getUserinfo (token: string): Promise<any> {
43
+ logger.debug('Trying to find OIDC well-known endpoints...')
44
+ const oidcRes = await oidcApi.discoverOidcConfiguration()
45
+
46
+ const userinfoEndpoint = oidcRes.data?.userinfo_endpoint
47
+ if (!userinfoEndpoint) {
48
+ throw new Error('No userinfo endpoint found in OIDC configuration')
49
+ }
50
+ logger.debug(`Found userinfo endpoint: ${userinfoEndpoint}`)
51
+
52
+ const userinfoRes = await fetch(userinfoEndpoint, {
53
+ headers: {
54
+ Authorization: `Bearer ${token}`
55
+ }
56
+ })
57
+
58
+ if (userinfoRes.status < 200 || userinfoRes.status >= 300) {
59
+ throw new Error(`Failed to fetch userinfo: ${userinfoRes.status} ${userinfoRes.statusText}`)
60
+ }
61
+
62
+ return await userinfoRes.json()
63
+ }
64
+
34
65
  /**
35
66
  * A helper function which finds an local available port to listen on.
36
67
  * @returns A promise that resolves to an available port.
@@ -223,13 +254,24 @@ async function extendCurrentSessionWithRefreshToken (refreshToken: string): Prom
223
254
  logger.debug('Refreshing tokens...')
224
255
 
225
256
  // Send a POST request to refresh tokens
226
- const response = await fetch(tokenEndpoint, {
227
- method: 'POST',
228
- headers: {
229
- 'Content-Type': 'application/x-www-form-urlencoded',
230
- },
231
- body: params.toString(),
232
- })
257
+ let response
258
+ try {
259
+ response = await fetch(tokenEndpoint, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/x-www-form-urlencoded',
263
+ },
264
+ body: params.toString(),
265
+ })
266
+ } catch (error: any) {
267
+ // \todo duplicate code in stackapi.ts -- refactor
268
+ logger.error(`Failed to refresh tokens via endpoint ${tokenEndpoint}`)
269
+ logger.error('Fetch error details:', error)
270
+ if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
271
+ throw new Error(`Failed to refresh tokens: SSL certificate validation failed for ${tokenEndpoint}. Is someone trying to tamper with your internet connection?`)
272
+ }
273
+ throw new Error(`Failed to refresh tokens via ${tokenEndpoint}: ${error}`)
274
+ }
233
275
 
234
276
  // Check if the response is OK
235
277
  if (!response.ok) {
package/src/deployment.ts CHANGED
@@ -1,13 +1,13 @@
1
- import { existsSync } from 'fs'
2
1
  import { KubeConfig, CoreV1Api, V1Pod } from '@kubernetes/client-node'
3
2
  import { logger } from './logging.js'
4
3
  import type { CoreV1ApiListNamespacedPodRequest, CoreV1ApiReadNamespacedPodLogRequest } from '@kubernetes/client-node'
5
4
 
6
5
  enum GameServerPodPhase {
7
- Pending = 'Pending', // Still being deployed
8
6
  Ready = 'Ready', // Successfully started and ready to accept traffic
9
- Failed = 'Failed', // Failed to start
7
+ Running = 'Running', // Started but not yet serving traffic (eg, waiting for peers to connect)
8
+ Pending = 'Pending', // Still being deployed
10
9
  Unknown = 'Unknown', // Unknown status -- treat like Pending
10
+ Failed = 'Failed', // Failed to start
11
11
  }
12
12
 
13
13
  interface GameServerPodStatus {
@@ -18,6 +18,7 @@ interface GameServerPodStatus {
18
18
 
19
19
  async function fetchGameServerPods (k8sApi: CoreV1Api, namespace: string) {
20
20
  // Define label selector for gameserver
21
+ logger.debug(`Fetching game server pods from Kubernetes: namespace=${namespace}`)
21
22
  const param: CoreV1ApiListNamespacedPodRequest = {
22
23
  namespace,
23
24
  labelSelector: 'app=metaplay-server'
@@ -42,14 +43,14 @@ function resolvePodContainersConditions (pod: V1Pod): GameServerPodStatus {
42
43
  return { phase: GameServerPodPhase.Unknown, message: 'Unable to determine pod container statuses: pod.status.containerStatuses is empty' }
43
44
  }
44
45
 
45
- // Only one container allowed in the game server pod
46
- if (containerStatuses.length !== 1) {
47
- throw new Error(`Internal error: Expecting only one container in the game server pod, got ${containerStatuses.length}`)
46
+ // Find the shard-server container from the pod (ignore others)
47
+ const containerStatus = containerStatuses.find(status => status.name === 'shard-server')
48
+ if (!containerStatus) {
49
+ return { phase: GameServerPodPhase.Unknown, message: 'Unable to find container shard-server from the pod' }
48
50
  }
49
51
 
50
52
  // Handle missing container state
51
- const containerStatus = containerStatuses[0]
52
- console.log('Container status:', containerStatus)
53
+ logger.debug(`Container status for pod ${pod.metadata?.name ?? '<unnamed>'}: ${JSON.stringify(containerStatus, undefined, 2)}`)
53
54
  const containerState = containerStatus.state
54
55
  if (!containerState) {
55
56
  return { phase: GameServerPodPhase.Unknown, message: 'Unable to get container state' }
@@ -57,8 +58,12 @@ function resolvePodContainersConditions (pod: V1Pod): GameServerPodStatus {
57
58
 
58
59
  // Check if container running & ready
59
60
  const containerName = containerStatus.name
60
- if (containerStatus.ready && containerState.running) {
61
- return { phase: GameServerPodPhase.Ready, message: `Container ${containerName} is in ready phase and was started at ${containerState.running.startedAt}`, details: containerState.running }
61
+ if (containerState.running) {
62
+ if (containerStatus.ready) {
63
+ return { phase: GameServerPodPhase.Ready, message: `Container ${containerName} is in ready phase`, details: containerState.running }
64
+ } else {
65
+ return { phase: GameServerPodPhase.Running, message: `Container ${containerName} is in running phase`, details: containerState.running }
66
+ }
62
67
  }
63
68
 
64
69
  // \note these may not be complete (or completely accurate)
@@ -94,7 +99,7 @@ function resolvePodContainersConditions (pod: V1Pod): GameServerPodStatus {
94
99
  // \todo handle containerState.terminated states (what do these even mean)
95
100
  }
96
101
 
97
- console.log('Game server pod container in unknown state:', containerState)
102
+ logger.debug('Game server pod container in unknown state:', containerState)
98
103
  return { phase: GameServerPodPhase.Unknown, message: 'Container in unknown state', details: containerState }
99
104
  }
100
105
 
@@ -137,7 +142,11 @@ function resolvePodStatusConditions (pod: V1Pod): GameServerPodStatus {
137
142
  }
138
143
 
139
144
  function resolvePodStatus (pod: V1Pod): GameServerPodStatus {
140
- // console.log('Pod.status:', JSON.stringify(pod.status, undefined, 2))
145
+ // logger.debug('resolvePodStatus(): pod =', JSON.stringify(pod, undefined, 2))
146
+
147
+ if (!pod) {
148
+ return { phase: GameServerPodPhase.Unknown, message: 'Received empty pod from Kubernetes' }
149
+ }
141
150
 
142
151
  if (!pod.status) {
143
152
  return { phase: GameServerPodPhase.Unknown, message: 'Unable to access pod.status from Kubernetes' }
@@ -163,12 +172,12 @@ function resolvePodStatus (pod: V1Pod): GameServerPodStatus {
163
172
  }
164
173
 
165
174
  async function fetchPodLogs (k8sApi: CoreV1Api, pod: V1Pod) {
166
- console.log('Fetching logs for pod..')
167
175
  const podName = pod.metadata?.name
176
+ logger.debug(`Fetching logs for pod '${podName}'..`)
168
177
  const namespace = pod.metadata?.namespace
169
178
  const containerName = pod.spec?.containers[0].name // \todo Handle multiple containers?
170
179
  if (!podName || !namespace || !containerName) {
171
- throw new Error('Unable to determine pod metadata')
180
+ throw new Error('Unable to determine pod and container metadata')
172
181
  }
173
182
 
174
183
  const params: CoreV1ApiReadNamespacedPodLogRequest = {
@@ -196,14 +205,15 @@ async function checkGameServerPod (k8sApi: CoreV1Api, pod: V1Pod) {
196
205
 
197
206
  // Classify game server status
198
207
  const podStatus = resolvePodStatus(pod)
208
+ const podName = pod.metadata?.name ?? '<unable to resolve name>'
199
209
 
200
210
  // If game server launch failed, get the error logs
201
211
  if (podStatus.phase === GameServerPodPhase.Failed) {
202
212
  const logs = await fetchPodLogs(k8sApi, pod)
203
- console.log('Pod logs:\n' + logs)
213
+ console.log(`Logs from pod '${podName}':\n${logs}`)
204
214
  }
205
215
 
206
- console.log(`Pod ${pod.metadata?.name} status:`, podStatus)
216
+ logger.debug(`Pod ${podName} status: ${JSON.stringify(podStatus, undefined, 2)}`)
207
217
  return podStatus
208
218
  }
209
219
 
@@ -211,29 +221,16 @@ async function delay (ms: number): Promise<void> {
211
221
  return await new Promise<void>(resolve => setTimeout(resolve, ms))
212
222
  }
213
223
 
214
- export async function checkGameServerDeployment (namespace: string): Promise<number> {
215
- logger.info(`Validating game server deployment in namespace ${namespace}`)
224
+ function anyPodsInPhase (podStatuses: GameServerPodStatus[], phase: GameServerPodPhase): boolean {
225
+ return podStatuses.some(status => status.phase === phase)
226
+ }
216
227
 
217
- // Check that the KUBECONFIG environment variable exists
218
- const kubeconfigPath = process.env.KUBECONFIG
219
- if (!kubeconfigPath) {
220
- throw new Error('The KUBECONFIG environment variable must be specified')
221
- }
228
+ function allPodsInPhase (podStatuses: GameServerPodStatus[], phase: GameServerPodPhase): boolean {
229
+ return podStatuses.every(status => status.phase === phase)
230
+ }
222
231
 
223
- // Check that the kubeconfig file exists
224
- if (!await existsSync(kubeconfigPath)) {
225
- throw new Error(`The environment variable KUBECONFIG points to a file '${kubeconfigPath}' that doesn't exist`)
226
- }
227
-
228
- // Create Kubernetes API instance (with default kubeconfig)
229
- const kc = new KubeConfig()
230
- // Loda kubeconfig from file and throw error if validation fails.
231
- try {
232
- kc.loadFromFile(kubeconfigPath)
233
- } catch (error) {
234
- throw new Error(`Failed to load or validate kubeconfig. ${error}`)
235
- }
236
- const k8sApi = kc.makeApiClient(CoreV1Api)
232
+ export async function checkGameServerDeployment (namespace: string, kubeconfig: KubeConfig): Promise<number> {
233
+ const k8sApi = kubeconfig.makeApiClient(CoreV1Api)
237
234
 
238
235
  // Figure out when to stop
239
236
  const startTime = Date.now()
@@ -242,33 +239,49 @@ export async function checkGameServerDeployment (namespace: string): Promise<num
242
239
  while (true) {
243
240
  // Check pod states
244
241
  const pods = await fetchGameServerPods(k8sApi, namespace)
245
- const podStatus = await checkGameServerPod(k8sApi, pods[0]) // \todo Handle all pods
246
-
247
- switch (podStatus.phase) {
248
- case GameServerPodPhase.Ready:
249
- console.log('Gameserver successfully started')
242
+ logger.debug(`Found ${pods?.length} pod(s) deployed in Kubernetes`)
243
+ if (pods.length > 0) {
244
+ // Resolve status for all pods
245
+ const podStatuses = await Promise.all(pods.map(async pod => await checkGameServerPod(k8sApi, pod)))
246
+ logger.debug(`Pod phases: ${podStatuses.map(status => status.phase)}`)
247
+
248
+ // Handle state of the deployment
249
+ if (anyPodsInPhase(podStatuses, GameServerPodPhase.Failed)) {
250
+ logger.error(`Gameserver start failed with pod statuses: ${JSON.stringify(podStatuses, undefined, 2)}`)
251
+ console.log('Gameserver failed to start due to the pods not starting properly! See above for details.')
252
+ for (let ndx = 0; ndx < pods.length; ndx += 1) {
253
+ const status = podStatuses[ndx]
254
+ const suffix = (status.phase !== GameServerPodPhase.Ready) ? ` -- ${status.message}` : ''
255
+ console.log(` ${pods[ndx].metadata?.name}: ${status.phase}${suffix}`)
256
+ }
257
+ return 1
258
+ } else if (anyPodsInPhase(podStatuses, GameServerPodPhase.Unknown) || anyPodsInPhase(podStatuses, GameServerPodPhase.Pending) || anyPodsInPhase(podStatuses, GameServerPodPhase.Running)) {
259
+ console.log('Waiting for gameserver(s) to be ready...')
260
+ for (let ndx = 0; ndx < pods.length; ndx += 1) {
261
+ const status = podStatuses[ndx]
262
+ const suffix = (status.phase !== GameServerPodPhase.Ready) ? ` -- ${status.message}` : ''
263
+ console.log(` ${pods[ndx].metadata?.name}: ${status.phase}${suffix}`)
264
+ }
265
+ } else if (allPodsInPhase(podStatuses, GameServerPodPhase.Ready)) {
266
+ console.log('Gameserver is up and ready to serve!')
250
267
  // \todo add further readiness checks -- ping endpoint, ping dashboard, other checks?
251
268
  return 0
252
-
253
- case GameServerPodPhase.Failed:
254
- console.log('Gameserver failed to start')
255
- return 1
256
-
257
- case GameServerPodPhase.Pending:
258
- console.log('Gameserver still starting')
259
- break
260
-
261
- case GameServerPodPhase.Unknown:
262
- default:
263
- console.log('Gameserver in unknown state')
264
- break
269
+ } else {
270
+ console.log('Deployment in inconsistent state, waiting...')
271
+ for (let ndx = 0; ndx < pods.length; ndx += 1) {
272
+ const status = podStatuses[ndx]
273
+ const suffix = (status.phase !== GameServerPodPhase.Ready) ? ` -- ${status.message}` : ''
274
+ console.log(` ${pods[ndx].metadata?.name}: ${status.phase}${suffix}`)
275
+ }
276
+ }
265
277
  }
266
278
 
267
279
  if (Date.now() >= timeoutAt) {
268
- logger.error('Timeout while waiting for gameserver to initialize')
280
+ console.log('Deployment failed! Timeout while waiting for gameserver to initialize.')
269
281
  return 124 // timeout
270
282
  }
271
283
 
272
- await delay(1000)
284
+ // Sleep a bit to avoid spamming the log
285
+ await delay(2000)
273
286
  }
274
287
  }
package/src/stackapi.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { isValidFQDN, getGameserverAdminUrl } from './utils.js'
2
2
  import { logger } from './logging.js'
3
+ import { dump } from 'js-yaml'
4
+ import { getUserinfo } from './auth.js'
3
5
 
4
6
  interface AwsCredentialsResponse {
5
7
  AccessKeyId: string
@@ -8,34 +10,84 @@ interface AwsCredentialsResponse {
8
10
  Expiration: string
9
11
  }
10
12
 
11
- interface GameserverId {
13
+ export interface GameserverId {
12
14
  gameserver?: string
13
15
  organization?: string
14
16
  project?: string
15
17
  environment?: string
16
18
  }
17
19
 
20
+ interface KubeConfig {
21
+ apiVersion?: string
22
+ kind: string
23
+ 'current-context': string
24
+ clusters: KubeConfigCluster[]
25
+ contexts: KubeConfigContext[]
26
+ users: KubeConfigUser[]
27
+ preferences?: any
28
+ }
29
+
30
+ interface KubeConfigCluster {
31
+ cluster: {
32
+ 'certificate-authority-data': string
33
+ server: string
34
+ }
35
+ name: string
36
+ }
37
+
38
+ interface KubeConfigContext {
39
+ context: {
40
+ cluster: string
41
+ user: string
42
+ namespace?: string
43
+ }
44
+ name: string
45
+ }
46
+
47
+ interface KubeConfigUser {
48
+ name: string
49
+ user: {
50
+ token?: string
51
+ exec?: {
52
+ command: string
53
+ args: string[]
54
+ apiVersion: string
55
+ }
56
+ }
57
+ }
58
+
59
+ interface KubeExecCredential {
60
+ apiVersion: string
61
+ kind: string
62
+ spec: {
63
+ cluster?: {
64
+ server: string
65
+ certificateAuthorityData: string
66
+ }
67
+ }
68
+ status: {
69
+ token: string
70
+ expirationTimestamp: string
71
+ }
72
+ }
73
+
18
74
  export class StackAPI {
19
75
  private readonly accessToken: string
20
76
 
21
- private _stack_api_base_uri: string
77
+ private readonly _stackApiBaseUrl: string
22
78
 
23
- constructor (accessToken: string) {
79
+ constructor (accessToken: string, stackApiBaseUrl: string | undefined) {
24
80
  if (accessToken == null) {
25
81
  throw new Error('accessToken must be provided')
26
82
  }
27
83
 
28
84
  this.accessToken = accessToken
29
- this._stack_api_base_uri = 'https://infra.p1.metaplay.io/stackapi'
30
- }
31
85
 
32
- get stack_api_base_uri (): string {
33
- return this._stack_api_base_uri.replace(/\/$/, '')
34
- }
35
-
36
- set stack_api_base_uri (uri: string) {
37
- uri = uri.replace(/\/$/, '') // Remove trailing slash
38
- this._stack_api_base_uri = uri
86
+ if (stackApiBaseUrl) {
87
+ this._stackApiBaseUrl = stackApiBaseUrl.replace(/\/$/, '') // Remove trailing slash
88
+ } else {
89
+ this._stackApiBaseUrl = 'https://infra.p1.metaplay.io/stackapi'
90
+ }
39
91
  }
40
92
 
41
93
  async getAwsCredentials (gs: GameserverId): Promise<AwsCredentialsResponse> {
@@ -45,10 +97,10 @@ export class StackAPI {
45
97
  const adminUrl = getGameserverAdminUrl(gs.gameserver)
46
98
  url = `https://${adminUrl}/.infra/credentials/aws`
47
99
  } else {
48
- url = `${this.stack_api_base_uri}/v0/credentials/${gs.gameserver}/aws`
100
+ url = `${this._stackApiBaseUrl}/v0/credentials/${gs.gameserver}/aws`
49
101
  }
50
102
  } else if (gs.organization != null && gs.project != null && gs.environment != null) {
51
- url = `${this.stack_api_base_uri}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/aws`
103
+ url = `${this._stackApiBaseUrl}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/aws`
52
104
  } else {
53
105
  throw new Error('Invalid arguments for getAwsCredentials')
54
106
  }
@@ -76,16 +128,175 @@ export class StackAPI {
76
128
  const adminUrl = getGameserverAdminUrl(gs.gameserver)
77
129
  url = `https://${adminUrl}/.infra/credentials/k8s`
78
130
  } else {
79
- url = `${this.stack_api_base_uri}/v0/credentials/${gs.gameserver}/k8s`
131
+ url = `${this._stackApiBaseUrl}/v0/credentials/${gs.gameserver}/k8s`
80
132
  }
81
133
  } else if (gs.organization != null && gs.project != null && gs.environment != null) {
82
- url = `${this.stack_api_base_uri}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/k8s`
134
+ url = `${this._stackApiBaseUrl}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/k8s`
83
135
  } else {
84
136
  throw new Error('Invalid arguments for getKubeConfig')
85
137
  }
86
138
 
87
139
  logger.debug(`Getting KubeConfig from ${url}...`)
88
140
 
141
+ let response
142
+ try {
143
+ response = await fetch(url, {
144
+ method: 'POST',
145
+ headers: {
146
+ Authorization: `Bearer ${this.accessToken}`,
147
+ 'Content-Type': 'application/json'
148
+ }
149
+ })
150
+ } catch (error: any) {
151
+ logger.error(`Failed to fetch kubeconfig from ${url}`)
152
+ logger.error('Fetch error details:', error)
153
+ if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
154
+ throw new Error(`Failed to to fetch kubeconfig: SSL certificate validation failed for ${url}. Is someone trying to tamper with your internet connection?`)
155
+ }
156
+ throw new Error(`Failed to fetch kubeconfig from ${url}: ${error}`)
157
+ }
158
+
159
+ if (response.status !== 200) {
160
+ throw new Error(`Failed to fetch KubeConfigs: ${response.statusText}`)
161
+ }
162
+
163
+ return await response.text()
164
+ }
165
+
166
+ /**
167
+ * Get a `kubeconfig` payload which invokes `metaplay-auth get-kubernetes-execcredential` to get the actual
168
+ * access credentials each time the kubeconfig is used.
169
+ * @param gs Game server environment to get credentials for.
170
+ * @returns The kubeconfig YAML.
171
+ */
172
+ async getKubeConfigExecCredential (gs: GameserverId): Promise<string> {
173
+ let url = ''
174
+ let gsSlug = ''
175
+ const execArgs = ['get-kubernetes-execcredential']
176
+ if (gs.gameserver != null) {
177
+ const adminUrl = getGameserverAdminUrl(gs.gameserver)
178
+ url = `https://${adminUrl}/.infra/credentials/k8s?type=execcredential`
179
+ gsSlug = gs.gameserver
180
+ execArgs.push('--gameserver', gs.gameserver)
181
+ } else if (gs.organization != null && gs.project != null && gs.environment != null) {
182
+ url = `${this._stackApiBaseUrl}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/k8s?type=execcredential`
183
+ gsSlug = `${gs.organization}.${gs.project}.${gs.environment}`
184
+ execArgs.push(`${gs.organization}-${gs.project}-${gs.environment}`)
185
+ } else {
186
+ throw new Error('Invalid arguments for getKubeConfigExecCredential')
187
+ }
188
+
189
+ logger.debug(`Getting Kubernetes KubeConfig from ${url}...`)
190
+
191
+ let response
192
+ try {
193
+ response = await fetch(url, {
194
+ method: 'POST',
195
+ headers: {
196
+ Authorization: `Bearer ${this.accessToken}`,
197
+ 'Content-Type': 'application/json'
198
+ }
199
+ })
200
+ } catch (error: any) {
201
+ logger.error(`Failed to fetch kubeconfig from ${url}`)
202
+ logger.error('Fetch error details:', error)
203
+ if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
204
+ throw new Error(`Failed to to fetch kubeconfig: SSL certificate validation failed for ${url}. Is someone trying to tamper with your internet connection?`)
205
+ }
206
+ throw new Error(`Failed to fetch kubeconfig from ${url}: ${error}`)
207
+ }
208
+
209
+ if (response.status !== 200) {
210
+ throw new Error(`Failed to fetch Kubernetes KubeConfig: ${response.statusText}`)
211
+ }
212
+
213
+ // get the execcredential and morph it into a kubeconfig which calls metaplay-auth for the token
214
+ let kubeExecCredential
215
+ try {
216
+ kubeExecCredential = await response.json() as KubeExecCredential
217
+ } catch {
218
+ throw new Error('Failed to fetch Kubernetes KubeConfig: the server response is not JSON')
219
+ }
220
+
221
+ if (!kubeExecCredential.spec.cluster) {
222
+ throw new Error('Received kubeExecCredential with missing spec.cluster')
223
+ }
224
+
225
+ let namespace = 'default'
226
+ let user = 'user'
227
+
228
+ try {
229
+ const environment = await this.getEnvironmentDetails(gs)
230
+ namespace = environment.deployment?.kubernetes_namespace ?? namespace
231
+ } catch (e) {
232
+ logger.debug('Failed to get environment details, using defaults', e)
233
+ }
234
+
235
+ try {
236
+ const userinfo = await getUserinfo(this.accessToken)
237
+ user = userinfo.sub ?? user
238
+ } catch (e) {
239
+ logger.debug('Failed to get userinfo, using defaults', e)
240
+ }
241
+
242
+ const kubeConfig: KubeConfig = {
243
+ apiVersion: 'v1',
244
+ kind: 'Config',
245
+ 'current-context': gsSlug,
246
+ clusters: [
247
+ {
248
+ cluster: {
249
+ 'certificate-authority-data': kubeExecCredential.spec.cluster.certificateAuthorityData,
250
+ server: kubeExecCredential.spec.cluster.server
251
+ },
252
+ name: kubeExecCredential.spec.cluster.server
253
+ }
254
+ ],
255
+ contexts: [
256
+ {
257
+ context: {
258
+ cluster: kubeExecCredential.spec.cluster.server,
259
+ namespace,
260
+ user
261
+ },
262
+ name: gsSlug,
263
+ }
264
+ ],
265
+ users: [
266
+ {
267
+ name: user,
268
+ user: {
269
+ exec: {
270
+ apiVersion: 'client.authentication.k8s.io/v1beta1',
271
+ command: 'metaplay-auth', // todo: figure out how to refer to metaplay-auth itself
272
+ args: execArgs,
273
+ }
274
+ }
275
+ }
276
+ ],
277
+ }
278
+
279
+ // return as yaml for easier consumption
280
+ return dump(kubeConfig)
281
+ }
282
+
283
+ async getKubeExecCredential (gs: GameserverId): Promise<string> {
284
+ let url = ''
285
+ if (gs.gameserver != null) {
286
+ if (isValidFQDN(gs.gameserver)) {
287
+ const adminUrl = getGameserverAdminUrl(gs.gameserver)
288
+ url = `https://${adminUrl}/.infra/credentials/k8s?type=execcredential`
289
+ } else {
290
+ url = `${this._stackApiBaseUrl}/v0/credentials/${gs.gameserver}/k8s?type=execcredential`
291
+ }
292
+ } else if (gs.organization != null && gs.project != null && gs.environment != null) {
293
+ url = `${this._stackApiBaseUrl}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}/credentials/k8s?type=execcredential`
294
+ } else {
295
+ throw new Error('Invalid arguments for getKubeConfig')
296
+ }
297
+
298
+ logger.debug(`Getting Kubernetes ExecCredential from ${url}...`)
299
+
89
300
  const response = await fetch(url, {
90
301
  method: 'POST',
91
302
  headers: {
@@ -95,7 +306,7 @@ export class StackAPI {
95
306
  })
96
307
 
97
308
  if (response.status !== 200) {
98
- throw new Error(`Failed to fetch KubeConfigs: ${response.statusText}`)
309
+ throw new Error(`Failed to fetch Kubernetes ExecCredential: ${response.statusText}`)
99
310
  }
100
311
 
101
312
  return await response.text()
@@ -108,10 +319,10 @@ export class StackAPI {
108
319
  const adminUrl = getGameserverAdminUrl(gs.gameserver)
109
320
  url = `https://${adminUrl}/.infra/environment`
110
321
  } else {
111
- url = `${this.stack_api_base_uri}/v0/deployments/${gs.gameserver}`
322
+ url = `${this._stackApiBaseUrl}/v0/deployments/${gs.gameserver}`
112
323
  }
113
324
  } else if (gs.organization != null && gs.project != null && gs.environment != null) {
114
- url = `${this.stack_api_base_uri}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}`
325
+ url = `${this._stackApiBaseUrl}/v1/servers/${gs.organization}/${gs.project}/${gs.environment}`
115
326
  } else {
116
327
  throw new Error('Invalid arguments for environment details')
117
328
  }
package/src/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'child_process'
2
+
1
3
  export function stackApiBaseFromOptions (arg: string, options: Record<string, any>): string {
2
4
  return ''
3
5
  }
@@ -46,3 +48,38 @@ export function getGameserverAdminUrl (uri: string): string {
46
48
 
47
49
  return `${hostname}-admin.${domain}`
48
50
  }
51
+
52
+ interface CommandResult {
53
+ code: number | null
54
+ stdout: any[]
55
+ stderr: any[]
56
+ }
57
+
58
+ export async function executeCommand (command: string, args: string[]): Promise<CommandResult> {
59
+ return await new Promise((resolve, reject) => {
60
+ const childProcess = spawn(command, args) //, { stdio: 'inherit' }) -- this pipes stdout & stderr to our output
61
+ let stdout: any[] = []
62
+ let stderr: any[] = []
63
+
64
+ childProcess.stdout?.on('data', (data) => {
65
+ stdout += data
66
+ })
67
+
68
+ childProcess.stderr?.on('data', (data) => {
69
+ stderr += data
70
+ })
71
+
72
+ // If program runs to completion, resolve promise with success
73
+ childProcess.on('close', (code) => {
74
+ resolve({ code, stdout, stderr })
75
+ })
76
+
77
+ childProcess.on('error', (err) => {
78
+ reject(
79
+ new Error(
80
+ `Failed to execute command '${command} ${args.join(' ')}': ${err.message}`
81
+ )
82
+ )
83
+ })
84
+ })
85
+ }