-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.go
369 lines (299 loc) · 8.66 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
package main
import (
"crypto/sha256"
"fmt"
"io/ioutil"
"os"
"os/exec"
pathlib "path"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
"runon/shell_quote"
)
const helpMsg = `usage: <host> cmd ...`
// Config stores per-project hooks and configuration
type Config struct {
ProjectHash string `yaml:"project-hash,omitempty"`
Ignore []string `yaml:"ignore,omitempty"`
OnChange []string `yaml:"on change,omitempty"`
}
// @TODO CLI / flags/args for passing -S - also check if socket exists and offer an option accordingly if it doesnt
// @TODO when on-changed commands fail, re-run them next time even if no files changed?
// @TODO cleanup on panic
// @TODO info-level logging gives timings for profiling (when rsync is finished etc)
// @TODO support multiple runon files you can pick from, autocomplete according to a pattern, e.g.: `.runon.windows.yaml`
var master *exec.Cmd
var (
remoteShellCmd = "bash -lc"
)
func init() {
log.SetOutput(os.Stderr)
log.SetLevel(log.InfoLevel)
}
func parseYml(configPath string) (config *Config, errRet error) {
var err error
// check if file exists
_, err = os.Stat(configPath)
if err != nil {
return nil, err // file doesnt exist or other error
}
// parse config
config = &Config{}
rawConfig, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(rawConfig, &config)
if err != nil {
return nil, err
}
return
}
func sshCommand(socketPath string, host string, stdoutToStderr bool, remoteProjectPath string, remoteCmd string) error {
cmdArgs := []string{
"-S",
socketPath,
host,
}
isInteractiveTerminal := false
if remoteCmd == "" {
isInteractiveTerminal = true
}
if !isInteractiveTerminal {
cmdArgs = append(cmdArgs,
"--",
fmt.Sprintf(
"%s %s", remoteShellCmd, shell_quote.ShBackslashQuote(
fmt.Sprintf("cd %s && %s", remoteProjectPath, remoteCmd),
),
),
)
}
cmd := exec.Command("ssh", cmdArgs...)
// @TODO run this command in a shell this probably solves the $TERM issues
log.Debugf("%v", cmd.Args)
cmd.Stdin = os.Stdin
if stdoutToStderr {
cmd.Stdout = os.Stderr
} else {
cmd.Stdout = os.Stdout
}
cmd.Stderr = os.Stderr
err := cmd.Run()
if !isInteractiveTerminal && err != nil {
log.Errorf("remote command failed: \"%v\"\n", remoteCmd)
return err
}
return nil
}
// RunMasterOnly Runs a master daemon for a given host without running commands
func RunMasterOnly(host string) {
socketPath := AssembleDefaultSocketPath(host)
master := NewControlMaster(socketPath, host)
if master != nil {
defer master.Cleanup()
log.Infof("control-master starting up, listening on: \"%s\"\n", master.SocketPath)
// wait for the master process to close
if err := master.Cmd.Wait(); err != nil {
log.Error(err)
os.Exit(255)
}
os.Exit(0)
} else {
log.Errorf("master-control already listening on \"%s\"", socketPath)
}
}
func assemblePaths(config *Config) (remoteProjectPath string, projectPathHashVal string) {
var projectPathHash string
if config.ProjectHash == "" {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
hostname, err := os.Hostname()
if err != nil {
panic(err)
}
projectPathHashVal = hostname + ":" + cwd
log.Debugf("project path hashval: %s", projectPathHashVal)
projectPathHash = fmt.Sprintf("%04X", sha256.Sum256([]byte(projectPathHashVal)))[:16] // @NOTE breaking any security guarantee of sha256
} else {
projectPathHash = config.ProjectHash
}
remoteProjectPath = fmt.Sprintf("~/.runon/%s", projectPathHash)
return remoteProjectPath, projectPathHashVal
}
// Run executes a list of commands on a given host
func Run(cmd *cobra.Command, host string, cmdArgs []string) {
var err error
copyBack := cmd.Flag("copy-back").Value.String()
// parsePotential config
config, err := parseYml("./.runon.yml")
if err != nil && !os.IsNotExist(err) { // in case we found a file and an error occured during parsing
panic(err)
}
remoteProjectPath, projectPathHashVal := assemblePaths(config)
log.Debugf("remote project path: %s", remoteProjectPath)
socketPath := AssembleDefaultSocketPath(host)
master := NewControlMaster(socketPath, host)
defer master.Cleanup()
target := fmt.Sprintf("%s:%s", host, remoteProjectPath)
var rsyncStdout string
var rsync func(retry int)
rsync = func(retry int) { // rsync
ignoreList := []string{}
if config != nil && config.Ignore != nil {
ignoreList = config.Ignore
}
// assemble filterList from ignoreList
filterList := []string{}
for _, v := range ignoreList {
filterList = append(filterList, fmt.Sprintf("--exclude=%s", v))
}
// call rsync
rsyncArgs := append(append(
[]string{
"-ar",
"-i", // print status to stdout
},
filterList...,
),
[]string{
"-e", fmt.Sprintf("ssh -o ControlPath=%s", socketPath), // use ssh
".", // source
target, // target
}...,
)
log.Debugf("rsync arguments: %v", rsyncArgs)
var errCheck *ExecError
rsyncStdout, _, errCheck = CheckExec("rsync", rsyncArgs...)
log.Debugf("rsync output: \"%v\"", rsyncStdout)
if errCheck != nil {
// check if it's an error we can handle
if retry < 1 &&
strings.Contains(errCheck.stderr, "rsync:") &&
strings.Contains(errCheck.stderr, "mkdir") &&
strings.Contains(errCheck.stderr, "No such file or directory") {
err = sshCommand(socketPath, host, true, "~", "mkdir -p \"$PWD/.runon\"")
if err != nil {
log.Fatal(err)
}
rsync(1)
return
} else {
panic(errCheck)
}
}
if strings.HasPrefix(rsyncStdout, "cd+++++++++ ./") { // remoteProjectPath was created for the first time
log.Info("first time creating remote-project")
err := sshCommand(socketPath, host, true, remoteProjectPath, fmt.Sprintf("echo \"$(pwd)\t%s\" >> ../projects", projectPathHashVal))
if err != nil {
log.Fatal(err)
}
}
}
rsync(0)
// possibly run the on-changed commands on host
if len(rsyncStdout) > 0 { // some files were changed
// assemble onChangeList
onChangeList := []string{}
if config != nil && config.Ignore != nil {
onChangeList = config.OnChange
}
// run commands on-change
for _, v := range onChangeList {
log.Infof("running onChange command: \"%v\"\n", v)
err := sshCommand(socketPath, host, true, remoteProjectPath, v)
if err != nil {
log.Fatal(err)
}
}
}
// run passed command
if len(cmdArgs) > 0 {
log.Infof("running command: \"%v\"\n", cmdArgs)
err = sshCommand(socketPath, host, false, remoteProjectPath, strings.Join(cmdArgs, " "))
} else {
log.Infoln("starting interactive terminal")
sshCommand(socketPath, host, false, remoteProjectPath, "") // drop down to shell
}
if err != nil {
log.Error(err)
}
// possibly copy files back to local
if copyBack != "" {
paths := strings.Split(copyBack, ";")
absPaths := []string{}
for _, path := range paths {
absPaths = append(absPaths, pathlib.Join(target, path))
}
{ // rsync
// call rsync
rsyncArgs := append(append(
[]string{
"-ar",
"-i", // print status to stdout
"-e", fmt.Sprintf("ssh -o ControlPath=%s", socketPath), // use ssh
},
absPaths...,
),
[]string{
pathlib.Join(".", "copyback-"+host) + "/", // source
}...,
)
log.Debugf("rsync arguments: %v", rsyncArgs)
rsyncStdout, _, err = CheckExec("rsync", rsyncArgs...)
log.Debugf("rsync output: \"%v\"", rsyncStdout)
if err != nil {
panic(err)
}
}
}
}
// InitConfig initializes a bare config in the cwd
func InitConfig() {
var err error
cwd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
runonPath := pathlib.Join(cwd, ".runon.yml")
if _, err = os.Stat(runonPath); err == nil {
log.Errorf(".runon.yml file already exists: (%s)", runonPath)
return
}
log.Infof("creating .runon.yml file (%s)", runonPath)
err = ioutil.WriteFile(runonPath, []byte(`---
ignore:
- .git
- .svn
- node_modules
- build
# on change:`), 0644)
if err != nil {
log.Fatal(err)
}
}
// Clean cleans the current project that's stored on a given host
func Clean(host string) {
var err error
// parsePotential config
config, err := parseYml("./.runon.yml")
if err != nil && !os.IsNotExist(err) { // in case we found a file and an error occured during parsing
panic(err)
}
remoteProjectPath, projectPathHashVal := assemblePaths(config)
if projectPathHashVal == "" {
//
}
socketPath := AssembleDefaultSocketPath(host)
master := NewControlMaster(socketPath, host)
defer master.Cleanup()
log.Infof("cleaning project (%s)", remoteProjectPath)
err = sshCommand(socketPath, host, true, remoteProjectPath, fmt.Sprintf("cd .. && rm -rf %s", remoteProjectPath))
if err != nil {
log.Fatal(err)
}
}