diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 68e8cb5..1d01c06 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -17,6 +17,14 @@ jobs: go-version-file: 'go.mod' cache: false + - name: Clear stale module cache + run: go clean -modcache + + - name: Download dependencies + run: go mod download + env: + GOPRIVATE: git.marketally.com + - name: Vet run: make vet env: 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/go.mod b/go.mod index c442ebc..852742f 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module git.marketally.com/gitcaddy/gitcaddy-runner -go 1.24.0 - -toolchain go1.24.11 +go 1.25.5 require ( code.gitea.io/actions-proto-go v0.5.2 diff --git a/go.sum b/go.sum index 5917f61..1af3675 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -git.marketally.com/gitcaddy/actions-proto-go v0.5.7 h1:RUbafr3Vkw2l4WfSwa+oF+Ihakbm05W0FlAmXuQrDJc= -git.marketally.com/gitcaddy/actions-proto-go v0.5.7/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ= git.marketally.com/gitcaddy/actions-proto-go v0.5.8 h1:MBipeHvY6A0jcobvziUtzgatZTrV4fs/HE1rPQxREN4= git.marketally.com/gitcaddy/actions-proto-go v0.5.8/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ= gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742 h1:ulcquQluJbmNASkh6ina70LvcHEa9eWYfQ+DeAZ0VEE= diff --git a/internal/app/poll/poller.go b/internal/app/poll/poller.go index c08e004..7a77b64 100644 --- a/internal/app/poll/poller.go +++ b/internal/app/poll/poller.go @@ -42,8 +42,8 @@ type Poller struct { // New creates a new Poller instance. func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller { + // Use independent contexts - shutdown is handled explicitly via Shutdown() pollingCtx, shutdownPolling := context.WithCancel(context.Background()) - jobsCtx, shutdownJobs := context.WithCancel(context.Background()) done := make(chan struct{}) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 1bb6b8b..793429b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -138,6 +138,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/service/service_other.go b/internal/pkg/service/service_other.go new file mode 100644 index 0000000..4b67082 --- /dev/null +++ b/internal/pkg/service/service_other.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +// Package service provides Windows service integration for the runner. +// On non-Windows platforms, these functions are no-ops. +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(_ string, _ 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..686ab42 --- /dev/null +++ b/internal/pkg/service/service_windows.go @@ -0,0 +1,103 @@ +// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +// Package service provides Windows service integration for the runner. +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(_ []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 false, 0 +} + +// 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 6f37f8e..f1e8557 100644 --- a/main.go +++ b/main.go @@ -10,9 +10,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