@metaplay/metaplay-auth 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +2 -0
- package/CHANGELOG.md +58 -40
- package/dist/index.cjs +125 -125
- package/eslint.config.js +3 -0
- package/index.ts +717 -439
- package/package.json +18 -15
- package/prettier.config.js +3 -0
- package/src/auth.ts +115 -80
- package/src/buildCommand.ts +267 -0
- package/src/config.ts +12 -0
- package/src/deployment.ts +182 -59
- package/src/logging.ts +4 -4
- package/src/secret_store.ts +10 -7
- package/src/stackapi.ts +2 -33
- package/src/targetenvironment.ts +61 -57
- package/src/utils.ts +120 -29
- package/src/version.ts +1 -1
package/index.ts
CHANGED
|
@@ -1,49 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander'
|
|
3
3
|
import Docker from 'dockerode'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { portalBaseUrl, setPortalBaseUrl } from './src/config.js'
|
|
5
|
+
import {
|
|
6
|
+
loginAndSaveTokens,
|
|
7
|
+
machineLoginAndSaveTokens,
|
|
8
|
+
extendCurrentSession,
|
|
9
|
+
loadTokens,
|
|
10
|
+
removeTokens,
|
|
11
|
+
TokenSet,
|
|
12
|
+
} from './src/auth.js'
|
|
6
13
|
import { checkGameServerDeployment, debugGameServer } from './src/deployment.js'
|
|
14
|
+
import {
|
|
15
|
+
pathJoin,
|
|
16
|
+
isValidFQDN,
|
|
17
|
+
executeCommand,
|
|
18
|
+
removeTrailingSlash,
|
|
19
|
+
fetchHelmChartVersions,
|
|
20
|
+
resolveBestMatchingVersion,
|
|
21
|
+
} from './src/utils.js'
|
|
7
22
|
import { logger, setLogLevel } from './src/logging.js'
|
|
8
|
-
import { isValidFQDN, executeCommand, getGameserverAdminUrl } from './src/utils.js'
|
|
9
23
|
import { TargetEnvironment } from './src/targetenvironment.js'
|
|
10
24
|
import { exit } from 'process'
|
|
11
25
|
import { tmpdir } from 'os'
|
|
12
|
-
import { join } from 'path'
|
|
13
26
|
import { randomBytes } from 'crypto'
|
|
14
27
|
import { writeFile, unlink } from 'fs/promises'
|
|
15
28
|
import { existsSync } from 'fs'
|
|
16
29
|
import { KubeConfig } from '@kubernetes/client-node'
|
|
17
30
|
import * as semver from 'semver'
|
|
18
31
|
import { PACKAGE_VERSION } from './src/version.js'
|
|
32
|
+
import { registerBuildCommand } from './src/buildCommand.js'
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Base URL of StackAPI infra to use -- defaults to p1.metaplay.io. Override with the global --stack-api flag.
|
|
36
|
+
* Note: The dynamic `kubeconfig`s generated by `metaplay-auth` override this with '--stack-api <url>' flag.
|
|
37
|
+
*/
|
|
38
|
+
let defaultStackApiBaseUrl = 'https://infra.p1.metaplay.io/stackapi'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a TargetEnvironment from a fully-qualified domain name (eg, 'idler-develop.p1.metaplay.io').
|
|
42
|
+
* @param tokens Tokens to use for authenticating with the portal
|
|
43
|
+
* @param environmentDomain The environment domain name (eg, 'idler-develop.p1.metaplay.io')
|
|
44
|
+
* @returns The TargetEnvironment instance needed to operate with the environment.
|
|
45
|
+
*/
|
|
46
|
+
function resolveTargetEnvironmentFromFQDN(tokens: TokenSet, environmentDomain: string): TargetEnvironment {
|
|
47
|
+
// Extract the humanId from the domain, eg: 'idler-develop.p1.metaplay.io' -> 'idler-develop'
|
|
48
|
+
const humanId = environmentDomain.split('.')[0]
|
|
49
|
+
return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl) // \todo We could probably infer the stack API URL from the domain?
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Information about an environment received from the portal.
|
|
54
|
+
*/
|
|
55
|
+
// \todo Should share this type with the portal endpoint code that returns
|
|
56
|
+
interface PortalEnvironmentInfo {
|
|
57
|
+
/** UUID of the environment */
|
|
58
|
+
id: string
|
|
59
|
+
|
|
60
|
+
/** UUID of the project */
|
|
61
|
+
project_id: string
|
|
62
|
+
|
|
63
|
+
/** User-provided name for the environment (can change) */
|
|
64
|
+
name: string
|
|
65
|
+
|
|
66
|
+
/** TODO: What is this URL? */
|
|
67
|
+
url: string
|
|
68
|
+
|
|
69
|
+
/** Slug for the environment (simplified version of name) */
|
|
70
|
+
slug: string
|
|
71
|
+
|
|
72
|
+
/** Creation time of the environment (eg, '2024-02-02T08:20:18.457748+00:00') */
|
|
73
|
+
created_at: string
|
|
74
|
+
|
|
75
|
+
/** Type of the environment (eg, 'development' or 'production') */
|
|
76
|
+
type: string
|
|
77
|
+
|
|
78
|
+
/** Immutable human-readable identifier for the environment (eg, 'delicious-pumpkin' .. can also be legacy name like 'idler-develop') */
|
|
79
|
+
// \todo Make field mandatory when portal returns valid values
|
|
80
|
+
human_id?: string
|
|
81
|
+
|
|
82
|
+
// \todo Add stackapi url that the portal should return?
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch information about a specific managed environment from the Metaplay portal using slugs.
|
|
87
|
+
* @param tokens Tokens to use to for authenticating with the portal.
|
|
88
|
+
* @param organization Organization slug.
|
|
89
|
+
* @param project Project slug.
|
|
90
|
+
* @param environment Environment slug.
|
|
91
|
+
* @returns The portal's information about the environment.
|
|
92
|
+
*/
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
94
|
+
async function fetchManagedEnvironmentInfo(
|
|
95
|
+
tokens: TokenSet,
|
|
96
|
+
organization: string,
|
|
97
|
+
project: string,
|
|
98
|
+
environment: string
|
|
99
|
+
): Promise<PortalEnvironmentInfo> {
|
|
100
|
+
const url = `${portalBaseUrl}/api/v1/environments/with-slugs?organization_slug=${organization}&project_slug=${project}&environment_slug=${environment}`
|
|
101
|
+
logger.debug(`Getting environment information from portal: ${url}...`)
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
method: 'GET',
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Throw on server errors (eg, forbidden)
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const errorData = await response.json()
|
|
113
|
+
throw new Error(`Failed to fetch environment details with error ${response.status}: ${JSON.stringify(errorData)}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// \todo Validate response?
|
|
117
|
+
return (await response.json()) as PortalEnvironmentInfo
|
|
118
|
+
}
|
|
24
119
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the target environment based on (org, proj, env) tuple. We fetch the information
|
|
122
|
+
* from the portal and then construct the `TargetEnvironment` class with the required information
|
|
123
|
+
* for downstream operations.
|
|
124
|
+
* @param tokens Tokens to use for authenticating with the portal
|
|
125
|
+
* @param organization Organization slug.
|
|
126
|
+
* @param project Project slug.
|
|
127
|
+
* @param environment Environment slug.
|
|
128
|
+
* @returns The TargetEnvironment instance needed to operate with the environment.
|
|
129
|
+
*/
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
131
|
+
async function resolveTargetEnvironmentFromSlugs(
|
|
132
|
+
tokens: TokenSet,
|
|
133
|
+
organization: string,
|
|
134
|
+
project: string,
|
|
135
|
+
environment: string
|
|
136
|
+
): Promise<TargetEnvironment> {
|
|
137
|
+
// Fetch the deployment information from the portal
|
|
138
|
+
const portalEnvInfo = await fetchManagedEnvironmentInfo(tokens, organization, project, environment)
|
|
139
|
+
// \todo Use legacy '<project>-<environment>' -- should be filled in by portal!
|
|
140
|
+
const humanId = portalEnvInfo.human_id ?? `${project}-${environment}`
|
|
141
|
+
|
|
142
|
+
return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl)
|
|
30
143
|
}
|
|
31
144
|
|
|
32
|
-
async function
|
|
33
|
-
// \todo
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const environmentDomain = await stackApi.resolveManagedEnvironmentFQDN(organization, project, environment)
|
|
37
|
-
const environmentApiBaseUrl = `${stackApiBaseUrl}/v1/servers/${organization}/${project}/${environment}`
|
|
38
|
-
|
|
39
|
-
return new TargetEnvironment(
|
|
40
|
-
tokens.access_token,
|
|
41
|
-
environmentDomain,
|
|
42
|
-
stackApiBaseUrl,
|
|
43
|
-
environmentApiBaseUrl,
|
|
44
|
-
organization,
|
|
45
|
-
project,
|
|
46
|
-
environment)
|
|
145
|
+
async function resolveTargetEnvironmentHumanId(tokens: TokenSet, humanId: string): Promise<TargetEnvironment> {
|
|
146
|
+
// \todo Validate that the target environment exists?
|
|
147
|
+
|
|
148
|
+
return new TargetEnvironment(tokens.access_token, humanId, defaultStackApiBaseUrl)
|
|
47
149
|
}
|
|
48
150
|
|
|
49
151
|
/**
|
|
@@ -51,30 +153,44 @@ async function resolveTargetEnvironmentFromTuple (tokens: TokenSet, organization
|
|
|
51
153
|
* (idler-test.p1.metaplay.io), a shorthand address (metaplay-idler-test) or the (organization, project, environment)
|
|
52
154
|
* tuple from options.
|
|
53
155
|
*/
|
|
54
|
-
async function resolveTargetEnvironment
|
|
156
|
+
async function resolveTargetEnvironment(
|
|
157
|
+
address: string | undefined,
|
|
158
|
+
options: { organization?: string; project?: string; environment?: string }
|
|
159
|
+
): Promise<TargetEnvironment> {
|
|
55
160
|
const tokens = await loadTokens()
|
|
56
161
|
|
|
57
162
|
// If address is specified, use it, otherwise assume options has organization, project, and environment
|
|
58
163
|
if (address) {
|
|
59
|
-
// Address is
|
|
164
|
+
// Address is one of:
|
|
165
|
+
// - FQDN of the target environment, eg, 'idler-develop.p1.metaplay.io'
|
|
166
|
+
// - Tuple of '<organization>-<project>-<environment>' slugs (eg, 'metaplay-idler-develop')
|
|
167
|
+
// - Stable humanId, eg, 'delicious-elephant'
|
|
60
168
|
if (isValidFQDN(address)) {
|
|
61
|
-
if (options.stackApi) {
|
|
62
|
-
throw new Error('--stack-api override only supported with organization-project-environment naming, not with FQDN')
|
|
63
|
-
}
|
|
64
169
|
return resolveTargetEnvironmentFromFQDN(tokens, address)
|
|
65
170
|
} else {
|
|
66
171
|
const parts = address.split('-')
|
|
67
|
-
if (parts.length
|
|
68
|
-
|
|
172
|
+
if (parts.length === 2) {
|
|
173
|
+
// Two parts is humanId, eg, 'delicious-elephant'
|
|
174
|
+
return await resolveTargetEnvironmentHumanId(tokens, address)
|
|
175
|
+
} else if (parts.length === 3) {
|
|
176
|
+
// Three parts is tuple of slush '<organization>-<project>-<environment>'
|
|
177
|
+
return await resolveTargetEnvironmentFromSlugs(tokens, parts[0], parts[1], parts[2])
|
|
178
|
+
} else {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Invalid environment address syntax '${address}'. Specify either "<organization>-<project>-<environment>", a humanId (eg, "delicious-elephant"), or a fully-qualified domain name (eg, idler-develop.p1.metaplay.io)`
|
|
181
|
+
)
|
|
69
182
|
}
|
|
70
|
-
return await resolveTargetEnvironmentFromTuple(tokens, parts[0], parts[1], parts[2], options.stackApi)
|
|
71
183
|
}
|
|
72
184
|
} else if (options.organization && options.project && options.environment) {
|
|
73
185
|
// Parse tuple from command-line options (output to stderr to avoid messing up '$(eval metaplay-auth ... --format env)' invocations
|
|
74
|
-
console.warn(
|
|
75
|
-
|
|
186
|
+
console.warn(
|
|
187
|
+
`Warning: Specifying the target environment with -o (--organization), -p (--project), and -e (--environment) is deprecated! Use the '${options.organization}-${options.project}-${options.environment}' syntax instead.`
|
|
188
|
+
)
|
|
189
|
+
return await resolveTargetEnvironmentFromSlugs(tokens, options.organization, options.project, options.environment)
|
|
76
190
|
} else {
|
|
77
|
-
throw new Error(
|
|
191
|
+
throw new Error(
|
|
192
|
+
'Could not determine target environment from arguments: You need to specify either an environment FQDN or an organization, project, and environment. Run this command with --help flag for more information.'
|
|
193
|
+
)
|
|
78
194
|
}
|
|
79
195
|
}
|
|
80
196
|
|
|
@@ -85,6 +201,11 @@ program
|
|
|
85
201
|
.description('Authenticate with Metaplay and get AWS and Kubernetes credentials for game servers.')
|
|
86
202
|
.version(PACKAGE_VERSION)
|
|
87
203
|
.option('-d, --debug', 'enable debug output')
|
|
204
|
+
.option('--portal-base-url <portal-base-url>', 'override the default portal base URL (e.g. http://localhost:3000)')
|
|
205
|
+
.option(
|
|
206
|
+
'--stack-api <stack-api-base-url>',
|
|
207
|
+
'override the default stack API base URL (e.g. https://infra.p1.metaplay.io/stackapi/)'
|
|
208
|
+
)
|
|
88
209
|
.hook('preAction', (thisCommand) => {
|
|
89
210
|
// Handle debug flag for all commands.
|
|
90
211
|
const opts = thisCommand.opts()
|
|
@@ -93,17 +214,34 @@ program
|
|
|
93
214
|
} else {
|
|
94
215
|
setLogLevel(10)
|
|
95
216
|
}
|
|
217
|
+
|
|
218
|
+
// Store the portal base URL for accessing globally
|
|
219
|
+
if (opts.portalBaseUrl) {
|
|
220
|
+
setPortalBaseUrl(opts.portalBaseUrl as string)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Store the stack API base URL for accessing globally
|
|
224
|
+
if (opts.stackApi) {
|
|
225
|
+
defaultStackApiBaseUrl = opts.stackApi as string
|
|
226
|
+
}
|
|
96
227
|
})
|
|
97
228
|
|
|
98
|
-
program
|
|
229
|
+
program
|
|
230
|
+
.command('login')
|
|
99
231
|
.description('login to your Metaplay account')
|
|
100
232
|
.action(async () => {
|
|
101
233
|
await loginAndSaveTokens()
|
|
102
234
|
})
|
|
103
235
|
|
|
104
|
-
program
|
|
105
|
-
.
|
|
106
|
-
.
|
|
236
|
+
program
|
|
237
|
+
.command('machine-login')
|
|
238
|
+
.description(
|
|
239
|
+
'login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)'
|
|
240
|
+
)
|
|
241
|
+
.option(
|
|
242
|
+
'--dev-credentials',
|
|
243
|
+
'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!'
|
|
244
|
+
)
|
|
107
245
|
.action(async (options) => {
|
|
108
246
|
// Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
|
|
109
247
|
let credentials: string
|
|
@@ -121,7 +259,9 @@ program.command('machine-login')
|
|
|
121
259
|
// \note We can't be certain that the secret does not contain pluses so split at the first occurrence
|
|
122
260
|
const splitOffset = credentials.indexOf('+')
|
|
123
261
|
if (splitOffset === -1) {
|
|
124
|
-
throw new Error(
|
|
262
|
+
throw new Error(
|
|
263
|
+
'Invalid format for credentials, you should copy-paste the value from the developer portal verbatim'
|
|
264
|
+
)
|
|
125
265
|
}
|
|
126
266
|
const clientId = credentials.substring(0, splitOffset)
|
|
127
267
|
const clientSecret = credentials.substring(splitOffset + 1)
|
|
@@ -130,7 +270,8 @@ program.command('machine-login')
|
|
|
130
270
|
await machineLoginAndSaveTokens(clientId, clientSecret)
|
|
131
271
|
})
|
|
132
272
|
|
|
133
|
-
program
|
|
273
|
+
program
|
|
274
|
+
.command('logout')
|
|
134
275
|
.description('log out of your Metaplay account')
|
|
135
276
|
.action(async () => {
|
|
136
277
|
console.log('Logging out by removing locally stored tokens...')
|
|
@@ -147,12 +288,13 @@ program.command('logout')
|
|
|
147
288
|
}
|
|
148
289
|
})
|
|
149
290
|
|
|
150
|
-
program
|
|
291
|
+
program
|
|
292
|
+
.command('show-tokens')
|
|
151
293
|
.description('show loaded tokens')
|
|
152
294
|
.hook('preAction', async () => {
|
|
153
295
|
await extendCurrentSession()
|
|
154
296
|
})
|
|
155
|
-
.action(async (
|
|
297
|
+
.action(async () => {
|
|
156
298
|
try {
|
|
157
299
|
// TODO: Could detect if not logged in and fail more gracefully?
|
|
158
300
|
const tokens = await loadTokens()
|
|
@@ -165,54 +307,62 @@ program.command('show-tokens')
|
|
|
165
307
|
}
|
|
166
308
|
})
|
|
167
309
|
|
|
168
|
-
program
|
|
310
|
+
program
|
|
311
|
+
.command('get-kubeconfig')
|
|
169
312
|
.description('get kubeconfig for target environment')
|
|
170
313
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
171
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
172
314
|
.option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
|
|
173
315
|
.option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
|
|
174
316
|
.option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
|
|
175
317
|
.option('-t, --type <credentials-type>', 'type of credentials handling in kubeconfig (static or dynamic)')
|
|
176
|
-
.option(
|
|
318
|
+
.option(
|
|
319
|
+
'--output <kubeconfig-path>',
|
|
320
|
+
'path of the output file where to write kubeconfig (written to stdout if not specified)'
|
|
321
|
+
)
|
|
177
322
|
.hook('preAction', async () => {
|
|
178
323
|
await extendCurrentSession()
|
|
179
324
|
})
|
|
180
|
-
.action(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
325
|
+
.action(
|
|
326
|
+
async (
|
|
327
|
+
gameserver: string | undefined,
|
|
328
|
+
options: { organization?: string; project?: string; environment?: string; type?: string; output?: string }
|
|
329
|
+
) => {
|
|
330
|
+
try {
|
|
331
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
332
|
+
|
|
333
|
+
// Default to credentialsType==dynamic for human users, and credentialsType==static for machine users
|
|
334
|
+
const tokens = await loadTokens()
|
|
335
|
+
const isHumanUser = !!tokens.refresh_token
|
|
336
|
+
const credentialsType = options.type ?? (isHumanUser ? 'dynamic' : 'static')
|
|
337
|
+
|
|
338
|
+
// Generate kubeconfig
|
|
339
|
+
let kubeconfigPayload
|
|
340
|
+
if (credentialsType === 'dynamic') {
|
|
341
|
+
logger.debug('Fetching kubeconfig with execcredential')
|
|
342
|
+
kubeconfigPayload = await targetEnv.getKubeConfigWithExecCredential()
|
|
343
|
+
} else if (credentialsType === 'static') {
|
|
344
|
+
logger.debug('Fetching kubeconfig with embedded secret')
|
|
345
|
+
kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
|
|
346
|
+
} else {
|
|
347
|
+
throw new Error('Invalid credentials type; must be either "static" or "dynamic"')
|
|
348
|
+
}
|
|
200
349
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
350
|
+
// Write kubeconfig to output (file or stdout)
|
|
351
|
+
if (options.output) {
|
|
352
|
+
logger.debug(`Writing kubeconfig to file ${options.output}`)
|
|
353
|
+
await writeFile(options.output, kubeconfigPayload, { mode: 0o600 })
|
|
354
|
+
console.log(`Wrote kubeconfig to ${options.output}`)
|
|
355
|
+
} else {
|
|
356
|
+
console.log(kubeconfigPayload)
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof Error) {
|
|
360
|
+
console.error('Error getting KubeConfig:', error)
|
|
361
|
+
}
|
|
362
|
+
exit(1)
|
|
212
363
|
}
|
|
213
|
-
exit(1)
|
|
214
364
|
}
|
|
215
|
-
|
|
365
|
+
)
|
|
216
366
|
|
|
217
367
|
/**
|
|
218
368
|
* Get the Kubernetes credentials in the execcredential format which can be used within the `kubeconfig` file:
|
|
@@ -222,33 +372,38 @@ program.command('get-kubeconfig')
|
|
|
222
372
|
* kubeconfig that uses this command.
|
|
223
373
|
*/
|
|
224
374
|
// todo: maybe this should be a hidden command as it's not very useful for end users and clutters help?
|
|
225
|
-
program
|
|
375
|
+
program
|
|
376
|
+
.command('get-kubernetes-execcredential')
|
|
226
377
|
.description('[internal] get kubernetes credentials in execcredential format (used from the generated kubeconfigs)')
|
|
227
378
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
228
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
229
379
|
.option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
|
|
230
380
|
.option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
|
|
231
381
|
.option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
|
|
232
382
|
.hook('preAction', async () => {
|
|
233
383
|
await extendCurrentSession()
|
|
234
384
|
})
|
|
235
|
-
.action(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
385
|
+
.action(
|
|
386
|
+
async (
|
|
387
|
+
gameserver: string | undefined,
|
|
388
|
+
options: { organization?: string; project?: string; environment?: string }
|
|
389
|
+
) => {
|
|
390
|
+
try {
|
|
391
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
392
|
+
const credentials = await targetEnv.getKubeExecCredential()
|
|
393
|
+
console.log(credentials)
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (error instanceof Error) {
|
|
396
|
+
console.error(`Error getting Kubernetes ExecCredential: ${error.message}`)
|
|
397
|
+
}
|
|
398
|
+
exit(1)
|
|
243
399
|
}
|
|
244
|
-
exit(1)
|
|
245
400
|
}
|
|
246
|
-
|
|
401
|
+
)
|
|
247
402
|
|
|
248
|
-
program
|
|
403
|
+
program
|
|
404
|
+
.command('get-aws-credentials')
|
|
249
405
|
.description('get AWS credentials for target environment')
|
|
250
406
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
251
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
252
407
|
.option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
|
|
253
408
|
.option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
|
|
254
409
|
.option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
|
|
@@ -256,39 +411,46 @@ program.command('get-aws-credentials')
|
|
|
256
411
|
.hook('preAction', async () => {
|
|
257
412
|
await extendCurrentSession()
|
|
258
413
|
})
|
|
259
|
-
.action(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const credentials = await targetEnv.getAwsCredentials()
|
|
414
|
+
.action(
|
|
415
|
+
async (
|
|
416
|
+
gameserver: string | undefined,
|
|
417
|
+
options: { organization?: string; project?: string; environment?: string; format?: string }
|
|
418
|
+
) => {
|
|
419
|
+
try {
|
|
420
|
+
if (options.format !== 'json' && options.format !== 'env') {
|
|
421
|
+
throw new Error('Invalid format; must be one of json or env')
|
|
422
|
+
}
|
|
269
423
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
424
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
425
|
+
|
|
426
|
+
// Get the AWS credentials
|
|
427
|
+
const credentials = await targetEnv.getAwsCredentials()
|
|
428
|
+
|
|
429
|
+
if (options.format === 'env') {
|
|
430
|
+
console.log(`export AWS_ACCESS_KEY_ID=${credentials.AccessKeyId}`)
|
|
431
|
+
console.log(`export AWS_SECRET_ACCESS_KEY=${credentials.SecretAccessKey}`)
|
|
432
|
+
console.log(`export AWS_SESSION_TOKEN=${credentials.SessionToken}`)
|
|
433
|
+
} else {
|
|
434
|
+
console.log(
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
...credentials,
|
|
437
|
+
Version: 1, // this is needed to comply with `aws` format for external credential providers
|
|
438
|
+
})
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error instanceof Error) {
|
|
443
|
+
console.error(`Error getting AWS credentials: ${error.message}`)
|
|
444
|
+
}
|
|
445
|
+
exit(1)
|
|
283
446
|
}
|
|
284
|
-
exit(1)
|
|
285
447
|
}
|
|
286
|
-
|
|
448
|
+
)
|
|
287
449
|
|
|
288
|
-
program
|
|
450
|
+
program
|
|
451
|
+
.command('get-docker-login')
|
|
289
452
|
.description('[deprecated] get docker login credentials for pushing the server image to target environment')
|
|
290
453
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
291
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
292
454
|
.option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
|
|
293
455
|
.option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
|
|
294
456
|
.option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
|
|
@@ -296,392 +458,497 @@ program.command('get-docker-login')
|
|
|
296
458
|
.hook('preAction', async () => {
|
|
297
459
|
await extendCurrentSession()
|
|
298
460
|
})
|
|
299
|
-
.action(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
461
|
+
.action(
|
|
462
|
+
async (
|
|
463
|
+
gameserver: string | undefined,
|
|
464
|
+
options: { organization?: string; project?: string; environment?: string; format?: string }
|
|
465
|
+
) => {
|
|
466
|
+
try {
|
|
467
|
+
if (options.format !== 'json' && options.format !== 'env') {
|
|
468
|
+
throw new Error('Invalid format; must be one of json or env')
|
|
469
|
+
}
|
|
308
470
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
471
|
+
console.warn('The get-docker-login command is deprecated! Use the push-docker-image command instead.')
|
|
472
|
+
|
|
473
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
474
|
+
|
|
475
|
+
// Get environment info (region is needed for ECR)
|
|
476
|
+
logger.debug('Get environment info')
|
|
477
|
+
const environment = await targetEnv.getEnvironmentDetails()
|
|
478
|
+
const dockerRepo = environment.deployment.ecr_repo
|
|
479
|
+
|
|
480
|
+
// Resolve docker credentials for remote registry
|
|
481
|
+
logger.debug('Get docker credentials')
|
|
482
|
+
const dockerCredentials = await targetEnv.getDockerCredentials()
|
|
483
|
+
const { username, password } = dockerCredentials
|
|
484
|
+
|
|
485
|
+
// Output the docker repo & credentials
|
|
486
|
+
if (options.format === 'env') {
|
|
487
|
+
console.log(`export DOCKER_REPO=${dockerRepo}`)
|
|
488
|
+
console.log(`export DOCKER_USERNAME=${username}`)
|
|
489
|
+
console.log(`export DOCKER_PASSWORD=${password}`)
|
|
490
|
+
} else {
|
|
491
|
+
console.log(
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
dockerRepo,
|
|
494
|
+
username,
|
|
495
|
+
password,
|
|
496
|
+
})
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error instanceof Error) {
|
|
501
|
+
console.error(`Error getting docker login credentials: ${error.message}`)
|
|
502
|
+
}
|
|
503
|
+
exit(1)
|
|
334
504
|
}
|
|
335
|
-
exit(1)
|
|
336
505
|
}
|
|
337
|
-
|
|
506
|
+
)
|
|
338
507
|
|
|
339
|
-
program
|
|
508
|
+
program
|
|
509
|
+
.command('get-environment')
|
|
340
510
|
.description('get details of an environment')
|
|
341
511
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
342
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
343
512
|
.option('-o, --organization <organization>', '[deprecated] organization name (e.g. metaplay)')
|
|
344
513
|
.option('-p, --project <project>', '[deprecated] project name (e.g. idler)')
|
|
345
514
|
.option('-e, --environment <environment>', '[deprecated] environment name (e.g. develop)')
|
|
346
515
|
.hook('preAction', async () => {
|
|
347
516
|
await extendCurrentSession()
|
|
348
517
|
})
|
|
349
|
-
.action(
|
|
350
|
-
|
|
351
|
-
|
|
518
|
+
.action(
|
|
519
|
+
async (
|
|
520
|
+
gameserver: string | undefined,
|
|
521
|
+
options: { organization?: string; project?: string; environment?: string }
|
|
522
|
+
) => {
|
|
523
|
+
try {
|
|
524
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
352
525
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
526
|
+
const environment = await targetEnv.getEnvironmentDetails()
|
|
527
|
+
console.log(JSON.stringify(environment, undefined, 2))
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error instanceof Error) {
|
|
530
|
+
console.error(`Error getting environment details: ${error.message}`)
|
|
531
|
+
}
|
|
532
|
+
exit(1)
|
|
358
533
|
}
|
|
359
|
-
exit(1)
|
|
360
534
|
}
|
|
361
|
-
|
|
535
|
+
)
|
|
362
536
|
|
|
363
|
-
program
|
|
537
|
+
program
|
|
538
|
+
.command('push-docker-image')
|
|
364
539
|
.description('push docker image into the target environment image registry')
|
|
365
540
|
.argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
366
541
|
.argument('image-name', 'full name of the docker image to push (eg, the gameserver:<sha>)')
|
|
367
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
368
542
|
.hook('preAction', async () => {
|
|
369
543
|
await extendCurrentSession()
|
|
370
544
|
})
|
|
371
|
-
.action(
|
|
372
|
-
|
|
373
|
-
|
|
545
|
+
.action(
|
|
546
|
+
async (
|
|
547
|
+
gameserver: string | undefined,
|
|
548
|
+
imageName: string | undefined,
|
|
549
|
+
options: { organization?: string; project?: string; environment?: string; imageTag?: string }
|
|
550
|
+
) => {
|
|
551
|
+
try {
|
|
552
|
+
console.log(`Pushing docker image ${imageName} to target environment ${gameserver}...`)
|
|
374
553
|
|
|
375
|
-
|
|
554
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
376
555
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
556
|
+
// Get environment info (region is needed for ECR)
|
|
557
|
+
logger.debug('Get environment info')
|
|
558
|
+
const envInfo = await targetEnv.getEnvironmentDetails()
|
|
380
559
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
560
|
+
// Resolve docker credentials for remote registry
|
|
561
|
+
logger.debug('Get docker credentials')
|
|
562
|
+
const dockerCredentials = await targetEnv.getDockerCredentials()
|
|
384
563
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
564
|
+
// Resolve tag from src image
|
|
565
|
+
if (!imageName) {
|
|
566
|
+
throw new Error('Must specify a valid docker image name as the image-name argument')
|
|
567
|
+
}
|
|
568
|
+
const srcImageParts = imageName.split(':')
|
|
569
|
+
if (srcImageParts.length !== 2 || srcImageParts[0].length === 0 || srcImageParts[1].length === 0) {
|
|
570
|
+
throw new Error(`Invalid docker image name '${imageName}', expecting the name in format 'name:tag'`)
|
|
571
|
+
}
|
|
572
|
+
const imageTag = srcImageParts[1]
|
|
573
|
+
|
|
574
|
+
// Resolve source image
|
|
575
|
+
const srcImageName = imageName
|
|
576
|
+
const dstRepoName = envInfo.deployment.ecr_repo
|
|
577
|
+
const dstImageName = `${dstRepoName}:${imageTag}`
|
|
578
|
+
const dockerApi = new Docker()
|
|
579
|
+
const srcDockerImage = dockerApi.getImage(srcImageName)
|
|
580
|
+
|
|
581
|
+
// If names don't match, tag the src image as dst
|
|
582
|
+
if (srcImageName !== dstImageName) {
|
|
583
|
+
logger.debug(`Tagging image ${srcImageName} as ${dstImageName}`)
|
|
584
|
+
await srcDockerImage.tag({ repo: dstRepoName, tag: imageTag })
|
|
585
|
+
}
|
|
407
586
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
587
|
+
// Push the image
|
|
588
|
+
logger.debug(`Push image ${dstImageName}`)
|
|
589
|
+
const dstDockerImage = dockerApi.getImage(dstImageName)
|
|
590
|
+
const authConfig = {
|
|
591
|
+
username: dockerCredentials.username,
|
|
592
|
+
password: dockerCredentials.password,
|
|
593
|
+
serveraddress: dockerCredentials.registryUrl,
|
|
594
|
+
}
|
|
595
|
+
const pushStream = await dstDockerImage.push({ authconfig: authConfig, tag: options.imageTag })
|
|
596
|
+
|
|
597
|
+
// Follow push progress & wait until completed
|
|
598
|
+
logger.debug('Following image push stream...')
|
|
599
|
+
await new Promise((resolve, reject) => {
|
|
600
|
+
dockerApi.modem.followProgress(
|
|
601
|
+
pushStream,
|
|
602
|
+
(error: Error | null, result: any[]) => {
|
|
603
|
+
if (error) {
|
|
604
|
+
logger.debug('Failed to push docker image to target repository:', error)
|
|
605
|
+
reject(error)
|
|
606
|
+
} else {
|
|
607
|
+
// result contains an array of all the progress objects
|
|
608
|
+
logger.debug('Succesfully finished pushing docker image')
|
|
609
|
+
resolve(result)
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
() => {
|
|
613
|
+
// console.log('Progress:', obj)
|
|
614
|
+
// { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
|
|
615
|
+
// { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
|
|
427
616
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
} catch (error) {
|
|
438
|
-
if (error instanceof Error) {
|
|
439
|
-
console.error(`Failed to push docker image: ${error.message}`)
|
|
617
|
+
)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
console.log(`Successfully pushed docker image to ${dstImageName}!`)
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (error instanceof Error) {
|
|
623
|
+
console.error(`Failed to push docker image: ${error.message}`)
|
|
624
|
+
}
|
|
625
|
+
exit(1)
|
|
440
626
|
}
|
|
441
|
-
exit(1)
|
|
442
627
|
}
|
|
443
|
-
|
|
628
|
+
)
|
|
444
629
|
|
|
445
|
-
program
|
|
630
|
+
program
|
|
631
|
+
.command('deploy-server')
|
|
446
632
|
.description('deploy a game server image to target environment')
|
|
447
633
|
.argument('gameserver', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
448
634
|
.argument('image-tag', 'docker image tag to deploy (usually the SHA of the build)')
|
|
449
635
|
.requiredOption('-f, --values <path-to-values-file>', 'path to Helm values file to use for this deployment')
|
|
450
|
-
.option(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
636
|
+
.option(
|
|
637
|
+
'--local-chart-path <path-to-chart-directory>',
|
|
638
|
+
'path to local Helm chart directory (to use a chart from local disk)'
|
|
639
|
+
)
|
|
640
|
+
.option(
|
|
641
|
+
'--helm-chart-repo <url>',
|
|
642
|
+
'override the URL of the Helm chart repository (eg, https://charts.metaplay.dev/testing)'
|
|
643
|
+
)
|
|
644
|
+
.option('--helm-chart-version <version>', 'the Helm chart version to use (eg, 0.6.0)')
|
|
454
645
|
.option('--deployment-name', 'Helm deployment name to use', 'gameserver')
|
|
455
646
|
.hook('preAction', async () => {
|
|
456
647
|
await extendCurrentSession()
|
|
457
648
|
})
|
|
458
|
-
.action(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
649
|
+
.action(
|
|
650
|
+
async (
|
|
651
|
+
gameserver: string | undefined,
|
|
652
|
+
imageTag: string | undefined,
|
|
653
|
+
options: {
|
|
654
|
+
organization?: string
|
|
655
|
+
project?: string
|
|
656
|
+
environment?: string
|
|
657
|
+
values: string
|
|
658
|
+
localChartPath?: string
|
|
659
|
+
helmChartRepo?: string
|
|
660
|
+
helmChartVersion?: string
|
|
661
|
+
deploymentName?: string
|
|
662
|
+
}
|
|
663
|
+
) => {
|
|
664
|
+
try {
|
|
665
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
463
666
|
|
|
464
|
-
|
|
465
|
-
const envInfo = await targetEnv.getEnvironmentDetails()
|
|
667
|
+
console.log(`Deploying server to ${gameserver} with image tag ${imageTag}...`)
|
|
466
668
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
// \todo validate that imageTag is just the version part (i.e, contains no ':')
|
|
669
|
+
// Fetch target environment details
|
|
670
|
+
const envInfo = await targetEnv.getEnvironmentDetails()
|
|
471
671
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// Resolve information about docker image
|
|
480
|
-
// const dockerApi = new Docker({
|
|
481
|
-
// host: dockerCredentials.registryUrl,
|
|
482
|
-
// port: 443,
|
|
483
|
-
// protocol: 'https',
|
|
484
|
-
// username: dockerCredentials.username,
|
|
485
|
-
// headers: {
|
|
486
|
-
// Authorization: `Bearer ${dockerCredentials.password}`,
|
|
487
|
-
// Host: dockerCredentials.registryUrl.replace('https://', ''),
|
|
488
|
-
// }
|
|
489
|
-
// })
|
|
490
|
-
const dockerApi = new Docker()
|
|
491
|
-
const dockerRepo = envInfo.deployment.ecr_repo
|
|
492
|
-
const imageName = `${dockerRepo}:${imageTag}`
|
|
493
|
-
logger.debug(`Fetch docker image labels for ${imageName}`)
|
|
494
|
-
let imageLabels
|
|
495
|
-
try {
|
|
496
|
-
const localDockerImage = dockerApi.getImage(imageName)
|
|
497
|
-
imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
|
|
498
|
-
} catch (err) {
|
|
499
|
-
logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${err}`)
|
|
500
|
-
}
|
|
672
|
+
if (!imageTag) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
'Must specify a valid docker image tag as the image-tag argument, usually the SHA of the build'
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
// \todo validate that imageTag is just the version part (i.e, contains no ':')
|
|
501
678
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
console.log(`Image ${imageName} not found locally -- pulling docker image from target environment registry...`)
|
|
507
|
-
const authConfig = { username: dockerCredentials.username, password: dockerCredentials.password, serveraddress: dockerCredentials.registryUrl }
|
|
508
|
-
const pullStream = (await dockerApi.pull(imageName, { authconfig: authConfig }))
|
|
509
|
-
|
|
510
|
-
// Follow pull progress & wait until completed
|
|
511
|
-
logger.debug('Following image pull stream...')
|
|
512
|
-
await new Promise((resolve, reject) => {
|
|
513
|
-
dockerApi.modem.followProgress(
|
|
514
|
-
pullStream,
|
|
515
|
-
(error: Error | null, result: any[]) => {
|
|
516
|
-
if (error) {
|
|
517
|
-
logger.debug('Failed to pull image:', error)
|
|
518
|
-
reject(error)
|
|
519
|
-
} else {
|
|
520
|
-
// result contains an array of all the progress objects
|
|
521
|
-
logger.debug('Succesfully finished pulling image')
|
|
522
|
-
resolve(result)
|
|
523
|
-
}
|
|
524
|
-
},
|
|
525
|
-
(obj: any) => {
|
|
526
|
-
// console.log('Progress:', obj)
|
|
527
|
-
// { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
|
|
528
|
-
// { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
|
|
529
|
-
})
|
|
530
|
-
})
|
|
531
|
-
} catch (err) {
|
|
532
|
-
throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${err}`)
|
|
679
|
+
if (!options.deploymentName) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`Invalid Helm deployment name '${options.deploymentName}'; specify one with --deployment-name or use the default`
|
|
682
|
+
)
|
|
533
683
|
}
|
|
534
684
|
|
|
535
|
-
//
|
|
685
|
+
// Fetch Docker credentials for target environment registry
|
|
686
|
+
const dockerCredentials = await targetEnv.getDockerCredentials()
|
|
687
|
+
|
|
688
|
+
// Resolve information about docker image
|
|
689
|
+
// const dockerApi = new Docker({
|
|
690
|
+
// host: dockerCredentials.registryUrl,
|
|
691
|
+
// port: 443,
|
|
692
|
+
// protocol: 'https',
|
|
693
|
+
// username: dockerCredentials.username,
|
|
694
|
+
// headers: {
|
|
695
|
+
// Authorization: `Bearer ${dockerCredentials.password}`,
|
|
696
|
+
// Host: dockerCredentials.registryUrl.replace('https://', ''),
|
|
697
|
+
// }
|
|
698
|
+
// })
|
|
699
|
+
const dockerApi = new Docker()
|
|
700
|
+
const dockerRepo = envInfo.deployment.ecr_repo
|
|
701
|
+
const imageName = `${dockerRepo}:${imageTag}`
|
|
702
|
+
logger.debug(`Fetch docker image labels for ${imageName}`)
|
|
703
|
+
let imageLabels
|
|
536
704
|
try {
|
|
537
|
-
logger.debug('Get docker labels again (with pulled image)')
|
|
538
705
|
const localDockerImage = dockerApi.getImage(imageName)
|
|
539
706
|
imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
|
|
540
|
-
} catch (
|
|
541
|
-
|
|
707
|
+
} catch (error) {
|
|
708
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
709
|
+
logger.debug(`Failed to resolve docker image metadata from local image ${imageName}: ${errMessage}`)
|
|
542
710
|
}
|
|
543
|
-
}
|
|
544
711
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
712
|
+
// If wasn't able to resolve the images, pull image from the target environment registry & resolve labels
|
|
713
|
+
if (imageLabels === undefined) {
|
|
714
|
+
// Pull the image from remote registry
|
|
715
|
+
try {
|
|
716
|
+
console.log(
|
|
717
|
+
`Image ${imageName} not found locally -- pulling docker image from target environment registry...`
|
|
718
|
+
)
|
|
719
|
+
const authConfig = {
|
|
720
|
+
username: dockerCredentials.username,
|
|
721
|
+
password: dockerCredentials.password,
|
|
722
|
+
serveraddress: dockerCredentials.registryUrl,
|
|
723
|
+
}
|
|
724
|
+
const pullStream = await dockerApi.pull(imageName, { authconfig: authConfig })
|
|
725
|
+
|
|
726
|
+
// Follow pull progress & wait until completed
|
|
727
|
+
logger.debug('Following image pull stream...')
|
|
728
|
+
await new Promise((resolve, reject) => {
|
|
729
|
+
dockerApi.modem.followProgress(
|
|
730
|
+
pullStream,
|
|
731
|
+
(error: Error | null, result: any[]) => {
|
|
732
|
+
if (error) {
|
|
733
|
+
logger.debug('Failed to pull image:', error)
|
|
734
|
+
reject(error)
|
|
735
|
+
} else {
|
|
736
|
+
// result contains an array of all the progress objects
|
|
737
|
+
logger.debug('Succesfully finished pulling image')
|
|
738
|
+
resolve(result)
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
742
|
+
(obj: any) => {
|
|
743
|
+
// console.log('Progress:', obj)
|
|
744
|
+
// { status: 'Preparing', progressDetail: {}, id: '82730adcaeb0' }
|
|
745
|
+
// { status: 'Layer already exists', progressDetail: {}, id: '7cd701fff13a' }
|
|
746
|
+
}
|
|
747
|
+
)
|
|
748
|
+
})
|
|
749
|
+
} catch (error) {
|
|
750
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
751
|
+
throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${errMessage}`)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Resolve the labels
|
|
755
|
+
try {
|
|
756
|
+
logger.debug('Get docker labels again (with pulled image)')
|
|
757
|
+
const localDockerImage = dockerApi.getImage(imageName)
|
|
758
|
+
imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
761
|
+
throw new Error(`Failed to resolve docker image metadata from pulled image ${imageName}: ${errMessage}`)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
564
764
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
765
|
+
// Try to resolve SDK version and Helm repo and chart version from the docker labels
|
|
766
|
+
// \note These only exist for SDK R28 and newer images
|
|
767
|
+
logger.debug('Docker image labels: ', JSON.stringify(imageLabels))
|
|
768
|
+
const sdkVersion = imageLabels['io.metaplay.sdk_version']
|
|
769
|
+
|
|
770
|
+
// Resolve helmChartRepo, in order of precedence:
|
|
771
|
+
// - Specified in the cli options
|
|
772
|
+
// - Specified in the docker image label
|
|
773
|
+
// - Fall back to 'https://charts.metaplay.dev'
|
|
774
|
+
const helmChartRepo = removeTrailingSlash(
|
|
775
|
+
options.helmChartRepo ?? imageLabels['io.metaplay.default_helm_repo'] ?? 'https://charts.metaplay.dev'
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
// Resolve helmChartVersion, in order of precedence:
|
|
779
|
+
// - Specified in the cli options
|
|
780
|
+
// - Specified in the docker image label
|
|
781
|
+
// - Unknown, error out!
|
|
782
|
+
const helmChartVersionSpec = options.helmChartVersion ?? imageLabels['io.metaplay.default_server_chart_version']
|
|
783
|
+
if (!options.helmChartVersion) {
|
|
784
|
+
console.warn('You should specify the Helm chart version with --helm-chart-version=<version>!')
|
|
785
|
+
}
|
|
786
|
+
if (!helmChartVersionSpec) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
'No Helm chart version defined. With pre-R28 SDK versions, you must specify the Helm chart version explicitly with --helm-chart-version=<version>.'
|
|
789
|
+
)
|
|
790
|
+
}
|
|
569
791
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
792
|
+
// Parse the Helm chart version spec into a semver.Range, or null if 'latest' is specified
|
|
793
|
+
let helmChartRange: semver.Range | null = null
|
|
794
|
+
if (helmChartVersionSpec !== 'latest-prerelease') {
|
|
795
|
+
try {
|
|
796
|
+
helmChartRange = new semver.Range(helmChartVersionSpec)
|
|
797
|
+
} catch (error) {
|
|
798
|
+
throw new Error(`Helm chart version '${helmChartVersionSpec}' is not a valid SemVer range!`)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
578
801
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
|
|
584
|
-
await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
|
|
802
|
+
// A values file is required (at least for now it makes no sense to deploy without one)
|
|
803
|
+
if (!options.values) {
|
|
804
|
+
throw new Error('Path to a Helm values file must be specified with --values')
|
|
805
|
+
}
|
|
585
806
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
807
|
+
// Resolve available metaplay-gameserver Helm chart versions
|
|
808
|
+
const availableHelmChartVersions = await fetchHelmChartVersions(helmChartRepo, 'metaplay-gameserver')
|
|
809
|
+
logger.debug(`Available Helm chart versions: ${availableHelmChartVersions.join(', ')}`)
|
|
810
|
+
|
|
811
|
+
// Resolve the best matching Helm chart version
|
|
812
|
+
const resolvedHelmChartVersion = resolveBestMatchingVersion(availableHelmChartVersions, helmChartRange)
|
|
813
|
+
if (!resolvedHelmChartVersion) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`No Helm chart version found that satisfies '${helmChartVersionSpec}' in repository '${helmChartRepo}'`
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
logger.debug('Resolved Helm chart version: ', resolvedHelmChartVersion)
|
|
819
|
+
|
|
820
|
+
// Fetch kubeconfig and write it to a temporary file
|
|
821
|
+
// \todo allow passing a custom kubeconfig file?
|
|
822
|
+
const kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
|
|
823
|
+
const kubeconfigPath = pathJoin(tmpdir(), randomBytes(20).toString('hex'))
|
|
824
|
+
logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
|
|
825
|
+
await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
|
|
826
|
+
|
|
827
|
+
try {
|
|
828
|
+
// Construct Helm invocation
|
|
829
|
+
const chartNameOrPath = options.localChartPath ?? 'metaplay-gameserver'
|
|
830
|
+
const helmArgs = ['upgrade', '--install', '--wait'] // \note wait for the pods to stabilize -- otherwise status check can read state before any changes to pods are applied
|
|
591
831
|
.concat(['--kubeconfig', kubeconfigPath])
|
|
592
832
|
.concat(['-n', envInfo.deployment.kubernetes_namespace])
|
|
593
833
|
.concat(['--values', options.values])
|
|
594
834
|
.concat(['--set-string', `image.tag=${imageTag}`])
|
|
595
835
|
.concat(sdkVersion ? ['--set-string', `sdk.version=${sdkVersion}`] : [])
|
|
596
|
-
.concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version',
|
|
836
|
+
.concat(!options.localChartPath ? ['--repo', helmChartRepo, '--version', resolvedHelmChartVersion] : [])
|
|
597
837
|
.concat([options.deploymentName])
|
|
598
838
|
.concat([chartNameOrPath])
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
839
|
+
logger.info(`Execute: helm ${helmArgs.join(' ')}`)
|
|
840
|
+
|
|
841
|
+
// Execute Helm
|
|
842
|
+
let helmResult
|
|
843
|
+
try {
|
|
844
|
+
helmResult = await executeCommand('helm', helmArgs)
|
|
845
|
+
// \todo output something from Helm result?
|
|
846
|
+
} catch (error) {
|
|
847
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Failed to execute 'helm': ${errMessage}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Throw on Helm non-success exit code
|
|
854
|
+
if (helmResult.exitCode !== 0) {
|
|
855
|
+
throw new Error(`Helm deploy failed with exit code ${helmResult.exitCode}: ${String(helmResult.stderr)}`)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const testingRepoSuffix =
|
|
859
|
+
!options.localChartPath && helmChartRepo !== 'https://charts.metaplay.dev'
|
|
860
|
+
? ` from repo ${helmChartRepo}`
|
|
861
|
+
: ''
|
|
862
|
+
console.log(
|
|
863
|
+
`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${resolvedHelmChartVersion}${testingRepoSuffix}!`
|
|
864
|
+
)
|
|
865
|
+
} finally {
|
|
866
|
+
// Remove temporary kubeconfig file
|
|
867
|
+
await unlink(kubeconfigPath)
|
|
608
868
|
}
|
|
609
869
|
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
870
|
+
// Check the status of the game server deployment
|
|
871
|
+
try {
|
|
872
|
+
const kubeconfig = new KubeConfig()
|
|
873
|
+
kubeconfig.loadFromString(kubeconfigPayload)
|
|
874
|
+
|
|
875
|
+
console.log('Validating game server deployment...')
|
|
876
|
+
const exitCode = await checkGameServerDeployment(
|
|
877
|
+
envInfo.deployment.kubernetes_namespace,
|
|
878
|
+
kubeconfig,
|
|
879
|
+
imageTag
|
|
880
|
+
)
|
|
881
|
+
exit(exitCode)
|
|
882
|
+
} catch (error) {
|
|
883
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
884
|
+
console.error(`Failed to resolve game server deployment status: ${errMessage}`)
|
|
885
|
+
exit(2)
|
|
613
886
|
}
|
|
614
|
-
|
|
615
|
-
const testingRepoSuffix = (!options.localChartPath && helmChartRepo !== 'https://charts.metaplay.dev') ? ` from repo ${helmChartRepo}` : ''
|
|
616
|
-
console.log(`Game server deployed to ${gameserver} with tag ${imageTag} using chart version ${helmChartVersion}${testingRepoSuffix}!`)
|
|
617
|
-
} finally {
|
|
618
|
-
// Remove temporary kubeconfig file
|
|
619
|
-
await unlink(kubeconfigPath)
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Check the status of the game server deployment
|
|
623
|
-
try {
|
|
624
|
-
const kubeconfig = new KubeConfig()
|
|
625
|
-
kubeconfig.loadFromString(kubeconfigPayload)
|
|
626
|
-
|
|
627
|
-
console.log('Validating game server deployment...')
|
|
628
|
-
const exitCode = await checkGameServerDeployment(envInfo.deployment.kubernetes_namespace, kubeconfig, imageTag)
|
|
629
|
-
exit(exitCode)
|
|
630
887
|
} catch (error) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (error instanceof Error) {
|
|
636
|
-
console.error(`Error deploying game server into target environment: ${error.message}`)
|
|
888
|
+
if (error instanceof Error) {
|
|
889
|
+
console.error(`Error deploying game server into target environment: ${error.message}`)
|
|
890
|
+
}
|
|
891
|
+
exit(1)
|
|
637
892
|
}
|
|
638
|
-
exit(1)
|
|
639
893
|
}
|
|
640
|
-
|
|
894
|
+
)
|
|
641
895
|
|
|
642
|
-
program
|
|
643
|
-
.
|
|
896
|
+
program
|
|
897
|
+
.command('check-server-status')
|
|
898
|
+
.description(
|
|
899
|
+
'check the status of a deployed server and print out information that is helpful in debugging failed deployments'
|
|
900
|
+
)
|
|
644
901
|
.argument('[gameserver]', 'address of gameserver (e.g. metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
645
|
-
.option('-s, --stack-api <stack-api-base-path>', 'explicit stack api (e.g. https://infra.p1.metaplay.io/stackapi/)')
|
|
646
902
|
.hook('preAction', async () => {
|
|
647
903
|
await extendCurrentSession()
|
|
648
904
|
})
|
|
649
|
-
.action(
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
|
|
905
|
+
.action(
|
|
906
|
+
async (
|
|
907
|
+
gameserver: string | undefined,
|
|
908
|
+
options: { organization?: string; project?: string; environment?: string }
|
|
909
|
+
) => {
|
|
910
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
656
911
|
|
|
657
|
-
// Load kubeconfig from file and throw error if validation fails.
|
|
658
|
-
logger.debug('Get kubeconfig')
|
|
659
|
-
const kubeconfig = new KubeConfig()
|
|
660
912
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
kubeconfig.loadFromString(kubeconfigPayload)
|
|
665
|
-
} catch (error) {
|
|
666
|
-
throw new Error(`Failed to load or validate kubeconfig. ${error}`)
|
|
667
|
-
}
|
|
913
|
+
logger.debug('Get environment info')
|
|
914
|
+
const envInfo = await targetEnv.getEnvironmentDetails()
|
|
915
|
+
const kubernetesNamespace = envInfo.deployment.kubernetes_namespace
|
|
668
916
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
917
|
+
// Load kubeconfig from file and throw error if validation fails.
|
|
918
|
+
logger.debug('Get kubeconfig')
|
|
919
|
+
const kubeconfig = new KubeConfig()
|
|
920
|
+
try {
|
|
921
|
+
// Initialize kubeconfig with the payload fetched from the cloud
|
|
922
|
+
// \todo allow passing a custom kubeconfig file?
|
|
923
|
+
const kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
|
|
924
|
+
kubeconfig.loadFromString(kubeconfigPayload)
|
|
925
|
+
} catch (error) {
|
|
926
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
927
|
+
throw new Error(`Failed to load or validate kubeconfig. ${errMessage}`)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Run the checks and exit with success/failure exitCode depending on result
|
|
931
|
+
console.log(`Validating game server deployment in namespace ${kubernetesNamespace}`)
|
|
932
|
+
// \todo Get requiredImageTag from the Helm chart
|
|
933
|
+
const exitCode = await checkGameServerDeployment(kubernetesNamespace, kubeconfig, /* requiredImageTag: */ null)
|
|
934
|
+
exit(exitCode)
|
|
935
|
+
} catch (error: any) {
|
|
936
|
+
console.error(`Failed to check deployment status: ${error.message}`)
|
|
937
|
+
exit(1)
|
|
938
|
+
}
|
|
677
939
|
}
|
|
678
|
-
|
|
940
|
+
)
|
|
679
941
|
|
|
680
|
-
program
|
|
681
|
-
.
|
|
942
|
+
program
|
|
943
|
+
.command('check-deployment')
|
|
944
|
+
.description(
|
|
945
|
+
'[deprecated] check that a game server was successfully deployed, or print out useful error messages in case of failure'
|
|
946
|
+
)
|
|
682
947
|
.argument('[namespace]', 'kubernetes namespace of the deployment')
|
|
683
948
|
.action(async (namespace: string) => {
|
|
684
|
-
console.error(
|
|
949
|
+
console.error(
|
|
950
|
+
'DEPRECATED! Use the "metaplay-auth check-server-status [gameserver]" command instead! This command will be removed soon.'
|
|
951
|
+
)
|
|
685
952
|
|
|
686
953
|
try {
|
|
687
954
|
if (!namespace) {
|
|
@@ -705,7 +972,8 @@ program.command('check-deployment')
|
|
|
705
972
|
try {
|
|
706
973
|
kubeconfig.loadFromFile(kubeconfigPath)
|
|
707
974
|
} catch (error) {
|
|
708
|
-
|
|
975
|
+
const errMessage = error instanceof Error ? error.message : String(error)
|
|
976
|
+
throw new Error(`Failed to load or validate kubeconfig: ${errMessage}`)
|
|
709
977
|
}
|
|
710
978
|
|
|
711
979
|
// Run the checks and exit with success/failure exitCode depending on result
|
|
@@ -719,19 +987,29 @@ program.command('check-deployment')
|
|
|
719
987
|
}
|
|
720
988
|
})
|
|
721
989
|
|
|
722
|
-
program
|
|
990
|
+
program
|
|
991
|
+
.command('debug-server')
|
|
723
992
|
.description('run an ephemeral debug container against a game server pod running in the cloud')
|
|
724
993
|
.argument('gameserver', 'address of gameserver (e.g., metaplay-idler-develop or idler-develop.p1.metaplay.io)')
|
|
725
994
|
.argument('[pod-name]', 'name of the pod to debug (must be specified if deployment has multiple pods)')
|
|
726
995
|
.hook('preAction', async () => {
|
|
727
996
|
await extendCurrentSession()
|
|
728
997
|
})
|
|
729
|
-
.action(
|
|
730
|
-
|
|
731
|
-
|
|
998
|
+
.action(
|
|
999
|
+
async (
|
|
1000
|
+
gameserver: string,
|
|
1001
|
+
podName: string | undefined,
|
|
1002
|
+
options: { organization?: string; project?: string; environment?: string }
|
|
1003
|
+
) => {
|
|
1004
|
+
// Resolve target environment
|
|
1005
|
+
const targetEnv = await resolveTargetEnvironment(gameserver, options)
|
|
732
1006
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1007
|
+
// Exec 'kubectl debug ...'
|
|
1008
|
+
await debugGameServer(targetEnv, podName)
|
|
1009
|
+
}
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
// Register docker build command
|
|
1013
|
+
registerBuildCommand(program)
|
|
736
1014
|
|
|
737
1015
|
void program.parseAsync()
|