feat(installer): build worker source over SSH
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s

Этот коммит содержится в:
Gleb Tv
2026-08-13 00:07:43 +03:00
родитель 4651deb280
Коммит bd6070ee1f
18 изменённых файлов: 2194 добавлений и 96 удалений

Просмотреть файл

@@ -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 {