From 1d6c6ffef968aa80fe5654374178920803f06d96 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 6 Aug 2026 05:15:32 +0000 Subject: [PATCH] enhance: improve config, comment out values in example file (#1145) `config.example.yaml` now has every value commented out, as gitea's `app.example.ini` does, so it documents each option with its default instead of imposing it. Copying it no longer pins values the runner would otherwise pick, and a changed default reaches configs that never named the option. `config init` writes the file to configure: a header comment and no option, so every option keeps its default. It refuses to overwrite an existing config without `--force`, and writes `config.yaml` in the working directory when `-c` is absent. `config set`, `add` and `remove` keep such a file readable. Comments go back to the indentation they were written at, which the encoder drops for a comment block that has no key below it, and a file of only comments keeps its text instead of being emptied by the first edit. Also rewrote the config docs sections for clarity. Reviewed-on: https://gitea.com/gitea/runner/pulls/1145 Reviewed-by: Lunny Xiao Reviewed-by: bircni Co-authored-by: silverwind --- README.md | 44 +++----- examples/systemd/README.md | 6 +- examples/vm/rootless-docker.md | 4 +- internal/app/cmd/config.go | 31 +++++- internal/app/cmd/config_test.go | 24 ++++ internal/pkg/config/config.example.yaml | 140 ++++++++++++------------ internal/pkg/config/config.go | 6 + internal/pkg/config/config_test.go | 17 ++- internal/pkg/config/edit.go | 64 ++++++++--- internal/pkg/config/edit_test.go | 43 ++++++-- 10 files changed, 248 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index a42f9ae4..370f8b9b 100644 --- a/README.md +++ b/README.md @@ -129,48 +129,38 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a ### Configuration -The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree): +The runner reads a YAML file. Without one, every option keeps its default. ```bash -./gitea-runner config generate > config.yaml +./gitea-runner config init # write config.yaml, with no option set +./gitea-runner config generate | less # read what the options do +./gitea-runner -c config.yaml daemon # -c also works on register and cache-server ``` -> The top-level `generate-config` command still does the same thing, but is deprecated in favour of `config generate`. - -Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`): - -```bash -./gitea-runner -c config.yaml register -./gitea-runner -c config.yaml daemon -./gitea-runner -c config.yaml cache-server -``` - -Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `config generate` prints). +`config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them. #### Editing a config file -`config` changes an existing file in place, keeping its comments and key order, which is handy in provisioning scripts: +`config` edits a file in place, which is handy in provisioning scripts: ```bash -./gitea-runner -c config.yaml config set runner.capacity 4 -./gitea-runner -c config.yaml config set runner.timeout 90m # written as 1h30m0s -./gitea-runner -c config.yaml config set runner.envs.MY_VAR value -./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22' -./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22' -./gitea-runner -c config.yaml config get runner.labels +./gitea-runner config set runner.capacity 4 +./gitea-runner config set runner.timeout 90m # written as 1h30m0s +./gitea-runner config set runner.envs.MY_VAR value +./gitea-runner config add runner.labels 'ubuntu:docker://node:22' +./gitea-runner config remove runner.labels 'ubuntu:docker://node:22' +./gitea-runner config get runner.labels ``` -`-c` is optional for these subcommands: without it they use `config.yaml` (or `config.yml`) from the working directory, falling back to the directory of the `gitea-runner` binary, and print which file they picked to stderr. +A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values. -Keys are the dotted YAML path and are validated against the known options, so a typo is rejected instead of being written. `add` and `remove` only work on list options such as `runner.labels` and `container.valid_volumes`, and fail if the value is already present or missing. `set` replaces the whole list when given several values. +An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped. -The file is re-encoded on every edit, so indentation is normalised to two spaces and blank lines inside a section are dropped. +`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path. -#### Without a config file +#### Environment variables -If you omit `-c`, built-in defaults apply (same as an empty YAML document). - -Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below. +Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below. ### Labels diff --git a/examples/systemd/README.md b/examples/systemd/README.md index cc6217da..3a726c51 100644 --- a/examples/systemd/README.md +++ b/examples/systemd/README.md @@ -12,11 +12,13 @@ the runner as a background service on a systemd host. sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner ``` -3. Generate a config and register the runner (as the service user), so the +3. Write a config, hand it to the service user, and register as that user so the `.runner` file ends up in the working directory: ```bash - sudo -u gitea-runner gitea-runner config generate > /etc/gitea-runner/config.yaml + sudo mkdir -p /etc/gitea-runner + sudo gitea-runner config init --config /etc/gitea-runner/config.yaml + sudo chown gitea-runner /etc/gitea-runner/config.yaml cd /var/lib/gitea-runner sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml ``` diff --git a/examples/vm/rootless-docker.md b/examples/vm/rootless-docker.md index 4f057cf4..ac4ff435 100644 --- a/examples/vm/rootless-docker.md +++ b/examples/vm/rootless-docker.md @@ -49,10 +49,10 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock gitea-runner register ``` -- Generate a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system. +- Write a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system, `gitea-runner config generate` documents every option. ```bash - gitea-runner config generate >/home/rootless/gitea-runner/config + gitea-runner config init --config /home/rootless/gitea-runner/config ``` - Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents: diff --git a/internal/app/cmd/config.go b/internal/app/cmd/config.go index 65d9f0be..844281dc 100644 --- a/internal/app/cmd/config.go +++ b/internal/app/cmd/config.go @@ -26,6 +26,7 @@ func loadConfigCmd(configFile *string) *cobra.Command { } configCmd.AddCommand(loadGenerateConfigCmd("generate")) + configCmd.AddCommand(loadInitConfigCmd(configFile)) configCmd.AddCommand(&cobra.Command{ Use: "get ", @@ -73,10 +74,38 @@ func loadConfigCmd(configFile *string) *cobra.Command { return configCmd } +func loadInitConfigCmd(configFile *string) *cobra.Command { + var force bool + initCmd := &cobra.Command{ + Use: "init", + Short: "Write a minimal config file", + Long: "Write a minimal config file, leaving every option at its default.\nWithout --config it writes config.yaml in the working directory.", + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + file, taken := *configFile, []string{*configFile} + if file == "" { + file, taken = defaultConfigFileNames[0], defaultConfigFileNames // any of them would shadow the new file + } + for _, name := range taken { + if _, err := os.Stat(name); err == nil && !force { + return fmt.Errorf("config file %q already exists, pass --force to overwrite it", name) + } + } + if err := config.WriteFile(file, []byte(config.Minimal)); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "wrote config file %q\n", file) + return nil + }, + } + initCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite an existing config file") + return initCmd +} + func loadGenerateConfigCmd(use string) *cobra.Command { return &cobra.Command{ Use: use, - Short: "Generate an example config file", + Short: "Print the example config, which documents every option", Args: cobra.MaximumNArgs(0), Run: func(cmd *cobra.Command, _ []string) { fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example) diff --git a/internal/app/cmd/config_test.go b/internal/app/cmd/config_test.go index 3109ef54..d89961fc 100644 --- a/internal/app/cmd/config_test.go +++ b/internal/app/cmd/config_test.go @@ -32,6 +32,30 @@ func TestConfigCmdGeneratePrintsTheExample(t *testing.T) { assert.Equal(t, string(config.Example), out) } +func TestConfigCmdInitWritesTheMinimalConfig(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "config.yaml") + + out, _, err := runConfigCmd(t, file, "init") + require.NoError(t, err) + assert.Contains(t, out, file) + content, err := os.ReadFile(file) + require.NoError(t, err) + assert.Equal(t, config.Minimal, string(content)) + + _, _, err = runConfigCmd(t, file, "init") + require.Error(t, err) + assert.Contains(t, err.Error(), "--force") + + _, _, err = runConfigCmd(t, file, "init", "--force") + require.NoError(t, err) + + t.Chdir(t.TempDir()) + _, _, err = runConfigCmd(t, "", "init") + require.NoError(t, err) + assert.FileExists(t, defaultConfigFileNames[0]) +} + // The subcommands only wire arguments through, so one pass over all of them is enough. func TestConfigCmdEditsTheFile(t *testing.T) { file := filepath.Join(t.TempDir(), "config.yaml") diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 51322cc0..5a8a33cf 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -1,7 +1,5 @@ -# 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 `./gitea-runner config generate > config.yaml` to generate a config file. +# Every option with its default value, all commented out. Read this file, do not copy it. +# `./gitea-runner config init` writes a config file to copy the lines you change into. # Logging for the runner process itself (messages printed to stderr). # This does not control how workflow step output is streamed to the Gitea UI; @@ -9,92 +7,92 @@ log: # logrus severity: trace, debug, info, warn, error, fatal, panic. # trace and debug turn on caller/file:line in log lines. Default if omitted: info. - level: info + #level: info runner: # Where to store the registration result. - file: .runner + #file: .runner # Execute how many tasks concurrently at the same time. # With `container.network` empty, each concurrent docker job takes a subnet from the # daemon's address pool, so a high capacity can exhaust it. See `default-address-pools` # in the docker daemon config. - capacity: 1 + #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 + #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 + #env_file: .env # The timeout for a job to be finished. # Please note that the Gitea instance also has a timeout (3h by default) for the job. # So the job could be stopped by the Gitea instance if its timeout is shorter than this. - timeout: 3h + #timeout: 3h # The timeout for the runner to wait for running jobs to finish when shutting down. # Any running jobs that haven't finished after this timeout will be cancelled. - shutdown_timeout: 0s + #shutdown_timeout: 0s # Whether skip verifying the TLS certificate of the Gitea instance. - insecure: false + #insecure: false # The timeout for fetching the job from the Gitea instance. - fetch_timeout: 5s + #fetch_timeout: 5s # The interval for fetching the job from the Gitea instance. - fetch_interval: 2s + #fetch_interval: 2s # The maximum interval for fetching the job from the Gitea instance. # The runner uses exponential backoff when idle, increasing the interval up to this maximum. # Set to 0 or same as fetch_interval to disable backoff. - fetch_interval_max: 5s + #fetch_interval_max: 5s # While idle, remove stale bind-workdir task directories and orphaned host-mode # scratch directories (left behind when a host cleanup delete stalls) older than # this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0 # (or any non-positive value) disables stale-directory cleanup entirely, along with # the docker network cleanup below. - workdir_cleanup_age: 24h + #workdir_cleanup_age: 24h # Cadence for the idle cleanup pass. Besides the directories above, on runners that use # docker it removes the per-job networks of jobs this runner did not live to tear down, # which would otherwise hold a subnet of the daemon address pool until the host is rebuilt. - idle_cleanup_interval: 10m + #idle_cleanup_interval: 10m # The base interval for periodic log flush to the Gitea instance. # Logs may be sent earlier if the buffer reaches log_report_batch_size # or if log_report_max_latency expires after the first buffered row. - log_report_interval: 5s + #log_report_interval: 5s # The maximum time a log row can wait before being sent. # This ensures even a single log line appears on the frontend within this duration. # Must be less than log_report_interval to have any effect. - log_report_max_latency: 3s + #log_report_max_latency: 3s # Flush logs immediately when the buffer reaches this many rows. # This ensures bursty output (e.g., npm install) is delivered promptly. - log_report_batch_size: 100 + #log_report_batch_size: 100 # The interval for reporting task state (step status, timing) to the Gitea instance. # State is also reported immediately on step transitions (start/stop). - state_report_interval: 5s + #state_report_interval: 5s # Per-attempt deadline for flushing the final logs and task state when a job # finishes, on a detached context so a server cancel can't block the acknowledgement. - report_close_timeout: 10s + #report_close_timeout: 10s # The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository. # It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github, # and github_mirror is not empty. In this case, # it replaces https://github.com with the value here, which is useful for some special network environments. - github_mirror: '' - # When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history. + #github_mirror: '' + # When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history. # Set to false to clone the full history. - action_shallow_clone: true + #action_shallow_clone: true # When true (the default), inject the ACT=true environment variable into jobs. # Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub. - set_act_env: true + #set_act_env: true # The labels of a runner are used to determine which jobs the runner can run, and how to run them. # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" # Find more images provided by Gitea at https://gitea.com/gitea/runner-images . # If it's empty when registering, it will ask for inputting labels. # If it's empty when execute `daemon`, will use labels in `.runner` file. - labels: - - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" - - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" - - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + #labels: + # - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + # - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" + # - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" # Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends. # Default false matches GitHub actions/runner. Enable only for jobs that need an interactive # terminal; tools like `docker build` emit redrawing progress frames into the captured log # when a TTY is present. - allocate_pty: false + #allocate_pty: false # Optional executable on the host, run once after each task's built-in cleanup # (post-steps, container teardown, bind-workdir removal). Additive only. # @@ -107,24 +105,24 @@ runner: # Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as # the configured path; wrap PowerShell commands in a .cmd file instead. # Full guide: docs/post-task-script.md - post_task_script: '' + #post_task_script: '' # Hard limit on post_task_script runtime. Default if omitted: 5m. - post_task_script_timeout: 5m + #post_task_script_timeout: 5m # Scripts run inside the job environment before the job's first step and after its last # one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and # ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are # resolved inside the job environment. Either one failing fails the job. # Full guide: docs/job-hooks.md - hooks: - job_started: '' - job_completed: '' + #hooks: + # job_started: '' + # job_completed: '' cache: # Enable the built-in cache server (used by actions/cache and similar actions). - enabled: true + #enabled: true # Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache # Ignored when external_server is set. - dir: "" + #dir: "" # Outbound IP or hostname that job containers use to reach this runner's cache server. # Leave empty to detect automatically. 0.0.0.0 is not valid here. # If the runner itself runs in Docker, automatic detection can choose an @@ -133,33 +131,33 @@ cache: # to a hostname/IP reachable from job containers, and set port to a fixed # published port or put the job containers on a shared Docker network. # Ignored when external_server is set. - host: "" + #host: "" # Port for the built-in cache server. 0 picks a random free port. # Ignored when external_server is set. - port: 0 + #port: 0 # URL of a shared `gitea-runner cache-server` to use instead of starting a local one. # Set on every runner that should share a cache pool. A trailing slash is optional. # Example: "http://cache-host:8088/" # Requires external_secret (below) to match the value on the cache-server. - external_server: "" + #external_server: "" # Shared secret between this runner and the external cache-server. # Required when external_server is set. Must be identical on every runner and the cache-server. # Generate with: openssl rand -hex 32 - external_secret: "" + #external_secret: "" # Path to a file containing the shared secret, as an alternative to external_secret. # Use this to keep the secret out of this file. # Surrounding whitespace is trimmed, so a trailing newline in the file is fine. # Setting both external_secret and external_secret_file is an error. - external_secret_file: "" + #external_secret_file: "" # When true, reuse a cached action instead of fetching from the remote on every job. # A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit # until its cache entry expires or is manually removed. - offline_mode: false + #offline_mode: false # Serve the actions cache service v2 API, used by actions/cache@v4.2 and later. Those actions # refuse any host they do not take for GitHub, so reaching it means editing that check out of # the action's own bundle, keeping the untouched copy beside it. The same edit lets the stock # upload-artifact and download-artifact work here. A bundle that does not match is left alone. - v2: true + #v2: true container: # Specifies the network to which the container will connect. @@ -168,31 +166,31 @@ container: # For dockerized runners using the built-in cache server, a custom shared # network can be required so job containers can reach cache.host/cache.port. # Deprecated: `network_mode` is still accepted for old configs; use `network` instead. - network: "" + #network: "" # network_create_options only apply when `network` is left empty and the runner # auto-creates a per-job network that does not already exist. They have no effect # when a custom `network` name is set, because that network is used as-is and never # created by the runner. Omit the entire block to use Docker's defaults. An auto-created # network is labelled com.gitea.runner.uuid=, which is how the idle # cleanup tells its own leftovers apart from those of other runners on the same daemon. - network_create_options: - enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4. - enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6. + #network_create_options: + # enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4. + # enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6. # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker). - privileged: false + #privileged: false # Any other options to be used when the container is started, for example: # options: --add-host=my.gitea.url:host-gateway # A volume declared here replaces the one the runner mounts on the same container path, so the # tool cache can be kept on the host. Its source must also be allowed by valid_volumes below: # options: --volume /host/toolcache:/opt/hostedtoolcache - options: + #options: # The parent directory of a job's working directory. # NOTE: There is no need to add the first '/' of the path as runner will add it automatically. # If the path starts with '/', the '/' will be trimmed. # For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir # If it's empty, /workspace will be used. # Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup. - workdir_parent: + #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: @@ -202,62 +200,62 @@ container: # If you want to allow any volume, please use the following configuration: # valid_volumes: # - '**' - valid_volumes: [] + #valid_volumes: [] # Overrides the docker client host with the specified one. # If it's empty, runner will find an available docker host automatically. # If it's "-", 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: "" + #docker_host: "" # Pull docker image(s) even if already present. # Defaults to false when the key is omitted. # Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never # re-pulled, and a pull that fails while a copy is already on the host does not fail the job, # which runs on that copy with a warning in its log. - force_pull: false + #force_pull: false # Rebuild docker image(s) even if already present - force_rebuild: false + #force_rebuild: false # Always require a reachable docker daemon, even if not required by runner - require_docker: false + #require_docker: false # Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner - docker_timeout: 0s + #docker_timeout: 0s # Bind the workspace to the host filesystem instead of using Docker volumes. # This is required for Docker-in-Docker (DinD) setups when jobs use docker compose # with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible # from the DinD daemon's filesystem. When enabled, ensure the workspace parent # directory is also mounted into the runner container and listed in valid_volumes. - bind_workdir: false + #bind_workdir: false # How long a job waits for a service container that declares a healthcheck to become # healthy. A negative value (e.g. -1s) starts the steps without waiting. - service_ready_timeout: 5m + #service_ready_timeout: 5m host: # The parent directory of a job's working directory. # If it's empty, $HOME/.cache/act/ will be used. - workdir_parent: + #workdir_parent: # Optional local task-admission checks. Disabled by default. When enabled, low # disk space or a failing script pauses new task fetching; existing jobs continue. # No health checks run while any job is active; the last result is reused until idle. health_check: - enabled: false + #enabled: false # Minimum free space required on the filesystem holding runner workspaces. # Defaults to 1024 MiB when omitted or set to zero. - min_free_disk_space_mb: 1024 + #min_free_disk_space_mb: 1024 # Optional additional executable. A non-zero exit, timeout, or startup failure # marks the runner unavailable. - script: '' + #script: '' # How long a script result is cached and its maximum execution time. - interval: 30s - timeout: 10s + #interval: 30s + #timeout: 10s metrics: # Enable the Prometheus metrics endpoint. # When enabled, metrics are served at /metrics, liveness at /healthz, and # task-admission readiness at /readyz. - enabled: false + #enabled: false # The address for the metrics HTTP server to listen on. # Defaults to localhost only. Set to ":9101" to allow external access, # but ensure the port is firewall-protected as there is no authentication. - addr: "127.0.0.1:9101" + #addr: "127.0.0.1:9101" # Consecutive polling failures may last this long before /readyz returns 503. - readiness_grace: 30s + #readiness_grace: 30s diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 3dc7e5ac..a66716d1 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -24,6 +24,12 @@ import ( // (so a programmatically built config still gets a sane bound). const DefaultPostTaskScriptTimeout = 5 * time.Minute +// Minimal is the smallest config file that runs the runner: options it does not +// name keep their default, and it names none. +const Minimal = `# Minimal config file. Every option it does not set keeps its default. +# "gitea-runner config generate" prints all options, "config set " sets one here. +` + // Log represents the configuration for logging. type Log struct { Level string `yaml:"level"` // Level indicates the logging level. diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index bbf04e00..09ff1b89 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -348,12 +348,23 @@ cache: assert.Contains(t, err.Error(), "contains no secret") } -// The shipped example must parse, and every key in it must be one the config knows. -func TestLoadDefault_ExampleConfigParses(t *testing.T) { +// The shipped configs must parse, hold no key the config does not know, and leave +// every option at its default, as all of their values are commented out. +func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) { hook := test.NewGlobal() defer hook.Reset() - _, err := LoadDefault("config.example.yaml") + defaults, err := LoadDefault("") require.NoError(t, err) + + dir := t.TempDir() + for name, content := range map[string][]byte{"config.example.yaml": Example, "minimal.yaml": []byte(Minimal)} { + file := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(file, content, 0o600)) + + cfg, err := LoadDefault(file) + require.NoError(t, err, name) + assert.Equal(t, defaults, cfg, name) + } assert.Empty(t, hook.AllEntries()) } diff --git a/internal/pkg/config/edit.go b/internal/pkg/config/edit.go index f0a36b9d..72c5ed87 100644 --- a/internal/pkg/config/edit.go +++ b/internal/pkg/config/edit.go @@ -160,6 +160,7 @@ type editSession struct { root *yaml.Node field *fieldInfo segments []string + preamble []byte // text of a file that holds no YAML node, which the encoder cannot give back } // loadForEdit validates the path and parses the file, so every caller fails before anything is written. @@ -176,7 +177,7 @@ func loadForEdit(file, path string) (*editSession, error) { content, err := os.ReadFile(file) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("config file %q does not exist, create one with `config generate`", file) + return nil, fmt.Errorf("config file %q does not exist, create one with `config init`", file) } return nil, err } @@ -185,7 +186,9 @@ func loadForEdit(file, path string) (*editSession, error) { if err := yaml.Unmarshal(content, &root); err != nil { return nil, fmt.Errorf("parse config file %q: %w", file, err) } + var preamble []byte if root.Kind == 0 || len(root.Content) == 0 { + preamble = bytes.TrimSpace(content) // all the file has is comments root = yaml.Node{ Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}, @@ -195,7 +198,7 @@ func loadForEdit(file, path string) (*editSession, error) { return nil, fmt.Errorf("config file %q is not a YAML mapping", file) } - return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments}, nil + return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments, preamble: preamble}, nil } func loadSequenceEdit(file, path string, values []string) (*editSession, error) { @@ -319,16 +322,28 @@ func encodeYAML(node *yaml.Node) ([]byte, error) { return buf.Bytes(), nil } -// restoreBlankLines re-inserts the blank lines between top-level sections that the encoder drops. -func restoreBlankLines(original, generated []byte) []byte { +// restoreLayout re-applies the spacing the encoder drops: the blank lines between +// top-level sections, and the indentation of comments, which the encoder emits at the +// indentation of the node it attached them to rather than the one they were written at. +func restoreLayout(original, generated []byte) []byte { + type comment struct { + line string // as written, indentation included + trimmed string + blankBefore bool + } + + var comments []comment spaced := map[string]bool{} blank := false for line := range strings.Lines(string(original)) { line = strings.TrimRight(line, "\r\n") + trimmed := strings.TrimSpace(line) switch { - case strings.TrimSpace(line) == "": + case trimmed == "": blank = true - case strings.HasPrefix(line, "#"): // the block belongs to the key below it + case strings.HasPrefix(trimmed, "#"): + comments = append(comments, comment{line: line, trimmed: trimmed, blankBefore: blank}) + blank = false default: if key, ok := topLevelKey(line); ok && blank { spaced[key] = true @@ -338,16 +353,26 @@ func restoreBlankLines(original, generated []byte) []byte { } var out []string + appendBlank := func() { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + } for line := range strings.Lines(string(generated)) { line = strings.TrimRight(line, "\r\n") - if key, ok := topLevelKey(line); ok && spaced[key] { - start := len(out) - for start > 0 && strings.HasPrefix(out[start-1], "#") { - start-- - } - if start > 0 && strings.TrimSpace(out[start-1]) != "" { - out = slices.Insert(out, start, "") + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + if i := slices.IndexFunc(comments, func(c comment) bool { return c.trimmed == trimmed }); i >= 0 { + if comments[i].blankBefore { + appendBlank() + } else if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] // the encoder separates a comment block it moved + } + line = comments[i].line + comments = comments[i+1:] // the encoder keeps their order, so earlier ones cannot match again } + } else if key, ok := topLevelKey(line); ok && spaced[key] { + appendBlank() } out = append(out, line) } @@ -377,12 +402,21 @@ func (s *editSession) write() error { return fmt.Errorf("the edit would produce a config the runner cannot load: %w", err) } - content := restoreBlankLines(s.original, generated) + if len(s.preamble) > 0 { // before restoreLayout, so that it spaces the preamble too + generated = slices.Concat(s.preamble, []byte("\n"), generated) + } + + content := restoreLayout(s.original, generated) if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n")) } - file := s.file + return WriteFile(s.file, content) +} + +// WriteFile replaces the config file in one step, keeping the mode and owner of the +// file it replaces, so a half-written config never reaches a runner reading it. +func WriteFile(file string, content []byte) error { if resolved, err := filepath.EvalSymlinks(file); err == nil { file = resolved // keeps a config linked in from elsewhere intact } diff --git a/internal/pkg/config/edit_test.go b/internal/pkg/config/edit_test.go index 76adbcd8..9c33b302 100644 --- a/internal/pkg/config/edit_test.go +++ b/internal/pkg/config/edit_test.go @@ -252,16 +252,39 @@ func TestEditValuesFileHandling(t *testing.T) { }) } -// The example config is the file users edit, so it has to stay written the way -// the encoder emits it, down to the single space before a trailing comment. -func TestEditValuesKeepsExampleConfigIntact(t *testing.T) { - file := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(file, Example, 0o600)) +// An edit has to give the file back unchanged around it, down to the indentation of +// a commented-out option, as that documentation is what the user reads and edits. +func TestEditValuesPreservesFileText(t *testing.T) { + tests := []struct { + name string + content []byte + edit func(file string) error + added string // the only text the edit may add + }{ + { + name: "example config", + content: Example, + edit: func(file string) error { return AddValue(file, "runner.labels", "ubuntu:docker://node:22") }, + added: " labels:\n - ubuntu:docker://node:22\n", + }, + { + name: "minimal config", + content: []byte(Minimal), + edit: func(file string) error { return SetValue(file, "runner.capacity", "4") }, + added: "runner:\n capacity: 4\n", + }, + } - require.NoError(t, AddValue(file, "runner.labels", "ubuntu:docker://node:22")) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(file, tc.content, 0o600)) - content, err := os.ReadFile(file) - require.NoError(t, err) - withoutAdded := strings.Replace(string(content), " - ubuntu:docker://node:22\n", "", 1) - assert.Equal(t, string(Example), withoutAdded, "only the appended label may differ") + require.NoError(t, tc.edit(file)) + + content, err := os.ReadFile(file) + require.NoError(t, err) + assert.Equal(t, string(tc.content), strings.Replace(string(content), tc.added, "", 1)) + }) + } }