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.
Этот коммит содержится в:
@@ -77,6 +77,13 @@ Docker image references. The Harbor project is appended separately as `rsmon`.
|
||||
|
||||
## systemd
|
||||
|
||||
The `install` subcommand turns a built binary into an enabled systemd service:
|
||||
it writes a hardened unit, a mode-0600 env file, the data directory, then
|
||||
enables and starts the worker. It reads configuration from flags, `--env-file`,
|
||||
a `.env` in the working directory, or the process environment (in that order),
|
||||
and supports several co-located workers per host via `--name`. The full
|
||||
reference is in [`docs/install.md`](docs/install.md).
|
||||
|
||||
Install host dependencies first. On Debian or Ubuntu:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,10 +36,14 @@ var (
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
// Load .env before any subcommand dispatch so management commands
|
||||
// (install/deploy) read the same environment as the worker runtime.
|
||||
// godotenv.Load only fills variables not already present in the
|
||||
// process environment, so an explicit export or sudo -E still wins.
|
||||
loadDotEnv()
|
||||
if handled, code := dispatchManagementCommand(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
loadDotEnv()
|
||||
|
||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||
noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)")
|
||||
|
||||
@@ -28,18 +28,24 @@ func installCommand(args []string) int {
|
||||
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var opts installer.InstallOptions
|
||||
var tokenFile string
|
||||
var tokenFile, passwordFile string
|
||||
fs.StringVar(&opts.Binary, "binary", "", "worker binary to install (default: this executable)")
|
||||
fs.StringVar(&opts.EnvFile, "env-file", "", "existing worker environment file")
|
||||
fs.StringVar(&opts.Token, "token", "", "worker API token")
|
||||
fs.StringVar(&opts.EnvFile, "env-file", "", "worker environment file (systemd-safe KEY=VALUE; overrides .env/process env)")
|
||||
fs.StringVar(&opts.Token, "token", "", "worker API token (RSMON_TOKEN)")
|
||||
fs.StringVar(&opts.Token, "api-key", "", "worker API token (alias for --token)")
|
||||
fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token")
|
||||
fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL")
|
||||
fs.StringVar(&opts.URL, "url", "", "RSMon server URL (RSMON_URL; default https://rsmon.ru)")
|
||||
fs.StringVar(&opts.Host, "host", "", "operator console bind address (WORKER_HOST; default 127.0.0.1)")
|
||||
fs.StringVar(&opts.Port, "port", "", "operator console port (WORKER_PORT; required with --name)")
|
||||
fs.StringVar(&opts.Login, "login", "", "operator console login (WORKER_LOGIN; default admin with a generated password)")
|
||||
fs.StringVar(&opts.Password, "password", "", "operator console password (WORKER_PASSWORD)")
|
||||
fs.StringVar(&passwordFile, "password-file", "", "file containing the operator console password")
|
||||
fs.StringVar(&opts.Name, "name", "", "instance name: installs a co-located worker (rsmon-worker-<name>) with its own config/data/unit/port")
|
||||
fs.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary")
|
||||
fs.StringVar(&opts.Image, "image", installer.DefaultImage, "immutable Docker repository@sha256 digest required with --docker")
|
||||
fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install --token TOKEN [--url URL] [--no-start]")
|
||||
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install [--token TOKEN|--env-file FILE] [--name NAME] [--port PORT] [options]")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -57,11 +63,14 @@ func installCommand(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if opts.Password, err = secretValue(opts.Password, passwordFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := installer.Install(opts); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "rsmon-worker installed")
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
251
docs/install.md
Обычный файл
251
docs/install.md
Обычный файл
@@ -0,0 +1,251 @@
|
||||
# Installation
|
||||
|
||||
The `rsmon-worker install` subcommand turns a built binary (or a published
|
||||
Docker image) into a running, enabled systemd service on a Linux host. It is
|
||||
the supported way to deploy the worker: it writes the configuration, the
|
||||
systemd unit, and the data directory, then starts the service.
|
||||
|
||||
> A shell installer that downloads a pre-built binary is planned. Today
|
||||
> `install` copies the binary you invoke it from (or pulls the `--image`
|
||||
> digest), so build first with `make build` and run the resulting
|
||||
> `./bin/rsmon-worker`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with systemd.
|
||||
- Root (`install must be run as root`). The worker runs as `root` so it can
|
||||
reach the Docker socket for Compose discovery and use `CAP_NET_RAW` for ping
|
||||
checks without extra setup.
|
||||
- For the binary install: the worker binary you want to install.
|
||||
- For the Docker install (`--docker`): the `docker` CLI and an immutable
|
||||
`repository@sha256:<64 lowercase hex>` image digest.
|
||||
|
||||
Host dependencies for browser-backed HTTP checks (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates chromium libcap2-bin tzdata
|
||||
```
|
||||
|
||||
## Configuration sources
|
||||
|
||||
The installer reads the same environment variables the worker runtime reads.
|
||||
Each variable is resolved with this precedence (highest first):
|
||||
|
||||
1. **Explicit flags** (`--url`, `--token`, `--host`, `--port`, `--login`,
|
||||
`--password`, `--name`).
|
||||
2. **`--env-file`** — a strict, systemd-safe `KEY=VALUE` file (validated before
|
||||
anything is written to disk).
|
||||
3. **Process environment**, including a `.env` file in the working directory
|
||||
(loaded automatically at startup via `godotenv`).
|
||||
4. **Built-in defaults** (`RSMON_URL=https://rsmon.ru`, `WORKER_HOST=127.0.0.1`,
|
||||
`WORKER_PORT=27401` for the primary instance).
|
||||
|
||||
`.env` files are loaded before the install command runs, so
|
||||
`sudo rsmon-worker install` from a directory containing a `.env` picks up those
|
||||
values automatically. To override a value, pass the matching flag.
|
||||
|
||||
### Variables
|
||||
|
||||
| Variable | Required | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `RSMON_URL` | yes | `https://rsmon.ru` | Control-plane base URL. |
|
||||
| `RSMON_TOKEN` | yes | none | Worker bearer token. |
|
||||
| `WORKER_HOST` | no | `127.0.0.1` | Operator-console bind address. |
|
||||
| `WORKER_PORT` | no | `27401` (primary) | Operator-console port. **Required** for named instances. |
|
||||
| `WORKER_URL` | no | none | Public URL advertised to the control plane. |
|
||||
| `WORKER_LOGIN` | no | `admin` (generated) | Operator-console basic-auth login. |
|
||||
| `WORKER_PASSWORD` | no | generated | Operator-console basic-auth password. |
|
||||
| `WORKER_COMPOSE_ENABLED` | no | feature default (on) | Enable Docker Compose discovery/management. |
|
||||
| `WORKER_CLUSTER_*` | no | none | Optional Raft cluster (`ID`, `PORT`, `PEERS`, `DATA_DIR`, `ENABLED`). |
|
||||
| `WORKER_RELEASE_URL` | no | none | Self-update release feed URL. |
|
||||
|
||||
`WORKER_LOGIN` and `WORKER_PASSWORD` must both be set or both be left empty. If
|
||||
both are empty, the installer generates a random password (login `admin`),
|
||||
writes it to the env file, and prints it once. Record it; the operator console
|
||||
requires it for both the browser login and the `/web/api/*` HTTP basic-auth
|
||||
endpoints.
|
||||
|
||||
Values must be systemd-safe: no whitespace, quotes, backslashes, or `$`
|
||||
interpolation inside a value. This keeps the file unambiguous across systemd
|
||||
`EnvironmentFile` and `docker --env-file`.
|
||||
|
||||
## Single-instance install (one worker per host)
|
||||
|
||||
The classic install uses the canonical paths and the default port 27401.
|
||||
|
||||
```bash
|
||||
make build
|
||||
printf '%s\n' 'YOUR_WORKER_TOKEN' > worker-token
|
||||
chmod 600 worker-token
|
||||
sudo ./bin/rsmon-worker install \
|
||||
--token-file worker-token \
|
||||
--port 27401 \
|
||||
--password 'choose-a-console-password'
|
||||
rm worker-token
|
||||
```
|
||||
|
||||
If you keep configuration in a `.env` (recommended for repeatability):
|
||||
|
||||
```bash
|
||||
# .env
|
||||
RSMON_URL=https://rsmon.ru
|
||||
RSMON_TOKEN=...
|
||||
WORKER_PORT=27401
|
||||
WORKER_LOGIN=admin
|
||||
WORKER_PASSWORD=...
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ./bin/rsmon-worker install
|
||||
```
|
||||
|
||||
Installed layout:
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `/usr/local/bin/rsmon-worker` | Worker binary. |
|
||||
| `/etc/rsmon-worker/worker.env` | Configuration, mode `0600`. |
|
||||
| `/var/lib/rsmon-worker/` | Data directory (SQLite, webapp state). |
|
||||
| `/etc/systemd/system/rsmon-worker.service` | systemd unit. |
|
||||
|
||||
## Multi-instance install (several workers per host)
|
||||
|
||||
`--name` installs a co-located worker under `rsmon-worker-<name>` with its own
|
||||
binary, config, data, systemd unit, and port. This is how you run a staging
|
||||
build next to production, or isolate tenants on one host.
|
||||
|
||||
```bash
|
||||
sudo ./bin/rsmon-worker install --name edge --port 27403 --password '...'
|
||||
```
|
||||
|
||||
A named instance must have an explicit `WORKER_PORT` (the default 27401 belongs
|
||||
to the primary). The name must be 1–32 chars, lowercase alphanumeric and
|
||||
hyphens, starting and ending alphanumeric.
|
||||
|
||||
Named layout (for `--name edge`):
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `/usr/local/bin/rsmon-worker-edge` | Binary. |
|
||||
| `/etc/rsmon-worker-edge/worker.env` | Configuration, mode `0600`. |
|
||||
| `/var/lib/rsmon-worker-edge/` | Data directory. |
|
||||
| `/etc/systemd/system/rsmon-worker-edge.service` | systemd unit. |
|
||||
|
||||
Each instance is an independent service (`rsmon-worker.service`,
|
||||
`rsmon-worker-edge.service`, …) and can be managed separately.
|
||||
|
||||
## What the installer does
|
||||
|
||||
For a binary install, `rsmon-worker install` performs these actions in order:
|
||||
|
||||
1. **Validates** the instance name, the `--env-file` (if any), and the resolved
|
||||
`RSMON_URL`/`RSMON_TOKEN`. Nothing on the host changes before validation
|
||||
passes.
|
||||
2. **Resolves** every supported variable with the precedence above and, when
|
||||
`WORKER_LOGIN`/`WORKER_PASSWORD` are both unset, generates a random console
|
||||
password.
|
||||
3. **Copies the binary** from the running executable (or `--binary`) to
|
||||
`/usr/local/bin/rsmon-worker[-<name>]` with an atomic rename.
|
||||
4. **Creates** the data directory (`/var/lib/rsmon-worker[-<name>]/webapp`) and
|
||||
the config directory (`/etc/rsmon-worker[-<name>]`, mode `0750`).
|
||||
5. **Writes the env file** to `<config dir>/worker.env` (mode `0600`), in a
|
||||
stable canonical order.
|
||||
6. **Writes the systemd unit** to
|
||||
`/etc/systemd/system/rsmon-worker[-<name>].service`.
|
||||
7. Runs `systemctl daemon-reload`, `systemctl enable`, and, unless `--no-start`,
|
||||
`systemctl restart` followed by `systemctl is-active --quiet` to confirm the
|
||||
service came up.
|
||||
8. **Prints a summary**: unit name, binary, env file, data dir, console URL,
|
||||
status, and the generated password if one was created.
|
||||
|
||||
### The generated systemd unit
|
||||
|
||||
The unit is a hardened `Type=simple` root service ordered after
|
||||
`network-online.target` and `docker.service`:
|
||||
|
||||
- `ExecStart=/usr/local/bin/rsmon-worker[-<name>]`
|
||||
- `EnvironmentFile=/etc/rsmon-worker[-<name>]/worker.env`
|
||||
- `Environment=HOME=/var/lib/rsmon-worker[-<name>]`
|
||||
- `Environment=RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker[-<name>]/webapp`
|
||||
- `AmbientCapabilities=CAP_NET_RAW` / `CapabilityBoundingSet=CAP_NET_RAW` for
|
||||
ICMP checks.
|
||||
- `PrivateTmp`, `ProtectHome`, `ProtectSystem=full`, and
|
||||
`ReadWritePaths=<data dir>` to constrain writes.
|
||||
- `Restart=always`, `RestartSec=5s`.
|
||||
|
||||
## Docker install
|
||||
|
||||
Installs a systemd unit that runs the published image instead of a local
|
||||
binary. Requires an immutable digest; mutable tags are rejected.
|
||||
|
||||
```bash
|
||||
sudo ./bin/rsmon-worker install --docker \
|
||||
--image 'reg.rsxx.ru/rsmon/rsmon-worker@sha256:<64-hex-digest>' \
|
||||
--token-file worker-token
|
||||
```
|
||||
|
||||
`--name` works with `--docker` too; the container and volume are namespaced by
|
||||
instance (`rsmon-worker-edge`, `rsmon-worker-data-edge`). The container always
|
||||
mounts its data volume at the in-image `/var/lib/rsmon-worker`.
|
||||
|
||||
## Options reference
|
||||
|
||||
```
|
||||
rsmon-worker install [--token TOKEN|--token-file FILE|--env-file FILE]
|
||||
[--name NAME] [--port PORT] [options]
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `--token`, `--api-key` | Worker API token (`RSMON_TOKEN`). |
|
||||
| `--token-file` | File containing the worker token (avoids shell history). |
|
||||
| `--env-file` | Strict worker env file; validated then used as the config source. |
|
||||
| `--url` | Control-plane URL (`RSMON_URL`). |
|
||||
| `--host` | Console bind address (`WORKER_HOST`). |
|
||||
| `--port` | Console port (`WORKER_PORT`; required with `--name`). |
|
||||
| `--login` | Console login (`WORKER_LOGIN`). |
|
||||
| `--password`, `--password-file` | Console password, or a file containing it. |
|
||||
| `--name` | Instance name for a co-located worker. |
|
||||
| `--binary` | Binary to install (default: this executable). |
|
||||
| `--docker` | Install the prebuilt Docker image (requires `--image`). |
|
||||
| `--image` | Immutable `repository@sha256:<64 hex>` digest. |
|
||||
| `--no-start` | Enable without starting. |
|
||||
|
||||
`--token`/`--token-file` and `--password`/`--password-file` are mutually
|
||||
exclusive within each pair; combining a direct secret with its file form is an
|
||||
error.
|
||||
|
||||
## Post-install operations
|
||||
|
||||
```bash
|
||||
systemctl status rsmon-worker[-<name>]
|
||||
journalctl -u rsmon-worker[-<name>] -f
|
||||
sudo systemctl restart rsmon-worker[-<name>]
|
||||
```
|
||||
|
||||
The public liveness endpoint is `GET /healthz`; probe it with
|
||||
`rsmon-worker liveness`. The operator console is at `http://127.0.0.1:<port>`
|
||||
(loopback by default) and exposes the JSON API under `/web/api/*` using HTTP
|
||||
basic auth.
|
||||
|
||||
### Uninstall
|
||||
|
||||
There is no `uninstall` subcommand yet. To remove an instance manually:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now rsmon-worker-<name>
|
||||
sudo rm /etc/systemd/system/rsmon-worker-<name>.service
|
||||
sudo rm -rf /etc/rsmon-worker-<name> /var/lib/rsmon-worker-<name> /usr/local/bin/rsmon-worker-<name>
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Keep `/etc/rsmon-worker[-<name>]/worker.env` mode `0600`; the installer writes
|
||||
it that way.
|
||||
- Prefer `--token-file`/`--password-file` or a `.env` over passing secrets as
|
||||
flags, which can leak through shell history and process inspection.
|
||||
- Bind the console to loopback (`WORKER_HOST=127.0.0.1`, the default) or front
|
||||
it with an authenticated TLS reverse proxy.
|
||||
- Each worker should use its own control-plane token.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user