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:
cachito-worker
2023-11-28 01:33:27 +00:00
parent 91bfe4c186
commit c9ec99c632
5 changed files with 292 additions and 111 deletions

View File

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

View File

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