feat: add worker install and deploy
Все проверки выполнены успешно
CI / test (push) Successful in 2m24s
Docker / Build and publish worker image (push) Successful in 13m24s
Все проверки выполнены успешно
CI / test (push) Successful in 2m24s
Docker / Build and publish worker image (push) Successful in 13m24s
Этот коммит содержится в:
260
internal/installer/deploy.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user