From 459c9ef3173ab7fd7cc46ade1a582e08de20e96d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:34:55 +0200 Subject: [PATCH] [client] Add env and status flags for netbird service command (#3975) --- .github/workflows/golang-test-linux.yml | 10 +- client/cmd/root.go | 16 +- client/cmd/service.go | 80 +++++-- client/cmd/service_controller.go | 162 +++++++-------- client/cmd/service_installer.go | 248 ++++++++++++++++------ client/cmd/service_test.go | 263 ++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 178 deletions(-) create mode 100644 client/cmd/service_test.go diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 1fa8b406f..0d7233c3e 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 outputs: management: ${{ steps.filter.outputs.management }} - steps: + steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,8 +24,8 @@ jobs: id: filter with: filters: | - management: - - 'management/**' + management: + - 'management/**' - name: Install Go uses: actions/setup-go@v5 @@ -148,7 +148,7 @@ jobs: test_client_on_docker: name: "Client (Docker) / Unit" - needs: [build-cache] + needs: [ build-cache ] runs-on: ubuntu-22.04 steps: - name: Install Go @@ -181,6 +181,7 @@ jobs: env: HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }} HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }} + CONTAINER: "true" run: | CONTAINER_GOCACHE="/root/.cache/go-build" CONTAINER_GOMODCACHE="/go/pkg/mod" @@ -198,6 +199,7 @@ jobs: -e GOARCH=${GOARCH_TARGET} \ -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ + -e CONTAINER=${CONTAINER} \ golang:1.23-alpine \ sh -c ' \ apk update; apk add --no-cache \ diff --git a/client/cmd/root.go b/client/cmd/root.go index fa4bd4d42..bfd0d06c5 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -67,7 +67,6 @@ var ( interfaceName string wireguardPort uint16 networkMonitor bool - serviceName string autoConnectDisabled bool extraIFaceBlackList []string anonymizeFlag bool @@ -116,15 +115,9 @@ func init() { defaultDaemonAddr = "tcp://127.0.0.1:41731" } - defaultServiceName := "netbird" - if runtime.GOOS == "windows" { - defaultServiceName = "Netbird" - } - rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", internal.DefaultManagementURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", internal.DefaultAdminURL)) - rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", defaultConfigPath, "Netbird config file location") rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets Netbird log level") rootCmd.PersistentFlags().StringVar(&logFile, "log-file", defaultLogFile, "sets Netbird log path. If console is specified the log will be output to stdout. If syslog is specified the log will be sent to syslog daemon.") @@ -135,7 +128,6 @@ func init() { rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device") rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output") - rootCmd.AddCommand(serviceCmd) rootCmd.AddCommand(upCmd) rootCmd.AddCommand(downCmd) rootCmd.AddCommand(statusCmd) @@ -146,9 +138,6 @@ func init() { rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) - serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd) // service control commands are subcommands of service - serviceCmd.AddCommand(installCmd, uninstallCmd) // service installer commands are subcommands of service - networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) @@ -186,14 +175,13 @@ func SetupCloseHandler(ctx context.Context, cancel context.CancelFunc) { termCh := make(chan os.Signal, 1) signal.Notify(termCh, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { - done := ctx.Done() + defer cancel() select { - case <-done: + case <-ctx.Done(): case <-termCh: } log.Info("shutdown signal received") - cancel() }() } diff --git a/client/cmd/service.go b/client/cmd/service.go index 156e67d6d..178f4bf0e 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -1,12 +1,15 @@ +//go:build !ios && !android + package cmd import ( "context" + "fmt" "runtime" + "strings" "sync" "github.com/kardianos/service" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -14,6 +17,16 @@ import ( "github.com/netbirdio/netbird/client/server" ) +var serviceCmd = &cobra.Command{ + Use: "service", + Short: "manages Netbird service", +} + +var ( + serviceName string + serviceEnvVars []string +) + type program struct { ctx context.Context cancel context.CancelFunc @@ -22,12 +35,31 @@ type program struct { serverInstanceMu sync.Mutex } +func init() { + defaultServiceName := "netbird" + if runtime.GOOS == "windows" { + defaultServiceName = "Netbird" + } + + serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd) + + rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") + serviceEnvDesc := `Sets extra environment variables for the service. ` + + `You can specify a comma-separated list of KEY=VALUE pairs. ` + + `E.g. --service-env LOG_LEVEL=debug,CUSTOM_VAR=value` + + installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc) + reconfigureCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc) + + rootCmd.AddCommand(serviceCmd) +} + func newProgram(ctx context.Context, cancel context.CancelFunc) *program { ctx = internal.CtxInitState(ctx) return &program{ctx: ctx, cancel: cancel} } -func newSVCConfig() *service.Config { +func newSVCConfig() (*service.Config, error) { config := &service.Config{ Name: serviceName, DisplayName: "Netbird", @@ -36,23 +68,47 @@ func newSVCConfig() *service.Config { EnvVars: make(map[string]string), } + if len(serviceEnvVars) > 0 { + extraEnvs, err := parseServiceEnvVars(serviceEnvVars) + if err != nil { + return nil, fmt.Errorf("parse service environment variables: %w", err) + } + config.EnvVars = extraEnvs + } + if runtime.GOOS == "linux" { config.EnvVars["SYSTEMD_UNIT"] = serviceName } - return config + return config, nil } func newSVC(prg *program, conf *service.Config) (service.Service, error) { - s, err := service.New(prg, conf) - if err != nil { - log.Fatal(err) - return nil, err - } - return s, nil + return service.New(prg, conf) } -var serviceCmd = &cobra.Command{ - Use: "service", - Short: "manages Netbird service", +func parseServiceEnvVars(envVars []string) (map[string]string, error) { + envMap := make(map[string]string) + + for _, env := range envVars { + if env == "" { + continue + } + + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid environment variable format: %s (expected KEY=VALUE)", env) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + if key == "" { + return nil, fmt.Errorf("empty environment variable key in: %s", env) + } + + envMap[key] = value + } + + return envMap, nil } diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 5e3c63e57..2545623ec 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -1,3 +1,5 @@ +//go:build !ios && !android + package cmd import ( @@ -47,14 +49,13 @@ func (p *program) Start(svc service.Service) error { listen, err := net.Listen(split[0], split[1]) if err != nil { - return fmt.Errorf("failed to listen daemon interface: %w", err) + return fmt.Errorf("listen daemon interface: %w", err) } go func() { defer listen.Close() if split[0] == "unix" { - err = os.Chmod(split[1], 0666) - if err != nil { + if err := os.Chmod(split[1], 0666); err != nil { log.Errorf("failed setting daemon permissions: %v", split[1]) return } @@ -100,37 +101,49 @@ func (p *program) Stop(srv service.Service) error { return nil } +// Common setup for service control commands +func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(serviceCmd) + + cmd.SetOut(cmd.OutOrStdout()) + + if err := handleRebrand(cmd); err != nil { + return nil, err + } + + if err := util.InitLog(logLevel, logFile); err != nil { + return nil, fmt.Errorf("init log: %w", err) + } + + cfg, err := newSVCConfig() + if err != nil { + return nil, fmt.Errorf("create service config: %w", err) + } + + s, err := newSVC(newProgram(ctx, cancel), cfg) + if err != nil { + return nil, err + } + + return s, nil +} + var runCmd = &cobra.Command{ Use: "run", Short: "runs Netbird as service", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - - cmd.SetOut(cmd.OutOrStdout()) - - err := handleRebrand(cmd) - if err != nil { - return err - } - - err = util.InitLog(logLevel, logFile) - if err != nil { - return fmt.Errorf("failed initializing log %v", err) - } - ctx, cancel := context.WithCancel(cmd.Context()) + SetupCloseHandler(ctx, cancel) SetupDebugHandler(ctx, nil, nil, nil, logFile) - s, err := newSVC(newProgram(ctx, cancel), newSVCConfig()) + s, err := setupServiceControlCommand(cmd, ctx, cancel) if err != nil { return err } - err = s.Run() - if err != nil { - return err - } - return nil + + return s.Run() }, } @@ -138,31 +151,14 @@ var startCmd = &cobra.Command{ Use: "start", Short: "starts Netbird service", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - - cmd.SetOut(cmd.OutOrStdout()) - - err := handleRebrand(cmd) - if err != nil { - return err - } - - err = util.InitLog(logLevel, logFile) - if err != nil { - return err - } - ctx, cancel := context.WithCancel(cmd.Context()) - - s, err := newSVC(newProgram(ctx, cancel), newSVCConfig()) + s, err := setupServiceControlCommand(cmd, ctx, cancel) if err != nil { - cmd.PrintErrln(err) return err } - err = s.Start() - if err != nil { - cmd.PrintErrln(err) - return err + + if err := s.Start(); err != nil { + return fmt.Errorf("start service: %w", err) } cmd.Println("Netbird service has been started") return nil @@ -173,29 +169,14 @@ var stopCmd = &cobra.Command{ Use: "stop", Short: "stops Netbird service", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - - cmd.SetOut(cmd.OutOrStdout()) - - err := handleRebrand(cmd) - if err != nil { - return err - } - - err = util.InitLog(logLevel, logFile) - if err != nil { - return fmt.Errorf("failed initializing log %v", err) - } - ctx, cancel := context.WithCancel(cmd.Context()) - - s, err := newSVC(newProgram(ctx, cancel), newSVCConfig()) + s, err := setupServiceControlCommand(cmd, ctx, cancel) if err != nil { return err } - err = s.Stop() - if err != nil { - return err + + if err := s.Stop(); err != nil { + return fmt.Errorf("stop service: %w", err) } cmd.Println("Netbird service has been stopped") return nil @@ -206,31 +187,48 @@ var restartCmd = &cobra.Command{ Use: "restart", Short: "restarts Netbird service", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - - cmd.SetOut(cmd.OutOrStdout()) - - err := handleRebrand(cmd) - if err != nil { - return err - } - - err = util.InitLog(logLevel, logFile) - if err != nil { - return fmt.Errorf("failed initializing log %v", err) - } - ctx, cancel := context.WithCancel(cmd.Context()) - - s, err := newSVC(newProgram(ctx, cancel), newSVCConfig()) + s, err := setupServiceControlCommand(cmd, ctx, cancel) if err != nil { return err } - err = s.Restart() - if err != nil { - return err + + if err := s.Restart(); err != nil { + return fmt.Errorf("restart service: %w", err) } cmd.Println("Netbird service has been restarted") return nil }, } + +var svcStatusCmd = &cobra.Command{ + Use: "status", + Short: "shows Netbird service status", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(cmd.Context()) + s, err := setupServiceControlCommand(cmd, ctx, cancel) + if err != nil { + return err + } + + status, err := s.Status() + if err != nil { + return fmt.Errorf("get service status: %w", err) + } + + var statusText string + switch status { + case service.StatusRunning: + statusText = "Running" + case service.StatusStopped: + statusText = "Stopped" + case service.StatusUnknown: + statusText = "Unknown" + default: + statusText = fmt.Sprintf("Unknown (%d)", status) + } + + cmd.Printf("Netbird service status: %s\n", statusText) + return nil + }, +} diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index c1d6308c6..951efcc73 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -1,87 +1,121 @@ +//go:build !ios && !android + package cmd import ( "context" + "errors" + "fmt" "os" "path/filepath" "runtime" + "github.com/kardianos/service" "github.com/spf13/cobra" ) +var ErrGetServiceStatus = fmt.Errorf("failed to get service status") + +// Common service command setup +func setupServiceCommand(cmd *cobra.Command) error { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(serviceCmd) + cmd.SetOut(cmd.OutOrStdout()) + return handleRebrand(cmd) +} + +// Build service arguments for install/reconfigure +func buildServiceArguments() []string { + args := []string{ + "service", + "run", + "--config", + configPath, + "--log-level", + logLevel, + "--daemon-addr", + daemonAddr, + } + + if managementURL != "" { + args = append(args, "--management-url", managementURL) + } + + if logFile != "" { + args = append(args, "--log-file", logFile) + } + + return args +} + +// Configure platform-specific service settings +func configurePlatformSpecificSettings(svcConfig *service.Config) error { + if runtime.GOOS == "linux" { + // Respected only by systemd systems + svcConfig.Dependencies = []string{"After=network.target syslog.target"} + + if logFile != "console" { + setStdLogPath := true + dir := filepath.Dir(logFile) + + if _, err := os.Stat(dir); err != nil { + if err = os.MkdirAll(dir, 0750); err != nil { + setStdLogPath = false + } + } + + if setStdLogPath { + svcConfig.Option["LogOutput"] = true + svcConfig.Option["LogDirectory"] = dir + } + } + } + + if runtime.GOOS == "windows" { + svcConfig.Option["OnFailure"] = "restart" + } + + return nil +} + +// Create fully configured service config for install/reconfigure +func createServiceConfigForInstall() (*service.Config, error) { + svcConfig, err := newSVCConfig() + if err != nil { + return nil, fmt.Errorf("create service config: %w", err) + } + + svcConfig.Arguments = buildServiceArguments() + if err = configurePlatformSpecificSettings(svcConfig); err != nil { + return nil, fmt.Errorf("configure platform-specific settings: %w", err) + } + + return svcConfig, nil +} + var installCmd = &cobra.Command{ Use: "install", Short: "installs Netbird service", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - - cmd.SetOut(cmd.OutOrStdout()) - - err := handleRebrand(cmd) - if err != nil { + if err := setupServiceCommand(cmd); err != nil { return err } - svcConfig := newSVCConfig() - - svcConfig.Arguments = []string{ - "service", - "run", - "--config", - configPath, - "--log-level", - logLevel, - "--daemon-addr", - daemonAddr, - } - - if managementURL != "" { - svcConfig.Arguments = append(svcConfig.Arguments, "--management-url", managementURL) - } - - if logFile != "" { - svcConfig.Arguments = append(svcConfig.Arguments, "--log-file", logFile) - } - - if runtime.GOOS == "linux" { - // Respected only by systemd systems - svcConfig.Dependencies = []string{"After=network.target syslog.target"} - - if logFile != "console" { - setStdLogPath := true - dir := filepath.Dir(logFile) - - _, err := os.Stat(dir) - if err != nil { - err = os.MkdirAll(dir, 0750) - if err != nil { - setStdLogPath = false - } - } - - if setStdLogPath { - svcConfig.Option["LogOutput"] = true - svcConfig.Option["LogDirectory"] = dir - } - } - } - - if runtime.GOOS == "windows" { - svcConfig.Option["OnFailure"] = "restart" + svcConfig, err := createServiceConfigForInstall() + if err != nil { + return err } ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() s, err := newSVC(newProgram(ctx, cancel), svcConfig) if err != nil { - cmd.PrintErrln(err) return err } - err = s.Install() - if err != nil { - cmd.PrintErrln(err) - return err + if err := s.Install(); err != nil { + return fmt.Errorf("install service: %w", err) } cmd.Println("Netbird service has been installed") @@ -93,27 +127,109 @@ var uninstallCmd = &cobra.Command{ Use: "uninstall", Short: "uninstalls Netbird service from system", RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) + if err := setupServiceCommand(cmd); err != nil { + return err + } - cmd.SetOut(cmd.OutOrStdout()) + cfg, err := newSVCConfig() + if err != nil { + return fmt.Errorf("create service config: %w", err) + } - err := handleRebrand(cmd) + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + s, err := newSVC(newProgram(ctx, cancel), cfg) + if err != nil { + return err + } + + if err := s.Uninstall(); err != nil { + return fmt.Errorf("uninstall service: %w", err) + } + + cmd.Println("Netbird service has been uninstalled") + return nil + }, +} + +var reconfigureCmd = &cobra.Command{ + Use: "reconfigure", + Short: "reconfigures Netbird service with new settings", + Long: `Reconfigures the Netbird service with new settings without manual uninstall/install. +This command will temporarily stop the service, update its configuration, and restart it if it was running.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := setupServiceCommand(cmd); err != nil { + return err + } + + wasRunning, err := isServiceRunning() + if err != nil && !errors.Is(err, ErrGetServiceStatus) { + return fmt.Errorf("check service status: %w", err) + } + + svcConfig, err := createServiceConfigForInstall() if err != nil { return err } ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() - s, err := newSVC(newProgram(ctx, cancel), newSVCConfig()) + s, err := newSVC(newProgram(ctx, cancel), svcConfig) if err != nil { - return err + return fmt.Errorf("create service: %w", err) } - err = s.Uninstall() - if err != nil { - return err + if wasRunning { + cmd.Println("Stopping Netbird service...") + if err := s.Stop(); err != nil { + cmd.Printf("Warning: failed to stop service: %v\n", err) + } } - cmd.Println("Netbird service has been uninstalled") + + cmd.Println("Removing existing service configuration...") + if err := s.Uninstall(); err != nil { + return fmt.Errorf("uninstall existing service: %w", err) + } + + cmd.Println("Installing service with new configuration...") + if err := s.Install(); err != nil { + return fmt.Errorf("install service with new config: %w", err) + } + + if wasRunning { + cmd.Println("Starting Netbird service...") + if err := s.Start(); err != nil { + return fmt.Errorf("start service after reconfigure: %w", err) + } + cmd.Println("Netbird service has been reconfigured and started") + } else { + cmd.Println("Netbird service has been reconfigured") + } + return nil }, } + +func isServiceRunning() (bool, error) { + cfg, err := newSVCConfig() + if err != nil { + return false, err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctx, cancel), cfg) + if err != nil { + return false, err + } + + status, err := s.Status() + if err != nil { + return false, fmt.Errorf("%w: %w", ErrGetServiceStatus, err) + } + + return status == service.StatusRunning, nil +} diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go new file mode 100644 index 000000000..6d75ca524 --- /dev/null +++ b/client/cmd/service_test.go @@ -0,0 +1,263 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/kardianos/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serviceStartTimeout = 10 * time.Second + serviceStopTimeout = 5 * time.Second + statusPollInterval = 500 * time.Millisecond +) + +// waitForServiceStatus waits for service to reach expected status with timeout +func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { + cfg, err := newSVCConfig() + if err != nil { + return false, err + } + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + return false, err + } + + ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) + defer timeoutCancel() + + ticker := time.NewTicker(statusPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) + case <-ticker.C: + status, err := s.Status() + if err != nil { + // Continue polling on transient errors + continue + } + if status == expectedStatus { + return true, nil + } + } + } +} + +// TestServiceLifecycle tests the complete service lifecycle +func TestServiceLifecycle(t *testing.T) { + // TODO: Add support for Windows and macOS + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) + } + + if os.Getenv("CONTAINER") == "true" { + t.Skip("Skipping service lifecycle test in container environment") + } + + originalServiceName := serviceName + serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) + defer func() { + serviceName = originalServiceName + }() + + tempDir := t.TempDir() + configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) + logLevel = "info" + daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) + + ctx := context.Background() + + t.Run("Install", func(t *testing.T) { + installCmd.SetContext(ctx) + err := installCmd.RunE(installCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + status, err := s.Status() + assert.NoError(t, err) + assert.NotEqual(t, service.StatusUnknown, status) + }) + + t.Run("Start", func(t *testing.T) { + startCmd.SetContext(ctx) + err := startCmd.RunE(startCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Restart", func(t *testing.T) { + restartCmd.SetContext(ctx) + err := restartCmd.RunE(restartCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Reconfigure", func(t *testing.T) { + originalLogLevel := logLevel + logLevel = "debug" + defer func() { + logLevel = originalLogLevel + }() + + reconfigureCmd.SetContext(ctx) + err := reconfigureCmd.RunE(reconfigureCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Stop", func(t *testing.T) { + stopCmd.SetContext(ctx) + err := stopCmd.RunE(stopCmd, []string{}) + require.NoError(t, err) + + stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) + require.NoError(t, err) + assert.True(t, stopped) + }) + + t.Run("Uninstall", func(t *testing.T) { + uninstallCmd.SetContext(ctx) + err := uninstallCmd.RunE(uninstallCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + _, err = s.Status() + assert.Error(t, err) + }) +} + +// TestServiceEnvVars tests environment variable parsing +func TestServiceEnvVars(t *testing.T) { + tests := []struct { + name string + envVars []string + expected map[string]string + expectErr bool + }{ + { + name: "Valid single env var", + envVars: []string{"LOG_LEVEL=debug"}, + expected: map[string]string{ + "LOG_LEVEL": "debug", + }, + }, + { + name: "Valid multiple env vars", + envVars: []string{"LOG_LEVEL=debug", "CUSTOM_VAR=value"}, + expected: map[string]string{ + "LOG_LEVEL": "debug", + "CUSTOM_VAR": "value", + }, + }, + { + name: "Env var with spaces", + envVars: []string{" KEY = value "}, + expected: map[string]string{ + "KEY": "value", + }, + }, + { + name: "Invalid format - no equals", + envVars: []string{"INVALID"}, + expectErr: true, + }, + { + name: "Invalid format - empty key", + envVars: []string{"=value"}, + expectErr: true, + }, + { + name: "Empty value is valid", + envVars: []string{"KEY="}, + expected: map[string]string{ + "KEY": "", + }, + }, + { + name: "Empty slice", + envVars: []string{}, + expected: map[string]string{}, + }, + { + name: "Empty string in slice", + envVars: []string{"", "KEY=value", ""}, + expected: map[string]string{"KEY": "value"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseServiceEnvVars(tt.envVars) + + if tt.expectErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +// TestServiceConfigWithEnvVars tests service config creation with env vars +func TestServiceConfigWithEnvVars(t *testing.T) { + originalServiceName := serviceName + originalServiceEnvVars := serviceEnvVars + defer func() { + serviceName = originalServiceName + serviceEnvVars = originalServiceEnvVars + }() + + serviceName = "test-service" + serviceEnvVars = []string{"TEST_VAR=test_value", "ANOTHER_VAR=another_value"} + + cfg, err := newSVCConfig() + require.NoError(t, err) + + assert.Equal(t, "test-service", cfg.Name) + assert.Equal(t, "test_value", cfg.EnvVars["TEST_VAR"]) + assert.Equal(t, "another_value", cfg.EnvVars["ANOTHER_VAR"]) + + if runtime.GOOS == "linux" { + assert.Equal(t, "test-service", cfg.EnvVars["SYSTEMD_UNIT"]) + } +}