From b2922e332aa55d8627d0b1e9364cc53707e7e117 Mon Sep 17 00:00:00 2001 From: logikonline Date: Sun, 25 Jan 2026 11:40:30 -0500 Subject: [PATCH] feat(i18n): add windows service support and graceful shutdown - Add native Windows service detection and signal handling - Implement configurable shutdown timeout for graceful job completion - Improve HTTP client with connection pooling and timeouts - Propagate context through poller for proper shutdown coordination - Add documentation for Windows service installation (NSSM and sc.exe) - Add *.exe to .gitignore for Windows builds --- .gitignore | 1 + README.md | 67 ++++++++++++++++ internal/app/cmd/daemon.go | 2 +- internal/app/poll/poller.go | 8 +- internal/pkg/client/http.go | 23 ++++-- internal/pkg/config/config.go | 3 + internal/pkg/report/reporter.go | 16 +++- internal/pkg/service/service_other.go | 25 ++++++ internal/pkg/service/service_windows.go | 102 ++++++++++++++++++++++++ main.go | 11 +++ 10 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 internal/pkg/service/service_other.go create mode 100644 internal/pkg/service/service_windows.go diff --git a/.gitignore b/.gitignore index 7d4060c..1583874 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /act_runner +*.exe .env .runner coverage.txt diff --git a/README.md b/README.md index 2bc8f9a..18afb28 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ runner: file: .runner capacity: 2 # Number of concurrent jobs (default: 1) timeout: 3h + shutdown_timeout: 3m # Grace period for running jobs on shutdown insecure: false fetch_timeout: 5s fetch_interval: 2s @@ -229,6 +230,71 @@ sudo systemctl enable gitcaddy-runner sudo systemctl start gitcaddy-runner ``` +### Windows (NSSM or Native Service) + +GitCaddy Runner has native Windows service support. When running as a service, it automatically detects the Windows Service Control Manager (SCM) and handles stop/shutdown signals properly. + +**Option 1: Using NSSM (Recommended)** + +Install NSSM via Chocolatey: + +```powershell +choco install nssm -y +``` + +Create the service: + +```powershell +# Install the service +nssm install GiteaRunnerSvc C:\gitea-runner\gitcaddy-runner.exe daemon --config C:\gitea-runner\config.yaml + +# Set working directory +nssm set GiteaRunnerSvc AppDirectory C:\gitea-runner + +# Set environment variables +nssm set GiteaRunnerSvc AppEnvironmentExtra HOME=C:\gitea-runner USERPROFILE=C:\gitea-runner + +# Configure auto-restart on failure +sc failure GiteaRunnerSvc reset=86400 actions=restart/60000/restart/60000/restart/60000 + +# Start the service +sc start GiteaRunnerSvc +``` + +**Option 2: Native sc.exe (requires wrapper)** + +Create a wrapper batch file `C:\gitea-runner\start-runner.bat`: + +```batch +@echo off +set HOME=C:\gitea-runner +set USERPROFILE=C:\gitea-runner +cd /d C:\gitea-runner +C:\gitea-runner\gitcaddy-runner.exe daemon --config C:\gitea-runner\config.yaml +``` + +**Service Management:** + +```powershell +# Check service status +sc query GiteaRunnerSvc + +# Start service +sc start GiteaRunnerSvc + +# Stop service +sc stop GiteaRunnerSvc + +# View service logs (if using NSSM with log rotation) +Get-Content C:\gitea-runner\logs\runner.log -Tail 50 +``` + +**Environment Variables for Windows Services:** + +| Variable | Description | Example | +|----------|-------------|---------| +| `GITEA_RUNNER_SERVICE_NAME` | Override service name detection | `GiteaRunnerSvc` | + ## Capability Detection GitCaddy Runner automatically detects and reports system capabilities: @@ -306,6 +372,7 @@ GitCaddy Runner automatically detects and reports system capabilities: |--------|------|---------|-------------| | `capacity` | int | 1 | Maximum concurrent jobs | | `timeout` | duration | 3h | Maximum job execution time | +| `shutdown_timeout` | duration | 3m | Grace period for jobs to complete on shutdown | | `insecure` | bool | false | Allow insecure HTTPS | | `fetch_timeout` | duration | 5s | Timeout for fetching tasks | | `fetch_interval` | duration | 2s | Interval between task fetches | diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index a9a6189..62467b3 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -217,7 +217,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } }() - poller := poll.New(cfg, cli, runner) + poller := poll.New(ctx, cfg, cli, runner) poller.SetBandwidthManager(bandwidthManager) if daemArgs.Once || reg.Ephemeral { diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index 1d96b53..f061fd6 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -38,10 +38,10 @@ type Poller struct { done chan struct{} } -func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller { - pollingCtx, shutdownPolling := context.WithCancel(context.Background()) - - jobsCtx, shutdownJobs := context.WithCancel(context.Background()) +func New(ctx context.Context, cfg *config.Config, client client.Client, runner *run.Runner) *Poller { + // Inherit from parent context so shutdown signals propagate properly + pollingCtx, shutdownPolling := context.WithCancel(ctx) + jobsCtx, shutdownJobs := context.WithCancel(ctx) done := make(chan struct{}) diff --git a/internal/pkg/client/http.go b/internal/pkg/client/http.go index d365a77..ee7a6d3 100644 --- a/internal/pkg/client/http.go +++ b/internal/pkg/client/http.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "net/http" "strings" + "time" "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" @@ -15,16 +16,24 @@ import ( ) func getHTTPClient(endpoint string, insecure bool) *http.Client { + transport := &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + DisableKeepAlives: false, + } + if strings.HasPrefix(endpoint, "https://") && insecure { - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, } } - return http.DefaultClient + + return &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } } // New returns a new runner client. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 93e8206..dd6bc5a 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -137,6 +137,9 @@ func LoadDefault(file string) (*Config, error) { if cfg.Runner.FetchInterval <= 0 { cfg.Runner.FetchInterval = 2 * time.Second } + if cfg.Runner.ShutdownTimeout <= 0 { + cfg.Runner.ShutdownTimeout = 3 * time.Minute + } // although `container.network_mode` will be deprecated, but we have to be compatible with it for now. if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" { diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index b88773a..f30c4e2 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -183,10 +183,20 @@ func (r *Reporter) RunDaemon() { return } - _ = r.ReportLog(false) - _ = r.ReportState() + if err := r.ReportLog(false); err != nil { + log.WithError(err).Warn("failed to report log") + } + if err := r.ReportState(); err != nil { + log.WithError(err).Warn("failed to report state") + } - time.AfterFunc(time.Second, r.RunDaemon) + // Use select with context to allow clean shutdown + select { + case <-r.ctx.Done(): + return + case <-time.After(time.Second): + r.RunDaemon() + } } func (r *Reporter) Logf(format string, a ...interface{}) { diff --git a/internal/pkg/service/service_other.go b/internal/pkg/service/service_other.go new file mode 100644 index 0000000..4a4c5f9 --- /dev/null +++ b/internal/pkg/service/service_other.go @@ -0,0 +1,25 @@ +// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package service + +import ( + "context" +) + +// IsWindowsService returns false on non-Windows platforms +func IsWindowsService() bool { + return false +} + +// RunAsService is a no-op on non-Windows platforms +func RunAsService(serviceName string, run func(ctx context.Context)) error { + return nil +} + +// GetServiceName returns empty on non-Windows platforms +func GetServiceName() string { + return "" +} diff --git a/internal/pkg/service/service_windows.go b/internal/pkg/service/service_windows.go new file mode 100644 index 0000000..cf01e26 --- /dev/null +++ b/internal/pkg/service/service_windows.go @@ -0,0 +1,102 @@ +// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package service + +import ( + "context" + "os" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows/svc" +) + +// runnerService implements svc.Handler for Windows service management +type runnerService struct { + ctx context.Context + cancel context.CancelFunc +} + +// Execute is called by the Windows Service Control Manager +func (s *runnerService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + + changes <- svc.Status{State: svc.StartPending} + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + + log.Info("Windows service started") + +loop: + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + // Windows wants two responses for interrogate + time.Sleep(100 * time.Millisecond) + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + log.Info("Windows service stop/shutdown requested") + s.cancel() + break loop + default: + log.Warnf("unexpected control request #%d", c) + } + case <-s.ctx.Done(): + break loop + } + } + + changes <- svc.Status{State: svc.StopPending} + return +} + +// IsWindowsService returns true if the process is running as a Windows service +func IsWindowsService() bool { + // Check if we're running interactively + isInteractive, err := svc.IsWindowsService() + if err != nil { + log.WithError(err).Debug("failed to detect if running as Windows service") + return false + } + return isInteractive +} + +// RunAsService runs the application as a Windows service +func RunAsService(serviceName string, run func(ctx context.Context)) error { + ctx, cancel := context.WithCancel(context.Background()) + + // Start the actual runner in a goroutine + go run(ctx) + + // Run the service handler - this blocks until service stops + err := svc.Run(serviceName, &runnerService{ctx: ctx, cancel: cancel}) + if err != nil { + log.WithError(err).Error("Windows service run failed") + return err + } + + return nil +} + +// GetServiceName returns the service name from environment or default +func GetServiceName() string { + if name := os.Getenv("GITEA_RUNNER_SERVICE_NAME"); name != "" { + return name + } + // Try to detect from executable name + exe, err := os.Executable() + if err == nil { + base := strings.TrimSuffix(exe, ".exe") + if idx := strings.LastIndex(base, string(os.PathSeparator)); idx >= 0 { + return base[idx+1:] + } + return base + } + return "GiteaRunnerSvc" +} diff --git a/main.go b/main.go index 0ec436b..468f9d7 100644 --- a/main.go +++ b/main.go @@ -9,9 +9,20 @@ import ( "syscall" "git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/cmd" + "git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/service" ) func main() { + // Check if running as Windows service + if service.IsWindowsService() { + // Run as Windows service with proper SCM handling + _ = service.RunAsService(service.GetServiceName(), func(ctx context.Context) { + cmd.Execute(ctx) + }) + return + } + + // Normal interactive mode with signal handling ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // run the command