feat(installer): multi-instance install with full env resolution
Все проверки выполнены успешно
CI / test (push) Successful in 1m0s
Docker / Build and publish worker image (push) Successful in 16m19s
Все проверки выполнены успешно
CI / test (push) Successful in 1m0s
Docker / Build and publish worker image (push) Successful in 16m19s
Rework `rsmon-worker install` so one host can run several isolated workers and so the installer consumes the full worker env-var set. - main.go now loads .env before dispatching management commands, so install/deploy read the same environment as the runtime. - New --name flag installs a co-located worker as rsmon-worker-<name> with its own binary (/usr/local/bin/rsmon-worker-<name>), config (/etc/rsmon-worker-<name>), data dir (/var/lib/rsmon-worker-<name>), and systemd unit. Named instances require an explicit WORKER_PORT. - Configuration is resolved flags > --env-file > process env/.env (godotenv) > defaults; the resolved set is written as a stable, systemd-safe 0600 env file. - WORKER_LOGIN/WORKER_PASSWORD default to a generated admin password (printed once) when both are unset; XOR is rejected. - The generated unit is now hardened (After=docker.service, CAP_NET_RAW, ProtectSystem=full, ReadWritePaths=data dir) and parameterized by instance; the Docker unit is namespaced by instance too. - install creates the data + config directories and prints a summary (unit, binary, env file, data dir, console URL, generated password). - New flags: --name, --host, --port, --login, --password/--password-file. - Tests: resolvePaths, validateInstanceName, resolveInstallEnv precedence/XOR/port-required, renderEnvFile, validateEnvValue, plus named-instance unit assertions. End-to-end verified by installing and removing a throwaway --name instance. - docs/install.md documents the tool, config sources/precedence, single- and multi-instance flows, the exact actions performed, the generated unit, options, and uninstall.
Этот коммит содержится в:
@@ -118,9 +118,29 @@ func TestValidateImage(t *testing.T) {
|
||||
}
|
||||
|
||||
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")
|
||||
primary := systemdUnitFor(resolvePaths(""))
|
||||
if !strings.Contains(primary, "Type=simple\nUser=root\n") ||
|
||||
!strings.Contains(primary, "ExecStart=/usr/local/bin/rsmon-worker\n") ||
|
||||
!strings.Contains(primary, "EnvironmentFile=/etc/rsmon-worker/worker.env\n") {
|
||||
t.Fatal("binary systemd unit is not the simple root service at the classic paths")
|
||||
}
|
||||
if !strings.Contains(primary, "After=network-online.target docker.service") ||
|
||||
!strings.Contains(primary, "CAP_NET_RAW") || !strings.Contains(primary, "ProtectSystem=full") {
|
||||
t.Fatal("binary systemd unit must order after docker and harden for ping/compose")
|
||||
}
|
||||
|
||||
named := systemdUnitFor(resolvePaths("edge"))
|
||||
for _, want := range []string{
|
||||
"ExecStart=/usr/local/bin/rsmon-worker-edge",
|
||||
"EnvironmentFile=/etc/rsmon-worker-edge/worker.env",
|
||||
"RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker-edge/webapp",
|
||||
"(edge)",
|
||||
} {
|
||||
if !strings.Contains(named, want) {
|
||||
t.Fatalf("named binary unit missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
unit := DockerUnit(image)
|
||||
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", image} {
|
||||
@@ -128,4 +148,137 @@ func TestSystemdUnits(t *testing.T) {
|
||||
t.Fatalf("Docker systemd unit missing %q", want)
|
||||
}
|
||||
}
|
||||
namedDocker := dockerUnitFor(resolvePaths("edge"), image)
|
||||
for _, want := range []string{"docker rm -f rsmon-worker-edge", "--name rsmon-worker-edge ", "-v rsmon-worker-data-edge:/var/lib/rsmon-worker"} {
|
||||
if !strings.Contains(namedDocker, want) {
|
||||
t.Fatalf("named Docker unit missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaths(t *testing.T) {
|
||||
primary := resolvePaths("")
|
||||
if primary.binary != "/usr/local/bin/rsmon-worker" ||
|
||||
primary.configDir != "/etc/rsmon-worker" ||
|
||||
primary.envFile != "/etc/rsmon-worker/worker.env" ||
|
||||
primary.dataDir != "/var/lib/rsmon-worker" ||
|
||||
primary.unitName != "rsmon-worker.service" ||
|
||||
primary.unitFile != "/etc/systemd/system/rsmon-worker.service" ||
|
||||
primary.container != "rsmon-worker" || primary.volume != "rsmon-worker-data" {
|
||||
t.Fatalf("primary paths wrong: %+v", primary)
|
||||
}
|
||||
edge := resolvePaths("edge")
|
||||
if edge.binary != "/usr/local/bin/rsmon-worker-edge" ||
|
||||
edge.configDir != "/etc/rsmon-worker-edge" ||
|
||||
edge.envFile != "/etc/rsmon-worker-edge/worker.env" ||
|
||||
edge.dataDir != "/var/lib/rsmon-worker-edge" ||
|
||||
edge.unitName != "rsmon-worker-edge.service" ||
|
||||
edge.unitFile != "/etc/systemd/system/rsmon-worker-edge.service" ||
|
||||
edge.container != "rsmon-worker-edge" || edge.volume != "rsmon-worker-data-edge" {
|
||||
t.Fatalf("named paths wrong: %+v", edge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceName(t *testing.T) {
|
||||
for _, n := range []string{"", "dev", "edge-1", "a", "ab"} {
|
||||
if err := validateInstanceName(n); err != nil {
|
||||
t.Fatalf("validateInstanceName(%q): %v", n, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"Dev", "dev_", "-dev", "dev-", "a.b", strings.Repeat("a", 33), "dev zone"} {
|
||||
if err := validateInstanceName(n); err == nil {
|
||||
t.Fatalf("validateInstanceName(%q) succeeded", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallEnv(t *testing.T) {
|
||||
// Force a deterministic process environment so precedence is exact.
|
||||
t.Setenv("RSMON_URL", "https://proc.test")
|
||||
t.Setenv("RSMON_TOKEN", "proc-token")
|
||||
t.Setenv("WORKER_PORT", "29999")
|
||||
t.Setenv("WORKER_LOGIN", "")
|
||||
t.Setenv("WORKER_PASSWORD", "")
|
||||
|
||||
t.Run("flag beats process env", func(t *testing.T) {
|
||||
v, err := resolveInstallEnv(InstallOptions{
|
||||
URL: "https://flag.test", Token: "flag-token", Port: "28080",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v["RSMON_URL"] != "https://flag.test" || v["RSMON_TOKEN"] != "flag-token" || v["WORKER_PORT"] != "28080" {
|
||||
t.Fatalf("flag did not win: %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("process env fills when flags empty", func(t *testing.T) {
|
||||
v, err := resolveInstallEnv(InstallOptions{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v["RSMON_URL"] != "https://proc.test" || v["RSMON_TOKEN"] != "proc-token" || v["WORKER_PORT"] != "29999" {
|
||||
t.Fatalf("process env not used: %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env file beats process env", func(t *testing.T) {
|
||||
v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{
|
||||
"RSMON_URL": "https://file.test", "RSMON_TOKEN": "file-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v["RSMON_URL"] != "https://file.test" || v["RSMON_TOKEN"] != "file-token" {
|
||||
t.Fatalf("env file did not beat process: %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token required", func(t *testing.T) {
|
||||
t.Setenv("RSMON_TOKEN", "")
|
||||
if _, err := resolveInstallEnv(InstallOptions{}, "", nil); err == nil {
|
||||
t.Fatal("missing token accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("named instance requires port", func(t *testing.T) {
|
||||
t.Setenv("WORKER_PORT", "")
|
||||
if _, err := resolveInstallEnv(InstallOptions{}, "edge", nil); err == nil {
|
||||
t.Fatal("named instance without port accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("basic auth XOR rejected", func(t *testing.T) {
|
||||
_, err := resolveInstallEnv(InstallOptions{Login: "admin"}, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("login-only accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderEnvFileOrderAndOmission(t *testing.T) {
|
||||
got := string(renderEnvFile(map[string]string{
|
||||
"WORKER_PORT": "27402",
|
||||
"RSMON_TOKEN": "secret",
|
||||
"RSMON_URL": "https://rsmon.ru",
|
||||
"WORKER_LOGIN": "", // omitted
|
||||
"WORKER_PASSWORD": "",
|
||||
}))
|
||||
want := "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_PORT=27402\n"
|
||||
if got != want {
|
||||
t.Fatalf("renderEnvFile = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnvValue(t *testing.T) {
|
||||
for _, v := range []string{"secret", "abc-123", "https://rsmon.ru", "27402"} {
|
||||
if err := validateEnvValue("KEY", v); err != nil {
|
||||
t.Fatalf("validateEnvValue(%q): %v", v, err)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"a b", `"q"`, `'q'`, "a$b", `a\b`, "a\nb"} {
|
||||
if err := validateEnvValue("KEY", v); err == nil {
|
||||
t.Fatalf("validateEnvValue(%q) succeeded", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user