mirror of
https://gitea.com/gitea/act_runner.git
synced 2025-10-26 18:40:41 +01:00
feat: Add support for service on Windows, Linux, and macOS
Introduce functionality to support the execution of the `act_runner` daemon as a service on various operating systems, including Windows, Linux, and macOS. This enhancement includes the ability to set the working directory using the `--working-directory` flag. Details: - The daemon can be installed and enabled with the command `act_runner daemon install`. - The service can be stopped and uninstalled using `act_runner daemon uninstall`. - The default working directory is set to the directory containing the `act_runner` executable. - During the installation process (`act_runner daemon install`), the service checks for the existence of `.runner` and `config.yaml` files in the same directory. If found, these files are loaded into the service. Note: Prior to running `act_runner daemon install`, ensure registration of `act_runner` with the command `act_runner register` to generate the required `.runner` file.
This commit is contained in:
@@ -6,7 +6,9 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/helpers"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -23,9 +25,16 @@ func Execute(ctx context.Context) {
|
||||
Version: ver.Version(),
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
configFile := ""
|
||||
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path")
|
||||
|
||||
workingDirectory := ""
|
||||
rootCmd.PersistentFlags().StringVarP(&workingDirectory, "working-directory", "d", helpers.GetCurrentWorkingDirectory(), "Specify custom root directory where all data are stored")
|
||||
|
||||
daemonUser := ""
|
||||
rootCmd.PersistentFlags().StringVarP(&daemonUser, "user", "u", getCurrentUserName(), "Specify user-name to run the daemon service as")
|
||||
|
||||
// ./act_runner register
|
||||
var regArgs registerArgs
|
||||
registerCmd := &cobra.Command{
|
||||
@@ -45,11 +54,33 @@ func Execute(ctx context.Context) {
|
||||
daemonCmd := &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run as a runner daemon",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runDaemon(ctx, &configFile),
|
||||
Args: cobra.MaximumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return daemon(cmd, ctx, &configFile, &workingDirectory, &daemonUser)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
|
||||
// ./act_runner daemon install
|
||||
installDaemonCmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install the daemon",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return daemon(cmd, ctx, &configFile, &workingDirectory, &daemonUser)
|
||||
},
|
||||
}
|
||||
daemonCmd.AddCommand(installDaemonCmd)
|
||||
|
||||
// ./act_runner daemon uninstall
|
||||
uninstallDaemonCmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Uninstall the daemon",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return daemon(cmd, ctx, &configFile, &workingDirectory, &daemonUser)
|
||||
},
|
||||
}
|
||||
daemonCmd.AddCommand(uninstallDaemonCmd)
|
||||
|
||||
// ./act_runner exec
|
||||
rootCmd.AddCommand(loadExecCmd(ctx))
|
||||
|
||||
@@ -83,3 +114,11 @@ func Execute(ctx context.Context) {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getCurrentUserName() string {
|
||||
user, _ := user.Current()
|
||||
if user != nil {
|
||||
return user.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,17 +6,19 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/kardianos/service"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"syscall"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/poll"
|
||||
"gitea.com/gitea/act_runner/internal/app/run"
|
||||
@@ -27,100 +29,228 @@ import (
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
)
|
||||
|
||||
func runDaemon(ctx context.Context, configFile *string) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.LoadDefault(*configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
initLogging(cfg)
|
||||
log.Infoln("Starting runner daemon")
|
||||
|
||||
reg, err := config.LoadRegistration(cfg.Runner.File)
|
||||
if os.IsNotExist(err) {
|
||||
log.Error("registration file not found, please register the runner first")
|
||||
return err
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to load registration file: %w", err)
|
||||
}
|
||||
|
||||
lbls := reg.Labels
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
lbls = cfg.Runner.Labels
|
||||
}
|
||||
|
||||
ls := labels.Labels{}
|
||||
for _, l := range lbls {
|
||||
label, err := labels.Parse(l)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("ignored invalid label %q", l)
|
||||
continue
|
||||
}
|
||||
ls = append(ls, label)
|
||||
}
|
||||
if len(ls) == 0 {
|
||||
log.Warn("no labels configured, runner may not be able to pick up jobs")
|
||||
}
|
||||
|
||||
if ls.RequireDocker() {
|
||||
dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := envcheck.CheckIfDockerRunning(ctx, dockerSocketPath); err != nil {
|
||||
return err
|
||||
}
|
||||
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
|
||||
os.Setenv("DOCKER_HOST", dockerSocketPath)
|
||||
// empty cfg.Container.DockerHost means act_runner need to find an available docker host automatically
|
||||
// and assign the path to cfg.Container.DockerHost
|
||||
if cfg.Container.DockerHost == "" {
|
||||
cfg.Container.DockerHost = dockerSocketPath
|
||||
}
|
||||
// check the scheme, if the scheme is not npipe or unix
|
||||
// set cfg.Container.DockerHost to "-" because it can't be mounted to the job container
|
||||
if protoIndex := strings.Index(cfg.Container.DockerHost, "://"); protoIndex != -1 {
|
||||
scheme := cfg.Container.DockerHost[:protoIndex]
|
||||
if !strings.EqualFold(scheme, "npipe") && !strings.EqualFold(scheme, "unix") {
|
||||
cfg.Container.DockerHost = "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli := client.New(
|
||||
reg.Address,
|
||||
cfg.Runner.Insecure,
|
||||
reg.UUID,
|
||||
reg.Token,
|
||||
ver.Version(),
|
||||
)
|
||||
|
||||
runner := run.NewRunner(cfg, reg, cli)
|
||||
// declare the labels of the runner before fetching tasks
|
||||
resp, err := runner.Declare(ctx, ls.Names())
|
||||
if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented {
|
||||
// Gitea instance is older version. skip declare step.
|
||||
log.Warn("Because the Gitea instance is an old version, skip declare labels and version.")
|
||||
} else if err != nil {
|
||||
log.WithError(err).Error("fail to invoke Declare")
|
||||
return err
|
||||
} else {
|
||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
||||
// if declare successfully, override the labels in the.runner file with valid labels in the config file (if specified)
|
||||
reg.Labels = ls.ToStrings()
|
||||
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
|
||||
return fmt.Errorf("failed to save runner config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
|
||||
poller.Poll(ctx)
|
||||
|
||||
return nil
|
||||
func daemon(cmd *cobra.Command, ctx context.Context, configFile *string, directory *string, daemonUser *string) error {
|
||||
svc, err := svc(ctx, configFile, directory, daemonUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serviceAction := cmd.Use
|
||||
switch serviceAction {
|
||||
case "install":
|
||||
if err := svc.Install(); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.Start()
|
||||
case "uninstall":
|
||||
if err := svc.Stop(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
return svc.Uninstall()
|
||||
default:
|
||||
return svc.Run()
|
||||
}
|
||||
}
|
||||
|
||||
func svc(ctx context.Context, configFile *string, directory *string, daemonUser *string) (service.Service, error) {
|
||||
/*
|
||||
* The following struct fields are used to set the service's Name,
|
||||
* Display name, description and Arguments only when service gets
|
||||
* installed via the `admin-helper install-daemon` command for CRC
|
||||
* in production these values are not used as the MSI installs the
|
||||
* service
|
||||
*/
|
||||
svcConfig := &service.Config{
|
||||
Name: "act-runner",
|
||||
DisplayName: "Gitea-CI runner",
|
||||
Description: "Gitea CI runner written in GO",
|
||||
Arguments: []string{"daemon"},
|
||||
}
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--working-directory", *directory)
|
||||
|
||||
if *configFile != "" {
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--config", *configFile)
|
||||
} else {
|
||||
configFile := filepath.Join(*directory, "config.yaml")
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
file, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
log.Error("Error creating config.yaml:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = file.Write(config.Example)
|
||||
if err != nil {
|
||||
log.Error("Error writing to config.yaml:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Error("Error checking config.yaml:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
svcConfig.Arguments = append(svcConfig.Arguments, "--config", configFile)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
if os.Getuid() != 0 {
|
||||
log.Fatal("The --user is not supported for non-root users")
|
||||
}
|
||||
if *daemonUser != "" {
|
||||
svcConfig.UserName = *daemonUser
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
svcConfig.EnvVars = map[string]string{
|
||||
"PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
}
|
||||
svcConfig.Option = service.KeyValue{
|
||||
"KeepAlive": true,
|
||||
"RunAtLoad": true,
|
||||
"UserService": os.Getuid() != 0,
|
||||
}
|
||||
}
|
||||
|
||||
prg := &program{
|
||||
ctx: ctx,
|
||||
configFile: configFile,
|
||||
workingDirectory: directory,
|
||||
}
|
||||
return service.New(prg, svcConfig)
|
||||
}
|
||||
|
||||
type program struct {
|
||||
ctx context.Context
|
||||
configFile *string
|
||||
workingDirectory *string
|
||||
|
||||
// stopSignals is to catch a signals notified to process: SIGTERM, SIGQUIT, Interrupt, Kill
|
||||
stopSignals chan os.Signal
|
||||
|
||||
// done channel to signal the completion of the run method
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (p *program) Start(s service.Service) error {
|
||||
p.stopSignals = make(chan os.Signal)
|
||||
p.done = make(chan struct{})
|
||||
|
||||
// Start should not block. Do the actual work async.
|
||||
go p.run()
|
||||
return nil
|
||||
}
|
||||
func (p *program) Stop(s service.Service) error {
|
||||
close(p.stopSignals)
|
||||
<-p.done // Wait for the run method to complete
|
||||
|
||||
// Stop should not block. Return with a few seconds.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) run() {
|
||||
signal.Notify(p.stopSignals, syscall.SIGQUIT, syscall.SIGTERM, os.Interrupt)
|
||||
// Do work here
|
||||
if err := os.Chdir(*p.workingDirectory); err != nil {
|
||||
log.Error("error changing working directory:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadDefault(*p.configFile)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration: %w", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
initLogging(cfg)
|
||||
log.Infoln("Starting runner daemon")
|
||||
|
||||
reg, err := config.LoadRegistration(cfg.Runner.File)
|
||||
if os.IsNotExist(err) {
|
||||
log.Error("registration file not found, please register the runner first")
|
||||
os.Exit(1)
|
||||
} else if err != nil {
|
||||
log.Error("failed to load registration file: %w", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
lbls := reg.Labels
|
||||
if len(cfg.Runner.Labels) > 0 {
|
||||
lbls = cfg.Runner.Labels
|
||||
}
|
||||
|
||||
ls := labels.Labels{}
|
||||
for _, l := range lbls {
|
||||
label, err := labels.Parse(l)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("ignored invalid label %q", l)
|
||||
continue
|
||||
}
|
||||
ls = append(ls, label)
|
||||
}
|
||||
if len(ls) == 0 {
|
||||
log.Warn("no labels configured, runner may not be able to pick up jobs")
|
||||
}
|
||||
|
||||
if ls.RequireDocker() {
|
||||
dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
if err := envcheck.CheckIfDockerRunning(p.ctx, dockerSocketPath); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
|
||||
os.Setenv("DOCKER_HOST", dockerSocketPath)
|
||||
// empty cfg.Container.DockerHost means act_runner need to find an available docker host automatically
|
||||
// and assign the path to cfg.Container.DockerHost
|
||||
if cfg.Container.DockerHost == "" {
|
||||
cfg.Container.DockerHost = dockerSocketPath
|
||||
}
|
||||
// check the scheme, if the scheme is not npipe or unix
|
||||
// set cfg.Container.DockerHost to "-" because it can't be mounted to the job container
|
||||
if protoIndex := strings.Index(cfg.Container.DockerHost, "://"); protoIndex != -1 {
|
||||
scheme := cfg.Container.DockerHost[:protoIndex]
|
||||
if !strings.EqualFold(scheme, "npipe") && !strings.EqualFold(scheme, "unix") {
|
||||
cfg.Container.DockerHost = "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli := client.New(
|
||||
reg.Address,
|
||||
cfg.Runner.Insecure,
|
||||
reg.UUID,
|
||||
reg.Token,
|
||||
ver.Version(),
|
||||
)
|
||||
|
||||
runner := run.NewRunner(cfg, reg, cli)
|
||||
// declare the labels of the runner before fetching tasks
|
||||
resp, err := runner.Declare(p.ctx, ls.Names())
|
||||
if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented {
|
||||
// Gitea instance is older version. skip declare step.
|
||||
log.Warn("Because the Gitea instance is an old version, skip declare labels and version.")
|
||||
} else if err != nil {
|
||||
log.WithError(err).Error("fail to invoke Declare")
|
||||
return
|
||||
} else {
|
||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
||||
// if declare successfully, override the labels in the.runner file with valid labels in the config file (if specified)
|
||||
reg.Labels = ls.ToStrings()
|
||||
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
|
||||
log.Error("failed to save runner config: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
poller.Poll(p.ctx)
|
||||
|
||||
close(p.done) // Signal that the run method has completed
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// initLogging setup the global logrus logger.
|
||||
|
||||
Reference in New Issue
Block a user