@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 +29 -0
- package/README.md +0 -42
- package/dist/index.js +395 -48
- package/dist/index.js.map +1 -1
- package/dist/src/auth.js +46 -8
- package/dist/src/auth.js.map +1 -1
- package/dist/src/deployment.js +72 -53
- package/dist/src/deployment.js.map +1 -1
- package/dist/src/stackapi.js +170 -17
- package/dist/src/stackapi.js.map +1 -1
- package/dist/src/utils.js +21 -0
- package/dist/src/utils.js.map +1 -1
- package/index.ts +421 -55
- package/package.json +13 -5
- package/src/auth.ts +50 -8
- package/src/deployment.ts +70 -57
- package/src/stackapi.ts +230 -19
- package/src/utils.ts +37 -0
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 (
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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('
|
|
213
|
+
console.log(`Logs from pod '${podName}':\n${logs}`)
|
|
204
214
|
}
|
|
205
215
|
|
|
206
|
-
|
|
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
|
-
|
|
215
|
-
|
|
224
|
+
function anyPodsInPhase (podStatuses: GameServerPodStatus[], phase: GameServerPodPhase): boolean {
|
|
225
|
+
return podStatuses.some(status => status.phase === phase)
|
|
226
|
+
}
|
|
216
227
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
280
|
+
console.log('Deployment failed! Timeout while waiting for gameserver to initialize.')
|
|
269
281
|
return 124 // timeout
|
|
270
282
|
}
|
|
271
283
|
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|