Все проверки выполнены успешно
CI / test (push) Successful in 10m15s
Docker / Build and publish worker image (push) Successful in 34m59s
599 строки
18 KiB
Go
599 строки
18 KiB
Go
package installer
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
"rocketgit.ru/rsmon/worker/internal/distworker"
|
|
)
|
|
|
|
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_]*$`)
|
|
instanceNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|
)
|
|
|
|
const (
|
|
DefaultURL = "https://rsmon.ru"
|
|
DefaultImage = ""
|
|
|
|
defaultLogin = "admin"
|
|
defaultPort = "27401"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// PUBLIC_URL is the canonical advertised origin. WORKER_URL stays in the
|
|
// list for the bounded migration so legacy env files still resolve; it
|
|
// is dropped from the written file whenever PUBLIC_URL is also present.
|
|
var installEnvKeys = []string{
|
|
"RSMON_URL",
|
|
"RSMON_TOKEN",
|
|
"WORKER_HOST",
|
|
"WORKER_PORT",
|
|
"PUBLIC_URL",
|
|
"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
|
|
PublicURL 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")
|
|
}
|
|
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 fmt.Errorf("--env-file: %w", err)
|
|
}
|
|
fe, err := godotenv.Read(opts.EnvFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read --env-file: %w", err)
|
|
}
|
|
fileEnv = fe
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
} 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 := 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 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", p.unitName); err != nil {
|
|
return err
|
|
}
|
|
if !opts.NoStart {
|
|
if err := command("systemctl", "restart", p.unitName); err != nil {
|
|
return err
|
|
}
|
|
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,
|
|
"PUBLIC_URL": opts.PublicURL,
|
|
"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")
|
|
}
|
|
|
|
// PUBLIC_URL is the canonical advertised origin. When both the
|
|
// canonical and the legacy WORKER_URL resolve, the legacy variable
|
|
// is superseded and must not be written to a fresh env file.
|
|
if values["PUBLIC_URL"] != "" {
|
|
delete(values, "WORKER_URL")
|
|
}
|
|
// Canonical PUBLIC_URL is held to the strict origin shape; the
|
|
// legacy WORKER_URL only to the tolerant absolute-URL check so env
|
|
// files that previously installed keep working.
|
|
if v := values["PUBLIC_URL"]; v != "" {
|
|
if err := distworker.ValidatePublicURL(v); err != nil {
|
|
return nil, fmt.Errorf("PUBLIC_URL: %w", err)
|
|
}
|
|
}
|
|
if v := values["WORKER_URL"]; v != "" {
|
|
if err := distworker.ValidateAdvertisedURL(v); err != nil {
|
|
return nil, fmt.Errorf("WORKER_URL: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
|
return fmt.Errorf("server URL must be an absolute HTTP(S) URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
func ValidateToken(token string) error {
|
|
if strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\r\n") {
|
|
return errors.New("worker token must be non-empty and contain no newlines")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
return fmt.Errorf("read worker environment: %w", err)
|
|
}
|
|
values := make(map[string]string)
|
|
seenRequired := make(map[string]bool)
|
|
for number, line := range strings.Split(string(data), "\n") {
|
|
lineNumber := number + 1
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.ContainsRune(line, '\r') {
|
|
return fmt.Errorf("worker environment line %d contains a carriage return", lineNumber)
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok || !envKeyPattern.MatchString(key) {
|
|
return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", 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] {
|
|
return fmt.Errorf("worker environment line %d duplicates %s", lineNumber, key)
|
|
}
|
|
seenRequired[key] = true
|
|
}
|
|
values[key] = value
|
|
}
|
|
if err := ValidateURL(values["RSMON_URL"]); err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateToken(values["RSMON_TOKEN"]); err != nil {
|
|
return err
|
|
}
|
|
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")
|
|
}
|
|
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 dockerUnitFor(resolvePaths(""), image)
|
|
}
|
|
|
|
func copyAtomic(src, dst string, mode os.FileMode) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
return atomicFile(dst, mode, func(out *os.File) error {
|
|
_, err := io.Copy(out, in)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func writeAtomic(dst string, data []byte, mode os.FileMode) error {
|
|
return atomicFile(dst, mode, func(out *os.File) error {
|
|
_, err := out.Write(data)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func atomicFile(dst string, mode os.FileMode, write func(*os.File) error) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(dst), ".rsmon-worker-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := tmp.Name()
|
|
defer os.Remove(name)
|
|
if err := tmp.Chmod(mode); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := write(tmp); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(name, dst)
|
|
}
|
|
|
|
func command(name string, args ...string) error {
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s failed: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|