summaryrefslogtreecommitdiffstats
path: root/internal/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'internal/pkg')
-rw-r--r--internal/pkg/client/client.go19
-rw-r--r--internal/pkg/client/header.go11
-rw-r--r--internal/pkg/client/http.go82
-rw-r--r--internal/pkg/client/mocks/Client.go219
-rw-r--r--internal/pkg/config/config.example.yaml100
-rw-r--r--internal/pkg/config/config.go166
-rw-r--r--internal/pkg/config/config_test.go37
-rw-r--r--internal/pkg/config/deprecated.go62
-rw-r--r--internal/pkg/config/embed.go9
-rw-r--r--internal/pkg/config/registration.go54
-rw-r--r--internal/pkg/envcheck/doc.go5
-rw-r--r--internal/pkg/envcheck/docker.go34
-rw-r--r--internal/pkg/labels/labels.go109
-rw-r--r--internal/pkg/labels/labels_test.go63
-rw-r--r--internal/pkg/report/reporter.go437
-rw-r--r--internal/pkg/report/reporter_test.go198
-rw-r--r--internal/pkg/ver/version.go11
17 files changed, 1616 insertions, 0 deletions
diff --git a/internal/pkg/client/client.go b/internal/pkg/client/client.go
new file mode 100644
index 0000000..57f91ad
--- /dev/null
+++ b/internal/pkg/client/client.go
@@ -0,0 +1,19 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+ "code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
+ "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
+)
+
+// A Client manages communication with the runner.
+//
+//go:generate mockery --name Client
+type Client interface {
+ pingv1connect.PingServiceClient
+ runnerv1connect.RunnerServiceClient
+ Address() string
+ Insecure() bool
+}
diff --git a/internal/pkg/client/header.go b/internal/pkg/client/header.go
new file mode 100644
index 0000000..24844fa
--- /dev/null
+++ b/internal/pkg/client/header.go
@@ -0,0 +1,11 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package client
+
+const (
+ UUIDHeader = "x-runner-uuid"
+ TokenHeader = "x-runner-token"
+ // Deprecated: could be removed after Gitea 1.20 released
+ VersionHeader = "x-runner-version"
+)
diff --git a/internal/pkg/client/http.go b/internal/pkg/client/http.go
new file mode 100644
index 0000000..d365a77
--- /dev/null
+++ b/internal/pkg/client/http.go
@@ -0,0 +1,82 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+ "context"
+ "crypto/tls"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
+ "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
+ "connectrpc.com/connect"
+)
+
+func getHTTPClient(endpoint string, insecure bool) *http.Client {
+ if strings.HasPrefix(endpoint, "https://") && insecure {
+ return &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ }
+ return http.DefaultClient
+}
+
+// New returns a new runner client.
+func New(endpoint string, insecure bool, uuid, token, version string, opts ...connect.ClientOption) *HTTPClient {
+ baseURL := strings.TrimRight(endpoint, "/") + "/api/actions"
+
+ opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
+ return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
+ if uuid != "" {
+ req.Header().Set(UUIDHeader, uuid)
+ }
+ if token != "" {
+ req.Header().Set(TokenHeader, token)
+ }
+ // TODO: version will be removed from request header after Gitea 1.20 released.
+ if version != "" {
+ req.Header().Set(VersionHeader, version)
+ }
+ return next(ctx, req)
+ }
+ })))
+
+ return &HTTPClient{
+ PingServiceClient: pingv1connect.NewPingServiceClient(
+ getHTTPClient(endpoint, insecure),
+ baseURL,
+ opts...,
+ ),
+ RunnerServiceClient: runnerv1connect.NewRunnerServiceClient(
+ getHTTPClient(endpoint, insecure),
+ baseURL,
+ opts...,
+ ),
+ endpoint: endpoint,
+ insecure: insecure,
+ }
+}
+
+func (c *HTTPClient) Address() string {
+ return c.endpoint
+}
+
+func (c *HTTPClient) Insecure() bool {
+ return c.insecure
+}
+
+var _ Client = (*HTTPClient)(nil)
+
+// An HTTPClient manages communication with the runner API.
+type HTTPClient struct {
+ pingv1connect.PingServiceClient
+ runnerv1connect.RunnerServiceClient
+ endpoint string
+ insecure bool
+}
diff --git a/internal/pkg/client/mocks/Client.go b/internal/pkg/client/mocks/Client.go
new file mode 100644
index 0000000..a8bfdb1
--- /dev/null
+++ b/internal/pkg/client/mocks/Client.go
@@ -0,0 +1,219 @@
+// Code generated by mockery v2.26.1. DO NOT EDIT.
+
+package mocks
+
+import (
+ context "context"
+
+ connect "connectrpc.com/connect"
+
+ mock "github.com/stretchr/testify/mock"
+
+ pingv1 "code.gitea.io/actions-proto-go/ping/v1"
+
+ runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
+)
+
+// Client is an autogenerated mock type for the Client type
+type Client struct {
+ mock.Mock
+}
+
+// Address provides a mock function with given fields:
+func (_m *Client) Address() string {
+ ret := _m.Called()
+
+ var r0 string
+ if rf, ok := ret.Get(0).(func() string); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(string)
+ }
+
+ return r0
+}
+
+// Declare provides a mock function with given fields: _a0, _a1
+func (_m *Client) Declare(_a0 context.Context, _a1 *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[runnerv1.DeclareResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) *connect.Response[runnerv1.DeclareResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[runnerv1.DeclareResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.DeclareRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// FetchTask provides a mock function with given fields: _a0, _a1
+func (_m *Client) FetchTask(_a0 context.Context, _a1 *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[runnerv1.FetchTaskResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) *connect.Response[runnerv1.FetchTaskResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[runnerv1.FetchTaskResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Insecure provides a mock function with given fields:
+func (_m *Client) Insecure() bool {
+ ret := _m.Called()
+
+ var r0 bool
+ if rf, ok := ret.Get(0).(func() bool); ok {
+ r0 = rf()
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ return r0
+}
+
+// Ping provides a mock function with given fields: _a0, _a1
+func (_m *Client) Ping(_a0 context.Context, _a1 *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[pingv1.PingResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[pingv1.PingRequest]) *connect.Response[pingv1.PingResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[pingv1.PingResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[pingv1.PingRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Register provides a mock function with given fields: _a0, _a1
+func (_m *Client) Register(_a0 context.Context, _a1 *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[runnerv1.RegisterResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) *connect.Response[runnerv1.RegisterResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[runnerv1.RegisterResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.RegisterRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// UpdateLog provides a mock function with given fields: _a0, _a1
+func (_m *Client) UpdateLog(_a0 context.Context, _a1 *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[runnerv1.UpdateLogResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) *connect.Response[runnerv1.UpdateLogResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[runnerv1.UpdateLogResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// UpdateTask provides a mock function with given fields: _a0, _a1
+func (_m *Client) UpdateTask(_a0 context.Context, _a1 *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *connect.Response[runnerv1.UpdateTaskResponse]
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error)); ok {
+ return rf(_a0, _a1)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) *connect.Response[runnerv1.UpdateTaskResponse]); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*connect.Response[runnerv1.UpdateTaskResponse])
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+type mockConstructorTestingTNewClient interface {
+ mock.TestingT
+ Cleanup(func())
+}
+
+// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+func NewClient(t mockConstructorTestingTNewClient) *Client {
+ mock := &Client{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml
new file mode 100644
index 0000000..32dfb68
--- /dev/null
+++ b/internal/pkg/config/config.example.yaml
@@ -0,0 +1,100 @@
+# Example configuration file, it's safe to copy this as the default config file without any modification.
+
+# You don't have to copy this file to your instance,
+# just run `./act_runner generate-config > config.yaml` to generate a config file.
+
+log:
+ # The level of logging, can be trace, debug, info, warn, error, fatal
+ level: info
+
+runner:
+ # Where to store the registration result.
+ file: .runner
+ # Execute how many tasks concurrently at the same time.
+ capacity: 1
+ # Extra environment variables to run jobs.
+ envs:
+ A_TEST_ENV_NAME_1: a_test_env_value_1
+ A_TEST_ENV_NAME_2: a_test_env_value_2
+ # Extra environment variables to run jobs from a file.
+ # It will be ignored if it's empty or the file doesn't exist.
+ env_file: .env
+ # The timeout for a job to be finished.
+ # Please note that the Forgejo instance also has a timeout (3h by default) for the job.
+ # So the job could be stopped by the Forgejo instance if it's timeout is shorter than this.
+ timeout: 3h
+ # The timeout for the runner to wait for running jobs to finish when
+ # shutting down because a TERM or INT signal has been received. Any
+ # running jobs that haven't finished after this timeout will be
+ # cancelled.
+ # If unset or zero the jobs will be cancelled immediately.
+ shutdown_timeout: 3h
+ # Whether skip verifying the TLS certificate of the instance.
+ insecure: false
+ # The timeout for fetching the job from the Forgejo instance.
+ fetch_timeout: 5s
+ # The interval for fetching the job from the Forgejo instance.
+ fetch_interval: 2s
+ # The interval for reporting the job status and logs to the Forgejo instance.
+ report_interval: 1s
+ # The labels of a runner are used to determine which jobs the runner can run, and how to run them.
+ # Like: ["macos-arm64:host", "ubuntu-latest:docker://node:20-bookworm", "ubuntu-22.04:docker://node:20-bookworm"]
+ # If it's empty when registering, it will ask for inputting labels.
+ # If it's empty when execute `deamon`, will use labels in `.runner` file.
+ labels: []
+
+cache:
+ # Enable cache server to use actions/cache.
+ enabled: true
+ # The directory to store the cache data.
+ # If it's empty, the cache data will be stored in $HOME/.cache/actcache.
+ dir: ""
+ # The host of the cache server.
+ # It's not for the address to listen, but the address to connect from job containers.
+ # So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
+ host: ""
+ # The port of the cache server.
+ # 0 means to use a random available port.
+ port: 0
+ # The external cache server URL. Valid only when enable is true.
+ # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
+ # The URL should generally end with "/".
+ external_server: ""
+
+container:
+ # Specifies the network to which the container will connect.
+ # Could be host, bridge or the name of a custom network.
+ # If it's empty, create a network automatically.
+ network: ""
+ # Whether to create networks with IPv6 enabled. Requires the Docker daemon to be set up accordingly.
+ # Only takes effect if "network" is set to "".
+ enable_ipv6: false
+ # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
+ privileged: false
+ # And other options to be used when the container is started (eg, --add-host=my.forgejo.url:host-gateway).
+ options:
+ # The parent directory of a job's working directory.
+ # If it's empty, /workspace will be used.
+ workdir_parent:
+ # Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
+ # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
+ # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
+ # valid_volumes:
+ # - data
+ # - /src/*.json
+ # If you want to allow any volume, please use the following configuration:
+ # valid_volumes:
+ # - '**'
+ valid_volumes: []
+ # overrides the docker client host with the specified one.
+ # If it's empty, act_runner will find an available docker host automatically.
+ # If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
+ # If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
+ docker_host: ""
+ # Pull docker image(s) even if already present
+ force_pull: false
+
+host:
+ # The parent directory of a job's working directory.
+ # If it's empty, $HOME/.cache/act/ will be used.
+ workdir_parent:
diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go
new file mode 100644
index 0000000..a1536b3
--- /dev/null
+++ b/internal/pkg/config/config.go
@@ -0,0 +1,166 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/joho/godotenv"
+ log "github.com/sirupsen/logrus"
+ "gopkg.in/yaml.v3"
+)
+
+// Log represents the configuration for logging.
+type Log struct {
+ Level string `yaml:"level"` // Level indicates the logging level.
+}
+
+// Runner represents the configuration for the runner.
+type Runner struct {
+ File string `yaml:"file"` // File specifies the file path for the runner.
+ Capacity int `yaml:"capacity"` // Capacity specifies the capacity of the runner.
+ Envs map[string]string `yaml:"envs"` // Envs stores environment variables for the runner.
+ EnvFile string `yaml:"env_file"` // EnvFile specifies the path to the file containing environment variables for the runner.
+ Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
+ ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner.
+ Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
+ FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
+ FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
+ ReportInterval time.Duration `yaml:"report_interval"` // ReportInterval specifies the interval duration for reporting status and logs of a running job.
+ Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
+}
+
+// Cache represents the configuration for caching.
+type Cache struct {
+ Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
+ Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
+ Host string `yaml:"host"` // Host specifies the caching host.
+ Port uint16 `yaml:"port"` // Port specifies the caching port.
+ ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
+}
+
+// Container represents the configuration for the container.
+type Container struct {
+ Network string `yaml:"network"` // Network specifies the network for the container.
+ NetworkMode string `yaml:"network_mode"` // Deprecated: use Network instead. Could be removed after Gitea 1.20
+ EnableIPv6 bool `yaml:"enable_ipv6"` // EnableIPv6 indicates whether the network is created with IPv6 enabled.
+ Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
+ Options string `yaml:"options"` // Options specifies additional options for the container.
+ WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory.
+ ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers.
+ DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
+ ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present
+}
+
+// Host represents the configuration for the host.
+type Host struct {
+ WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
+}
+
+// Config represents the overall configuration.
+type Config struct {
+ Log Log `yaml:"log"` // Log represents the configuration for logging.
+ Runner Runner `yaml:"runner"` // Runner represents the configuration for the runner.
+ Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
+ Container Container `yaml:"container"` // Container represents the configuration for the container.
+ Host Host `yaml:"host"` // Host represents the configuration for the host.
+}
+
+// Tune the config settings accordingly to the Forgejo instance that will be used.
+func (c *Config) Tune(instanceURL string) {
+ if instanceURL == "https://codeberg.org" {
+ if c.Runner.FetchInterval < 30*time.Second {
+ log.Info("The runner is configured to be used by a public instance, fetch interval is set to 30 seconds.")
+ c.Runner.FetchInterval = 30 * time.Second
+ }
+ }
+}
+
+// LoadDefault returns the default configuration.
+// If file is not empty, it will be used to load the configuration.
+func LoadDefault(file string) (*Config, error) {
+ cfg := &Config{}
+ if file != "" {
+ content, err := os.ReadFile(file)
+ if err != nil {
+ return nil, fmt.Errorf("open config file %q: %w", file, err)
+ }
+ if err := yaml.Unmarshal(content, cfg); err != nil {
+ return nil, fmt.Errorf("parse config file %q: %w", file, err)
+ }
+ }
+ compatibleWithOldEnvs(file != "", cfg)
+
+ if cfg.Runner.EnvFile != "" {
+ if stat, err := os.Stat(cfg.Runner.EnvFile); err == nil && !stat.IsDir() {
+ envs, err := godotenv.Read(cfg.Runner.EnvFile)
+ if err != nil {
+ return nil, fmt.Errorf("read env file %q: %w", cfg.Runner.EnvFile, err)
+ }
+ if cfg.Runner.Envs == nil {
+ cfg.Runner.Envs = map[string]string{}
+ }
+ for k, v := range envs {
+ cfg.Runner.Envs[k] = v
+ }
+ }
+ }
+
+ if cfg.Log.Level == "" {
+ cfg.Log.Level = "info"
+ }
+ if cfg.Runner.File == "" {
+ cfg.Runner.File = ".runner"
+ }
+ if cfg.Runner.Capacity <= 0 {
+ cfg.Runner.Capacity = 1
+ }
+ if cfg.Runner.Timeout <= 0 {
+ cfg.Runner.Timeout = 3 * time.Hour
+ }
+ if cfg.Cache.Enabled == nil {
+ b := true
+ cfg.Cache.Enabled = &b
+ }
+ if *cfg.Cache.Enabled {
+ if cfg.Cache.Dir == "" {
+ home, _ := os.UserHomeDir()
+ cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
+ }
+ }
+ if cfg.Container.WorkdirParent == "" {
+ cfg.Container.WorkdirParent = "workspace"
+ }
+ if cfg.Host.WorkdirParent == "" {
+ home, _ := os.UserHomeDir()
+ cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
+ }
+ if cfg.Runner.FetchTimeout <= 0 {
+ cfg.Runner.FetchTimeout = 5 * time.Second
+ }
+ if cfg.Runner.FetchInterval <= 0 {
+ cfg.Runner.FetchInterval = 2 * time.Second
+ }
+ if cfg.Runner.ReportInterval <= 0 {
+ cfg.Runner.ReportInterval = time.Second
+ }
+
+ // although `container.network_mode` will be deprecated, but we have to be compatible with it for now.
+ if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {
+ log.Warn("You are trying to use deprecated configuration item of `container.network_mode`, please use `container.network` instead.")
+ if cfg.Container.NetworkMode == "bridge" {
+ // Previously, if the value of `container.network_mode` is `bridge`, we will create a new network for job.
+ // But “bridge” is easily confused with the bridge network created by Docker by default.
+ // So we set the value of `container.network` to empty string to make `act_runner` automatically create a new network for job.
+ cfg.Container.Network = ""
+ } else {
+ cfg.Container.Network = cfg.Container.NetworkMode
+ }
+ }
+
+ return cfg, nil
+}
diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go
new file mode 100644
index 0000000..d2ddf2f
--- /dev/null
+++ b/internal/pkg/config/config_test.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestConfigTune(t *testing.T) {
+ c := &Config{
+ Runner: Runner{},
+ }
+
+ t.Run("Public instance tuning", func(t *testing.T) {
+ c.Runner.FetchInterval = 60 * time.Second
+ c.Tune("https://codeberg.org")
+ assert.EqualValues(t, 60*time.Second, c.Runner.FetchInterval)
+
+ c.Runner.FetchInterval = 2 * time.Second
+ c.Tune("https://codeberg.org")
+ assert.EqualValues(t, 30*time.Second, c.Runner.FetchInterval)
+ })
+
+ t.Run("Non-public instance tuning", func(t *testing.T) {
+ c.Runner.FetchInterval = 60 * time.Second
+ c.Tune("https://example.com")
+ assert.EqualValues(t, 60*time.Second, c.Runner.FetchInterval)
+
+ c.Runner.FetchInterval = 2 * time.Second
+ c.Tune("https://codeberg.com")
+ assert.EqualValues(t, 2*time.Second, c.Runner.FetchInterval)
+ })
+}
diff --git a/internal/pkg/config/deprecated.go b/internal/pkg/config/deprecated.go
new file mode 100644
index 0000000..b5051aa
--- /dev/null
+++ b/internal/pkg/config/deprecated.go
@@ -0,0 +1,62 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "os"
+ "strconv"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Deprecated: could be removed in the future. TODO: remove it when Gitea 1.20.0 is released.
+// Be compatible with old envs.
+func compatibleWithOldEnvs(fileUsed bool, cfg *Config) {
+ handleEnv := func(key string) (string, bool) {
+ if v, ok := os.LookupEnv(key); ok {
+ if fileUsed {
+ log.Warnf("env %s has been ignored because config file is used", key)
+ return "", false
+ }
+ log.Warnf("env %s will be deprecated, please use config file instead", key)
+ return v, true
+ }
+ return "", false
+ }
+
+ if v, ok := handleEnv("GITEA_DEBUG"); ok {
+ if b, _ := strconv.ParseBool(v); b {
+ cfg.Log.Level = "debug"
+ }
+ }
+ if v, ok := handleEnv("GITEA_TRACE"); ok {
+ if b, _ := strconv.ParseBool(v); b {
+ cfg.Log.Level = "trace"
+ }
+ }
+ if v, ok := handleEnv("GITEA_RUNNER_CAPACITY"); ok {
+ if i, _ := strconv.Atoi(v); i > 0 {
+ cfg.Runner.Capacity = i
+ }
+ }
+ if v, ok := handleEnv("GITEA_RUNNER_FILE"); ok {
+ cfg.Runner.File = v
+ }
+ if v, ok := handleEnv("GITEA_RUNNER_ENVIRON"); ok {
+ splits := strings.Split(v, ",")
+ if cfg.Runner.Envs == nil {
+ cfg.Runner.Envs = map[string]string{}
+ }
+ for _, split := range splits {
+ kv := strings.SplitN(split, ":", 2)
+ if len(kv) == 2 && kv[0] != "" {
+ cfg.Runner.Envs[kv[0]] = kv[1]
+ }
+ }
+ }
+ if v, ok := handleEnv("GITEA_RUNNER_ENV_FILE"); ok {
+ cfg.Runner.EnvFile = v
+ }
+}
diff --git a/internal/pkg/config/embed.go b/internal/pkg/config/embed.go
new file mode 100644
index 0000000..cf445cf
--- /dev/null
+++ b/internal/pkg/config/embed.go
@@ -0,0 +1,9 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import _ "embed"
+
+//go:embed config.example.yaml
+var Example []byte
diff --git a/internal/pkg/config/registration.go b/internal/pkg/config/registration.go
new file mode 100644
index 0000000..be66b4f
--- /dev/null
+++ b/internal/pkg/config/registration.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package config
+
+import (
+ "encoding/json"
+ "os"
+)
+
+const registrationWarning = "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner."
+
+// Registration is the registration information for a runner
+type Registration struct {
+ Warning string `json:"WARNING"` // Warning message to display, it's always the registrationWarning constant
+
+ ID int64 `json:"id"`
+ UUID string `json:"uuid"`
+ Name string `json:"name"`
+ Token string `json:"token"`
+ Address string `json:"address"`
+ Labels []string `json:"labels"`
+}
+
+func LoadRegistration(file string) (*Registration, error) {
+ f, err := os.Open(file)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var reg Registration
+ if err := json.NewDecoder(f).Decode(&reg); err != nil {
+ return nil, err
+ }
+
+ reg.Warning = ""
+
+ return &reg, nil
+}
+
+func SaveRegistration(file string, reg *Registration) error {
+ f, err := os.Create(file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ reg.Warning = registrationWarning
+
+ enc := json.NewEncoder(f)
+ enc.SetIndent("", " ")
+ return enc.Encode(reg)
+}
diff --git a/internal/pkg/envcheck/doc.go b/internal/pkg/envcheck/doc.go
new file mode 100644
index 0000000..8641a77
--- /dev/null
+++ b/internal/pkg/envcheck/doc.go
@@ -0,0 +1,5 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package envcheck provides a simple way to check if the environment is ready to run jobs.
+package envcheck
diff --git a/internal/pkg/envcheck/docker.go b/internal/pkg/envcheck/docker.go
new file mode 100644
index 0000000..f115bc7
--- /dev/null
+++ b/internal/pkg/envcheck/docker.go
@@ -0,0 +1,34 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package envcheck
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/docker/docker/client"
+)
+
+func CheckIfDockerRunning(ctx context.Context, configDockerHost string) error {
+ opts := []client.Opt{
+ client.FromEnv,
+ }
+
+ if configDockerHost != "" {
+ opts = append(opts, client.WithHost(configDockerHost))
+ }
+
+ cli, err := client.NewClientWithOpts(opts...)
+ if err != nil {
+ return err
+ }
+ defer cli.Close()
+
+ _, err = cli.Ping(ctx)
+ if err != nil {
+ return fmt.Errorf("cannot ping the docker daemon. is it running? %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go
new file mode 100644
index 0000000..f448fdf
--- /dev/null
+++ b/internal/pkg/labels/labels.go
@@ -0,0 +1,109 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package labels
+
+import (
+ "fmt"
+ "strings"
+)
+
+const (
+ SchemeHost = "host"
+ SchemeDocker = "docker"
+ SchemeLXC = "lxc"
+)
+
+type Label struct {
+ Name string
+ Schema string
+ Arg string
+}
+
+func Parse(str string) (*Label, error) {
+ splits := strings.SplitN(str, ":", 3)
+ label := &Label{
+ Name: splits[0],
+ Schema: "host",
+ Arg: "",
+ }
+ if len(splits) >= 2 {
+ label.Schema = splits[1]
+ }
+ if len(splits) >= 3 {
+ label.Arg = splits[2]
+ }
+ if label.Schema != SchemeHost && label.Schema != SchemeDocker && label.Schema != SchemeLXC {
+ return nil, fmt.Errorf("unsupported schema: %s", label.Schema)
+ }
+ return label, nil
+}
+
+type Labels []*Label
+
+func (l Labels) RequireDocker() bool {
+ for _, label := range l {
+ if label.Schema == SchemeDocker {
+ return true
+ }
+ }
+ return false
+}
+
+func (l Labels) PickPlatform(runsOn []string) string {
+ platforms := make(map[string]string, len(l))
+ for _, label := range l {
+ switch label.Schema {
+ case SchemeDocker:
+ // "//" will be ignored
+ platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
+ case SchemeHost:
+ platforms[label.Name] = "-self-hosted"
+ case SchemeLXC:
+ platforms[label.Name] = "lxc:" + strings.TrimPrefix(label.Arg, "//")
+ default:
+ // It should not happen, because Parse has checked it.
+ continue
+ }
+ }
+ for _, v := range runsOn {
+ if v, ok := platforms[v]; ok {
+ return v
+ }
+ }
+
+ // TODO: support multiple labels
+ // like:
+ // ["ubuntu-22.04"] => "ubuntu:22.04"
+ // ["with-gpu"] => "linux:with-gpu"
+ // ["ubuntu-22.04", "with-gpu"] => "ubuntu:22.04_with-gpu"
+
+ // return default.
+ // So the runner receives a task with a label that the runner doesn't have,
+ // it happens when the user have edited the label of the runner in the web UI.
+ // TODO: it may be not correct, what if the runner is used as host mode only?
+ return "node:20-bullseye"
+}
+
+func (l Labels) Names() []string {
+ names := make([]string, 0, len(l))
+ for _, label := range l {
+ names = append(names, label.Name)
+ }
+ return names
+}
+
+func (l Labels) ToStrings() []string {
+ ls := make([]string, 0, len(l))
+ for _, label := range l {
+ lbl := label.Name
+ if label.Schema != "" {
+ lbl += ":" + label.Schema
+ if label.Arg != "" {
+ lbl += ":" + label.Arg
+ }
+ }
+ ls = append(ls, lbl)
+ }
+ return ls
+}
diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go
new file mode 100644
index 0000000..e46a27b
--- /dev/null
+++ b/internal/pkg/labels/labels_test.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package labels
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "gotest.tools/v3/assert"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ args string
+ want *Label
+ wantErr bool
+ }{
+ {
+ args: "ubuntu:docker://node:18",
+ want: &Label{
+ Name: "ubuntu",
+ Schema: "docker",
+ Arg: "//node:18",
+ },
+ wantErr: false,
+ },
+ {
+ args: "ubuntu:host",
+ want: &Label{
+ Name: "ubuntu",
+ Schema: "host",
+ Arg: "",
+ },
+ wantErr: false,
+ },
+ {
+ args: "ubuntu",
+ want: &Label{
+ Name: "ubuntu",
+ Schema: "host",
+ Arg: "",
+ },
+ wantErr: false,
+ },
+ {
+ args: "ubuntu:vm:ubuntu-18.04",
+ want: nil,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.args, func(t *testing.T) {
+ got, err := Parse(tt.args)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.DeepEqual(t, got, tt.want)
+ })
+ }
+}
diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go
new file mode 100644
index 0000000..cee5062
--- /dev/null
+++ b/internal/pkg/report/reporter.go
@@ -0,0 +1,437 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package report
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
+ "connectrpc.com/connect"
+ retry "github.com/avast/retry-go/v4"
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "gitea.com/gitea/act_runner/internal/pkg/client"
+)
+
+type Reporter struct {
+ ctx context.Context
+ cancel context.CancelFunc
+
+ closed bool
+ client client.Client
+ clientM sync.Mutex
+
+ logOffset int
+ logRows []*runnerv1.LogRow
+ logReplacer *strings.Replacer
+ oldnew []string
+ reportInterval time.Duration
+
+ state *runnerv1.TaskState
+ stateMu sync.RWMutex
+ outputs sync.Map
+
+ debugOutputEnabled bool
+ stopCommandEndToken string
+}
+
+func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, reportInterval time.Duration) *Reporter {
+ var oldnew []string
+ if v := task.Context.Fields["token"].GetStringValue(); v != "" {
+ oldnew = append(oldnew, v, "***")
+ }
+ if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
+ oldnew = append(oldnew, v, "***")
+ }
+ for _, v := range task.Secrets {
+ oldnew = append(oldnew, v, "***")
+ }
+
+ rv := &Reporter{
+ ctx: ctx,
+ cancel: cancel,
+ client: client,
+ oldnew: oldnew,
+ reportInterval: reportInterval,
+ logReplacer: strings.NewReplacer(oldnew...),
+ state: &runnerv1.TaskState{
+ Id: task.Id,
+ },
+ }
+
+ if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
+ rv.debugOutputEnabled = true
+ }
+
+ return rv
+}
+
+func (r *Reporter) ResetSteps(l int) {
+ r.stateMu.Lock()
+ defer r.stateMu.Unlock()
+ for i := 0; i < l; i++ {
+ r.state.Steps = append(r.state.Steps, &runnerv1.StepState{
+ Id: int64(i),
+ })
+ }
+}
+
+func (r *Reporter) Levels() []log.Level {
+ return log.AllLevels
+}
+
+func appendIfNotNil[T any](s []*T, v *T) []*T {
+ if v != nil {
+ return append(s, v)
+ }
+ return s
+}
+
+func (r *Reporter) Fire(entry *log.Entry) error {
+ r.stateMu.Lock()
+ defer r.stateMu.Unlock()
+
+ log.WithFields(entry.Data).Trace(entry.Message)
+
+ timestamp := entry.Time
+ if r.state.StartedAt == nil {
+ r.state.StartedAt = timestamppb.New(timestamp)
+ }
+
+ stage := entry.Data["stage"]
+
+ if stage != "Main" {
+ if v, ok := entry.Data["jobResult"]; ok {
+ if jobResult, ok := r.parseResult(v); ok {
+ r.state.Result = jobResult
+ r.state.StoppedAt = timestamppb.New(timestamp)
+ for _, s := range r.state.Steps {
+ if s.Result == runnerv1.Result_RESULT_UNSPECIFIED {
+ s.Result = runnerv1.Result_RESULT_CANCELLED
+ if jobResult == runnerv1.Result_RESULT_SKIPPED {
+ s.Result = runnerv1.Result_RESULT_SKIPPED
+ }
+ }
+ }
+ }
+ }
+ if !r.duringSteps() {
+ r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
+ }
+ return nil
+ }
+
+ var step *runnerv1.StepState
+ if v, ok := entry.Data["stepNumber"]; ok {
+ if v, ok := v.(int); ok && len(r.state.Steps) > v {
+ step = r.state.Steps[v]
+ }
+ }
+ if step == nil {
+ if !r.duringSteps() {
+ r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
+ }
+ return nil
+ }
+
+ if step.StartedAt == nil {
+ step.StartedAt = timestamppb.New(timestamp)
+ }
+ if v, ok := entry.Data["raw_output"]; ok {
+ if rawOutput, ok := v.(bool); ok && rawOutput {
+ if row := r.parseLogRow(entry); row != nil {
+ if step.LogLength == 0 {
+ step.LogIndex = int64(r.logOffset + len(r.logRows))
+ }
+ step.LogLength++
+ r.logRows = append(r.logRows, row)
+ }
+ }
+ } else if !r.duringSteps() {
+ r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
+ }
+ if v, ok := entry.Data["stepResult"]; ok {
+ if stepResult, ok := r.parseResult(v); ok {
+ if step.LogLength == 0 {
+ step.LogIndex = int64(r.logOffset + len(r.logRows))
+ }
+ step.Result = stepResult
+ step.StoppedAt = timestamppb.New(timestamp)
+ }
+ }
+
+ return nil
+}
+
+func (r *Reporter) RunDaemon() {
+ if r.closed {
+ return
+ }
+ if r.ctx.Err() != nil {
+ return
+ }
+
+ _ = r.ReportLog(false)
+ _ = r.ReportState()
+
+ time.AfterFunc(r.reportInterval, r.RunDaemon)
+}
+
+func (r *Reporter) Logf(format string, a ...interface{}) {
+ r.stateMu.Lock()
+ defer r.stateMu.Unlock()
+
+ r.logf(format, a...)
+}
+
+func (r *Reporter) logf(format string, a ...interface{}) {
+ if !r.duringSteps() {
+ r.logRows = append(r.logRows, &runnerv1.LogRow{
+ Time: timestamppb.Now(),
+ Content: fmt.Sprintf(format, a...),
+ })
+ }
+}
+
+func (r *Reporter) SetOutputs(outputs map[string]string) {
+ r.stateMu.Lock()
+ defer r.stateMu.Unlock()
+
+ for k, v := range outputs {
+ if len(k) > 255 {
+ r.logf("ignore output because the key is too long: %q", k)
+ continue
+ }
+ if l := len(v); l > 1024*1024 {
+ log.Println("ignore output because the value is too long:", k, l)
+ r.logf("ignore output because the value %q is too long: %d", k, l)
+ }
+ if _, ok := r.outputs.Load(k); ok {
+ continue
+ }
+ r.outputs.Store(k, v)
+ }
+}
+
+func (r *Reporter) Close(lastWords string) error {
+ r.closed = true
+
+ r.stateMu.Lock()
+ if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
+ if lastWords == "" {
+ lastWords = "Early termination"
+ }
+ for _, v := range r.state.Steps {
+ if v.Result == runnerv1.Result_RESULT_UNSPECIFIED {
+ v.Result = runnerv1.Result_RESULT_CANCELLED
+ }
+ }
+ r.state.Result = runnerv1.Result_RESULT_FAILURE
+ r.logRows = append(r.logRows, &runnerv1.LogRow{
+ Time: timestamppb.Now(),
+ Content: lastWords,
+ })
+ r.state.StoppedAt = timestamppb.Now()
+ } else if lastWords != "" {
+ r.logRows = append(r.logRows, &runnerv1.LogRow{
+ Time: timestamppb.Now(),
+ Content: lastWords,
+ })
+ }
+ r.stateMu.Unlock()
+
+ return retry.Do(func() error {
+ if err := r.ReportLog(true); err != nil {
+ return err
+ }
+ return r.ReportState()
+ }, retry.Context(r.ctx))
+}
+
+func (r *Reporter) ReportLog(noMore bool) error {
+ r.clientM.Lock()
+ defer r.clientM.Unlock()
+
+ r.stateMu.RLock()
+ rows := r.logRows
+ r.stateMu.RUnlock()
+
+ resp, err := r.client.UpdateLog(r.ctx, connect.NewRequest(&runnerv1.UpdateLogRequest{
+ TaskId: r.state.Id,
+ Index: int64(r.logOffset),
+ Rows: rows,
+ NoMore: noMore,
+ }))
+ if err != nil {
+ return err
+ }
+
+ ack := int(resp.Msg.AckIndex)
+ if ack < r.logOffset {
+ return fmt.Errorf("submitted logs are lost")
+ }
+
+ r.stateMu.Lock()
+ r.logRows = r.logRows[ack-r.logOffset:]
+ r.logOffset = ack
+ r.stateMu.Unlock()
+
+ if noMore && ack < r.logOffset+len(rows) {
+ return fmt.Errorf("not all logs are submitted")
+ }
+
+ return nil
+}
+
+func (r *Reporter) ReportState() error {
+ r.clientM.Lock()
+ defer r.clientM.Unlock()
+
+ r.stateMu.RLock()
+ state := proto.Clone(r.state).(*runnerv1.TaskState)
+ r.stateMu.RUnlock()
+
+ outputs := make(map[string]string)
+ r.outputs.Range(func(k, v interface{}) bool {
+ if val, ok := v.(string); ok {
+ outputs[k.(string)] = val
+ }
+ return true
+ })
+
+ resp, err := r.client.UpdateTask(r.ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
+ State: state,
+ Outputs: outputs,
+ }))
+ if err != nil {
+ return err
+ }
+
+ for _, k := range resp.Msg.SentOutputs {
+ r.outputs.Store(k, struct{}{})
+ }
+
+ if resp.Msg.State != nil && resp.Msg.State.Result == runnerv1.Result_RESULT_CANCELLED {
+ r.cancel()
+ }
+
+ var noSent []string
+ r.outputs.Range(func(k, v interface{}) bool {
+ if _, ok := v.(string); ok {
+ noSent = append(noSent, k.(string))
+ }
+ return true
+ })
+ if len(noSent) > 0 {
+ return fmt.Errorf("there are still outputs that have not been sent: %v", noSent)
+ }
+
+ return nil
+}
+
+func (r *Reporter) duringSteps() bool {
+ if steps := r.state.Steps; len(steps) == 0 {
+ return false
+ } else if first := steps[0]; first.Result == runnerv1.Result_RESULT_UNSPECIFIED && first.LogLength == 0 {
+ return false
+ } else if last := steps[len(steps)-1]; last.Result != runnerv1.Result_RESULT_UNSPECIFIED {
+ return false
+ }
+ return true
+}
+
+var stringToResult = map[string]runnerv1.Result{
+ "success": runnerv1.Result_RESULT_SUCCESS,
+ "failure": runnerv1.Result_RESULT_FAILURE,
+ "skipped": runnerv1.Result_RESULT_SKIPPED,
+ "cancelled": runnerv1.Result_RESULT_CANCELLED,
+}
+
+func (r *Reporter) parseResult(result interface{}) (runnerv1.Result, bool) {
+ str := ""
+ if v, ok := result.(string); ok { // for jobResult
+ str = v
+ } else if v, ok := result.(fmt.Stringer); ok { // for stepResult
+ str = v.String()
+ }
+
+ ret, ok := stringToResult[str]
+ return ret, ok
+}
+
+var cmdRegex = regexp.MustCompile(`^::([^ :]+)( .*)?::(.*)$`)
+
+func (r *Reporter) handleCommand(originalContent, command, parameters, value string) *string {
+ if r.stopCommandEndToken != "" && command != r.stopCommandEndToken {
+ return &originalContent
+ }
+
+ switch command {
+ case "add-mask":
+ r.addMask(value)
+ return nil
+ case "debug":
+ if r.debugOutputEnabled {
+ return &value
+ }
+ return nil
+
+ case "notice":
+ // Not implemented yet, so just return the original content.
+ return &originalContent
+ case "warning":
+ // Not implemented yet, so just return the original content.
+ return &originalContent
+ case "error":
+ // Not implemented yet, so just return the original content.
+ return &originalContent
+ case "group":
+ // Rewriting into ##[] syntax which the frontend understands
+ content := "##[group]" + value
+ return &content
+ case "endgroup":
+ // Ditto
+ content := "##[endgroup]"
+ return &content
+ case "stop-commands":
+ r.stopCommandEndToken = value
+ return nil
+ case r.stopCommandEndToken:
+ r.stopCommandEndToken = ""
+ return nil
+ }
+ return &originalContent
+}
+
+func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
+ content := strings.TrimRightFunc(entry.Message, func(r rune) bool { return r == '\r' || r == '\n' })
+
+ matches := cmdRegex.FindStringSubmatch(content)
+ if matches != nil {
+ if output := r.handleCommand(content, matches[1], matches[2], matches[3]); output != nil {
+ content = *output
+ } else {
+ return nil
+ }
+ }
+
+ content = r.logReplacer.Replace(content)
+
+ return &runnerv1.LogRow{
+ Time: timestamppb.New(entry.Time),
+ Content: strings.ToValidUTF8(content, "?"),
+ }
+}
+
+func (r *Reporter) addMask(msg string) {
+ r.oldnew = append(r.oldnew, msg, "***")
+ r.logReplacer = strings.NewReplacer(r.oldnew...)
+}
diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go
new file mode 100644
index 0000000..524e972
--- /dev/null
+++ b/internal/pkg/report/reporter_test.go
@@ -0,0 +1,198 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package report
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
+ connect_go "connectrpc.com/connect"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/structpb"
+
+ "gitea.com/gitea/act_runner/internal/pkg/client/mocks"
+)
+
+func TestReporter_parseLogRow(t *testing.T) {
+ tests := []struct {
+ name string
+ debugOutputEnabled bool
+ args []string
+ want []string
+ }{
+ {
+ "No command", false,
+ []string{"Hello, world!"},
+ []string{"Hello, world!"},
+ },
+ {
+ "Add-mask", false,
+ []string{
+ "foo mysecret bar",
+ "::add-mask::mysecret",
+ "foo mysecret bar",
+ },
+ []string{
+ "foo mysecret bar",
+ "<nil>",
+ "foo *** bar",
+ },
+ },
+ {
+ "Debug enabled", true,
+ []string{
+ "::debug::GitHub Actions runtime token access controls",
+ },
+ []string{
+ "GitHub Actions runtime token access controls",
+ },
+ },
+ {
+ "Debug not enabled", false,
+ []string{
+ "::debug::GitHub Actions runtime token access controls",
+ },
+ []string{
+ "<nil>",
+ },
+ },
+ {
+ "notice", false,
+ []string{
+ "::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ []string{
+ "::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ },
+ {
+ "warning", false,
+ []string{
+ "::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ []string{
+ "::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ },
+ {
+ "error", false,
+ []string{
+ "::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ []string{
+ "::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work",
+ },
+ },
+ {
+ "group", false,
+ []string{
+ "::group::",
+ "::endgroup::",
+ },
+ []string{
+ "##[group]",
+ "##[endgroup]",
+ },
+ },
+ {
+ "stop-commands", false,
+ []string{
+ "::add-mask::foo",
+ "::stop-commands::myverycoolstoptoken",
+ "::add-mask::bar",
+ "::debug::Stuff",
+ "myverycoolstoptoken",
+ "::add-mask::baz",
+ "::myverycoolstoptoken::",
+ "::add-mask::wibble",
+ "foo bar baz wibble",
+ },
+ []string{
+ "<nil>",
+ "<nil>",
+ "::add-mask::bar",
+ "::debug::Stuff",
+ "myverycoolstoptoken",
+ "::add-mask::baz",
+ "<nil>",
+ "<nil>",
+ "*** bar baz ***",
+ },
+ },
+ {
+ "unknown command", false,
+ []string{
+ "::set-mask::foo",
+ },
+ []string{
+ "::set-mask::foo",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Reporter{
+ logReplacer: strings.NewReplacer(),
+ debugOutputEnabled: tt.debugOutputEnabled,
+ }
+ for idx, arg := range tt.args {
+ rv := r.parseLogRow(&log.Entry{Message: arg})
+ got := "<nil>"
+
+ if rv != nil {
+ got = rv.Content
+ }
+
+ assert.Equal(t, tt.want[idx], got)
+ }
+ })
+ }
+}
+
+func TestReporter_Fire(t *testing.T) {
+ t.Run("ignore command lines", func(t *testing.T) {
+ client := mocks.NewClient(t)
+ client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
+ t.Logf("Received UpdateLog: %s", req.Msg.String())
+ return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
+ AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
+ }), nil
+ })
+ client.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
+ t.Logf("Received UpdateTask: %s", req.Msg.String())
+ return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
+ })
+ ctx, cancel := context.WithCancel(context.Background())
+ taskCtx, err := structpb.NewStruct(map[string]interface{}{})
+ require.NoError(t, err)
+ reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
+ Context: taskCtx,
+ }, time.Second)
+ defer func() {
+ assert.NoError(t, reporter.Close(""))
+ }()
+ reporter.ResetSteps(5)
+
+ dataStep0 := map[string]interface{}{
+ "stage": "Main",
+ "stepNumber": 0,
+ "raw_output": true,
+ }
+
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "::debug::debug log line", Data: dataStep0}))
+ assert.NoError(t, reporter.Fire(&log.Entry{Message: "regular log line", Data: dataStep0}))
+
+ assert.Equal(t, int64(3), reporter.state.Steps[0].LogLength)
+ })
+}
diff --git a/internal/pkg/ver/version.go b/internal/pkg/ver/version.go
new file mode 100644
index 0000000..3c07a18
--- /dev/null
+++ b/internal/pkg/ver/version.go
@@ -0,0 +1,11 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ver
+
+// go build -ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=1.2.3"
+var version = "dev"
+
+func Version() string {
+ return version
+}