Errors should be handled a bit more nicely now.
The SFTP part could also be done from deploy scripts like:
 scp -i {runner.ssh.identity} \
   -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
   {runner.ssh.user}@{runner.ssh.address%:*} -p {runner.ssh.address#*:}
but that is deemed way too annoying, so we do it from Go.
			
			
This commit is contained in:
		
							parent
							
								
									bd13053773
								
							
						
					
					
						commit
						a09b11256b
					
				| @ -45,7 +45,7 @@ file present in the distribution. | ||||
| 
 | ||||
| All paths are currently relative to the directory you launch *acid* from. | ||||
| 
 | ||||
| The *notify*, *setup*, and *build* scripts are processed using Go's | ||||
| The *notify*, *setup*, *build*, and *deploy* scripts are processed using Go's | ||||
| _text/template_ package, and take an object describing the task, | ||||
| which has the following fields: | ||||
| 
 | ||||
| @ -79,7 +79,8 @@ in *sh*(1) command arguments. | ||||
| 
 | ||||
| Runners | ||||
| ------- | ||||
| Runners receive the following additional environment variables: | ||||
| Runners and deploy scripts receive the following additional | ||||
| environment variables: | ||||
| 
 | ||||
| *ACID_ROOT*::   The same as the base directory for configuration. | ||||
| *ACID_RUNNER*:: The same as *Runner* in script templates. | ||||
|  | ||||
							
								
								
									
										627
									
								
								acid.go
									
									
									
									
									
								
							
							
						
						
									
										627
									
								
								acid.go
									
									
									
									
									
								
							| @ -21,6 +21,7 @@ import ( | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"os/signal" | ||||
| 	"path/filepath" | ||||
| 	"sort" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| @ -30,6 +31,7 @@ import ( | ||||
| 	"time" | ||||
| 
 | ||||
| 	_ "github.com/mattn/go-sqlite3" | ||||
| 	"github.com/pkg/sftp" | ||||
| 	"golang.org/x/crypto/ssh" | ||||
| 	"gopkg.in/yaml.v3" | ||||
| ) | ||||
| @ -96,6 +98,7 @@ func (cf *ConfigProject) AutomaticRunners() (runners []string) { | ||||
| type ConfigProjectRunner struct { | ||||
| 	Setup   string `yaml:"setup"`   // project setup script (SSH) | ||||
| 	Build   string `yaml:"build"`   // project build script (SSH) | ||||
| 	Deploy  string `yaml:"deploy"`  // project deploy script (local) | ||||
| 	Timeout string `yaml:"timeout"` // timeout duration | ||||
| } | ||||
| 
 | ||||
| @ -153,7 +156,8 @@ func giteaNewRequest(ctx context.Context, method, path string, body io.Reader) ( | ||||
| func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) { | ||||
| 	rows, err := gDB.QueryContext(ctx, ` | ||||
| 		SELECT id, owner, repo, hash, runner, | ||||
| 			state, detail, notified, runlog, tasklog FROM task `+query, args...) | ||||
| 			state, detail, notified, | ||||
| 			runlog, tasklog, deploylog FROM task `+query, args...) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| @ -163,7 +167,8 @@ func getTasks(ctx context.Context, query string, args ...any) ([]Task, error) { | ||||
| 	for rows.Next() { | ||||
| 		var t Task | ||||
| 		err := rows.Scan(&t.ID, &t.Owner, &t.Repo, &t.Hash, &t.Runner, | ||||
| 			&t.State, &t.Detail, &t.Notified, &t.RunLog, &t.TaskLog) | ||||
| 			&t.State, &t.Detail, &t.Notified, | ||||
| 			&t.RunLog, &t.TaskLog, &t.DeployLog) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @ -259,6 +264,10 @@ var templateTask = template.Must(template.New("tasks").Parse(` | ||||
| <h2>Task log</h2> | ||||
| <pre>{{printf "%s" .TaskLog}}</pre> | ||||
| {{end}} | ||||
| {{if .DeployLog}} | ||||
| <h2>Deploy log</h2> | ||||
| <pre>{{printf "%s" .DeployLog}}</pre> | ||||
| {{end}} | ||||
| </table> | ||||
| </body> | ||||
| </html> | ||||
| @ -302,9 +311,12 @@ func handleTask(w http.ResponseWriter, r *http.Request) { | ||||
| 		defer rt.RunLog.mu.Unlock() | ||||
| 		rt.TaskLog.mu.Lock() | ||||
| 		defer rt.TaskLog.mu.Unlock() | ||||
| 		rt.DeployLog.mu.Lock() | ||||
| 		defer rt.DeployLog.mu.Unlock() | ||||
| 
 | ||||
| 		task.RunLog = rt.RunLog.b | ||||
| 		task.TaskLog = rt.TaskLog.b | ||||
| 		task.DeployLog = rt.DeployLog.b | ||||
| 	}() | ||||
| 
 | ||||
| 	if err := templateTask.Execute(w, &task); err != nil { | ||||
| @ -816,43 +828,297 @@ func (tw *terminalWriter) Write(p []byte) (written int, err error) { | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | ||||
| // ~~~ Running task ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
| 
 | ||||
| // RunningTask stores all data pertaining to a currently running task. | ||||
| type RunningTask struct { | ||||
| 	DB            Task | ||||
| 	Runner        ConfigRunner | ||||
| 	ProjectRunner ConfigProjectRunner | ||||
| 
 | ||||
| 	RunLog  terminalWriter | ||||
| 	TaskLog terminalWriter | ||||
| 	RunLog    terminalWriter | ||||
| 	TaskLog   terminalWriter | ||||
| 	DeployLog terminalWriter | ||||
| 
 | ||||
| 	wd         string              // acid working directory | ||||
| 	timeout    time.Duration       // time limit on task execution | ||||
| 	signer     ssh.Signer          // SSH private key | ||||
| 	tmplScript *ttemplate.Template // remote build script | ||||
| 	tmplDeploy *ttemplate.Template // local deployment script | ||||
| } | ||||
| 
 | ||||
| func executorUpdate(rt *RunningTask) error { | ||||
| 	rt.RunLog.mu.Lock() | ||||
| 	defer rt.RunLog.mu.Unlock() | ||||
| 	rt.DB.RunLog = bytes.Clone(rt.RunLog.b) | ||||
| 	if rt.DB.RunLog == nil { | ||||
| 		rt.DB.RunLog = []byte{} | ||||
| // newRunningTask prepares a task for running, without executing anything yet. | ||||
| func newRunningTask(task Task) (*RunningTask, error) { | ||||
| 	rt := &RunningTask{DB: task} | ||||
| 
 | ||||
| 	var ok bool | ||||
| 	rt.Runner, ok = gConfig.Runners[rt.DB.Runner] | ||||
| 	if !ok { | ||||
| 		return nil, fmt.Errorf("unknown runner: %s", rt.DB.Runner) | ||||
| 	} | ||||
| 	project, ok := gConfig.Projects[rt.DB.FullName()] | ||||
| 	if !ok { | ||||
| 		return nil, fmt.Errorf("project configuration not found") | ||||
| 	} | ||||
| 	rt.ProjectRunner, ok = project.Runners[rt.DB.Runner] | ||||
| 	if !ok { | ||||
| 		return nil, fmt.Errorf( | ||||
| 			"project not configured for runner %s", rt.DB.Runner) | ||||
| 	} | ||||
| 
 | ||||
| 	rt.TaskLog.mu.Lock() | ||||
| 	defer rt.TaskLog.mu.Unlock() | ||||
| 	rt.DB.TaskLog = bytes.Clone(rt.TaskLog.b) | ||||
| 	if rt.DB.TaskLog == nil { | ||||
| 		rt.DB.TaskLog = []byte{} | ||||
| 	var err error | ||||
| 	if rt.wd, err = os.Getwd(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := gDB.ExecContext(context.Background(), `UPDATE task | ||||
| 		SET state = ?, detail = ?, notified = ?, runlog = ?, tasklog = ? | ||||
| 		WHERE id = ?`, | ||||
| 		rt.DB.State, rt.DB.Detail, rt.DB.Notified, rt.DB.RunLog, rt.DB.TaskLog, | ||||
| 		rt.DB.ID) | ||||
| 	if err == nil { | ||||
| 		notifierAwaken() | ||||
| 	// Lenient or not, some kind of a time limit is desirable. | ||||
| 	rt.timeout = time.Hour | ||||
| 	if rt.ProjectRunner.Timeout != "" { | ||||
| 		rt.timeout, err = time.ParseDuration(rt.ProjectRunner.Timeout) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("timeout: %w", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	privateKey, err := os.ReadFile(rt.Runner.SSH.Identity) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf( | ||||
| 			"cannot read SSH identity for runner %s: %w", rt.DB.Runner, err) | ||||
| 	} | ||||
| 	rt.signer, err = ssh.ParsePrivateKey(privateKey) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf( | ||||
| 			"cannot parse SSH identity for runner %s: %w", rt.DB.Runner, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// The runner setup script may change the working directory, | ||||
| 	// so do everything in one go. However, this approach also makes it | ||||
| 	// difficult to distinguish project-independent runner failures. | ||||
| 	// (For that, we can start multiple ssh.Sessions.) | ||||
| 	// | ||||
| 	// We could pass variables through SSH environment variables, | ||||
| 	// which would require enabling PermitUserEnvironment in sshd_config, | ||||
| 	// or through prepending script lines, but templates are a bit simpler. | ||||
| 	// | ||||
| 	// We let the runner itself clone the repository: | ||||
| 	//  - it is a more flexible in that it can test AUR packages more normally, | ||||
| 	//  - we might have to clone submodules as well. | ||||
| 	// Otherwise, we could download a source archive from Gitea, | ||||
| 	// and use SFTP to upload it to the runner. | ||||
| 	rt.tmplScript, err = ttemplate.New("script").Funcs(shellFuncs). | ||||
| 		Parse(rt.Runner.Setup + "\n" + | ||||
| 			rt.ProjectRunner.Setup + "\n" + rt.ProjectRunner.Build) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("script/build: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	rt.tmplDeploy, err = ttemplate.New("deploy").Funcs(shellFuncs). | ||||
| 		Parse(rt.ProjectRunner.Deploy) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("script/deploy: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return rt, nil | ||||
| } | ||||
| 
 | ||||
| // localEnv creates a process environment for locally run executables. | ||||
| func (rt *RunningTask) localEnv() []string { | ||||
| 	return append(os.Environ(), | ||||
| 		"ACID_ROOT="+rt.wd, | ||||
| 		"ACID_RUNNER="+rt.DB.Runner, | ||||
| 	) | ||||
| } | ||||
| 
 | ||||
| // update stores the running task's state in the database. | ||||
| func (rt *RunningTask) update() error { | ||||
| 	for _, i := range []struct { | ||||
| 		tw  *terminalWriter | ||||
| 		log *[]byte | ||||
| 	}{ | ||||
| 		{&rt.RunLog, &rt.DB.RunLog}, | ||||
| 		{&rt.TaskLog, &rt.DB.TaskLog}, | ||||
| 		{&rt.DeployLog, &rt.DB.DeployLog}, | ||||
| 	} { | ||||
| 		i.tw.mu.Lock() | ||||
| 		defer i.tw.mu.Unlock() | ||||
| 		if *i.log = bytes.Clone(i.tw.b); *i.log == nil { | ||||
| 			*i.log = []byte{} | ||||
| 		} | ||||
| 	} | ||||
| 	return rt.DB.update() | ||||
| } | ||||
| 
 | ||||
| // ~~~ Deploy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
| 
 | ||||
| func executorDownloadNode(sc *sftp.Client, remotePath, localPath string, | ||||
| 	info os.FileInfo) error { | ||||
| 	if info.IsDir() { | ||||
| 		// Hoping that caller invokes us on parents first. | ||||
| 		return os.MkdirAll(localPath, info.Mode().Perm()) | ||||
| 	} | ||||
| 
 | ||||
| 	src, err := sc.Open(remotePath) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to open remote file %s: %w", remotePath, err) | ||||
| 	} | ||||
| 	defer src.Close() | ||||
| 
 | ||||
| 	dst, err := os.OpenFile( | ||||
| 		localPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to create local file: %w", err) | ||||
| 	} | ||||
| 	defer dst.Close() | ||||
| 
 | ||||
| 	if _, err = io.Copy(dst, src); err != nil { | ||||
| 		return fmt.Errorf("failed to copy file from remote %s to local %s: %w", | ||||
| 			remotePath, localPath, err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func executorDownload(client *ssh.Client, remoteRoot, localRoot string) error { | ||||
| 	sc, err := sftp.NewClient(client) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer sc.Close() | ||||
| 
 | ||||
| 	walker := sc.Walk(remoteRoot) | ||||
| 	for walker.Step() { | ||||
| 		if walker.Err() != nil { | ||||
| 			return walker.Err() | ||||
| 		} | ||||
| 		relativePath, err := filepath.Rel(remoteRoot, walker.Path()) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if err = executorDownloadNode(sc, walker.Path(), | ||||
| 			filepath.Join(localRoot, relativePath), walker.Stat()); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func executorLocalShell() string { | ||||
| 	if shell := os.Getenv("SHELL"); shell != "" { | ||||
| 		return shell | ||||
| 	} | ||||
| 	// The os/user package doesn't store the parsed out shell field. | ||||
| 	return "/bin/sh" | ||||
| } | ||||
| 
 | ||||
| func executorDeploy( | ||||
| 	ctx context.Context, client *ssh.Client, rt *RunningTask) error { | ||||
| 	script := bytes.NewBuffer(nil) | ||||
| 	if err := rt.tmplDeploy.Execute(script, &rt.DB); err != nil { | ||||
| 		return &executorError{"Deploy template failed", err} | ||||
| 	} | ||||
| 
 | ||||
| 	// Thus the deployment directory must exist iff the script is not empty. | ||||
| 	if script.Len() == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// We expect the files to be moved elsewhere on the filesystem, | ||||
| 	// and they may get very large, so avoid /tmp. | ||||
| 	// | ||||
| 	// See also: https://systemd.io/TEMPORARY_DIRECTORIES/ | ||||
| 	tmp := os.Getenv("TMPDIR") | ||||
| 	if tmp == "" { | ||||
| 		tmp = "/var/tmp" | ||||
| 	} | ||||
| 	dir := filepath.Join(tmp, "acid-deploy") | ||||
| 	if err := os.RemoveAll(dir); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := os.Mkdir(dir, 0755); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// The passed remoteRoot is relative to sc.Getwd. | ||||
| 	if err := executorDownload(client, "acid-deploy", dir); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	cmd := exec.CommandContext(ctx, executorLocalShell(), "-c", script.String()) | ||||
| 	cmd.Env = rt.localEnv() | ||||
| 	cmd.Dir = dir | ||||
| 	cmd.Stdout = &rt.DeployLog | ||||
| 	cmd.Stderr = &rt.DeployLog | ||||
| 	return cmd.Run() | ||||
| } | ||||
| 
 | ||||
| // ~~~ Build ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
| 
 | ||||
| func executorBuild( | ||||
| 	ctx context.Context, client *ssh.Client, rt *RunningTask) error { | ||||
| 	// This is here to fail early, though logically it is misplaced. | ||||
| 	script := bytes.NewBuffer(nil) | ||||
| 	if err := rt.tmplScript.Execute(script, &rt.DB); err != nil { | ||||
| 		return &executorError{"Script template failed", err} | ||||
| 	} | ||||
| 
 | ||||
| 	session, err := client.NewSession() | ||||
| 	if err != nil { | ||||
| 		return &executorError{"SSH failure", err} | ||||
| 	} | ||||
| 	defer session.Close() | ||||
| 
 | ||||
| 	modes := ssh.TerminalModes{ssh.ECHO: 0} | ||||
| 	if err := session.RequestPty("dumb", 24, 80, modes); err != nil { | ||||
| 		return &executorError{"SSH failure", err} | ||||
| 	} | ||||
| 
 | ||||
| 	log.Printf("task %d for %s: connected\n", rt.DB.ID, rt.DB.FullName()) | ||||
| 
 | ||||
| 	session.Stdout = &rt.TaskLog | ||||
| 	session.Stderr = &rt.TaskLog | ||||
| 
 | ||||
| 	// Although passing the script directly takes away the option to specify | ||||
| 	// a particular shell (barring here-documents!), it is simple and reliable. | ||||
| 	// | ||||
| 	// Passing the script over Stdin to sh tended to end up with commands | ||||
| 	// eating the script during processing, and resulted in a hang, | ||||
| 	// because closing the Stdin does not result in remote processes | ||||
| 	// getting a stream of EOF. | ||||
| 	// | ||||
| 	// Piping the script into `cat | sh` while appending a ^D to the end of it | ||||
| 	// appeared to work, but it seems likely that commands might still steal | ||||
| 	// script bytes from the cat program if they choose to read from the tty | ||||
| 	// and the script is longer than the buffer. | ||||
| 	chSession := make(chan error, 1) | ||||
| 	go func() { | ||||
| 		chSession <- session.Run(script.String()) | ||||
| 		close(chSession) | ||||
| 	}() | ||||
| 
 | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		// Either shutdown, or early runner termination. | ||||
| 		// The runner is not supposed to finish before the session. | ||||
| 		err = context.Cause(ctx) | ||||
| 	case err = <-chSession: | ||||
| 		// Killing a runner may perfectly well trigger this first, | ||||
| 		// in particular when it's on the same machine. | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | ||||
| 
 | ||||
| // executorError describes a taskStateError. | ||||
| type executorError struct { | ||||
| 	Detail string | ||||
| 	Err    error | ||||
| } | ||||
| 
 | ||||
| func (e *executorError) Unwrap() error { return e.Err } | ||||
| func (e *executorError) Error() string { | ||||
| 	return fmt.Sprintf("%s: %s", e.Detail, e.Err) | ||||
| } | ||||
| 
 | ||||
| func executorConnect( | ||||
| 	ctx context.Context, config *ssh.ClientConfig, address string) ( | ||||
| 	*ssh.Client, error) { | ||||
| @ -894,122 +1160,57 @@ func executorConnect( | ||||
| 			time.Sleep(1 * time.Second) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		return ssh.NewClient(sc, chans, reqs), nil | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func executorRunTask(ctx context.Context, task Task) error { | ||||
| 	rt := &RunningTask{DB: task} | ||||
| 
 | ||||
| 	var ok bool | ||||
| 	rt.Runner, ok = gConfig.Runners[rt.DB.Runner] | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("unknown runner: %s", rt.DB.Runner) | ||||
| 	} | ||||
| 	project, ok := gConfig.Projects[rt.DB.FullName()] | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("project configuration not found") | ||||
| 	} | ||||
| 	rt.ProjectRunner, ok = project.Runners[rt.DB.Runner] | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf( | ||||
| 			"project not configured for runner %s", rt.DB.Runner) | ||||
| 	} | ||||
| 
 | ||||
| 	wd, err := os.Getwd() | ||||
| 	rt, err := newRunningTask(task) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		task.State, task.Detail = taskStateError, "Misconfigured" | ||||
| 		task.Notified = 0 | ||||
| 		task.RunLog = []byte(err.Error()) | ||||
| 		task.TaskLog = []byte{} | ||||
| 		task.DeployLog = []byte{} | ||||
| 		return task.update() | ||||
| 	} | ||||
| 
 | ||||
| 	// The runner setup script may change the working directory, | ||||
| 	// so do everything in one go. However, this approach also makes it | ||||
| 	// difficult to distinguish project-independent runner failures. | ||||
| 	// (For that, we can start multiple ssh.Sessions.) | ||||
| 	// | ||||
| 	// We could pass variables through SSH environment variables, | ||||
| 	// which would require enabling PermitUserEnvironment in sshd_config, | ||||
| 	// or through prepending script lines, but templates are a bit simpler. | ||||
| 	// | ||||
| 	// We let the runner itself clone the repository: | ||||
| 	//  - it is a more flexible in that it can test AUR packages more normally, | ||||
| 	//  - we might have to clone submodules as well. | ||||
| 	// Otherwise, we could download a source archive from Gitea, | ||||
| 	// and use SFTP to upload it to the runner. | ||||
| 	tmplScript, err := ttemplate.New("script").Funcs(shellFuncs). | ||||
| 		Parse(rt.Runner.Setup + "\n" + | ||||
| 			rt.ProjectRunner.Setup + "\n" + rt.ProjectRunner.Build) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("script: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Lenient or not, some kind of a time limit is desirable. | ||||
| 	timeout := time.Hour | ||||
| 	if rt.ProjectRunner.Timeout != "" { | ||||
| 		timeout, err = time.ParseDuration(rt.ProjectRunner.Timeout) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("timeout: %w", err) | ||||
| 		} | ||||
| 	} | ||||
| 	ctx, cancelTimeout := context.WithTimeout(ctx, timeout) | ||||
| 	ctx, cancelTimeout := context.WithTimeout(ctx, rt.timeout) | ||||
| 	defer cancelTimeout() | ||||
| 
 | ||||
| 	privateKey, err := os.ReadFile(rt.Runner.SSH.Identity) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf( | ||||
| 			"cannot read SSH identity for runner %s: %w", rt.DB.Runner, err) | ||||
| 	} | ||||
| 	signer, err := ssh.ParsePrivateKey(privateKey) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf( | ||||
| 			"cannot parse SSH identity for runner %s: %w", rt.DB.Runner, err) | ||||
| 	} | ||||
| 
 | ||||
| 	defer func() { | ||||
| 	// RunningTasks can be concurrently accessed by HTTP handlers. | ||||
| 	locked := func(f func()) { | ||||
| 		gRunningMutex.Lock() | ||||
| 		defer gRunningMutex.Unlock() | ||||
| 
 | ||||
| 		delete(gRunning, rt.DB.ID) | ||||
| 	}() | ||||
| 	func() { | ||||
| 		gRunningMutex.Lock() | ||||
| 		defer gRunningMutex.Unlock() | ||||
| 
 | ||||
| 		f() | ||||
| 	} | ||||
| 	locked(func() { | ||||
| 		rt.DB.State, rt.DB.Detail = taskStateRunning, "" | ||||
| 		rt.DB.Notified = 0 | ||||
| 		rt.DB.RunLog = []byte{} | ||||
| 		rt.DB.TaskLog = []byte{} | ||||
| 		rt.DB.DeployLog = []byte{} | ||||
| 		gRunning[rt.DB.ID] = rt | ||||
| 	}() | ||||
| 	if err := executorUpdate(rt); err != nil { | ||||
| 	}) | ||||
| 	defer locked(func() { | ||||
| 		delete(gRunning, rt.DB.ID) | ||||
| 	}) | ||||
| 	if err := rt.update(); err != nil { | ||||
| 		return fmt.Errorf("SQL: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Errors happening while trying to write an error are unfortunate, | ||||
| 	// but not important enough to abort entirely. | ||||
| 	setError := func(detail string) { | ||||
| 		gRunningMutex.Lock() | ||||
| 		defer gRunningMutex.Unlock() | ||||
| 
 | ||||
| 		rt.DB.State, rt.DB.Detail = taskStateError, detail | ||||
| 		if err := executorUpdate(rt); err != nil { | ||||
| 		if err := rt.update(); err != nil { | ||||
| 			log.Printf("error: task %d for %s: SQL: %s", | ||||
| 				rt.DB.ID, rt.DB.FullName(), err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	script := bytes.NewBuffer(nil) | ||||
| 	if err := tmplScript.Execute(script, &rt.DB); err != nil { | ||||
| 		setError("Script template failed") | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	cmd := exec.CommandContext(ctx, rt.Runner.Run) | ||||
| 	cmd.Env = append( | ||||
| 		os.Environ(), | ||||
| 		"ACID_ROOT="+wd, | ||||
| 		"ACID_RUNNER="+rt.DB.Runner, | ||||
| 	) | ||||
| 	cmd.Env = rt.localEnv() | ||||
| 
 | ||||
| 	// Pushing the runner into a new process group that can be killed at once | ||||
| 	// with all its children isn't bullet-proof, it messes with job control | ||||
| @ -1033,7 +1234,8 @@ func executorRunTask(ctx context.Context, task Task) error { | ||||
| 	cmd.Stdout = &rt.RunLog | ||||
| 	cmd.Stderr = &rt.RunLog | ||||
| 	if err := cmd.Start(); err != nil { | ||||
| 		setError("Runner failed to start") | ||||
| 		fmt.Fprintf(&rt.TaskLog, "%s\n", err) | ||||
| 		locked(func() { setError("Runner failed to start") }) | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| @ -1059,78 +1261,61 @@ func executorRunTask(ctx context.Context, task Task) error { | ||||
| 
 | ||||
| 	client, err := executorConnect(ctxRunner, &ssh.ClientConfig{ | ||||
| 		User:            rt.Runner.SSH.User, | ||||
| 		Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)}, | ||||
| 		Auth:            []ssh.AuthMethod{ssh.PublicKeys(rt.signer)}, | ||||
| 		HostKeyCallback: ssh.InsecureIgnoreHostKey(), | ||||
| 	}, rt.Runner.SSH.Address) | ||||
| 	if err != nil { | ||||
| 		fmt.Fprintf(&rt.TaskLog, "%s\n", err) | ||||
| 		setError("SSH failure") | ||||
| 		locked(func() { setError("SSH failure") }) | ||||
| 		return err | ||||
| 	} | ||||
| 	defer client.Close() | ||||
| 
 | ||||
| 	session, err := client.NewSession() | ||||
| 	var ( | ||||
| 		ee1 *ssh.ExitError | ||||
| 		ee2 *executorError | ||||
| 	) | ||||
| 
 | ||||
| 	err = executorBuild(ctxRunner, client, rt) | ||||
| 	if err != nil { | ||||
| 		fmt.Fprintf(&rt.TaskLog, "%s\n", err) | ||||
| 		setError("SSH failure") | ||||
| 		return err | ||||
| 	} | ||||
| 	defer session.Close() | ||||
| 
 | ||||
| 	modes := ssh.TerminalModes{ssh.ECHO: 0} | ||||
| 	if err := session.RequestPty("dumb", 24, 80, modes); err != nil { | ||||
| 		fmt.Fprintf(&rt.TaskLog, "%s\n", err) | ||||
| 		setError("SSH failure") | ||||
| 		return err | ||||
| 		locked(func() { | ||||
| 			if errors.As(err, &ee1) { | ||||
| 				rt.DB.State, rt.DB.Detail = taskStateFailed, "Scripts failed" | ||||
| 				fmt.Fprintf(&rt.TaskLog, "\n%s\n", err) | ||||
| 			} else if errors.As(err, &ee2) { | ||||
| 				rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail | ||||
| 				fmt.Fprintf(&rt.TaskLog, "\n%s\n", ee2.Err) | ||||
| 			} else { | ||||
| 				rt.DB.State, rt.DB.Detail = taskStateError, "" | ||||
| 				fmt.Fprintf(&rt.TaskLog, "\n%s\n", err) | ||||
| 			} | ||||
| 		}) | ||||
| 		return rt.update() | ||||
| 	} | ||||
| 
 | ||||
| 	log.Printf("task %d for %s: connected\n", rt.DB.ID, rt.DB.FullName()) | ||||
| 
 | ||||
| 	session.Stdout = &rt.TaskLog | ||||
| 	session.Stderr = &rt.TaskLog | ||||
| 
 | ||||
| 	// Although passing the script directly takes away the option to specify | ||||
| 	// a particular shell (barring here-documents!), it is simple and reliable. | ||||
| 	// | ||||
| 	// Passing the script over Stdin to sh tended to end up with commands | ||||
| 	// eating the script during processing, and resulted in a hang, | ||||
| 	// because closing the Stdin does not result in remote processes | ||||
| 	// getting a stream of EOF. | ||||
| 	// | ||||
| 	// Piping the script into `cat | sh` while appending a ^D to the end of it | ||||
| 	// appeared to work, but it seems likely that commands might still steal | ||||
| 	// script bytes from the cat program if they choose to read from the tty | ||||
| 	// and the script is longer than the buffer. | ||||
| 	chSession := make(chan error, 1) | ||||
| 	// This is so that it doesn't stay hanging within the sftp package, | ||||
| 	// which uses context.Background() everywhere. | ||||
| 	go func() { | ||||
| 		chSession <- session.Run(script.String()) | ||||
| 		close(chSession) | ||||
| 		<-ctxRunner.Done() | ||||
| 		client.Close() | ||||
| 	}() | ||||
| 
 | ||||
| 	select { | ||||
| 	case <-ctxRunner.Done(): | ||||
| 		// Either shutdown, or early runner termination. | ||||
| 		// The runner is not supposed to finish before the session. | ||||
| 		err = context.Cause(ctxRunner) | ||||
| 	case err = <-chSession: | ||||
| 		// Killing a runner may perfectly well trigger this first, | ||||
| 		// in particular when it's on the same machine. | ||||
| 	} | ||||
| 
 | ||||
| 	gRunningMutex.Lock() | ||||
| 	defer gRunningMutex.Unlock() | ||||
| 
 | ||||
| 	var ee *ssh.ExitError | ||||
| 	if err == nil { | ||||
| 		rt.DB.State, rt.DB.Detail = taskStateSuccess, "" | ||||
| 	} else if errors.As(err, &ee) { | ||||
| 		rt.DB.State, rt.DB.Detail = taskStateFailed, "Scripts failed" | ||||
| 		fmt.Fprintf(&rt.TaskLog, "\n%s\n", err) | ||||
| 	} else { | ||||
| 		rt.DB.State, rt.DB.Detail = taskStateError, "" | ||||
| 		fmt.Fprintf(&rt.TaskLog, "\n%s\n", err) | ||||
| 	} | ||||
| 	return executorUpdate(rt) | ||||
| 	err = executorDeploy(ctxRunner, client, rt) | ||||
| 	locked(func() { | ||||
| 		if err == nil { | ||||
| 			rt.DB.State, rt.DB.Detail = taskStateSuccess, "" | ||||
| 		} else if errors.As(err, &ee1) { | ||||
| 			rt.DB.State, rt.DB.Detail = taskStateFailed, "Deployment failed" | ||||
| 			fmt.Fprintf(&rt.DeployLog, "\n%s\n", err) | ||||
| 		} else if errors.As(err, &ee2) { | ||||
| 			rt.DB.State, rt.DB.Detail = taskStateError, ee2.Detail | ||||
| 			fmt.Fprintf(&rt.DeployLog, "\n%s\n", ee2.Err) | ||||
| 		} else { | ||||
| 			rt.DB.State, rt.DB.Detail = taskStateError, "" | ||||
| 			fmt.Fprintf(&rt.DeployLog, "\n%s\n", err) | ||||
| 		} | ||||
| 	}) | ||||
| 	return rt.update() | ||||
| } | ||||
| 
 | ||||
| func executorRun(ctx context.Context) error { | ||||
| @ -1208,11 +1393,12 @@ type Task struct { | ||||
| 	Hash   string | ||||
| 	Runner string | ||||
| 
 | ||||
| 	State    taskState | ||||
| 	Detail   string | ||||
| 	Notified int64 | ||||
| 	RunLog   []byte | ||||
| 	TaskLog  []byte | ||||
| 	State     taskState | ||||
| 	Detail    string | ||||
| 	Notified  int64 | ||||
| 	RunLog    []byte | ||||
| 	TaskLog   []byte | ||||
| 	DeployLog []byte | ||||
| } | ||||
| 
 | ||||
| func (t *Task) FullName() string { return t.Owner + "/" + t.Repo } | ||||
| @ -1242,26 +1428,58 @@ func (t *Task) CloneURL() string { | ||||
| 	return fmt.Sprintf("%s/%s/%s.git", gConfig.Gitea, t.Owner, t.Repo) | ||||
| } | ||||
| 
 | ||||
| const schemaSQL = ` | ||||
| func (t *Task) update() error { | ||||
| 	_, err := gDB.ExecContext(context.Background(), `UPDATE task | ||||
| 		SET state = ?, detail = ?, notified = ?, | ||||
| 		runlog = ?, tasklog = ?, deploylog = ? WHERE id = ?`, | ||||
| 		t.State, t.Detail, t.Notified, | ||||
| 		t.RunLog, t.TaskLog, t.DeployLog, t.ID) | ||||
| 	if err == nil { | ||||
| 		notifierAwaken() | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | ||||
| 
 | ||||
| const initializeSQL = ` | ||||
| PRAGMA application_id = 0x61636964;          -- "acid" in big endian | ||||
| 
 | ||||
| CREATE TABLE IF NOT EXISTS task( | ||||
| 	id       INTEGER NOT NULL,  -- unique ID | ||||
| 	id        INTEGER NOT NULL,  -- unique ID | ||||
| 
 | ||||
| 	owner    TEXT NOT NULL,     -- Gitea username | ||||
| 	repo     TEXT NOT NULL,     -- Gitea repository name | ||||
| 	hash     TEXT NOT NULL,     -- commit hash | ||||
| 	runner   TEXT NOT NULL,     -- the runner to use | ||||
| 	owner     TEXT NOT NULL,     -- Gitea username | ||||
| 	repo      TEXT NOT NULL,     -- Gitea repository name | ||||
| 	hash      TEXT NOT NULL,     -- commit hash | ||||
| 	runner    TEXT NOT NULL,     -- the runner to use | ||||
| 
 | ||||
| 	state    INTEGER NOT NULL DEFAULT 0,    -- task state | ||||
| 	detail   TEXT    NOT NULL DEFAULT '',   -- task state detail | ||||
| 	notified INTEGER NOT NULL DEFAULT 0,    -- Gitea knows the state | ||||
| 	runlog   BLOB    NOT NULL DEFAULT x'',  -- combined task runner output | ||||
| 	tasklog  BLOB    NOT NULL DEFAULT x'',  -- combined task SSH output | ||||
| 	state     INTEGER NOT NULL DEFAULT 0,    -- task state | ||||
| 	detail    TEXT    NOT NULL DEFAULT '',   -- task state detail | ||||
| 	notified  INTEGER NOT NULL DEFAULT 0,    -- Gitea knows the state | ||||
| 	runlog    BLOB    NOT NULL DEFAULT x'',  -- combined task runner output | ||||
| 	tasklog   BLOB    NOT NULL DEFAULT x'',  -- combined task SSH output | ||||
| 	deploylog BLOB    NOT NULL DEFAULT x'',  -- deployment output | ||||
| 
 | ||||
| 	PRIMARY KEY (id) | ||||
| ) STRICT; | ||||
| ` | ||||
| 
 | ||||
| func openDB(path string) error { | ||||
| func dbEnsureColumn(tx *sql.Tx, table, column, definition string) error { | ||||
| 	var count int64 | ||||
| 	if err := tx.QueryRow( | ||||
| 		`SELECT count(*) FROM pragma_table_info(?) WHERE name = ?`, | ||||
| 		table, column).Scan(&count); err != nil { | ||||
| 		return err | ||||
| 	} else if count == 1 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := tx.Exec( | ||||
| 		`ALTER TABLE ` + table + ` ADD COLUMN ` + column + ` ` + definition) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func dbOpen(path string) error { | ||||
| 	var err error | ||||
| 	gDB, err = sql.Open("sqlite3", | ||||
| 		"file:"+path+"?_foreign_keys=1&_busy_timeout=1000") | ||||
| @ -1269,10 +1487,43 @@ func openDB(path string) error { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	_, err = gDB.Exec(schemaSQL) | ||||
| 	return err | ||||
| 	tx, err := gDB.BeginTx(context.Background(), nil) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer tx.Rollback() | ||||
| 
 | ||||
| 	var version int64 | ||||
| 	if err = tx.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	switch version { | ||||
| 	case 0: | ||||
| 		if _, err = tx.Exec(initializeSQL); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		// We had not initially set a database schema version, | ||||
| 		// so we're stuck checking this column even on new databases. | ||||
| 		if err = dbEnsureColumn(tx, | ||||
| 			`task`, `deploylog`, `BLOB NOT NULL DEFAULT x''`); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		break | ||||
| 	case 1: | ||||
| 		// The next migration goes here, remember to increment the number below. | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err = tx.Exec( | ||||
| 		`PRAGMA user_version = ` + strconv.Itoa(1)); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return tx.Commit() | ||||
| } | ||||
| 
 | ||||
| // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | ||||
| 
 | ||||
| // callRPC forwards command line commands to a running server. | ||||
| func callRPC(args []string) error { | ||||
| 	body, err := json.Marshal(args) | ||||
| @ -1334,7 +1585,7 @@ func main() { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := openDB(gConfig.DB); err != nil { | ||||
| 	if err := dbOpen(gConfig.DB); err != nil { | ||||
| 		log.Fatalln(err) | ||||
| 	} | ||||
| 	defer gDB.Close() | ||||
|  | ||||
| @ -61,7 +61,13 @@ projects: | ||||
|         # Project build script. | ||||
|         build: | | ||||
|           echo Computing line count... | ||||
|           find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l | ||||
|           mkdir ~/acid-deploy | ||||
|           find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l \ | ||||
|             > ~/acid-deploy/count | ||||
| 
 | ||||
|         # Project deployment script (runs locally in a temporary directory). | ||||
|         deploy: | | ||||
|           cat count | ||||
| 
 | ||||
|         # Time limit in time.ParseDuration format. | ||||
|         # The default of one hour should suffice. | ||||
|  | ||||
							
								
								
									
										10
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								go.mod
									
									
									
									
									
								
							| @ -3,9 +3,13 @@ module janouch.name/acid | ||||
| go 1.22.0 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/mattn/go-sqlite3 v1.14.22 | ||||
| 	golang.org/x/crypto v0.21.0 | ||||
| 	github.com/mattn/go-sqlite3 v1.14.24 | ||||
| 	github.com/pkg/sftp v1.13.7 | ||||
| 	golang.org/x/crypto v0.31.0 | ||||
| 	gopkg.in/yaml.v3 v3.0.1 | ||||
| ) | ||||
| 
 | ||||
| require golang.org/x/sys v0.18.0 // indirect | ||||
| require ( | ||||
| 	github.com/kr/fs v0.1.0 // indirect | ||||
| 	golang.org/x/sys v0.28.0 // indirect | ||||
| ) | ||||
|  | ||||
							
								
								
									
										69
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								go.sum
									
									
									
									
									
								
							| @ -1,12 +1,65 @@ | ||||
| github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||||
| github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||||
| golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= | ||||
| golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= | ||||
| golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= | ||||
| golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= | ||||
| golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= | ||||
| github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= | ||||
| github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= | ||||
| github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||||
| github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= | ||||
| github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= | ||||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||||
| github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||||
| golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= | ||||
| golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||||
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||
| golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||
| golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||
| golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||
| golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= | ||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||||
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= | ||||
| golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= | ||||
| golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= | ||||
| golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= | ||||
| golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||
| golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||||
| golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | ||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user