feat(installer): build worker source over SSH
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Этот коммит содержится в:
@@ -18,7 +18,14 @@ import (
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
type DeployOptions struct {
|
||||
// SSHOptions is the SSH connection and authentication surface shared by
|
||||
// the deploy command and the source installer. Secrets (passwords,
|
||||
// passphrases, sudo passwords) can be supplied through direct flags or
|
||||
// file options. The CLI layer should strongly prefer file options: when
|
||||
// read from a file they never appear in command arguments, logs, or
|
||||
// shell history, while direct flags expose the value through the process
|
||||
// list and shell history.
|
||||
type SSHOptions struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
@@ -29,12 +36,16 @@ type DeployOptions struct {
|
||||
KnownHostsFile string
|
||||
HostKeyFingerprint string
|
||||
InsecureHostKey bool
|
||||
Binary string
|
||||
Token string
|
||||
URL string
|
||||
Docker bool
|
||||
Image string
|
||||
NoStart bool
|
||||
}
|
||||
|
||||
type DeployOptions struct {
|
||||
SSHOptions
|
||||
Binary string
|
||||
Token string
|
||||
URL string
|
||||
Docker bool
|
||||
Image string
|
||||
NoStart bool
|
||||
}
|
||||
|
||||
func Deploy(opts DeployOptions) error {
|
||||
@@ -71,11 +82,11 @@ func Deploy(opts DeployOptions) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
auth, err := sshAuth(opts)
|
||||
auth, err := sshAuth(opts.SSHOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hostKey, err := hostKeyCallback(opts)
|
||||
hostKey, err := hostKeyCallback(opts.SSHOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,16 +122,7 @@ func Deploy(opts DeployOptions) error {
|
||||
if opts.NoStart {
|
||||
args += " --no-start"
|
||||
}
|
||||
var command string
|
||||
var stdin []byte
|
||||
if opts.User == "root" {
|
||||
command = args
|
||||
} else if opts.SudoPassword != "" {
|
||||
command = "sudo -S -p '' -- " + args
|
||||
stdin = []byte(opts.SudoPassword + "\n")
|
||||
} else {
|
||||
command = "sudo -n -- " + args
|
||||
}
|
||||
command, stdin := sudoWrap(opts.User, opts.SudoPassword, args)
|
||||
if err := runRemote(client, command, stdin); err != nil {
|
||||
return fmt.Errorf("remote install: %w", err)
|
||||
}
|
||||
@@ -137,23 +139,30 @@ func deployDocker(client *ssh.Client, opts DeployOptions, remoteEnv, remoteUnit
|
||||
if !opts.NoStart {
|
||||
script += " && systemctl restart rsmon-worker.service && systemctl is-active --quiet rsmon-worker.service"
|
||||
}
|
||||
command := "sh -c " + shellQuote(script)
|
||||
var stdin []byte
|
||||
if opts.User != "root" {
|
||||
if opts.SudoPassword != "" {
|
||||
command = "sudo -S -p '' -- " + command
|
||||
stdin = []byte(opts.SudoPassword + "\n")
|
||||
} else {
|
||||
command = "sudo -n -- " + command
|
||||
}
|
||||
}
|
||||
command, stdin := sudoWrap(opts.User, opts.SudoPassword, "sh -c "+shellQuote(script))
|
||||
if err := runRemote(client, command, stdin); err != nil {
|
||||
return fmt.Errorf("remote Docker install: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) {
|
||||
// sudoWrap prefixes a remote command with the privilege path required to
|
||||
// run it as root: the plain command for a root user, `sudo -n` when the
|
||||
// user has passwordless sudo, and `sudo -S` with an empty prompt when a
|
||||
// sudo password is configured. The sudo password is delivered only over
|
||||
// the session's stdin, never in the command string, so it cannot appear
|
||||
// in process listings, logs, or shell history.
|
||||
func sudoWrap(user, sudoPassword, command string) (string, []byte) {
|
||||
if user == "root" {
|
||||
return command, nil
|
||||
}
|
||||
if sudoPassword != "" {
|
||||
return "sudo -S -p '' -- " + command, []byte(sudoPassword + "\n")
|
||||
}
|
||||
return "sudo -n -- " + command, nil
|
||||
}
|
||||
|
||||
func sshAuth(opts SSHOptions) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
if opts.IdentityFile != "" {
|
||||
key, err := os.ReadFile(opts.IdentityFile)
|
||||
@@ -180,7 +189,7 @@ func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) {
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
func hostKeyCallback(opts DeployOptions) (ssh.HostKeyCallback, error) {
|
||||
func hostKeyCallback(opts SSHOptions) (ssh.HostKeyCallback, error) {
|
||||
if opts.HostKeyFingerprint != "" {
|
||||
want := opts.HostKeyFingerprint
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
@@ -242,6 +251,79 @@ func uploadReader(client *ssh.Client, src io.Reader, remotePath string, mode os.
|
||||
}
|
||||
|
||||
func runRemote(client *ssh.Client, command string, stdin []byte) error {
|
||||
// Deploy commands are short and stream to the terminal; they keep the
|
||||
// historical behavior with no timeout.
|
||||
return runRemoteWithTimeout(client, command, stdin, os.Stdout, os.Stderr, 0)
|
||||
}
|
||||
|
||||
// maxRemoteError bounds the stderr snippet folded into runRemoteOutput
|
||||
// errors so a verbose remote failure cannot produce an unbounded error
|
||||
// string.
|
||||
const maxRemoteError = 4096
|
||||
|
||||
// maxRemoteOutput bounds the stdout captured by runRemoteOutput so a
|
||||
// noisy remote command cannot exhaust memory.
|
||||
const maxRemoteOutput = 1 << 20 // 1 MiB
|
||||
|
||||
// boundedBuffer is an io.Writer that silently discards everything past
|
||||
// max bytes and remembers whether truncation happened.
|
||||
type boundedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if b.buf.Len() >= b.max {
|
||||
b.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
remaining := b.max - b.buf.Len()
|
||||
if len(p) > remaining {
|
||||
b.buf.Write(p[:remaining])
|
||||
b.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
|
||||
|
||||
func (b *boundedBuffer) String() string { return b.buf.String() }
|
||||
|
||||
// runRemoteOutput executes a remote command and returns its captured,
|
||||
// size-bounded stdout. Stderr is folded into the returned error on
|
||||
// failure (bounded to maxRemoteError bytes) so operators see what went
|
||||
// wrong without a bounded failure dumping unbounded output. The command
|
||||
// is aborted if it outlives timeout (<= 0 disables the timeout).
|
||||
func runRemoteOutput(client *ssh.Client, command string, stdin []byte, timeout time.Duration) ([]byte, error) {
|
||||
var stdout, stderr boundedBuffer
|
||||
stdout.max = maxRemoteOutput
|
||||
stderr.max = maxRemoteError
|
||||
if err := runRemoteWithTimeout(client, command, stdin, &stdout, &stderr, timeout); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg != "" {
|
||||
if stderr.truncated {
|
||||
msg += "..."
|
||||
}
|
||||
return stdout.Bytes(), fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
return stdout.Bytes(), err
|
||||
}
|
||||
out := stdout.Bytes()
|
||||
if stdout.truncated {
|
||||
out = append(out, []byte("\n...[output truncated]")...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runRemoteWithTimeout runs a remote command, optionally aborting it
|
||||
// when it outlives timeout (<= 0 disables the timeout). The session is
|
||||
// closed and the blocked Run is unblocked when the timer fires.
|
||||
func runRemoteWithTimeout(client *ssh.Client, command string, stdin []byte, stdout, stderr io.Writer, timeout time.Duration) error {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -250,9 +332,24 @@ func runRemote(client *ssh.Client, command string, stdin []byte) error {
|
||||
if stdin != nil {
|
||||
session.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
session.Stdout = os.Stdout
|
||||
session.Stderr = os.Stderr
|
||||
return session.Run(command)
|
||||
session.Stdout = stdout
|
||||
session.Stderr = stderr
|
||||
|
||||
if timeout <= 0 {
|
||||
return session.Run(command)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session.Run(command) }()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-timer.C:
|
||||
_ = session.Close() // abort the remote command and free the session
|
||||
<-done
|
||||
return fmt.Errorf("remote command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
|
||||
Ссылка в новой задаче
Block a user