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
|
/act_runner
|
||||||
|
*.exe
|
||||||
.env
|
.env
|
||||||
.runner
|
.runner
|
||||||
coverage.txt
|
coverage.txt
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -85,6 +85,7 @@ runner:
|
|||||||
file: .runner
|
file: .runner
|
||||||
capacity: 2 # Number of concurrent jobs (default: 1)
|
capacity: 2 # Number of concurrent jobs (default: 1)
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
|
shutdown_timeout: 3m # Grace period for running jobs on shutdown
|
||||||
insecure: false
|
insecure: false
|
||||||
fetch_timeout: 5s
|
fetch_timeout: 5s
|
||||||
fetch_interval: 2s
|
fetch_interval: 2s
|
||||||
@@ -229,6 +230,71 @@ sudo systemctl enable gitcaddy-runner
|
|||||||
sudo systemctl start 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
|
## Capability Detection
|
||||||
|
|
||||||
GitCaddy Runner automatically detects and reports system capabilities:
|
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 |
|
| `capacity` | int | 1 | Maximum concurrent jobs |
|
||||||
| `timeout` | duration | 3h | Maximum job execution time |
|
| `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 |
|
| `insecure` | bool | false | Allow insecure HTTPS |
|
||||||
| `fetch_timeout` | duration | 5s | Timeout for fetching tasks |
|
| `fetch_timeout` | duration | 5s | Timeout for fetching tasks |
|
||||||
| `fetch_interval` | duration | 2s | Interval between task fetches |
|
| `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)
|
poller.SetBandwidthManager(bandwidthManager)
|
||||||
|
|
||||||
if daemArgs.Once || reg.Ephemeral {
|
if daemArgs.Once || reg.Ephemeral {
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ type Poller struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
|
func New(ctx context.Context, cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
|
||||||
pollingCtx, shutdownPolling := context.WithCancel(context.Background())
|
// Inherit from parent context so shutdown signals propagate properly
|
||||||
|
pollingCtx, shutdownPolling := context.WithCancel(ctx)
|
||||||
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
|
jobsCtx, shutdownJobs := context.WithCancel(ctx)
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
@@ -15,16 +16,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
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 {
|
if strings.HasPrefix(endpoint, "https://") && insecure {
|
||||||
return &http.Client{
|
transport.TLSClientConfig = &tls.Config{
|
||||||
Transport: &http.Transport{
|
InsecureSkipVerify: true,
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
InsecureSkipVerify: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return http.DefaultClient
|
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new runner client.
|
// New returns a new runner client.
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.FetchInterval <= 0 {
|
if cfg.Runner.FetchInterval <= 0 {
|
||||||
cfg.Runner.FetchInterval = 2 * time.Second
|
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.
|
// although `container.network_mode` will be deprecated, but we have to be compatible with it for now.
|
||||||
if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {
|
if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {
|
||||||
|
|||||||
@@ -183,10 +183,20 @@ func (r *Reporter) RunDaemon() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = r.ReportLog(false)
|
if err := r.ReportLog(false); err != nil {
|
||||||
_ = r.ReportState()
|
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{}) {
|
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"
|
"syscall"
|
||||||
|
|
||||||
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/cmd"
|
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/cmd"
|
||||||
|
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
// run the command
|
// run the command
|
||||||
|
|||||||
Reference in New Issue
Block a user