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

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.
Этот коммит содержится в:
root
2026-08-03 12:15:29 +03:00
родитель dde720eb44
Коммит 9b82c9f82f
6 изменённых файлов: 812 добавлений и 93 удалений

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

@@ -1,6 +1,8 @@
package installer
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -11,137 +13,408 @@ import (
"regexp"
"strings"
"unicode"
"github.com/joho/godotenv"
)
var (
dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`)
envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`)
envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
instanceNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
)
const (
DefaultURL = "https://rsmon.ru"
DefaultImage = ""
binaryPath = "/usr/local/bin/rsmon-worker"
envPath = "/etc/rsmon-worker/worker.env"
unitPath = "/etc/systemd/system/rsmon-worker.service"
defaultLogin = "admin"
defaultPort = "27401"
)
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
// installEnvKeys is the canonical, ordered set of worker environment
// variables the installer understands and writes to the unit's env
// file. Order matters: the rendered file is stable and readable.
var installEnvKeys = []string{
"RSMON_URL",
"RSMON_TOKEN",
"WORKER_HOST",
"WORKER_PORT",
"WORKER_URL",
"WORKER_LOGIN",
"WORKER_PASSWORD",
"WORKER_COMPOSE_ENABLED",
"WORKER_CLUSTER_ENABLED",
"WORKER_CLUSTER_ID",
"WORKER_CLUSTER_PORT",
"WORKER_CLUSTER_PEERS",
"WORKER_CLUSTER_DATA_DIR",
"WORKER_RELEASE_URL",
}
// InstallOptions captures the install/deploy command-line knobs. The
// classic single-instance install leaves Name empty; a non-empty Name
// installs a co-located worker under rsmon-worker-<name> with its own
// binary path, config dir, data dir, systemd unit, and port.
type InstallOptions struct {
Binary string
EnvFile string
Token string
URL string
Host string
Port string
Login string
Password string
Name string
Docker bool
Image string
NoStart bool
}
// paths is the fully-resolved on-disk layout for an instance. Every
// installer write targets these. The empty Name yields the classic
// rsmon-worker paths; a named instance appends -<name> everywhere.
type paths struct {
name string
binary string // /usr/local/bin/rsmon-worker[-name]
configDir string // /etc/rsmon-worker[-name]
envFile string // <configDir>/worker.env
dataDir string // /var/lib/rsmon-worker[-name]
unitName string // rsmon-worker[-name].service
unitFile string // /etc/systemd/system/<unitName>
container string // docker container name (rsmon-worker[-name])
volume string // docker volume name (rsmon-worker-data[-name])
}
// resolvePaths derives the on-disk layout for an instance name. The
// empty name reproduces the legacy single-instance paths so existing
// deployments keep upgrading in place.
func resolvePaths(name string) paths {
p := paths{
name: name,
binary: "/usr/local/bin/rsmon-worker",
configDir: "/etc/rsmon-worker",
dataDir: "/var/lib/rsmon-worker",
container: "rsmon-worker",
volume: "rsmon-worker-data",
}
if name == "" {
p.unitName = "rsmon-worker.service"
} else {
suffix := "-" + name
p.binary += suffix
p.configDir += suffix
p.dataDir += suffix
p.container += suffix
p.volume += suffix
p.unitName = "rsmon-worker" + suffix + ".service"
}
p.envFile = filepath.Join(p.configDir, "worker.env")
p.unitFile = "/etc/systemd/system/" + p.unitName
return p
}
// validateInstanceName allows lowercase alphanumeric and hyphens, 1-32
// chars, starting and ending alphanumeric. The empty string (the
// primary instance) is always valid.
func validateInstanceName(name string) error {
if name == "" {
return nil
}
if len(name) > 32 || !instanceNamePattern.MatchString(name) {
return errors.New("instance name must be 1-32 chars, lowercase alphanumeric and hyphens, starting and ending alphanumeric")
}
return nil
}
// Install copies the worker binary (or pulls the Docker image), writes
// a hardened per-instance systemd unit and env file, then enables and
// (unless --no-start) starts the service.
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 {
name := strings.TrimSpace(opts.Name)
if err := validateInstanceName(name); err != nil {
return err
}
p := resolvePaths(name)
var fileEnv map[string]string
if opts.EnvFile != "" {
// ValidateEnvironmentFile is the strict systemd-safe gate; it
// also confirms RSMON_URL/RSMON_TOKEN are present in the file.
if err := ValidateEnvironmentFile(opts.EnvFile); err != nil {
return err
return fmt.Errorf("--env-file: %w", err)
}
} else {
if err := ValidateToken(opts.Token); err != nil {
return err
fe, err := godotenv.Read(opts.EnvFile)
if err != nil {
return fmt.Errorf("read --env-file: %w", err)
}
fileEnv = fe
}
if opts.Image == "" {
opts.Image = DefaultImage
values, err := resolveInstallEnv(opts, name, fileEnv)
if err != nil {
return err
}
generatedPassword := ""
if values["WORKER_LOGIN"] == "" && values["WORKER_PASSWORD"] == "" {
gen, gerr := generatePassword(18)
if gerr != nil {
return gerr
}
values["WORKER_LOGIN"] = defaultLogin
values["WORKER_PASSWORD"] = gen
generatedPassword = gen
}
// Stage the binary (or pull the image) before touching config so a
// download/build failure leaves the host untouched.
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)
} else {
binary := opts.Binary
if binary == "" {
ex, err := os.Executable()
if err != nil {
return fmt.Errorf("locate worker executable: %w", err)
}
binary = ex
}
if err := copyAtomic(binary, p.binary, 0755); err != nil {
return fmt.Errorf("install binary: %w", err)
}
}
if err := writeAtomic(unitPath, []byte(unit), 0644); err != nil {
if err := os.MkdirAll(filepath.Join(p.dataDir, "webapp"), 0755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
if err := os.MkdirAll(p.configDir, 0750); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
unit := systemdUnitFor(p)
if opts.Docker {
unit = dockerUnitFor(p, opts.Image)
}
if err := writeAtomic(p.unitFile, []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 := writeAtomic(p.envFile, renderEnvFile(values), 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 {
if err := command("systemctl", "enable", p.unitName); err != nil {
return err
}
if !opts.NoStart {
if err := command("systemctl", "restart", "rsmon-worker.service"); err != nil {
if err := command("systemctl", "restart", p.unitName); err != nil {
return err
}
if err := command("systemctl", "is-active", "--quiet", "rsmon-worker.service"); err != nil {
if err := command("systemctl", "is-active", "--quiet", p.unitName); err != nil {
return err
}
}
// Operator-facing summary. Keep it on stdout so it composes with
// scripts; the generated password is only printed once, here.
fmt.Printf("rsmon-worker installed: %s\n", p.unitName)
fmt.Printf(" binary: %s\n", p.binary)
fmt.Printf(" env file: %s (mode 0600)\n", p.envFile)
fmt.Printf(" data dir: %s\n", p.dataDir)
fmt.Printf(" console: http://%s:%s\n", values["WORKER_HOST"], values["WORKER_PORT"])
if opts.NoStart {
fmt.Printf(" status: enabled (not started, --no-start)\n")
} else {
fmt.Printf(" status: enabled and active\n")
}
if generatedPassword != "" {
fmt.Printf(" generated operator console password (login=%s): %s\n",
defaultLogin, generatedPassword)
fmt.Println(" This password is stored in the env file above; record it now.")
}
return nil
}
// resolveInstallEnv applies the installer's precedence for every known
// worker variable: explicit flag > --env-file > process environment >
// built-in default. It fills defaults, enforces required values, and
// validates every resolved value is systemd-safe. Basic-auth XOR is
// enforced here; password generation happens in Install so the value
// can be printed.
func resolveInstallEnv(opts InstallOptions, name string, fileEnv map[string]string) (map[string]string, error) {
flagVals := map[string]string{
"RSMON_URL": opts.URL,
"RSMON_TOKEN": opts.Token,
"WORKER_HOST": opts.Host,
"WORKER_PORT": opts.Port,
"WORKER_LOGIN": opts.Login,
"WORKER_PASSWORD": opts.Password,
}
values := make(map[string]string, len(installEnvKeys))
for _, key := range installEnvKeys {
if v, ok := flagVals[key]; ok && strings.TrimSpace(v) != "" {
values[key] = v
continue
}
if fileEnv != nil {
if v, ok := fileEnv[key]; ok && strings.TrimSpace(v) != "" {
values[key] = v
continue
}
}
if v := os.Getenv(key); strings.TrimSpace(v) != "" {
values[key] = v
}
}
if values["RSMON_URL"] == "" {
values["RSMON_URL"] = DefaultURL
}
if values["WORKER_HOST"] == "" {
values["WORKER_HOST"] = "127.0.0.1"
}
if values["WORKER_PORT"] == "" {
if name == "" {
values["WORKER_PORT"] = defaultPort
} else {
return nil, fmt.Errorf(
"--name %q requires WORKER_PORT (the default %s belongs to the primary instance); "+
"set it via --port, WORKER_PORT, or the env file", name, defaultPort)
}
}
if strings.TrimSpace(values["RSMON_TOKEN"]) == "" {
return nil, errors.New("RSMON_TOKEN is required (set --token/--token-file, WORKER_TOKEN in --env-file, or RSMON_TOKEN in the environment/.env)")
}
if err := ValidateURL(values["RSMON_URL"]); err != nil {
return nil, fmt.Errorf("RSMON_URL: %w", err)
}
if err := ValidateToken(values["RSMON_TOKEN"]); err != nil {
return nil, fmt.Errorf("RSMON_TOKEN: %w", err)
}
// Basic auth: both set or both empty. Generation happens later.
login, pass := values["WORKER_LOGIN"], values["WORKER_PASSWORD"]
if (login == "") != (pass == "") {
return nil, fmt.Errorf("WORKER_LOGIN and WORKER_PASSWORD must both be set or both be empty")
}
// Validate every value we will write is systemd/docker safe.
for _, key := range installEnvKeys {
v, ok := values[key]
if !ok || v == "" {
continue
}
if err := validateEnvValue(key, v); err != nil {
return nil, err
}
}
return values, nil
}
// renderEnvFile produces the canonical KEY=VALUE env file in a stable
// order. Empty values are omitted.
func renderEnvFile(values map[string]string) []byte {
var b strings.Builder
for _, key := range installEnvKeys {
v := values[key]
if v == "" {
continue
}
fmt.Fprintf(&b, "%s=%s\n", key, v)
}
return []byte(b.String())
}
func generatePassword(nBytes int) (string, error) {
buf := make([]byte, nBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// systemdUnitFor renders the hardened binary systemd unit for an
// instance. It mirrors the hardening the operator console expects
// (CAP_NET_RAW for ping, docker.service ordering for Compose
// discovery, a private writable data tree) while staying a plain
// Type=simple root service.
func systemdUnitFor(p paths) string {
description := "RSMon distributed monitoring worker"
if p.name != "" {
description += " (" + p.name + ")"
}
return fmt.Sprintf(`[Unit]
Description=%s
Documentation=https://rocketgit.ru/rsmon/worker
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
Environment=HOME=%[2]s
Environment=RSMON_WEBAPP_DATA_DIR=%[2]s/webapp
EnvironmentFile=%[3]s
WorkingDirectory=%[2]s
ExecStart=%[4]s
Restart=always
RestartSec=5s
TimeoutStopSec=20s
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths=%[2]s
[Install]
WantedBy=multi-user.target
`, description, p.dataDir, p.envFile, p.binary)
}
// dockerUnitFor renders the Docker-backed systemd unit. The container
// always mounts its data volume at the in-image /var/lib/rsmon-worker;
// only the volume (and container) name vary by instance.
func dockerUnitFor(p paths, image string) string {
description := "RSMon distributed monitoring worker (Docker)"
if p.name != "" {
description += " (" + p.name + ")"
}
return fmt.Sprintf(`[Unit]
Description=%s
Documentation=https://rocketgit.ru/rsmon/worker
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
ExecStartPre=-docker rm -f %[2]s
ExecStart=docker run --rm --name %[2]s --network host --cap-add NET_RAW --env-file %[3]s -v %[4]s:/var/lib/rsmon-worker %[5]s
ExecStop=docker stop %[2]s
Restart=on-failure
[Install]
WantedBy=multi-user.target
`, description, p.container, p.envFile, p.volume, image)
}
func ValidateURL(raw string) error {
if strings.ContainsAny(raw, "\r\n") {
return fmt.Errorf("server URL must be an absolute HTTP(S) URL")
@@ -153,6 +426,9 @@ func ValidateURL(raw string) error {
return nil
}
// Environment renders the minimal legacy env file (URL/token/host).
// Kept for callers and tests that depend on the classic shape; the
// installer now writes the full resolved set via renderEnvFile.
func Environment(serverURL, token string) []byte {
return []byte("RSMON_URL=" + serverURL + "\nRSMON_TOKEN=" + token + "\nWORKER_HOST=127.0.0.1\n")
}
@@ -164,8 +440,10 @@ func ValidateToken(token string) error {
return nil
}
// ValidateEnvironmentFile checks the worker credentials before installation
// changes the binary, systemd unit, or Docker image on the host.
// ValidateEnvironmentFile checks a worker credentials file before it
// is used for installation. It enforces strict, systemd-safe
// KEY=VALUE syntax (no quoting, interpolation, or whitespace in
// values) and requires RSMON_URL and RSMON_TOKEN.
func ValidateEnvironmentFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
@@ -185,8 +463,8 @@ func ValidateEnvironmentFile(path string) error {
if !ok || !envKeyPattern.MatchString(key) {
return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber)
}
if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") {
return fmt.Errorf("worker environment line %d uses unsupported quoting, interpolation, or whitespace", lineNumber)
if err := validateEnvValue(key, value); err != nil {
return fmt.Errorf("worker environment line %d: %w", lineNumber, err)
}
if key == "RSMON_URL" || key == "RSMON_TOKEN" {
if seenRequired[key] {
@@ -205,6 +483,20 @@ func ValidateEnvironmentFile(path string) error {
return nil
}
// validateEnvValue enforces the value rules shared by the strict env
// file parser and the installer's resolved values: no whitespace, no
// shell quoting, and no interpolation metacharacters. This keeps the
// file unambiguous across systemd EnvironmentFile and docker --env-file.
func validateEnvValue(key, value string) error {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("%s contains a newline", key)
}
if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") {
return fmt.Errorf("%s uses unsupported quoting, interpolation, or whitespace", key)
}
return nil
}
func ValidateImage(image string) error {
if !dockerImagePattern.MatchString(image) {
return errors.New("Docker image must be an immutable repository@sha256:<64 lowercase hex characters> reference")
@@ -212,8 +504,11 @@ func ValidateImage(image string) error {
return nil
}
// DockerUnit renders the Docker-backed systemd unit for the classic
// single instance. Kept for compatibility; named installs go through
// dockerUnitFor.
func DockerUnit(image string) string {
return fmt.Sprintf(dockerSystemdUnit, image)
return dockerUnitFor(resolvePaths(""), image)
}
func copyAtomic(src, dst string, mode os.FileMode) error {

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

@@ -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)
}
}
}