feat(installer): activate source builds atomically
Все проверки выполнены успешно
CI / test (push) Successful in 4m30s
Docker / Build and publish worker image (push) Successful in 17m26s
Все проверки выполнены успешно
CI / test (push) Successful in 4m30s
Docker / Build and publish worker image (push) Successful in 17m26s
Этот коммит содержится в:
409
internal/installer/sourceactivate_test.go
Обычный файл
409
internal/installer/sourceactivate_test.go
Обычный файл
@@ -0,0 +1,409 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rocketgit.ru/rsmon/worker/internal/sshinstall"
|
||||
)
|
||||
|
||||
func TestActivateScriptMarkers(t *testing.T) {
|
||||
script := activateScript(activateParams{
|
||||
Stage: "/opt/rsmon-worker-src/rsmon-worker",
|
||||
Binary: "/usr/local/bin/rsmon-worker",
|
||||
ConfigDir: "/etc/rsmon-worker",
|
||||
EnvFile: "/etc/rsmon-worker/worker.env",
|
||||
EnvTmp: "/tmp/rsmon-worker-abc.env",
|
||||
DataDir: "/var/lib/rsmon-worker",
|
||||
UnitTmp: "/tmp/rsmon-worker-abc.service",
|
||||
UnitFile: "/etc/systemd/system/rsmon-worker.service",
|
||||
UnitMode: "0644",
|
||||
UnitName: "rsmon-worker.service",
|
||||
RCName: "rsmon-worker",
|
||||
Supervisor: "none",
|
||||
NoStart: "0",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"set -eu",
|
||||
// staged binary validated before any state is touched
|
||||
"\"$stage\" --version >/dev/null",
|
||||
// snapshot + rollback
|
||||
"backup_dir=\"$data_dir/.rsmon-backup\"",
|
||||
"rm -rf \"$backup_dir\"",
|
||||
"cp -p \"$binary\" \"$backup_dir/binary\"",
|
||||
"cp -p \"$env_file\" \"$backup_dir/worker.env\"",
|
||||
"printf '%s\\n' \"$unit_enabled\" > \"$backup_dir/.marker\"",
|
||||
"trap rollback EXIT HUP INT TERM",
|
||||
"restoring the prior install",
|
||||
// atomic installs with the right perms
|
||||
"install -m 0755 \"$stage\" \"$binary.new\"",
|
||||
"mv -f \"$binary.new\" \"$binary\"",
|
||||
"install -m 0600 \"$env_tmp\" \"$env_file.new\"",
|
||||
"mv -f \"$env_file.new\" \"$env_file\"",
|
||||
"chmod 0750 \"$config_dir\"",
|
||||
"mkdir -p \"$data_dir\" \"$data_dir/webapp\"",
|
||||
"install -m \"$unit_mode\" \"$unit_tmp\" \"$unit_file.new\"",
|
||||
"mv -f \"$unit_file.new\" \"$unit_file\"",
|
||||
// supervisor + pid + health verification
|
||||
"pid_file=\"$data_dir/worker.pid\"",
|
||||
"env -i PATH=\"/usr/bin:/bin:/sbin:/usr/sbin\"",
|
||||
"RSMON_WORKER_ENV_FILE=\"$env_file\"",
|
||||
". \"$RSMON_WORKER_ENV_FILE\"",
|
||||
"exec \"$@\"",
|
||||
"nohup \"$1\" >>\"$2\" 2>&1 & echo $! > \"$3\"",
|
||||
"run_env \"$binary\" liveness >/dev/null 2>&1",
|
||||
"while [ \"$i\" -lt 30 ]; do",
|
||||
"rsmon-worker activated:",
|
||||
// activation lock + interrupted-run recovery
|
||||
"lock_dir=\"$data_dir/.rsmon-activate.lock\"",
|
||||
"acquire_lock",
|
||||
"another rsmon-worker activation is in progress",
|
||||
"recovering interrupted activation from $backup_dir",
|
||||
".marker",
|
||||
// pid belongs to the expected binary before kill
|
||||
"readlink \"/proc/$1/exe\"",
|
||||
"\"$binary (deleted)\"",
|
||||
"process_is_worker \"$pid\"",
|
||||
// rollback preserves metadata and enable state
|
||||
"cp -p \"$backup_dir/binary\" \"$binary\"",
|
||||
"cp -p \"$backup_dir/worker.env\" \"$env_file\"",
|
||||
"set_unit_enabled \"$unit_enabled\"",
|
||||
"systemctl is-enabled",
|
||||
// temp/backup cleanup on success
|
||||
"rm -rf \"$backup_dir\"",
|
||||
"release_lock",
|
||||
"trap - EXIT HUP INT TERM",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("activate script missing %q:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
// The rollback must be armed only after the snapshot so a corrupt
|
||||
// staging binary (validated first) never triggers a destructive
|
||||
// rollback of the prior install.
|
||||
if !strings.Contains(script, "\"$stage\" --version") || !strings.Contains(script, "trap rollback EXIT HUP INT TERM") {
|
||||
t.Fatalf("activate script must validate the staged binary before arming rollback:\n%s", script)
|
||||
}
|
||||
stageCheck := strings.Index(script, "\"$stage\" --version")
|
||||
trapIdx := strings.Index(script, "trap rollback EXIT HUP INT TERM")
|
||||
if stageCheck < 0 || trapIdx < stageCheck {
|
||||
t.Fatalf("staged-binary validation must precede the rollback trap:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateScriptSystemdSupervisor(t *testing.T) {
|
||||
script := activateScript(activateParams{
|
||||
Stage: "/opt/rsmon-worker-src/rsmon-worker",
|
||||
Binary: "/usr/local/bin/rsmon-worker",
|
||||
ConfigDir: "/etc/rsmon-worker",
|
||||
EnvFile: "/etc/rsmon-worker/worker.env",
|
||||
EnvTmp: "/tmp/e.env",
|
||||
DataDir: "/var/lib/rsmon-worker",
|
||||
UnitTmp: "/tmp/u.service",
|
||||
UnitFile: "/etc/systemd/system/rsmon-worker.service",
|
||||
UnitMode: "0644",
|
||||
UnitName: "rsmon-worker.service",
|
||||
RCName: "rsmon-worker",
|
||||
Supervisor: "systemd",
|
||||
NoStart: "0",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"supervisor=systemd",
|
||||
"systemctl daemon-reload",
|
||||
"systemctl restart \"$unit_name\"",
|
||||
"systemctl is-active --quiet \"$unit_name\"",
|
||||
"systemctl enable \"$unit_name\"",
|
||||
"svc_active()",
|
||||
"start failure: systemctl restart $unit_name failed",
|
||||
"start failure: worker service is not active",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("systemd supervisor missing %q:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
// The systemd health loop must use `systemctl is-active`, never the
|
||||
// embedded supervisor's pid file.
|
||||
if !strings.Contains(script, "systemd) systemctl is-active --quiet \"$unit_name\"") {
|
||||
t.Fatalf("systemd health loop must use systemctl is-active:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateScriptOpenRCSupervisor(t *testing.T) {
|
||||
script := activateScript(activateParams{
|
||||
Stage: "/opt/rsmon-worker-src/rsmon-worker",
|
||||
Binary: "/usr/local/bin/rsmon-worker",
|
||||
ConfigDir: "/etc/rsmon-worker",
|
||||
EnvFile: "/etc/rsmon-worker/worker.env",
|
||||
EnvTmp: "/tmp/e.env",
|
||||
DataDir: "/var/lib/rsmon-worker",
|
||||
UnitTmp: "/tmp/u",
|
||||
UnitFile: "/etc/init.d/rsmon-worker",
|
||||
UnitMode: "0755",
|
||||
UnitName: "rsmon-worker.service",
|
||||
RCName: "rsmon-worker",
|
||||
Supervisor: "openrc",
|
||||
NoStart: "0",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"rc-service \"$rc_name\" restart",
|
||||
"rc-service \"$rc_name\" status",
|
||||
"rc-update add \"$rc_name\" default",
|
||||
"start failure: rc-service restart $rc_name failed",
|
||||
"rc-update del \"$rc_name\" default",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("openrc supervisor missing %q:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateScriptNoStartSkipsRestart(t *testing.T) {
|
||||
script := activateScript(activateParams{
|
||||
Stage: "/opt/rsmon-worker-src/rsmon-worker", Binary: "/usr/local/bin/rsmon-worker",
|
||||
ConfigDir: "/etc/rsmon-worker", EnvFile: "/etc/rsmon-worker/worker.env", EnvTmp: "/tmp/e.env",
|
||||
DataDir: "/var/lib/rsmon-worker", UnitTmp: "", UnitFile: "", UnitMode: "",
|
||||
UnitName: "rsmon-worker.service", RCName: "rsmon-worker", Supervisor: "none", NoStart: "1",
|
||||
})
|
||||
if !strings.Contains(script, "[ \"$no_start\" -ne 1 ]") {
|
||||
t.Fatalf("no-start gate missing:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(script, "started=no") {
|
||||
t.Fatalf("no-start summary missing:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateScriptNeverContainsSecrets(t *testing.T) {
|
||||
const secret = "super-secret-token-value"
|
||||
params := activateParams{
|
||||
Stage: "/opt/rsmon-worker-src/rsmon-worker", Binary: "/usr/local/bin/rsmon-worker",
|
||||
ConfigDir: "/etc/rsmon-worker", EnvFile: "/etc/rsmon-worker/worker.env", EnvTmp: "/tmp/e.env",
|
||||
DataDir: "/var/lib/rsmon-worker", UnitTmp: "/tmp/u", UnitFile: "/etc/systemd/system/rsmon-worker.service",
|
||||
UnitMode: "0644", UnitName: "rsmon-worker.service", RCName: "rsmon-worker",
|
||||
Supervisor: "systemd", NoStart: "0",
|
||||
}
|
||||
script := activateScript(params)
|
||||
if strings.Contains(script, secret) {
|
||||
t.Fatalf("activate script contains a secret:\n%s", script)
|
||||
}
|
||||
// The env file is referenced by path; its contents (the secrets) are
|
||||
// only sourced at runtime and never echoed.
|
||||
for _, want := range []string{"RSMON_TOKEN=", secret} {
|
||||
if strings.Contains(script, want) {
|
||||
t.Fatalf("activate script must not embed env contents (%q):\n%s", want, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRCInit(t *testing.T) {
|
||||
unit := openrcInitFor(resolvePaths(""))
|
||||
for _, want := range []string{
|
||||
"#!/sbin/openrc-run",
|
||||
"name=rsmon-worker",
|
||||
"description=RSMon distributed monitoring worker",
|
||||
"command=/usr/local/bin/rsmon-worker",
|
||||
"command_background=true",
|
||||
"pidfile=/var/lib/rsmon-worker/worker.pid",
|
||||
"output_log=/var/lib/rsmon-worker/worker.log",
|
||||
"need net",
|
||||
". /etc/rsmon-worker/worker.env",
|
||||
"set -a",
|
||||
"set +a",
|
||||
"RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp",
|
||||
} {
|
||||
if !strings.Contains(unit, want) {
|
||||
t.Fatalf("openrc init missing %q:\n%s", want, unit)
|
||||
}
|
||||
}
|
||||
named := openrcInitFor(resolvePaths("edge"))
|
||||
for _, want := range []string{
|
||||
"name=rsmon-worker-edge", "command=/usr/local/bin/rsmon-worker-edge",
|
||||
"pidfile=/var/lib/rsmon-worker-edge/worker.pid", "/etc/rsmon-worker-edge/worker.env", "(edge)",
|
||||
} {
|
||||
if !strings.Contains(named, want) {
|
||||
t.Fatalf("named openrc init missing %q:\n%s", want, named)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningInitScript(t *testing.T) {
|
||||
for _, want := range []string{"set -eu", "/run/systemd/system", "systemctl", "/run/openrc/softlevel", "rc-service", "echo none"} {
|
||||
if !strings.Contains(runningInitScript, want) {
|
||||
t.Fatalf("running-init script missing %q:\n%s", want, runningInitScript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSupervisor(t *testing.T) {
|
||||
for _, in := range []string{"systemd", "openrc", "systemd\n", " openrc "} {
|
||||
want := strings.TrimSpace(in)
|
||||
if got := normalizeSupervisor(in); got != want {
|
||||
t.Fatalf("normalizeSupervisor(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "none", "sysvinit", " "} {
|
||||
if got := normalizeSupervisor(in); got != "none" {
|
||||
t.Fatalf("normalizeSupervisor(%q) = %q, want none", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitContent(t *testing.T) {
|
||||
p := resolvePaths("")
|
||||
systemd, file, mode := unitContent(sshinstall.InitSystemd, p)
|
||||
if systemd == "" || file != "/etc/systemd/system/rsmon-worker.service" || mode != "0644" {
|
||||
t.Fatalf("systemd unit content = %q, %q, %q", systemd, file, mode)
|
||||
}
|
||||
if !strings.Contains(systemd, "ExecStart=/usr/local/bin/rsmon-worker\n") {
|
||||
t.Fatalf("systemd unit not the classic hardened unit:\n%s", systemd)
|
||||
}
|
||||
openrc, file, mode := unitContent(sshinstall.InitOpenRC, p)
|
||||
if openrc == "" || file != "/etc/init.d/rsmon-worker" || mode != "0755" {
|
||||
t.Fatalf("openrc unit content = %q, %q, %q", openrc, file, mode)
|
||||
}
|
||||
if content, file, mode := unitContent(sshinstall.InitUnknown, p); content != "" || file != "" || mode != "" {
|
||||
t.Fatalf("unknown-init unit content = %q, %q, %q, want empty (no-service gate)", content, file, mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEnvActivation(t *testing.T) {
|
||||
t.Setenv("RSMON_URL", "")
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
t.Setenv("WORKER_HOST", "")
|
||||
t.Setenv("WORKER_PORT", "")
|
||||
t.Setenv("WORKER_LOGIN", "")
|
||||
t.Setenv("WORKER_PASSWORD", "")
|
||||
|
||||
data, err := (ActivationOptions{Activate: true, URL: "https://rsmon.ru", Token: "secret"}).renderEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"RSMON_URL=https://rsmon.ru\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n", "WORKER_PORT=27401\n"} {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("rendered env missing %q:\n%s", want, data)
|
||||
}
|
||||
}
|
||||
|
||||
// PUBLIC_URL canonicalization is reused from the classic installer.
|
||||
data, err = (ActivationOptions{
|
||||
Activate: true, URL: "https://rsmon.ru", Token: "secret",
|
||||
PublicURL: "https://worker.example.com",
|
||||
}).renderEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "PUBLIC_URL=https://worker.example.com\n") {
|
||||
t.Fatalf("rendered env missing PUBLIC_URL:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEnvActivationRejectsMixedBasicAuth(t *testing.T) {
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
if _, err := (ActivationOptions{
|
||||
Activate: true, URL: "https://rsmon.ru", Token: "secret",
|
||||
Login: "admin",
|
||||
}).renderEnv(); err == nil {
|
||||
t.Fatal("login-only basic auth accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEnvActivationRequiresToken(t *testing.T) {
|
||||
if _, err := (ActivationOptions{Activate: true, URL: "https://rsmon.ru"}).renderEnv(); err == nil {
|
||||
t.Fatal("activation without a token accepted")
|
||||
}
|
||||
if _, err := (ActivationOptions{Activate: true, Token: "x", URL: "not-a-url"}).renderEnv(); err == nil {
|
||||
t.Fatal("activation with a malformed URL accepted")
|
||||
}
|
||||
if _, err := (ActivationOptions{Activate: true, Token: "x", URL: "https://rsmon.ru", Name: "Bad_Name"}).renderEnv(); err == nil {
|
||||
t.Fatal("activation with an invalid instance name accepted")
|
||||
}
|
||||
if _, err := (ActivationOptions{Activate: true, Token: "x", URL: "https://rsmon.ru", Name: "edge"}).renderEnv(); err == nil {
|
||||
t.Fatal("named activation without WORKER_PORT accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderEnvActivationReadsEnvFileOnce proves the env file is read a
|
||||
// single time and validated from the in-memory bytes: swapping the file
|
||||
// after the read cannot smuggle a different value into the render, and a
|
||||
// malformed file fails on the read bytes.
|
||||
func TestRenderEnvActivationReadsEnvFileOnce(t *testing.T) {
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
path := filepath.Join(t.TempDir(), "worker.env")
|
||||
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=file-token\nWORKER_PORT=28888\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := (ActivationOptions{Activate: true, EnvFile: path}).renderEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "RSMON_TOKEN=file-token\n") || !strings.Contains(string(data), "WORKER_PORT=28888\n") {
|
||||
t.Fatalf("env file values not rendered:\n%s", data)
|
||||
}
|
||||
// A malformed file must be rejected from the same read.
|
||||
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=has space\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (ActivationOptions{Activate: true, EnvFile: path}).renderEnv(); err == nil {
|
||||
t.Fatal("malformed env file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSourceOptionsActivationRendersOnce verifies the rendered
|
||||
// env is computed during normalization and reused, so a later call cannot
|
||||
// re-read a changed env file.
|
||||
func TestNormalizeSourceOptionsActivationRendersOnce(t *testing.T) {
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
path := filepath.Join(t.TempDir(), "worker.env")
|
||||
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=once-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := SourceInstallOptions{
|
||||
SSHOptions: SSHOptions{Host: "h", User: "u"},
|
||||
Activation: ActivationOptions{Activate: true, EnvFile: path},
|
||||
}
|
||||
norm, err := normalizeSourceOptions(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(norm.activationEnv), "RSMON_TOKEN=once-token\n") {
|
||||
t.Fatalf("activation env not cached during normalization: %q", norm.activationEnv)
|
||||
}
|
||||
// Even after the file changes, the cached render is authoritative.
|
||||
if err := os.WriteFile(path, []byte("RSMON_URL=https://evil.test\nRSMON_TOKEN=evil\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(norm.activationEnv), "evil") {
|
||||
t.Fatalf("changed env file leaked into the cached render: %q", norm.activationEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSourceOptionsActivation(t *testing.T) {
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
base := SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}}
|
||||
if _, err := normalizeSourceOptions(base); err != nil {
|
||||
t.Fatalf("staging-only options must stay valid: %v", err)
|
||||
}
|
||||
act := base
|
||||
act.Activation = ActivationOptions{Activate: true, URL: "https://rsmon.ru"}
|
||||
if _, err := normalizeSourceOptions(act); err == nil || !strings.Contains(err.Error(), "RSMON_TOKEN") {
|
||||
t.Fatalf("activation without a token error = %v", err)
|
||||
}
|
||||
ok := base
|
||||
ok.Activation = ActivationOptions{Activate: true, URL: "https://rsmon.ru", Token: "secret"}
|
||||
if _, err := normalizeSourceOptions(ok); err != nil {
|
||||
t.Fatalf("valid activation rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenderedEnv(t *testing.T) {
|
||||
if err := validateRenderedEnv([]byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateRenderedEnv([]byte("RSMON_TOKEN=secret\n")); err == nil {
|
||||
t.Fatal("env without RSMON_URL accepted")
|
||||
}
|
||||
if err := validateRenderedEnv([]byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret value\n")); err == nil {
|
||||
t.Fatal("env with whitespace accepted")
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user