/
main.go
418 lines (354 loc) · 10.8 KB
/
main.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
/*
Wago (Watch, Go)
A general purpose watch / build development tool.
TODO: Catch SIGINT and reset terminal. See:
https://askubuntu.com/questions/171449/shell-does-not-show-typed-in-commands-reset-works-but-what-happened
*/
package main
import (
"crypto/tls"
"flag"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"sync"
"golang.org/x/net/http2"
"github.com/JonahBraun/dog"
"github.com/fsnotify/fsnotify"
)
// VERSION of wago
const VERSION = "1.3.1"
var (
log = dog.NewDog(dog.DEBUG)
verbose = flag.Bool("v", false, "Verbose")
quiet = flag.Bool("q", false, "Quiet, only warnings and errors")
buildCmd = flag.String("cmd", "", "Run command, wait for it to complete.")
daemonCmd = flag.String("daemon", "", "Run command and leave running in the background.")
daemonTimer = flag.Int("timer", 0, "Wait milliseconds after starting daemon, then continue.")
daemonTrigger = flag.String("trigger", "", "Wait for daemon to output this string, then continue.")
exitWait = flag.Int("exitwait", 50, "Max milliseconds a process has after a SIGTERM to exit before a SIGKILL.")
fiddle = flag.Bool("fiddle", false, "CLI fiddle mode! Start a web server, open browser to URL of targetDir/index.html")
postCmd = flag.String("pcmd", "", "Run command after daemon starts. Use this to kick off your test suite.")
recursive = flag.Bool("recursive", true, "Watch directory tree recursively.")
targetDir = flag.String("dir", "", "Directory to watch, defaults to current.")
url = flag.String("url", "", "Open browser to this URL after all commands are successful.")
watchRegex = flag.String("watch", `/[^\.][^/]*": (CREATE|MODIFY$)`, "React to FS events matching regex. Use -v to see all events.")
ignoreRegex = flag.String("ignore", `\.(git|hg|svn)`, "Ignore directories matching regex.")
httpPort = flag.String("http", "", "Start a HTTP server on this port, e.g. :8420")
http2Port = flag.String("h2", "", "Start a HTTP/TLS server on this port, e.g. :8421")
keyFile = flag.String("key", "", "X.509 key file for HTTP2/TLS, eg: key.pem")
certFile = flag.String("cert", "", "X.509 cert file for HTTP2/TLS, eg: cert.pem")
webRoot = flag.String("webroot", "", "Local directory to use as root for web server, defaults to -dir.")
shell = flag.String("shell", "", "Shell to interpret commands, defaults to $SHELL, fallback to /bin/sh")
subStdin chan *Cmd
unsubStdin chan *Cmd
)
// Watcher abstracts fsnotify.Watcher to facilitate testing with artifical events.
type Watcher struct {
Event chan fsnotify.Event
Error chan error
}
func main() {
// TODO: Consider moving config variables to a config struct for code readability
// and further dependency injection.
configSetup()
// If necessary, start an http or http2 server.
startWebServer()
// Begin managing user input, which will broadcast to subscribed commands.
subStdin, unsubStdin = ManageUserInput(os.Stdin)
// Setup action chain and run main loop.
runChain(newWatcher(), catchSignals())
}
// runChain creates the action chain and manages the main event loop.
func runChain(watcher *Watcher, quit chan struct{}) {
chain := make([]Runnable, 0, 5)
// Construct a chain of Runnables (user specified actions).
if len(*buildCmd) > 0 {
chain = append(chain, NewRunWait(*buildCmd))
}
if len(*daemonCmd) > 0 {
if len(*daemonTrigger) > 0 {
chain = append(chain, NewDaemonTrigger(*daemonCmd, *daemonTrigger))
} else {
chain = append(chain, NewDaemonTimer(*daemonCmd, *daemonTimer))
}
}
if len(*postCmd) > 0 {
chain = append(chain, NewRunWait(*postCmd))
}
if *url != "" {
chain = append(chain, NewBrowser(*url))
}
eventRegex, err := regexp.Compile(*watchRegex)
if err != nil {
log.Fatal("Watch regex compile error:", err)(1)
}
var wg sync.WaitGroup
// Main loop
for {
// Kill signals by closing. This allows it to broadcast to all Runnables and
// to the RunLoop below without the need for subscriber management.
//
// Because it signals by closing, a new channel needs to be created at the start
// of each loop.
kill := make(chan struct{})
// Events will cause the action chain to restart.
// Because we haven't started it yet, drain extra events.
var drain func()
drain = func() {
select {
case ev := <-watcher.Event:
log.Debug("Extra event ignored:", ev.String())
drain()
default:
}
}
drain()
// Launch concurrent file loop. When an event is matched, the kill channel
// is closed. This signals to all active Runnables and the RunLoop below.
go func() {
for {
select {
case ev := <-watcher.Event:
if eventRegex.MatchString(ev.String()) {
log.Info("Matched event:", ev.String())
close(kill)
return
} else {
log.Debug("Ignored event:", ev.String())
}
case err = <-watcher.Error:
log.Fatal("Watcher error:", err)(5)
case <-quit:
close(kill)
return
}
}
}()
RunLoop:
for _, runnable := range chain {
// Start the Runnable, which starts and manages a user defined process.
// Runnables may be running in parallel (a daemon and test suite).
done, dead := runnable(kill)
wg.Add(1)
go func() {
// Wait for the Runnable (process) to exit completely.
<-dead
wg.Done()
}()
// Wait for either an event to be received (<-kill) or for the Runnable to
// signal done. If done is successful, the next Runnable in the chain is started.
select {
case d := <-done:
if !d {
// Runnable's success metric failed, break out of the chain
break RunLoop
}
case <-kill:
break RunLoop
}
}
// Ensure an event has occured, we may be here because all runnables signalled done.
<-kill
// Ensure all runnables (procs) are dead before restarting the chain.
wg.Wait()
// Check if we should quit.
select {
case <-quit:
log.Debug("Quitting main event/action loop")
return
default:
}
}
}
// catchSignals catches OS signals and broadcasts by closing the returned channel quit.
func catchSignals() chan struct{} {
// quit needs to inform multiple receivers, sig can't do that
quit := make(chan struct{})
sig := make(chan os.Signal, 1)
// TODO add SIGTERM to this (need OS conditional)
signal.Notify(sig, os.Interrupt, os.Kill)
go func() {
<-sig
close(quit)
}()
return quit
}
// newWatcher configures a fsnotify watcher for the user specified path. The returned
// struct contains the two channels corresponding to those from the fsnotify package.
func newWatcher() *Watcher {
// Create the local watcher from fsnotify. See wago_test.go where an artificial
// watcher is used instead.
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
ignore, err := regexp.Compile(*ignoreRegex)
if err != nil {
log.Fatal("Ignore regex compile error:", err)(1)
}
if _, err := os.Stat(*targetDir); err != nil {
log.Fatal("Directory does not exist (path, error):", *targetDir, err)(1)
}
// checkForWatch determines if a folder should be watched or not.
checkForWatch := func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Err("Error reading dir, skipping:", path, err)
return filepath.SkipDir
}
if !info.IsDir() {
return nil
}
if ignore.MatchString(path) {
log.Debug("Ignoring dir:", path)
return filepath.SkipDir
}
log.Debug("Watching dir:", path)
err = watcher.Add(path)
if err != nil {
log.Err("Error watching dir (path, error):", path, err)
}
return nil
}
if *recursive == true {
// errors are handled in checkForWatch
filepath.Walk(*targetDir, checkForWatch)
} else {
err = watcher.Add(*targetDir)
if err != nil {
log.Fatal("Error watching dir (path, error):", *targetDir, err)(1)
}
}
// To facilitate testing (which sends artifical events from a timer),
// we have an abstracted struct Watcher that holds the applicable channels.
// Channels cannot be converted, an extra channel is required.
event := make(chan fsnotify.Event)
go func() {
for {
event <- <-watcher.Events
}
}()
return &Watcher{event, watcher.Errors}
}
// startWebServer starts a local http/2 web server if necessary.
func startWebServer() {
var err error
if *webRoot == "" {
*webRoot = *targetDir
}
if *httpPort != "" {
log.Info("HTTP port", *httpPort)
s := &http.Server{
Addr: *httpPort,
Handler: http.FileServer(http.Dir(*webRoot)),
}
http2.ConfigureServer(s, nil)
go func() {
err := s.ListenAndServe()
if err != nil {
log.Fatal("HTTP server error:", err)(2)
}
}()
}
if *http2Port != "" {
log.Info("HTTP2 & TLS port", *http2Port)
var key, cert []byte
if *keyFile == "" {
key = []byte(x509Key)
cert = []byte(x509Cert)
} else {
key, err = ioutil.ReadFile(*keyFile)
if err != nil {
log.Fatal(err)(15)
}
cert, err = ioutil.ReadFile(*certFile)
if err != nil {
log.Fatal(err)(15)
}
}
tlsPair, err := tls.X509KeyPair(cert, key)
if err != nil {
log.Fatal(err)(15)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsPair},
}
s := &http.Server{
Addr: *http2Port,
Handler: http.FileServer(http.Dir(*webRoot)),
TLSConfig: tlsConfig,
}
http2.ConfigureServer(s, nil)
go func() {
err := s.ListenAndServeTLS("", "")
if err != nil {
log.Fatal("HTTP2/TLS server error:", err)(2)
}
}()
}
}
// configSetup sets config variables from user params.
func configSetup() {
flag.Usage = func() {
fmt.Println("WaGo (Watch, Go) build tool. Version", VERSION)
flag.PrintDefaults()
}
// TODO: this should check for actions
if len(os.Args) < 2 {
flag.Usage()
log.Fatal("You must specify an action")(1)
}
flag.Parse()
if *verbose {
log = dog.NewDog(dog.DEBUG)
} else if *quiet {
log = dog.NewDog(dog.WARN)
} else {
log = dog.NewDog(dog.INFO)
}
if len(*shell) == 0 {
*shell = os.Getenv("SHELL")
if len(*shell) == 0 {
*shell = "/bin/sh"
}
}
log.Debug("Using shell", *shell)
if (len(*daemonTrigger) > 0) && (*daemonTimer > 0) {
log.Fatal("Both daemon trigger and timer specified, use only one")(1)
}
if (len(*daemonTrigger) > 0 || *daemonTimer > 0) && len(*daemonCmd) == 0 {
log.Fatal("Specify a daemon command to use the trigger or timer")(1)
}
if len(*buildCmd) == 0 && len(*daemonCmd) == 0 && !*fiddle &&
len(*postCmd) == 0 && len(*url) == 0 && len(*httpPort) == 0 &&
len(*http2Port) == 0 {
flag.Usage()
log.Fatal("You must specify an action")(1)
}
if *fiddle {
if *httpPort == "" {
*httpPort = ":8420"
}
if *http2Port == "" {
*http2Port = ":8421"
}
if *url == "" {
*url = "http://localhost" + *httpPort + "/"
}
}
if *targetDir == "" {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
targetDir = &cwd
}
if (*keyFile != "" && *certFile == "") || (*certFile != "" && *keyFile == "") {
log.Fatal("Set both key and cert or none to use default.")(1)
}
log.Debug("Target dir:", *targetDir)
}