/
gencmd.go
1520 lines (1388 loc) · 48.3 KB
/
gencmd.go
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 2020 The play-with-go.dev Authors. All rights reserved. Use of
// this source code is governed by a BSD-style license that can be found in the
// LICENSE file.
package main
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime/debug"
"sort"
"strconv"
"strings"
"text/template"
"time"
"cuelang.org/go/cue"
"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/format"
"cuelang.org/go/cue/load"
"cuelang.org/go/cue/parser"
"cuelang.org/go/cue/token"
"cuelang.org/go/encoding/gocode/gocodec"
"github.com/gohugoio/hugo/parser/pageparser"
"github.com/kr/pretty"
"github.com/play-with-go/preguide"
"github.com/play-with-go/preguide/internal/types"
"github.com/play-with-go/preguide/sanitisers"
"golang.org/x/net/html"
"mvdan.cc/sh/v3/syntax"
)
// genCmd defines the gen command of preguide. This is where the main work
// happens. gen operates on guides defined in directories within a working
// directory. Each directory d defines a different guide. A guide is composed
// of markdown files of prose, one per language, that reference directives
// defined in a CUE package located in d. The markdown files and CUE package
// represent the input to the gen command. The gen command is configured by
// CUE-specified configuration that follows the #PrestepServiceConfig schema.
//
// The gen command processes each directory d in turn. gen tries to evaluate a
// CUE package ./d/out to determine whether a guide's steps need to be re-run.
//
// The Go types for the input to the gen command are to be found in
// github.com/play-with-go/preguide/internal/types; they correspond to the
// definitions found in the github.com/play-with-go/preguide CUE package. The
// Go types that represent the output of the gen command are to be found in the
// github.com/play-with-go/preguide Go package; they correspond to the
// definitions found in the github.com/play-with-go/preguide/out CUE package.
//
// TODO a note on Go types vs CUE definitions
//
// Ideally the Go types would be the source of truth for all the CUE
// definitions used by preguide (this comment is attached to genCmd on the
// basis the bulk of the work happens in this command). However, the story in
// converting from Go interface types to CUE definitions is "not yet complete",
// per github.com/cuelang/cue/discussions/462.
//
// Hence for now we adopt the simple approach of dual-maintaining Go types and
// CUE definitions for configuration types
// (github.com/play-with-go/preguide/internal/types.PrestepServiceConfig ->
// github.com/play-with-go/preguide.#PrestepServiceConfig), the input types
// (github.com/play-with-go/preguide/internal/types ->
// github.com/play-with-go/preguide) and the output types
// (github.com/play-with-go/preguide -> github.com/play-with-go/preguide/out).
// Theoretically we could code generate from
// internal/types.PrestepServiceConfig -> #PrestepServiceConfig, however it's
// not really worth it given we are dual-maintaining the rest already.
//
// Given that we cannot automatically extract CUE definitions from Go types,
// there is then a gap when it comes to runtime validation of
// config/input/output CUE. This is explored in
// github.com/cuelang/cue/discussions/463. The crux of the problem is: how do
// we load the CUE definitions that correspond to the data we want to validate,
// given we can't derive those definitions from Go types (because if we could
// we would likely have been able to code generate from a Go source of truth in
// the first place). Our (temporary) answer to this problem is to embed the
// github.com/play-with-go/preguide and github.com/play-with-go/preguide/out
// CUE packages using go-bindata.
type genCmd struct {
*runner
fs *flag.FlagSet
// See the initialisation of the flag fields for comments on their purpose
fDir *string
flagDefaults string
fConfigs []string
fOutput *string
fSkipCache *bool
fImageOverride *string
fCompat *bool
fPullImage *string
fDocker *bool
fRaw *bool
fPackage *string
fDebugCache *bool
fRun *string
fRunArgs []string
fMode mode
// dir is the absolute path of the working directory specified by -dir
// (if specified)
dir string
// config is parse configuration that results from unifying all the provided
// config (which can be multiple CUE inputs)
config preguide.PrestepServiceConfig
// The following is context that current sits on genCmd but
// will likely have to move to a separate context object when
// we start to concurrently process guides
sanitiserHelper *sanitisers.S
stmtPrinter *syntax.Printer
}
type mode string
const (
modeJekyll mode = "jekyll"
modeGitHub mode = "github"
)
func (m *mode) String() string {
if m == nil {
return "nil"
}
return string(*m)
}
func (m *mode) Set(v string) error {
switch mode(v) {
case modeJekyll, modeGitHub:
default:
return fmt.Errorf("unknown mode %q", v)
}
*m = mode(v)
return nil
}
func newGenCmd(r *runner) *genCmd {
res := &genCmd{
runner: r,
sanitiserHelper: sanitisers.NewS(),
stmtPrinter: syntax.NewPrinter(),
fMode: modeJekyll,
}
res.flagDefaults = newFlagSet("preguide gen", func(fs *flag.FlagSet) {
res.fs = fs
fs.Var(stringFlagList{&res.fConfigs}, "config", "CUE-style configuration input; can appear multiple times. See 'cue help inputs'")
res.fDir = fs.String("dir", "", "the directory within which to run preguide")
res.fOutput = fs.String("out", "", "the target directory for generation. If no value is specified it defaults to the input directory")
res.fSkipCache = fs.Bool("skipcache", os.Getenv("PREGUIDE_SKIP_CACHE") == "true", "whether to skip any output cache checking")
res.fImageOverride = fs.String("image", os.Getenv("PREGUIDE_IMAGE_OVERRIDE"), "the image to use instead of the guide-specified image")
res.fCompat = fs.Bool("compat", false, "render old-style PWD code blocks")
res.fPullImage = fs.String("pull", os.Getenv("PREGUIDE_PULL_IMAGE"), "try and docker pull image if missing")
res.fDocker = fs.Bool("docker", false, "internal flag: run prestep requests in a docker container")
res.fRaw = fs.Bool("raw", false, "generate raw output for steps")
res.fPackage = fs.String("package", "", "the CUE package name to use for the generated guide structure file")
res.fDebugCache = fs.Bool("debugcache", false, "write a human-readable time-stamp-named file of the guide cache check to the current directory")
res.fRun = fs.String("run", envOrVal("PREGUIDE_RUN", "."), "regexp that describes which guides within dir to validate and run")
fs.Var(stringFlagList{&res.fRunArgs}, "runargs", "additional arguments to pass to the script that runs for a terminal. Format -run=$terminalName=args...; can appear multiple times")
fs.Var(&res.fMode, "mode", fmt.Sprintf("the output mode. Valid values are: %v, %v", modeJekyll, modeGitHub))
})
return res
}
// envOrVal evaluates environment variable e. If that variable is defined in
// the environment its value is returned, else v is returned.
func envOrVal(e string, v string) string {
ev, ok := os.LookupEnv(e)
if ok {
return ev
}
return v
}
func (g *genCmd) usage() string {
return fmt.Sprintf(`
usage: preguide gen
%s`[1:], g.flagDefaults)
}
func (gc *genCmd) usageErr(format string, args ...interface{}) usageErr {
return usageErr{fmt.Errorf(format, args...), gc}
}
// runGen is the implementation of the gen command.
func (gc *genCmd) run(args []string) error {
var err error
if err := gc.fs.Parse(args); err != nil {
return gc.usageErr("failed to parse flags: %v", err)
}
dir := *gc.fDir
dirArgs := gc.fs.Args()
gotDir := dir != ""
gotArgs := len(dirArgs) > 0
if gotDir && gotArgs {
return gc.usageErr("-dir and args are mutually exclusive")
}
if !gotDir && !gotArgs {
gotDir = true
dir = "."
}
if gotDir {
gc.dir, err = filepath.Abs(*gc.fDir)
check(err, "failed to derive absolute directory from %q: %v", *gc.fDir, err)
}
runRegex, err := regexp.Compile(*gc.fRun)
check(err, "failed to compile -run regex %q: %v", *gc.fRun, err)
if *gc.fCompat && gc.fMode == modeGitHub {
return gc.usageErr("-compat flag is not valid when output mode is %v", modeGitHub)
}
// Fallback to env-supplied config if no values supplied via -config flag
if len(gc.fConfigs) == 0 {
envVals := strings.Split(os.Getenv("PREGUIDE_CONFIG"), ":")
for _, v := range envVals {
v = strings.TrimSpace(v)
if v != "" {
gc.fConfigs = append(gc.fConfigs, v)
}
}
}
gc.runner.schemas, err = preguide.LoadSchemas(&gc.runtime)
check(err, "failed to load schemas: %v", err)
gc.loadConfig()
// Any args to gen are considered directories to walk
var toWalk []string
switch {
case gotArgs:
for _, a := range gc.fs.Args() {
fp, err := filepath.Abs(a)
check(err, "failed to make arg absolute: %v", err)
fi, err := os.Stat(fp)
check(err, "failed to stat arg %v: %v", a, err)
if !fi.IsDir() {
raise("arg %v is not a directory", a)
}
toWalk = append(toWalk, fp)
}
case gotDir:
// Read the source directory and process each guide (directory)
dir, err := filepath.Abs(*gc.fDir)
check(err, "failed to make path %q absolute: %v", *gc.fDir, err)
es, err := ioutil.ReadDir(dir)
check(err, "failed to read directory %v: %v", dir, err)
for _, e := range es {
if !e.IsDir() {
continue
}
// Like cmd/go we skip hidden dirs
if strings.HasPrefix(e.Name(), ".") || strings.HasPrefix(e.Name(), "_") || e.Name() == "testdata" {
continue
}
// Check against -run regexp
if !runRegex.MatchString(e.Name()) {
continue
}
toWalk = append(toWalk, filepath.Join(dir, e.Name()))
}
}
for _, d := range toWalk {
gc.processDir(d, gotArgs)
}
if gotDir {
gc.writeGuideStructures()
}
return nil
}
// loadConfig loads the configuration that drives the gen command. This
// configuration is described by the PrestepServiceConfig type, which is
// maintained as the #PrestepServiceConfig CUE definition.
func (gc *genCmd) loadConfig() {
if len(gc.fConfigs) == 0 {
return
}
// res will hold the config result
var res cue.Value
bis := load.Instances(gc.fConfigs, nil)
for i, bi := range bis {
inst, err := gc.runtime.Build(bi)
check(err, "failed to load config from %v: %v", gc.fConfigs[i], err)
res = res.Unify(inst.Value())
}
res = gc.schemas.PrestepServiceConfig.Unify(res)
err := res.Validate()
check(err, "failed to validate input config: %v", err)
// Now we can extract the config from the CUE
err = gc.codec.Encode(res, &gc.config)
check(err, "failed to decode config from CUE value: %v", err)
// Now validate that we don't have any networks for file protocol endpoints
for ps, conf := range gc.config {
if conf.Endpoint.Scheme == "file" {
if len(conf.Env) > 0 {
raise("prestep %v defined a file scheme endpoint %v but provided additional environment variables [%v]", ps, conf.Endpoint, conf.Env)
}
if len(conf.Networks) > 0 {
raise("prestep %v defined a file scheme endpoint %v but provided networks [%v]", ps, conf.Endpoint, conf.Networks)
}
}
}
}
// processDir processes the guide (CUE package and markdown files) found in
// dir. See the documentation for genCmd for more details. Returns a guide if
// markdown files are found and successfully processed, else nil.
func (gc *genCmd) processDir(dir string, mustContainGuide bool) {
target := *gc.fOutput
if target == "" {
target = dir
}
g := &guide{
dir: dir,
name: filepath.Base(dir),
target: target,
Langs: make(map[types.LangCode]*langSteps),
varMap: make(map[string]string),
}
gc.loadMarkdownFiles(g)
if len(g.mdFiles) == 0 {
if !mustContainGuide {
return
}
raise("%v did not contain a guide", dir)
}
gc.loadAndValidateSteps(g)
// If we are running in -raw mode, then we want to skip checking
// the out CUE package in g.dir. If we are not running in -raw
// mode, we do want to try and load the out CUE package; this is
// in effect like the Go build cache check.
if !*gc.fRaw {
gc.loadOutput(g, false)
}
stepCount := gc.validateStepAndRefDirs(g)
// If we have any steps to run, for each language build a bash file that
// represents the script to run. Then check whether the hash representing
// the contents of the bash file matches the hash in the out CUE package
// (i.e. the result of a previous run of this guide). If the hash matches,
// we don't have anything to do: the inputs are identical and hence (because
// guides should be idempotent) the output would be the same.
if stepCount > 0 {
outputLoadRequired := false
for _, l := range g.langs {
ls := g.Langs[l]
gc.buildBashFile(g, ls)
if !*gc.fSkipCache {
if out := g.outputGuide; out != nil {
if ols := out.Langs[l]; ols != nil {
if ols.Hash == ls.Hash {
// At this stage we know we have a cache hit. That means,
// the input steps are equivalent, in execution terms, to the
// steps in the output schema.
//
// However. There are parameters on the input steps that do
// not affect execution. e.g. on an upload step, the Renderer
// used. Hence we need to copy across fields that represent
// execution output from the output steps onto the input steps.
gc.debugf("cache hit for %v: will not re-run script\n", l)
for sn, ostep := range ols.Steps {
istep := ls.Steps[sn]
istep.setOutputFrom(ostep)
}
// Populate the guide's varMap based on the variables that resulted
// when the script did run. Empty values are fine, we just need
// the environment variable names.
for _, ps := range out.Presteps {
for _, v := range ps.Variables {
g.varMap[v] = ""
}
}
// Now set the guide's Presteps to be that of the output because
// we known they are equivalent in terms of inputs at this stage
// i.e. what presteps will run, the order, the args etc, because
// this check happened as part of the hash check.
g.Presteps = out.Presteps
continue
}
}
}
}
outputLoadRequired = true
gc.runBashFile(g, ls)
}
gc.writeOutPackage(g)
if !*gc.fRaw && (outputLoadRequired || g.outputGuide == nil) {
gc.loadOutput(g, true)
}
}
gc.validateOutRefsDirs(g)
gc.writeGuideOutput(g)
gc.writeLog(g)
gc.guides = append(gc.guides, g)
}
// loadMarkdownFiles loads the markdown files for a guide. Markdown
// files are named according to isMarkdown, e.g en.markdown.
func (gc *genCmd) loadMarkdownFiles(g *guide) {
es, err := ioutil.ReadDir(g.dir)
check(err, "failed to read directory %v: %v", g.dir, err)
for _, e := range es {
if !e.Mode().IsRegular() {
continue
}
path := filepath.Join(g.dir, e.Name())
// We only want non-"hidden" markdown files
if strings.HasPrefix(e.Name(), ".") {
continue
}
ext, ok := isMarkdown(e.Name())
if !ok {
continue
}
lang := strings.TrimSuffix(e.Name(), ext)
if !types.ValidLangCode(lang) {
continue
}
g.mdFiles = append(g.mdFiles, g.buildMarkdownFile(path, types.LangCode(lang), ext))
}
}
// loadAndValidateSteps loads the CUE package for a guide and ensures that
// package is a valid instance of github.com/play-with-go/preguide.#Guide.
// Essentially this step involves loading CUE via the input types defined
// in github.com/play-with-go/preguide/internal/types, and results in g
// being primed with steps, terminals etc that represent a guide.
func (gc *genCmd) loadAndValidateSteps(g *guide) {
conf := &load.Config{
Dir: g.dir,
}
bps := load.Instances([]string{"."}, conf)
gp := bps[0]
if gp.Err != nil {
if _, ok := gp.Err.(*load.NoFilesError); ok {
// absorb this error - we have nothing to do
return
}
check(gp.Err, "failed to load CUE package in %v: %v", g.dir, gp.Err)
}
gi, err := gc.runtime.Build(gp)
check(err, "failed to build %v: %v", gp.ImportPath, err)
g.instance = gi
// gv is the value that represents the guide's CUE package
gv := gi.Value()
// Double-check (because we are not guaranteed that the guide author) has
// enforced this themselves that the package satisfies the #Steps schema
//
// We derive dv here because default values will be available via that
// where required, but will not have source information (which is required
// below)
gv = gv.Unify(gc.schemas.Guide)
err = gv.Validate()
check(err, "%v does not satisfy github.com/play-with-go/preguide.#Guide: %v", gp.ImportPath, err)
var intGuide types.Guide
err = gv.Decode(&intGuide)
check(err, "failed to decode guide: %v", err)
g.Delims = intGuide.Delims
g.Networks = intGuide.Networks
g.Env = intGuide.Env
for _, s := range intGuide.Scenarios {
g.Scenarios = append(g.Scenarios, s)
}
type termPosition struct {
name string
pos token.Pos
}
var termPositions []termPosition
if len(intGuide.Terminals) > 1 {
raise("we only support a single terminal currently")
}
for n := range intGuide.Terminals {
termPositions = append(termPositions, termPosition{
name: n,
pos: structPos(gv.Lookup("Terminals", n)),
})
}
sort.Slice(termPositions, func(i, j int) bool {
return posLessThan(termPositions[i].pos, termPositions[j].pos)
})
for _, tp := range termPositions {
n := tp.name
t := intGuide.Terminals[n]
g.Terminals = append(g.Terminals, t)
}
if len(intGuide.Steps) > 0 {
// We only investigate the presteps if we have any steps
// to run
for _, prestep := range intGuide.Presteps {
ps := guidePrestep{
Package: prestep.Package,
Path: prestep.Path,
Args: prestep.Args,
}
if ps.Package == "" {
raise("Prestep had empty package")
}
if v, ok := gc.seenPrestepPkgs[ps.Package]; ok {
ps.Version = v
} else {
// Resolve and endpoint for the package
conf, ok := gc.config[ps.Package]
if !ok {
raise("no config found for prestep %v", ps.Package)
}
var version string
if conf.Endpoint.Scheme == "file" {
version = "file"
} else {
version = string(gc.doRequest("GET", conf.Endpoint.String()+"?get-version=1", conf))
}
gc.seenPrestepPkgs[ps.Package] = version
ps.Version = version
}
g.Presteps = append(g.Presteps, &ps)
}
}
seenLangs := make(map[types.LangCode]bool)
for stepName, langSteps := range intGuide.Steps {
for _, code := range types.Langs {
v, ok := langSteps[code]
if !ok {
continue
}
seenLangs[code] = true
var s step
switch is := v.(type) {
case *types.Command:
s, err = gc.commandStepFromCommand(is)
check(err, "failed to parse #Command from step %v: %v", stepName, err)
case *types.CommandFile:
if !filepath.IsAbs(is.Path) {
is.Path = filepath.Join(g.dir, is.Path)
}
s, err = gc.commandStepFromCommandFile(is)
check(err, "failed to parse #CommandFile from step %v: %v", stepName, err)
case *types.Upload:
// TODO: when we support non-Unix terminals,
s, err = gc.uploadStepFromUpload(is)
check(err, "failed to parse #Upload from step %v: %v", stepName, err)
case *types.UploadFile:
if !filepath.IsAbs(is.Path) {
is.Path = filepath.Join(g.dir, is.Path)
}
s, err = gc.uploadStepFromUploadFile(is)
check(err, "failed to parse #UploadFile from step %v: %v", stepName, err)
}
// Validate various things about the step
switch s := s.(type) {
case *uploadStep:
// TODO: this check needs to be made platform specific, specific
// to the platform on which it will run (which is determined
// by the terminal scenario). However for now we assume Unix
if !isAbsolute(s.Target) {
raise("target path %q must be absolute", s.Target)
}
}
ls, ok := g.Langs[code]
if !ok {
ls = newLangSteps()
g.Langs[code] = ls
}
ls.Steps[stepName] = s
}
}
for code := range seenLangs {
g.langs = append(g.langs, code)
}
sort.Slice(g.langs, func(i, j int) bool {
return g.langs[i] < g.langs[j]
})
// TODO: error on steps for multiple languages until we support
// github.com/play-with-go/preguide/issues/64
if len(g.langs) > 0 && (len(g.langs) > 2 || g.langs[0] != "en") {
raise("we only support steps for English language guides for now")
}
// TODO: error on multiple scenarios until we support
// github.com/play-with-go/preguide/issues/64
if len(g.Scenarios) > 1 {
raise("we only support a single scenario for now")
}
// Sort according to the order of the steps as declared in the
// guide [filename, offset]
type stepPosition struct {
name string
pos token.Pos
}
var stepPositions []stepPosition
for stepName := range intGuide.Steps {
stepPositions = append(stepPositions, stepPosition{
name: stepName,
pos: structPos(gi.Lookup("Steps", stepName)),
})
}
sort.Slice(stepPositions, func(i, j int) bool {
return posLessThan(stepPositions[i].pos, stepPositions[j].pos)
})
for _, code := range types.Langs {
ls, ok := g.Langs[code]
if !ok {
continue
}
for i, sp := range stepPositions {
s, ok := ls.Steps[sp.name]
if !ok {
raise("lang %v does not define step %v; we don't yet support fallback logic", code, sp.name)
}
ls.steps = append(ls.steps, s)
s.setorder(i)
}
}
}
// loadOutput attempts to load the CUE package found in filepath.Join(g.dir,
// "out"). Each successful run of preguide writes this package for multiple
// reasons. It is a human readable log of the input to the guide steps, the
// commands that were run, the output from those commands etc. But it also acts
// as a "build cache" in that the hash of the various inputs to a guide is also
// written to this package. That way, if a future run of preguide sees the same
// inputs, then the running of the steps can be skipped because the output will
// be the same (guides are meant to be idempotent). This massively speeds up
// the guide writing process.
//
// fail indicates whether we require the load of the out package to succeed
// or not. When we are looking to determine whether to run the steps of a guide
// or not, the out package may not exist (first time that preguide has been
// run for example). However, if we then go on to run the steps (cache miss),
// we then re-load the output in order to validate the outref directives.
func (gc *genCmd) loadOutput(g *guide, fail bool) {
conf := &load.Config{
Dir: g.dir,
}
bps := load.Instances([]string{"./out"}, conf)
gp := bps[0]
if gp.Err != nil {
if fail {
raise("failed to load out CUE package from %v: %v", filepath.Join(g.dir, "out"), gp.Err)
}
// absorb this error - we have nothing to do. We will fix
// out when we write the output later (all being well)
return
}
gi, err := gc.runtime.Build(gp)
if err != nil {
if fail {
raise("failed to build %v: %v", gp.ImportPath, err)
}
return
}
// gv is the value that represents the guide's CUE package
gv := gi.Value()
if err := gv.Unify(gc.schemas.GuideOutput).Validate(); err != nil {
if fail {
raise("failed to validate %v against out schema: %v", gp.ImportPath, err)
}
return
}
var out guide
err = gv.Decode(&out)
check(err, "failed to decode Guide from out value: %v", err)
// Now populate the steps slice for each langSteps
for _, ls := range out.Langs {
for _, step := range ls.Steps {
ls.steps = append(ls.steps, step)
}
sort.Slice(ls.steps, func(i, j int) bool {
return ls.steps[i].order() < ls.steps[j].order()
})
}
g.outputGuide = &out
g.output = gv
g.outinstance = gi
}
// validateStepAndRefDirs ensures that step (e.g. <!-- step: step1 -->) and
// reference (e.g. <!-- ref: world -->) directives in the guide's markdown
// files are valid. That is, they resolve to either a named step of a reference
// directive. Out reference directives (e.g. <!-- outref: cmdoutput -->) are
// checked later (once we are guaranteed the out CUE package exists).
func (gc *genCmd) validateStepAndRefDirs(g *guide) (stepCount int) {
// TODO: verify that we have identical sets of languages when we support
// multiple languages
stepDirCount := 0
for _, mdf := range g.mdFiles {
mdf.frontMatter[guideFrontMatterKey] = g.name
// TODO: improve language steps fallback
ls, ok := g.Langs[mdf.lang]
if !ok {
ls = g.Langs["en"]
}
for _, d := range mdf.directives {
switch d := d.(type) {
case *stepDirective:
stepDirCount++
var found bool
found = ls != nil
if found {
found = ls.Steps != nil
}
if found {
_, found = ls.Steps[d.key]
}
if !found {
raise("unknown step %q referened in file %v", d.key, mdf.path)
}
case *refDirective:
if g.instance == nil {
raise("found a ref directive %v but not CUE instance?", d.key)
}
key := "Defs." + d.key
expr, err := parser.ParseExpr("dummy", key)
check(err, "failed to parse CUE expression from %q: %v", key, err)
v := g.instance.Eval(expr)
if err := v.Err(); err != nil {
raise("failed to evaluate %v: %v", key, err)
}
switch v.Kind() {
case cue.StringKind:
default:
raise("value at %v is of unsupported kind %v", key, v.Kind())
}
d.val = v
case *outrefDirective:
// we don't validate this at this point
default:
panic(fmt.Errorf("don't yet know how to handle %T type", d))
}
}
}
for _, ls := range g.Langs {
stepCount += len(ls.steps)
}
if stepDirCount == 0 && stepCount > 0 {
fmt.Fprintln(os.Stderr, "This guide does not have any directives but does have steps to run.")
fmt.Fprintln(os.Stderr, "Not running those steps because they are not referenced.")
}
return stepCount
}
// validateOutRefsDirs ensures that outref directives (e.g. <!-- outref:
// cmdoutput -->) are valid (step and ref directives were checked earlier).
// This second pass of checking the outrefs specifically is required because
// only at this stage in the processing of a guide can we be guaranteed that
// the out package exists (and hence any outref directives) resolve.
func (gc *genCmd) validateOutRefsDirs(g *guide) {
for _, mdf := range g.mdFiles {
for _, d := range mdf.directives {
switch d := d.(type) {
case *stepDirective:
case *refDirective:
case *outrefDirective:
if g.outinstance == nil {
raise("found an outref directive %v but no out CUE instance?", d.key)
}
key := "Defs." + d.key
expr, err := parser.ParseExpr("dummy", key)
check(err, "failed to parse CUE expression from %q: %v", key, err)
v := g.outinstance.Eval(expr)
if err := v.Err(); err != nil {
raise("failed to evaluate %v: %v", key, err)
}
switch v.Kind() {
case cue.StringKind:
default:
raise("value at %v is of unsupported kind %v", key, v.Kind())
}
d.val = v
// we don't validate this at this point
default:
panic(fmt.Errorf("don't yet know how to handle %T type", d))
}
}
}
}
func (gc *genCmd) writeOutPackage(g *guide) {
enc := gocodec.New(&gc.runner.runtime, nil)
v, err := enc.Decode(g)
check(err, "failed to decode guide to CUE: %v", err)
out, err := valueToFile("out", v)
check(err, "failed to format CUE output: %v", err)
// If we are in raw mode we dump output to stdout. It's more of a debugging mode
if *gc.fRaw {
fmt.Printf("%s", out)
return
}
outDir := filepath.Join(g.dir, "out")
err = os.MkdirAll(outDir, 0777)
check(err, "failed to mkdir %v: %v", outDir, err)
outFilePath := filepath.Join(outDir, "gen_out.cue")
err = ioutil.WriteFile(outFilePath, []byte(out), 0666)
check(err, "failed to write output to %v: %v", outFilePath, err)
}
func (gc *genCmd) runBashFile(g *guide, ls *langSteps) {
// Now run the pre-step if there is one
var toWrite string
for _, ps := range g.Presteps {
// TODO: run the presteps concurrently, but add their args in order
// last prestep's args last etc
var jsonBody []byte
// At this stage we know we have a valid endpoint (because we previously
// checked it via a get-version=1 request)
conf := gc.config[ps.Package]
if conf.Endpoint.Scheme == "file" {
if ps.Args != nil {
raise("prestep %v (path %v) provides arguments [%v]: but prestep is configured with a file endpoint", ps.Package, ps.Path, pretty.Sprint(ps.Args))
}
// Notice this path takes no account of the -docker flag
var err error
path := conf.Endpoint.Path
jsonBody, err = ioutil.ReadFile(path)
check(err, "failed to read file endpoint %v (file %v): %v", conf.Endpoint, path, err)
} else {
u := *conf.Endpoint
u.Path = path.Join(u.Path, ps.Path)
jsonBody = gc.doRequest("POST", u.String(), conf, ps.Args)
}
// TODO: unmarshal jsonBody into a cue.Value, validate against a schema
// for valid prestep results then decode via gocodec into out (below)
var out struct {
Vars []string
}
err := json.Unmarshal(jsonBody, &out)
check(err, "failed to unmarshal output from prestep %v: %v\n%s", ps.Package, err, jsonBody)
for _, v := range out.Vars {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
raise("bad env var received from prestep: %q", v)
}
g.vars = append(g.vars, v)
g.varMap[parts[0]] = parts[1]
ps.Variables = append(ps.Variables, parts[0])
}
}
// If we have any vars we need to first perform an expansion of any
// templates instances {{.ENV}} that appear in the bashScript, and then
// append the result of that substitution. Note this substitution applies
// to both the commands AND the uploads
bashScript := ls.bashScript
if len(g.vars) > 0 {
t := template.New("pre-substitution bashScript")
t.Delims(g.Delims[0], g.Delims[1])
t.Option("missingkey=error")
_, err := t.Parse(bashScript)
check(err, "failed to parse pre-substitution bashScript: %v", err)
var b bytes.Buffer
err = t.Execute(&b, g.varMap)
check(err, "failed to execute pre-substitution bashScript template: %v", err)
bashScript = b.String()
}
// Concatenate the bash script
toWrite += bashScript
td, err := ioutil.TempDir("", fmt.Sprintf("preguide-%v-runner-", g.name))
check(err, "failed to create temp directory for guide %v: %v", g.dir, err)
defer os.RemoveAll(td)
sf := filepath.Join(td, "script.sh")
err = ioutil.WriteFile(sf, []byte(toWrite), 0777)
check(err, "failed to write temporary script to %v: %v", sf, err)
// Whilst we know we have a single terminal, we can use the g.Image() hack
// of finding the image for that single terminal. We we support multiple
// terminals we will need to move away from that hack
image := g.Image()
if *gc.fImageOverride != "" {
image = *gc.fImageOverride
}
// Whilst we know we have a single terminal, we know we can also safely
// address the single terminal's name for the purposes of checking our
// -runargs flag values
term := g.Terminals[0]
var termRunArgs []string
for _, a := range gc.fRunArgs {
var err error
p := term.Name + "="
if !strings.HasPrefix(a, p) {
raise("bad argument passed to -runargs, does not correspond to terminal: %q", a)
}
v := strings.TrimPrefix(a, p)
termRunArgs, err = split(v)
check(err, "failed to split -runargs in words: %v; value was %q", err, v)
}
imageCheck := exec.Command("docker", "inspect", image)
out, err := imageCheck.CombinedOutput()
if err != nil {
if *gc.fPullImage == pullImageMissing {
gc.debugf("failed to find docker image %v (%v); will attempt pull", image, err)
pull := exec.Command("docker", "pull", image)
out, err = pull.CombinedOutput()
check(err, "failed to find docker image %v; also failed to pull it: %v\n%s", image, err, out)
} else {
raise("failed to find docker image %v (%v); either pull this image manually or use -pull=missing", image, err)
}
}
cmd := gc.newDockerRunner(g.Networks,
"--rm",
"-v", fmt.Sprintf("%v:/scripts", td),
"-e", fmt.Sprintf("USER_UID=%v", os.Geteuid()),
"-e", fmt.Sprintf("USER_GID=%v", os.Getegid()),
)
cmd.Args = append(cmd.Args, termRunArgs...)
for _, v := range g.vars {
cmd.Args = append(cmd.Args, "-e", v)
}
for _, v := range g.Env {
cmd.Args = append(cmd.Args, "-e", v)
}
cmd.Args = append(cmd.Args, image, "/scripts/script.sh")
out, err = cmd.CombinedOutput()
check(err, "failed to run [%v]: %v\n%s", strings.Join(cmd.Args, " "), err, out)
gc.debugf("output from [%v]:\n%s", strings.Join(cmd.Args, " "), out)
walk := out
slurp := func(end []byte) (res string) {
endI := bytes.Index(walk, end)
if endI == -1 {
raise("failed to find %q before end of output:\n%s", end, out)
}
res, walk = string(walk[:endI]), walk[endI+len(end):]
return res
}
for _, step := range ls.steps {
switch step := step.(type) {
case *commandStep:
for _, stmt := range step.Stmts {
// TODO: tidy this up
fence := []byte(stmt.outputFence + "\n")
if !bytes.HasPrefix(walk, fence) {
raise("failed to find %q at position %v in output:\n%s", stmt.outputFence, len(out)-len(walk), out)
}