-
Notifications
You must be signed in to change notification settings - Fork 273
/
Copy pathbase.ts
1109 lines (986 loc) · 36.6 KB
/
base.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright (C) 2018-2023 Garden Technologies, Inc. <[email protected]>
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import Joi from "@hapi/joi"
import chalk from "chalk"
import dedent from "dedent"
import stripAnsi from "strip-ansi"
import { flatMap, fromPairs, mapValues, pickBy, size } from "lodash"
import {
PrimitiveMap,
createSchema,
joi,
joiArray,
joiIdentifierMap,
joiStringMap,
joiVariables,
} from "../config/common"
import { RuntimeError, GardenError, InternalError, toGardenError } from "../exceptions"
import { Garden } from "../garden"
import { Log } from "../logger/log-entry"
import { LoggerType, LoggerBase, LoggerConfigBase, eventLogLevel, LogLevel } from "../logger/logger"
import { printFooter } from "../logger/util"
import {
getCloudDistributionName,
getCloudLogSectionName,
getDurationMsec,
getPackageVersion,
userPrompt,
} from "../util/util"
import { renderOptions, renderCommands, renderArguments, cliStyles, optionsWithAliasValues } from "../cli/helpers"
import { GlobalOptions, ParameterValues, ParameterObject, globalOptions } from "../cli/params"
import { GardenCli } from "../cli/cli"
import { CommandLine } from "../cli/command-line"
import { SolveResult } from "../graph/solver"
import { waitForOutputFlush } from "../process"
import { BufferedEventStream } from "../cloud/buffered-event-stream"
import { CommandInfo } from "../plugin-context"
import type { GardenServer } from "../server/server"
import { CloudSession } from "../cloud/api"
import {
DeployState,
ForwardablePort,
ServiceIngress,
deployStates,
forwardablePortSchema,
serviceIngressSchema,
} from "../types/service"
import { GraphResultMapWithoutTask, GraphResultWithoutTask, GraphResults } from "../graph/results"
import { splitFirst } from "../util/string"
import { ActionMode } from "../actions/types"
import { AnalyticsHandler } from "../analytics/analytics"
import { withSessionContext } from "../util/open-telemetry/context"
import { wrapActiveSpan } from "../util/open-telemetry/spans"
export interface CommandConstructor {
new (parent?: CommandGroup): Command
}
export interface CommandResult<T = any> {
result?: T
errors?: GardenError[]
exitCode?: number
}
export interface BuiltinArgs {
// The raw unprocessed arguments
"$all"?: string[]
// Everything following -- on the command line
"--"?: string[]
}
export interface CommandParamsBase<
T extends ParameterObject = ParameterObject,
U extends ParameterObject = ParameterObject,
> {
args: ParameterValues<T> & BuiltinArgs
opts: ParameterValues<U & GlobalOptions>
}
export interface PrintHeaderParams<
T extends ParameterObject = ParameterObject,
U extends ParameterObject = ParameterObject,
> extends CommandParamsBase<T, U> {
log: Log
}
export interface PrepareParams<T extends ParameterObject = ParameterObject, U extends ParameterObject = ParameterObject>
extends CommandParamsBase<T, U> {
log: Log
commandLine?: CommandLine
// The ServeCommand or DevCommand when applicable
parentCommand?: Command
}
export interface CommandParams<T extends ParameterObject = ParameterObject, U extends ParameterObject = ParameterObject>
extends PrepareParams<T, U> {
cli?: GardenCli
garden: Garden
}
export interface RunCommandParams<
A extends ParameterObject = ParameterObject,
O extends ParameterObject = ParameterObject,
> extends CommandParams<A, O> {
sessionId: string
/**
* The session ID of the parent serve command (e.g. the 'garden dev' command that started the CLI process and the server)
* if applicable.
* Only defined if running in dev command or WS server.
*/
parentSessionId: string | null
/**
* In certain cases we need to override the log level at the "run command" level. This is because
* we're now re-using Garden instances via the InstanceManager and therefore cannot change the level
* on the instance proper.
*
* Used e.g. by the websocket server to set a high log level for internal commands.
*/
overrideLogLevel?: LogLevel
}
export interface SuggestedCommand {
name: string
description: string
source?: string
gardenCommand?: string
shellCommand?: {
command: string
args: string[]
cwd: string
}
openUrl?: string
icon?: {
name: string
src?: string
}
}
export const suggestedCommandSchema = createSchema({
name: "suggested-command",
keys: () => ({
name: joi.string().required().description("Name of the command"),
description: joi.string().required().description("Short description of what the command does."),
source: joi.string().description("The source of the suggestion, e.g. a plugin name."),
gardenCommand: joi.string().description("A Garden command to run (including arguments)."),
shellCommand: joi
.object()
.keys({
command: joi.string().required().description("The shell command to run (without arguments)."),
args: joi.array().items(joi.string()).required().description("Arguments to pass to the command."),
cwd: joi.string().required().description("Absolute path to run the shell command in."),
})
.description("A shell command to run."),
openUrl: joi.string().description("A URL to open in a browser window."),
icon: joi
.object()
.keys({
name: joi.string().required().description("A string reference (and alt text) for the icon."),
src: joi.string().description("A URI for the image. May be a data URI."),
})
.description("The icon to display next to the command, where applicable (e.g. in dashboard or Garden Desktop)."),
}),
xor: [["gardenCommand", "shellCommand", "openUrl"]],
})
type DataCallback = (data: string) => void
export type CommandArgsType<C extends Command> = C extends Command<infer Args, any> ? Args : never
export type CommandOptionsType<C extends Command> = C extends Command<any, infer Opts> ? Opts : never
export type CommandResultType<C extends Command> = C extends Command<any, any, infer R> ? R : never
export abstract class Command<
A extends ParameterObject = ParameterObject,
O extends ParameterObject = ParameterObject,
R = any,
> {
abstract name: string
abstract help: string
description?: string
aliases?: string[]
allowUndefinedArguments: boolean = false
arguments?: A
options?: O
outputsSchema?: () => Joi.ObjectSchema
cliOnly: boolean = false
hidden: boolean = false
noProject: boolean = false
protected: boolean = false
streamEvents: boolean = false // Set to true to stream events for the command
streamLogEntries: boolean = false // Set to true to stream log entries for the command
isCustom: boolean = false // Used to identify custom commands
isDevCommand: boolean = false // Set to true for internal commands in interactive command-line commands
ignoreOptions: boolean = false // Completely ignore all option flags and pass all arguments directly to the command
enableAnalytics: boolean = true // Set to false to avoid reporting analytics
subscribers: DataCallback[]
terminated: boolean
public server?: GardenServer
// FIXME: The parent command is not set via the constructor but rather needs to be set "manually" after
// the command class has been initialised.
// E.g: const cmd = new Command(); cmd["parent"] = parentCommand.
// This is so that commands that are initialised via arguments can be cloned which is required
// for the websocket server to work properly.
private parent?: CommandGroup
// FIXME: This is a little hack so that we can clone commands that are initialised with
// arbitrary parameters.
// See also comment above on the "parent" property.
constructor(private _params?: any) {
this.subscribers = []
this.terminated = false
const commandName = this.getFullName()
// Make sure arguments and options don't have overlapping key names.
if (this.arguments && this.options) {
for (const key of Object.keys(this.options)) {
if (key in this.arguments) {
throw new InternalError({
message: `Key ${key} is defined in both options and arguments for command ${commandName}`,
})
}
}
}
const args = Object.values(this.arguments || [])
let foundOptional = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
// Make sure arguments don't have default values
if (arg.defaultValue) {
throw new InternalError({
message: `A positional argument cannot have a default value`,
})
}
if (arg.required) {
// Make sure required arguments don't follow optional ones
if (foundOptional) {
throw new InternalError({
message: `A required argument cannot follow an optional one`,
})
}
} else {
foundOptional = true
}
// Make sure only last argument is spread
if (arg.spread && i < args.length - 1) {
throw new InternalError({
message: `Only the last command argument can set spread to true`,
})
}
}
}
/**
* Shorthand helper to call the action method on the given command class.
* Also validates the result against the outputsSchema on the command, if applicable.
*
* @returns The result from the command action
*/
async run(params: RunCommandParams<A, O>): Promise<CommandResult<R>> {
const {
garden: parentGarden,
args,
opts,
cli,
commandLine,
sessionId,
parentCommand,
parentSessionId,
overrideLogLevel,
} = params
return withSessionContext({ sessionId, parentSessionId }, () =>
wrapActiveSpan(this.getFullName(), async () => {
const commandStartTime = new Date()
const server = this.server
let garden = parentGarden
if (parentSessionId) {
// Make an instance clone to override anything that needs to be scoped to a specific command run
// TODO: this could be made more elegant
garden = parentGarden.cloneForCommand(sessionId)
}
const log = overrideLogLevel ? garden.log.createLog({ fixLevel: overrideLogLevel }) : garden.log
let cloudSession: CloudSession | undefined
// Session registration for the `dev` and `serve` commands is handled in the `serve` command's `action` method,
// so we skip registering here to avoid duplication.
//
// Persistent commands other than `dev` and `serve` (i.e. commands that delegate to the `dev` command, like
// `deploy --sync`) are also not registered here, since the `dev` command will have been registered already,
// and the `deploy --sync` command which is subsequently run interactively in the `dev` session will register
// itself (it will have a parent command, so the last condition in this expression will not match).
const skipRegistration =
!["dev", "serve"].includes(this.name) && this.maybePersistent(params) && !params.parentCommand
if (!skipRegistration && garden.cloudApi && garden.projectId && this.streamEvents) {
cloudSession = await garden.cloudApi.registerSession({
parentSessionId: parentSessionId || undefined,
sessionId: garden.sessionId,
projectId: garden.projectId,
commandInfo: garden.commandInfo,
localServerPort: server?.port,
environment: garden.environmentName,
namespace: garden.namespace,
isDevCommand: garden.commandInfo.name === "dev",
})
}
if (cloudSession) {
const distroName = getCloudDistributionName(cloudSession.api.domain)
const userId = (await cloudSession.api.getProfile()).id
const commandResultUrl = cloudSession.api.getCommandResultUrl({
sessionId: garden.sessionId,
projectId: cloudSession.projectId,
shortId: cloudSession.shortId,
}).href
const cloudLog = log.createLog({ name: getCloudLogSectionName(distroName) })
cloudLog.info(`View command results at: ${chalk.cyan(commandResultUrl)}\n`)
}
let analytics: AnalyticsHandler | undefined
if (this.enableAnalytics) {
analytics = await garden.getAnalyticsHandler()
}
analytics?.trackCommand(this.getFullName(), parentSessionId || undefined)
const allOpts = <ParameterValues<GlobalOptions & O>>{
...mapValues(globalOptions, (opt) => opt.defaultValue),
...opts,
}
const commandInfo: CommandInfo = {
name: this.getFullName(),
args,
opts: optionsWithAliasValues(this, allOpts),
}
const cloudEventStream = new BufferedEventStream({
log,
cloudSession,
maxLogLevel: eventLogLevel,
garden,
streamEvents: this.streamEvents,
streamLogEntries: this.streamLogEntries,
})
let result: CommandResult<R>
try {
if (cloudSession && this.streamEvents) {
log.silly(`Connecting Garden instance events to Cloud API`)
garden.events.emit("commandInfo", {
...commandInfo,
environmentName: garden.environmentName,
environmentId: cloudSession.environmentId,
projectName: garden.projectName,
projectId: cloudSession.projectId,
namespaceName: garden.namespace,
namespaceId: cloudSession.namespaceId,
coreVersion: getPackageVersion(),
vcsBranch: garden.vcsInfo.branch,
vcsCommitHash: garden.vcsInfo.commitHash,
vcsOriginUrl: garden.vcsInfo.originUrl,
sessionId: garden.sessionId,
})
}
// Check if the command is protected and ask for confirmation to proceed if production flag is "true".
if (await this.isAllowedToRun(garden, log, allOpts)) {
// Clear the VCS handler's tree cache to make sure we pick up any changed sources.
// FIXME: use file watching to be more surgical here, this is suboptimal
garden.treeCache.invalidateDown(log, ["path"])
log.silly(`Starting command '${this.getFullName()}' action`)
result = await this.action({
garden,
cli,
log,
args,
opts: allOpts,
commandLine,
parentCommand,
})
log.silly(`Completed command '${this.getFullName()}' action successfully`)
} else {
// The command is protected and the user decided to not continue with the execution.
log.info("\nCommand aborted.")
return {}
}
// Track the result of the command run
const allErrors = result.errors || []
analytics?.trackCommandResult(
this.getFullName(),
allErrors,
commandStartTime,
result.exitCode,
parentSessionId || undefined
)
if (allErrors.length > 0) {
garden.events.emit("sessionFailed", {})
} else {
garden.events.emit("sessionCompleted", {})
}
} catch (err) {
analytics?.trackCommandResult(
this.getFullName(),
[toGardenError(err)],
commandStartTime || new Date(),
1,
parentSessionId || undefined
)
garden.events.emit("sessionFailed", {})
throw err
} finally {
if (parentSessionId) {
garden.close()
parentGarden.nestedSessions.delete(sessionId)
}
await cloudEventStream.close()
}
// This is a little trick to do a round trip in the event loop, which may be necessary for event handlers to
// fire, which may be needed to e.g. capture monitors added in event handlers
await waitForOutputFlush()
return result
})
)
}
getFullName(): string {
return !!this.parent ? `${this.parent.getFullName()} ${this.name}` : this.name
}
getPath(): string[] {
return !!this.parent ? [...this.parent.getPath(), this.name] : [this.name]
}
/**
* Returns all paths that this command should match, including all aliases and permutations of those.
*/
getPaths(): string[][] {
if (this.parent) {
const parentPaths = this.parent.getPaths()
if (this.aliases) {
return parentPaths.flatMap((parentPath) => [
[...parentPath, this.name],
...this.aliases!.map((a) => [...parentPath, a]),
])
} else {
return parentPaths.map((parentPath) => [...parentPath, this.name])
}
} else if (this.aliases) {
return [[this.name], ...this.aliases.map((a) => [a])]
} else {
return [[this.name]]
}
}
getTerminalWriterType(_: CommandParamsBase<A, O>): LoggerType {
return "default"
}
describe() {
const { name, help, description, cliOnly } = this
return {
name,
fullName: this.getFullName(),
help,
description: description ? stripAnsi(description) : undefined,
cliOnly,
arguments: describeParameters(this.arguments),
options: describeParameters(this.options),
outputsSchema: this.outputsSchema,
}
}
/**
* Called to check if the command might run persistently, with the given args/opts
*/
maybePersistent(_: PrepareParams<A, O>) {
return false
}
/**
* Called to check if the command can be run in the dev console, with the given args/opts
*/
allowInDevCommand(_: PrepareParams<A, O>) {
return true
}
/**
* Called by the CLI before the command's action is run, but is not called again
* if the command restarts. Useful for commands in watch mode.
*/
async prepare(_: PrepareParams<A, O>): Promise<void> {}
/**
* Called by e.g. the WebSocket server to terminate persistent commands.
*/
terminate() {
this.terminated = true
}
/**
* Subscribe to any data emitted by commands via the .emit() method
*/
subscribe(cb: (data: string) => void) {
this.subscribers.push(cb)
}
/**
* Emit data to all subscribers
*/
emit(log: Log, data: string) {
for (const subscriber of this.subscribers) {
// Ignore any errors here
try {
subscriber(data)
} catch (err) {
log.debug(`Error when calling subscriber on ${this.getFullName()} command: ${err}`)
}
}
}
printHeader(_: PrintHeaderParams<A, O>) {}
/**
* Allow commands to specify what logger to use when executed by the server.
*
* Used e.g. by the logs command to disable logging for server requests since
* the log entries are emitted as events.
*/
getServerLogger(_?: LoggerConfigBase): LoggerBase | void {}
/**
* Helper function for creating a new instance of the command.
* Used e.g. by the server to ensure that each request gets a unique command instance
* so that subscribers are managed properly.
*/
clone(): Command {
// See: https://stackoverflow.com/a/64638986
const clone = new (this.constructor as new (params?: any) => this)(this._params)
if (this.parent) {
clone["parent"] = this.parent
}
return clone
}
// Note: Due to a current TS limitation (apparently covered by https://github.com/Microsoft/TypeScript/issues/7011),
// subclass implementations need to explicitly set the types in the implemented function signature. So for now we
// can't enforce the types of `args` and `opts` automatically at the abstract class level and have to specify
// the types explicitly on the subclassed methods.
abstract action(params: CommandParams<A, O>): Promise<CommandResult<R>>
/**
* Called on all commands and checks if the command is protected.
* If it's a protected command, the environment is "production" and the user hasn't specified the "--yes/-y" option
* it asks for confirmation to proceed.
*
* @param {Garden} garden
* @param {Log} log
* @param {GlobalOptions} opts
* @returns {Promise<Boolean>}
* @memberof Command
*/
async isAllowedToRun(garden: Garden, log: Log, opts: ParameterValues<GlobalOptions>): Promise<Boolean> {
if (!opts.yes && this.protected && garden.production) {
const defaultMessage = chalk.yellow(dedent`
Warning: you are trying to run "garden ${this.getFullName()}" against a production environment ([${
garden.environmentName
}])!
Are you sure you want to continue? (run the command with the "--yes" flag to skip this check).
`)
const answer = await userPrompt({
name: "continue",
message: defaultMessage,
type: "confirm",
default: false,
})
log.info("")
return answer.continue
}
return true
}
renderHelp() {
let out = this.description
? `\n${cliStyles.heading("DESCRIPTION")}\n\n${chalk.dim(this.description.trim())}\n\n`
: ""
out += `${cliStyles.heading("USAGE")}\n garden ${this.getFullName()} `
if (this.arguments) {
out +=
Object.entries(this.arguments)
.map(([name, param]) => cliStyles.usagePositional(name, param.required, param.spread))
.join(" ") + " "
}
out += cliStyles.optionsPlaceholder()
if (this.arguments) {
const table = renderArguments(this.arguments)
out += `\n\n${cliStyles.heading("ARGUMENTS")}\n${table}`
}
if (this.options) {
const table = renderOptions(this.options)
out += `\n\n${cliStyles.heading("OPTIONS")}\n${table}`
}
return out + "\n"
}
}
export abstract class ConsoleCommand<
A extends ParameterObject = {},
O extends ParameterObject = {},
R = any,
> extends Command<A, O, R> {
override isDevCommand = true
}
export abstract class CommandGroup extends Command {
abstract subCommands: CommandConstructor[]
getSubCommands(): Command[] {
return this.subCommands.flatMap((cls) => {
const cmd = new cls()
cmd["parent"] = this
if (cmd instanceof CommandGroup) {
return cmd.getSubCommands()
} else {
return [cmd]
}
})
}
override printHeader() {}
async action() {
return {}
}
override describe() {
const description = super.describe()
const subCommands = this.getSubCommands().map((c) => c.describe())
return {
...description,
subCommands,
}
}
override renderHelp() {
const commands = this.getSubCommands()
return `
${cliStyles.heading("USAGE")}
garden ${this.getFullName()} ${cliStyles.commandPlaceholder()} ${cliStyles.optionsPlaceholder()}
${cliStyles.heading("COMMANDS")}
${renderCommands(commands)}
`
}
}
// fixme: These interfaces and schemas are mostly copied from their original locations. This is to ensure that
// dynamically sized or nested fields don't accidentally get introduced to command results. We should find a neater
// wat to manage all this.
interface BuildResultForExport extends ProcessResultMetadata {
buildLog?: string
fresh?: boolean
outputs?: PrimitiveMap
}
const buildResultForExportSchema = createSchema({
name: "build-result-for-export",
keys: () => ({
buildLog: joi.string().allow("").description("The full log from the build."),
fetched: joi.boolean().description("Set to true if the build was fetched from a remote registry."),
fresh: joi
.boolean()
.description("Set to true if the build was performed, false if it was already built, or fetched from a registry"),
details: joi.object().description("Additional information, specific to the provider."),
}),
})
interface DeployResultForExport extends ProcessResultMetadata {
createdAt?: string
updatedAt?: string
mode?: ActionMode
externalId?: string
externalVersion?: string
forwardablePorts?: ForwardablePort[]
ingresses?: ServiceIngress[]
lastMessage?: string
lastError?: string
outputs?: PrimitiveMap
state: DeployState
}
const deployResultForExportSchema = createSchema({
name: "deploy-result-for-export",
keys: () => ({
createdAt: joi.string().description("When the service was first deployed by the provider."),
updatedAt: joi.string().description("When the service was first deployed by the provider."),
mode: joi.string().default("default").description("The mode the action is deployed in."),
externalId: joi
.string()
.description("The ID used for the service by the provider (if not the same as the service name)."),
externalVersion: joi
.string()
.description("The provider version of the deployed service (if different from the Garden module version."),
forwardablePorts: joiArray(forwardablePortSchema()).description(
"A list of ports that can be forwarded to from the Garden agent by the provider."
),
ingresses: joi
.array()
.items(serviceIngressSchema())
.description("List of currently deployed ingress endpoints for the service."),
lastMessage: joi.string().allow("").description("Latest status message of the service (if any)."),
lastError: joi.string().description("Latest error status message of the service (if any)."),
outputs: joiVariables().description("A map of values output from the deployment."),
runningReplicas: joi.number().description("How many replicas of the service are currently running."),
state: joi
.string()
.valid(...deployStates)
.default("unknown")
.description("The current deployment status of the service."),
version: joi.string().description("The Garden module version of the deployed service."),
}),
})
interface RunResultForExport extends TestResultForExport {}
const runResultForExportSchema = createSchema({
name: "run-result-for-export",
keys: () => ({
success: joi.boolean().required().description("Whether the module was successfully run."),
exitCode: joi.number().integer().description("The exit code of the run (if applicable)."),
startedAt: joi.date().required().description("When the module run was started."),
completedAt: joi.date().required().description("When the module run was completed."),
log: joi.string().allow("").default("").description("The output log from the run."),
}),
allowUnknown: true,
})
interface TestResultForExport extends ProcessResultMetadata {
success: boolean
exitCode?: number
// FIXME: we should avoid native Date objects
startedAt?: Date
completedAt?: Date
log?: string
}
const testResultForExportSchema = createSchema({
name: "test-result-for-export",
keys: () => ({}),
extend: runResultForExportSchema,
})
export type ProcessResultMetadata = {
aborted: boolean
durationMsec?: number | null
success: boolean
error?: string
inputVersion?: string
}
export interface ProcessCommandResult {
aborted: boolean
success: boolean
graphResults: GraphResultMapWithoutTask // TODO: Remove this.
build: { [name: string]: BuildResultForExport }
builds: { [name: string]: BuildResultForExport }
deploy: { [name: string]: DeployResultForExport }
deployments: { [name: string]: DeployResultForExport } // alias for backwards-compatibility
test: { [name: string]: TestResultForExport }
tests: { [name: string]: TestResultForExport }
run: { [name: string]: RunResultForExport }
tasks: { [name: string]: RunResultForExport } // alias for backwards-compatibility
}
export const resultMetadataKeys = () => ({
aborted: joi.boolean().description("Set to true if the action was not attempted, e.g. if a dependency failed."),
durationMsec: joi.number().integer().description("The duration of the action's execution in msec, if applicable."),
success: joi.boolean().required().description("Whether the action was successfully executed."),
error: joi.string().description("An error message, if the action's execution failed."),
inputVersion: joi
.string()
.description(
"The version of the task's inputs, before any resolution or execution happens. For action tasks, this will generally be the unresolved version."
),
version: joi
.string()
.description(
"Alias for `inputVersion`. The version of the task's inputs, before any resolution or execution happens. For action tasks, this will generally be the unresolved version."
),
outputs: joiVariables().description("A map of values output from the action's execution."),
})
export const processCommandResultSchema = createSchema({
name: "process-command-result-keys",
keys: () => ({
aborted: joi.boolean().description("Set to true if the command execution was aborted."),
success: joi.boolean().description("Set to false if the command execution was unsuccessful."),
// Hide this field from the docs, since we're planning to remove it.
graphResults: joi.any().meta({ internal: true }),
build: joiIdentifierMap(buildResultForExportSchema().keys(resultMetadataKeys()))
.description("A map of all executed Builds (or Builds scheduled/attempted) and information about the them.")
.meta({ keyPlaceholder: "<Build name>" }),
builds: joiIdentifierMap(buildResultForExportSchema().keys(resultMetadataKeys()))
.description(
"Alias for `build`. A map of all executed Builds (or Builds scheduled/attempted) and information about the them."
)
.meta({ keyPlaceholder: "<Build name>", deprecated: true }),
deploy: joiIdentifierMap(deployResultForExportSchema().keys(resultMetadataKeys()))
.description("A map of all executed Deploys (or Deployments scheduled/attempted) and the Deploy status.")
.meta({ keyPlaceholder: "<Deploy name>" }),
deployments: joiIdentifierMap(deployResultForExportSchema().keys(resultMetadataKeys()))
.description(
"Alias for `deploys`. A map of all executed Deploys (or Deployments scheduled/attempted) and the Deploy status."
)
.meta({ keyPlaceholder: "<Deploy name>", deprecated: true }),
test: joiStringMap(testResultForExportSchema())
.description("A map of all Tests that were executed (or scheduled/attempted) and the Test results.")
.meta({ keyPlaceholder: "<Test name>" }),
tests: joiStringMap(testResultForExportSchema())
.description(
"Alias for `test`. A map of all Tests that were executed (or scheduled/attempted) and the Test results."
)
.meta({ keyPlaceholder: "<Test name>", deprecated: true }),
run: joiStringMap(runResultForExportSchema())
.description("A map of all Runs that were executed (or scheduled/attempted) and the Run results.")
.meta({ keyPlaceholder: "<Run name>" }),
tasks: joiStringMap(runResultForExportSchema())
.description(
"Alias for `runs`. A map of all Runs that were executed (or scheduled/attempted) and the Run results."
)
.meta({ keyPlaceholder: "<Run name>", deprecated: true }),
}),
})
/**
* Extracts structured results for builds, deploys or tests from TaskGraph results, suitable for command output.
*/
function prepareProcessResults(taskType: string, graphResults: GraphResults) {
const resultsForType = Object.entries(graphResults.filterForGraphResult()).filter(
([name, _]) => name.split(".")[0] === taskType
)
return fromPairs(
resultsForType.map(([name, graphResult]) => {
return [splitFirst(name, ".")[1], prepareProcessResult(taskType, graphResult)]
})
)
}
function prepareProcessResult(taskType: string, res: GraphResultWithoutTask | null) {
if (!res) {
return {
aborted: true,
success: false,
}
}
if (taskType === "build") {
return prepareBuildResult(res)
}
if (taskType === "deploy") {
return prepareDeployResult(res)
}
if (taskType === "test") {
return prepareTestResult(res)
}
if (taskType === "run") {
return prepareRunResult(res)
}
return {
...(res?.outputs || {}),
aborted: !res,
durationMsec: res?.startedAt && res?.completedAt && getDurationMsec(res?.startedAt, res?.completedAt),
error: res?.error?.message,
success: !!res && !res.error,
inputVersion: res?.inputVersion,
}
}
function prepareBuildResult(graphResult: GraphResultWithoutTask): BuildResultForExport & ProcessResultMetadata {
const common = {
...commonResultFields(graphResult),
outputs: graphResult.outputs,
}
const buildResult = graphResult.result?.detail
if (buildResult) {
return {
...common,
buildLog: buildResult && buildResult.buildLog,
fresh: buildResult && buildResult.fresh,
}
} else {
return common
}
}
function prepareDeployResult(graphResult: GraphResultWithoutTask): DeployResultForExport & ProcessResultMetadata {
const common = {
...commonResultFields(graphResult),
outputs: graphResult.outputs,
state: "unknown" as DeployState,
}
const deployResult = graphResult.result
if (deployResult) {
const {
createdAt,
updatedAt,
externalVersion,
mode,
state,
externalId,
forwardablePorts,
ingresses,
lastMessage,
lastError,
} = deployResult.detail
return {
...common,
createdAt,
updatedAt,
mode,
state,
externalId,
externalVersion,
forwardablePorts,
ingresses,
lastMessage,
lastError,
}
} else {
return common
}
}
function prepareTestResult(graphResult: GraphResultWithoutTask): TestResultForExport & ProcessResultMetadata {
const common = commonResultFields(graphResult)
const detail = graphResult.result?.detail
if (detail) {
return {
...common,
exitCode: detail.exitCode,
startedAt: detail.startedAt,
completedAt: detail.completedAt,
log: detail.log,
}
} else {
return common
}
}
function prepareRunResult(graphResult: GraphResultWithoutTask): RunResultForExport & ProcessResultMetadata {
const common = commonResultFields(graphResult)
const detail = graphResult.result?.detail
if (detail) {
return {
...common,
exitCode: detail.exitCode,
startedAt: detail.startedAt,
completedAt: detail.completedAt,