2
0

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
This commit is contained in:
2026-01-25 11:40:30 -05:00
parent 4d6900b7a3
commit b2922e332a
10 changed files with 243 additions and 15 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/act_runner
*.exe
.env
.runner
coverage.txt

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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.

View File

@@ -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 == "" {

View File

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

View File

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

View File

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

11
main.go
View File

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