feat: add worker install and deploy
Все проверки выполнены успешно
CI / test (push) Successful in 2m24s
Docker / Build and publish worker image (push) Successful in 13m24s

Этот коммит содержится в:
Gleb Tv
2026-07-19 13:21:11 +03:00
родитель 579a14b403
Коммит 3256dcdc12
16 изменённых файлов: 870 добавлений и 80 удалений

260
internal/installer/deploy.go Обычный файл
Просмотреть файл

@@ -0,0 +1,260 @@
package installer
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
type DeployOptions struct {
Host string
Port int
User string
IdentityFile string
KeyPassphrase string
Password string
SudoPassword string
KnownHostsFile string
HostKeyFingerprint string
InsecureHostKey bool
Binary string
Token string
URL string
Docker bool
Image string
NoStart bool
}
func Deploy(opts DeployOptions) error {
if opts.Host == "" || opts.User == "" {
return errors.New("--host and --user are required")
}
if opts.Port == 0 {
opts.Port = 22
}
if opts.Port < 1 || opts.Port > 65535 {
return errors.New("SSH port must be between 1 and 65535")
}
if err := ValidateToken(opts.Token); err != nil {
return err
}
if opts.URL == "" {
opts.URL = DefaultURL
}
if opts.Image == "" {
opts.Image = DefaultImage
}
if opts.Docker {
if err := ValidateImage(opts.Image); err != nil {
return err
}
}
if err := ValidateURL(opts.URL); err != nil {
return err
}
if !opts.Docker && opts.Binary == "" {
var err error
opts.Binary, err = os.Executable()
if err != nil {
return err
}
}
auth, err := sshAuth(opts)
if err != nil {
return err
}
hostKey, err := hostKeyCallback(opts)
if err != nil {
return err
}
client, err := ssh.Dial("tcp", net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)), &ssh.ClientConfig{
User: opts.User,
Auth: auth,
HostKeyCallback: hostKey,
Timeout: 15 * time.Second,
})
if err != nil {
return fmt.Errorf("connect to %s: %w", opts.Host, err)
}
defer client.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
remoteBinary := "/tmp/rsmon-worker-" + suffix
remoteEnv := remoteBinary + ".env"
remoteUnit := remoteBinary + ".service"
if err := uploadBytes(client, Environment(opts.URL, opts.Token), remoteEnv, 0600); err != nil {
return fmt.Errorf("upload configuration: %w", err)
}
defer runRemote(client, "rm -f -- "+shellQuote(remoteBinary)+" "+shellQuote(remoteEnv)+" "+shellQuote(remoteUnit), nil) //nolint:errcheck
if opts.Docker {
if err := uploadBytes(client, []byte(DockerUnit(opts.Image)), remoteUnit, 0600); err != nil {
return fmt.Errorf("upload systemd unit: %w", err)
}
return deployDocker(client, opts, remoteEnv, remoteUnit)
}
if err := upload(client, opts.Binary, remoteBinary, 0700); err != nil {
return fmt.Errorf("upload worker: %w", err)
}
args := shellQuote(remoteBinary) + " install --binary " + shellQuote(remoteBinary) + " --env-file " + shellQuote(remoteEnv)
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
}
if err := runRemote(client, command, stdin); err != nil {
return fmt.Errorf("remote install: %w", err)
}
return nil
}
func deployDocker(client *ssh.Client, opts DeployOptions, remoteEnv, remoteUnit string) error {
script := "install -d -m 0755 /etc/rsmon-worker" +
" && install -m 0600 " + shellQuote(remoteEnv) + " /etc/rsmon-worker/worker.env" +
" && install -m 0644 " + shellQuote(remoteUnit) + " /etc/systemd/system/rsmon-worker.service" +
" && docker pull " + shellQuote(opts.Image) +
" && systemctl daemon-reload" +
" && systemctl enable rsmon-worker.service"
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
}
}
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) {
var methods []ssh.AuthMethod
if opts.IdentityFile != "" {
key, err := os.ReadFile(opts.IdentityFile)
if err != nil {
return nil, fmt.Errorf("read identity file: %w", err)
}
var signer ssh.Signer
if opts.KeyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(opts.KeyPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(key)
}
if err != nil {
return nil, fmt.Errorf("parse identity file: %w", err)
}
methods = append(methods, ssh.PublicKeys(signer))
}
if opts.Password != "" {
methods = append(methods, ssh.Password(opts.Password))
}
if len(methods) == 0 {
return nil, errors.New("provide --identity-file or --password")
}
return methods, nil
}
func hostKeyCallback(opts DeployOptions) (ssh.HostKeyCallback, error) {
if opts.HostKeyFingerprint != "" {
want := opts.HostKeyFingerprint
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
if got := ssh.FingerprintSHA256(key); got != want {
return fmt.Errorf("host key fingerprint mismatch: got %s", got)
}
return nil
}, nil
}
if opts.InsecureHostKey {
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // explicit operator opt-in
}
path := opts.KnownHostsFile
if path == "" {
u, err := user.Current()
if err != nil {
return nil, err
}
path = filepath.Join(u.HomeDir, ".ssh", "known_hosts")
}
return knownhosts.New(path)
}
func upload(client *ssh.Client, localPath, remotePath string, mode os.FileMode) error {
f, err := os.Open(localPath)
if err != nil {
return err
}
defer f.Close()
return uploadReader(client, f, remotePath, mode)
}
func uploadBytes(client *ssh.Client, data []byte, remotePath string, mode os.FileMode) error {
return uploadReader(client, bytes.NewReader(data), remotePath, mode)
}
func uploadReader(client *ssh.Client, src io.Reader, remotePath string, mode os.FileMode) error {
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return err
}
session.Stdout = os.Stdout
session.Stderr = os.Stderr
command := "umask 077; base64 -d > " + shellQuote(remotePath) + " && chmod " + fmt.Sprintf("%#o", mode.Perm()) + " " + shellQuote(remotePath)
if err := session.Start(command); err != nil {
return err
}
encoder := base64.NewEncoder(base64.StdEncoding, stdin)
_, copyErr := io.Copy(encoder, src)
closeErr := encoder.Close()
pipeErr := stdin.Close()
waitErr := session.Wait()
return errors.Join(copyErr, closeErr, pipeErr, waitErr)
}
func runRemote(client *ssh.Client, command string, stdin []byte) error {
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
if stdin != nil {
session.Stdin = bytes.NewReader(stdin)
}
session.Stdout = os.Stdout
session.Stderr = os.Stderr
return session.Run(command)
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

41
internal/installer/deploy_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,41 @@
package installer
import (
"crypto/ed25519"
"crypto/rand"
"net"
"testing"
"golang.org/x/crypto/ssh"
)
func TestFingerprintHostKeyCallback(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey, err := ssh.NewPublicKey(privateKey.Public())
if err != nil {
t.Fatal(err)
}
callback, err := hostKeyCallback(DeployOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)})
if err != nil {
t.Fatal(err)
}
if err := callback("host", &net.TCPAddr{}, publicKey); err != nil {
t.Fatalf("matching fingerprint rejected: %v", err)
}
callback, err = hostKeyCallback(DeployOptions{HostKeyFingerprint: "SHA256:wrong"})
if err != nil {
t.Fatal(err)
}
if err := callback("host", &net.TCPAddr{}, publicKey); err == nil {
t.Fatal("mismatched fingerprint accepted")
}
}
func TestKnownHostsMissingFile(t *testing.T) {
if _, err := hostKeyCallback(DeployOptions{KnownHostsFile: t.TempDir() + "/missing"}); err == nil {
t.Fatal("missing known_hosts file accepted")
}
}

225
internal/installer/install.go Обычный файл
Просмотреть файл

@@ -0,0 +1,225 @@
package installer
import (
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
var dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*$`)
const (
DefaultURL = "https://rsmon.ru"
DefaultImage = "reg.rsxx.ru/rsmon/rsmon-worker:latest"
binaryPath = "/usr/local/bin/rsmon-worker"
envPath = "/etc/rsmon-worker/worker.env"
unitPath = "/etc/systemd/system/rsmon-worker.service"
)
const systemdUnit = `[Unit]
Description=RSMon distributed monitoring worker
After=network-online.target
[Service]
Type=simple
User=root
EnvironmentFile=/etc/rsmon-worker/worker.env
ExecStart=/usr/local/bin/rsmon-worker
Restart=on-failure
[Install]
WantedBy=multi-user.target
`
const dockerSystemdUnit = `[Unit]
Description=RSMon distributed monitoring worker (Docker)
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
ExecStartPre=-docker rm -f rsmon-worker
ExecStart=docker run --rm --name rsmon-worker --network host --cap-add NET_RAW --env-file /etc/rsmon-worker/worker.env -v rsmon-worker-data:/var/lib/rsmon-worker %s
ExecStop=docker stop rsmon-worker
Restart=on-failure
[Install]
WantedBy=multi-user.target
`
type InstallOptions struct {
Binary string
EnvFile string
Token string
URL string
Docker bool
Image string
NoStart bool
}
func Install(opts InstallOptions) error {
if os.Geteuid() != 0 {
return errors.New("install must be run as root")
}
if opts.URL == "" {
opts.URL = DefaultURL
}
if err := ValidateURL(opts.URL); err != nil {
return err
}
if opts.EnvFile == "" {
if err := ValidateToken(opts.Token); err != nil {
return err
}
}
if opts.Image == "" {
opts.Image = DefaultImage
}
if opts.Docker {
if err := ValidateImage(opts.Image); err != nil {
return err
}
} else if opts.Binary == "" {
var err error
opts.Binary, err = os.Executable()
if err != nil {
return fmt.Errorf("locate worker executable: %w", err)
}
}
unit := systemdUnit
if opts.Docker {
if _, err := exec.LookPath("docker"); err != nil {
return errors.New("docker is required for --docker installation")
}
if err := command("docker", "pull", opts.Image); err != nil {
return err
}
unit = DockerUnit(opts.Image)
} else if err := copyAtomic(opts.Binary, binaryPath, 0755); err != nil {
return fmt.Errorf("install binary: %w", err)
}
if err := writeAtomic(unitPath, []byte(unit), 0644); err != nil {
return fmt.Errorf("install systemd unit: %w", err)
}
if opts.EnvFile != "" {
if err := copyAtomic(opts.EnvFile, envPath, 0600); err != nil {
return fmt.Errorf("install environment: %w", err)
}
} else {
if err := writeAtomic(envPath, Environment(opts.URL, opts.Token), 0600); err != nil {
return fmt.Errorf("install environment: %w", err)
}
}
if err := command("systemctl", "daemon-reload"); err != nil {
return err
}
if err := command("systemctl", "enable", "rsmon-worker.service"); err != nil {
return err
}
if !opts.NoStart {
if err := command("systemctl", "restart", "rsmon-worker.service"); err != nil {
return err
}
if err := command("systemctl", "is-active", "--quiet", "rsmon-worker.service"); err != nil {
return err
}
}
return nil
}
func ValidateURL(raw string) error {
if strings.ContainsAny(raw, "\r\n") {
return fmt.Errorf("server URL must be an absolute HTTP(S) URL")
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
return fmt.Errorf("server URL must be an absolute HTTP(S) URL")
}
return nil
}
func Environment(serverURL, token string) []byte {
return []byte("RSMON_URL=" + serverURL + "\nRSMON_TOKEN=" + token + "\nWORKER_HOST=127.0.0.1\n")
}
func ValidateToken(token string) error {
if strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\r\n") {
return errors.New("worker token must be non-empty and contain no newlines")
}
return nil
}
func ValidateImage(image string) error {
if !dockerImagePattern.MatchString(image) {
return errors.New("Docker image must be one non-option argument")
}
return nil
}
func DockerUnit(image string) string {
return fmt.Sprintf(dockerSystemdUnit, image)
}
func copyAtomic(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
return atomicFile(dst, mode, func(out *os.File) error {
_, err := io.Copy(out, in)
return err
})
}
func writeAtomic(dst string, data []byte, mode os.FileMode) error {
return atomicFile(dst, mode, func(out *os.File) error {
_, err := out.Write(data)
return err
})
}
func atomicFile(dst string, mode os.FileMode, write func(*os.File) error) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".rsmon-worker-*")
if err != nil {
return err
}
name := tmp.Name()
defer os.Remove(name)
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := write(tmp); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(name, dst)
}
func command(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s failed: %w", name, err)
}
return nil
}

68
internal/installer/install_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,68 @@
package installer
import (
"strings"
"testing"
)
func TestValidateURL(t *testing.T) {
for _, raw := range []string{"https://rsmon.ru", "http://localhost:7401"} {
if err := ValidateURL(raw); err != nil {
t.Fatalf("ValidateURL(%q): %v", raw, err)
}
}
for _, raw := range []string{"", "rsmon.ru", "file:///tmp/x"} {
if err := ValidateURL(raw); err == nil {
t.Fatalf("ValidateURL(%q) succeeded", raw)
}
}
}
func TestValidateToken(t *testing.T) {
if err := ValidateToken("token"); err != nil {
t.Fatal(err)
}
for _, token := range []string{"", " ", "token\nRSMON_URL=https://evil.test"} {
if err := ValidateToken(token); err == nil {
t.Fatalf("ValidateToken(%q) succeeded", token)
}
}
}
func TestEnvironment(t *testing.T) {
got := string(Environment("https://example.test", "secret"))
for _, want := range []string{"RSMON_URL=https://example.test\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n"} {
if !strings.Contains(got, want) {
t.Fatalf("environment missing %q", want)
}
}
}
func TestShellQuote(t *testing.T) {
if got, want := shellQuote("a'b"), `'a'\''b'`; got != want {
t.Fatalf("shellQuote() = %q, want %q", got, want)
}
}
func TestValidateImage(t *testing.T) {
if err := ValidateImage(DefaultImage); err != nil {
t.Fatal(err)
}
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`} {
if err := ValidateImage(image); err == nil {
t.Fatalf("ValidateImage(%q) succeeded", image)
}
}
}
func TestSystemdUnits(t *testing.T) {
if !strings.Contains(systemdUnit, "Type=simple\nUser=root\n") || !strings.Contains(systemdUnit, "ExecStart=/usr/local/bin/rsmon-worker\n") {
t.Fatal("binary systemd unit is not the simple root service")
}
unit := DockerUnit(DefaultImage)
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", DefaultImage} {
if !strings.Contains(unit, want) {
t.Fatalf("Docker systemd unit missing %q", want)
}
}
}