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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/act_runner
|
||||
*.exe
|
||||
.env
|
||||
.runner
|
||||
coverage.txt
|
||||
|
||||
67
README.md
67
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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
25
internal/pkg/service/service_other.go
Normal file
25
internal/pkg/service/service_other.go
Normal 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 ""
|
||||
}
|
||||
102
internal/pkg/service/service_windows.go
Normal file
102
internal/pkg/service/service_windows.go
Normal 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
11
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
|
||||
|
||||
Reference in New Issue
Block a user