mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-06-19 07:59:24 +03:00
treewide: format all inactive Nix files
After final improvements to the official formatter implementation, this commit now performs the first treewide reformat of Nix files using it. This is part of the implementation of RFC 166. Only "inactive" files are reformatted, meaning only files that aren't being touched by any PR with activity in the past 2 months. This is to avoid conflicts for PRs that might soon be merged. Later we can do a full treewide reformat to get the rest, which should not cause as many conflicts. A CI check has already been running for some time to ensure that new and already-formatted files are formatted, so the files being reformatted here should also stay formatted. This commit was automatically created and can be verified using nix-builda08b3a4d19
.tar.gz \ --argstr baseRevb32a094368
result/bin/apply-formatting $NIXPKGS_PATH
This commit is contained in:
parent
b32a094368
commit
4f0dadbf38
21293 changed files with 701351 additions and 428307 deletions
|
@ -1,6 +1,7 @@
|
|||
{ lib
|
||||
, pkgs
|
||||
, ...
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.services.github-runners = lib.mkOption {
|
||||
|
@ -23,242 +24,247 @@
|
|||
};
|
||||
};
|
||||
default = { };
|
||||
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: {
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Whether to enable GitHub Actions runner.
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Whether to enable GitHub Actions runner.
|
||||
|
||||
Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here:
|
||||
[About self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners).
|
||||
'';
|
||||
type = lib.types.bool;
|
||||
};
|
||||
Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here:
|
||||
[About self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners).
|
||||
'';
|
||||
type = lib.types.bool;
|
||||
};
|
||||
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Repository to add the runner to.
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Repository to add the runner to.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
Changing this option triggers a new runner registration.
|
||||
|
||||
IMPORTANT: If your token is org-wide (not per repository), you need to
|
||||
provide a github org link, not a single repository, so do it like this
|
||||
`https://github.com/nixos`, not like this
|
||||
`https://github.com/nixos/nixpkgs`.
|
||||
Otherwise, you are going to get a `404 NotFound`
|
||||
from `POST https://api.github.com/actions/runner-registration`
|
||||
in the configure script.
|
||||
'';
|
||||
example = "https://github.com/nixos/nixpkgs";
|
||||
};
|
||||
IMPORTANT: If your token is org-wide (not per repository), you need to
|
||||
provide a github org link, not a single repository, so do it like this
|
||||
`https://github.com/nixos`, not like this
|
||||
`https://github.com/nixos/nixpkgs`.
|
||||
Otherwise, you are going to get a `404 NotFound`
|
||||
from `POST https://api.github.com/actions/runner-registration`
|
||||
in the configure script.
|
||||
'';
|
||||
example = "https://github.com/nixos/nixpkgs";
|
||||
};
|
||||
|
||||
tokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = ''
|
||||
The full path to a file which contains either
|
||||
tokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = ''
|
||||
The full path to a file which contains either
|
||||
|
||||
* a fine-grained personal access token (PAT),
|
||||
* a classic PAT
|
||||
* or a runner registration token
|
||||
* a fine-grained personal access token (PAT),
|
||||
* a classic PAT
|
||||
* or a runner registration token
|
||||
|
||||
Changing this option or the `tokenFile`’s content triggers a new runner registration.
|
||||
Changing this option or the `tokenFile`’s content triggers a new runner registration.
|
||||
|
||||
We suggest using the fine-grained PATs. A runner registration token is valid
|
||||
only for 1 hour after creation, so the next time the runner configuration changes
|
||||
this will give you hard-to-debug HTTP 404 errors in the configure step.
|
||||
We suggest using the fine-grained PATs. A runner registration token is valid
|
||||
only for 1 hour after creation, so the next time the runner configuration changes
|
||||
this will give you hard-to-debug HTTP 404 errors in the configure step.
|
||||
|
||||
The file should contain exactly one line with the token without any newline.
|
||||
(Use `echo -n '…token…' > …token file…` to make sure no newlines sneak in.)
|
||||
The file should contain exactly one line with the token without any newline.
|
||||
(Use `echo -n '…token…' > …token file…` to make sure no newlines sneak in.)
|
||||
|
||||
If the file contains a PAT, the service creates a new registration token
|
||||
on startup as needed.
|
||||
If a registration token is given, it can be used to re-register a runner of the same
|
||||
name but is time-limited as noted above.
|
||||
If the file contains a PAT, the service creates a new registration token
|
||||
on startup as needed.
|
||||
If a registration token is given, it can be used to re-register a runner of the same
|
||||
name but is time-limited as noted above.
|
||||
|
||||
For fine-grained PATs:
|
||||
For fine-grained PATs:
|
||||
|
||||
Give it "Read and Write access to organization/repository self hosted runners",
|
||||
depending on whether it is organization wide or per-repository. You might have to
|
||||
experiment a little, fine-grained PATs are a `beta` Github feature and still subject
|
||||
to change; nonetheless they are the best option at the moment.
|
||||
Give it "Read and Write access to organization/repository self hosted runners",
|
||||
depending on whether it is organization wide or per-repository. You might have to
|
||||
experiment a little, fine-grained PATs are a `beta` Github feature and still subject
|
||||
to change; nonetheless they are the best option at the moment.
|
||||
|
||||
For classic PATs:
|
||||
For classic PATs:
|
||||
|
||||
Make sure the PAT has a scope of `admin:org` for organization-wide registrations
|
||||
or a scope of `repo` for a single repository.
|
||||
Make sure the PAT has a scope of `admin:org` for organization-wide registrations
|
||||
or a scope of `repo` for a single repository.
|
||||
|
||||
For runner registration tokens:
|
||||
For runner registration tokens:
|
||||
|
||||
Nothing special needs to be done, but updating will break after one hour,
|
||||
so these are not recommended.
|
||||
'';
|
||||
example = "/run/secrets/github-runner/nixos.token";
|
||||
};
|
||||
Nothing special needs to be done, but updating will break after one hour,
|
||||
so these are not recommended.
|
||||
'';
|
||||
example = "/run/secrets/github-runner/nixos.token";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the runner to configure. If null, defaults to the hostname.
|
||||
name = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the runner to configure. If null, defaults to the hostname.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
example = "nixos";
|
||||
default = name;
|
||||
};
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
example = "nixos";
|
||||
default = name;
|
||||
};
|
||||
|
||||
runnerGroup = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the runner group to add this runner to (defaults to the default runner group).
|
||||
runnerGroup = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Name of the runner group to add this runner to (defaults to the default runner group).
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
|
||||
extraLabels = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Extra labels in addition to the default (unless disabled through the `noDefaultLabels` option).
|
||||
extraLabels = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = ''
|
||||
Extra labels in addition to the default (unless disabled through the `noDefaultLabels` option).
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
example = lib.literalExpression ''[ "nixos" ]'';
|
||||
default = [ ];
|
||||
};
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
example = lib.literalExpression ''[ "nixos" ]'';
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
noDefaultLabels = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Disables adding the default labels. Also see the `extraLabels` option.
|
||||
noDefaultLabels = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Disables adding the default labels. Also see the `extraLabels` option.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
replace = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Replace any existing runner with the same name.
|
||||
replace = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
Replace any existing runner with the same name.
|
||||
|
||||
Without this flag, registering a new runner with the same name fails.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
Without this flag, registering a new runner with the same name fails.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
extraPackages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
description = ''
|
||||
Extra packages to add to `PATH` of the service to make them available to workflows.
|
||||
'';
|
||||
default = [ ];
|
||||
};
|
||||
extraPackages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
description = ''
|
||||
Extra packages to add to `PATH` of the service to make them available to workflows.
|
||||
'';
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
extraEnvironment = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = ''
|
||||
Extra environment variables to set for the runner, as an attrset.
|
||||
'';
|
||||
example = {
|
||||
GIT_CONFIG = "/path/to/git/config";
|
||||
extraEnvironment = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = ''
|
||||
Extra environment variables to set for the runner, as an attrset.
|
||||
'';
|
||||
example = {
|
||||
GIT_CONFIG = "/path/to/git/config";
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
|
||||
serviceOverrides = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = ''
|
||||
Modify the systemd service. Can be used to, e.g., adjust the sandboxing options.
|
||||
See {manpage}`systemd.exec(5)` for more options.
|
||||
'';
|
||||
example = {
|
||||
ProtectHome = false;
|
||||
RestrictAddressFamilies = [ "AF_PACKET" ];
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
|
||||
package = lib.mkPackageOption pkgs "github-runner" { };
|
||||
|
||||
ephemeral = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
If enabled, causes the following behavior:
|
||||
|
||||
- Passes the `--ephemeral` flag to the runner configuration script
|
||||
- De-registers and stops the runner with GitHub after it has processed one job
|
||||
- On stop, systemd wipes the runtime directory (this always happens, even without using the ephemeral option)
|
||||
- Restarts the service after its successful exit
|
||||
- On start, wipes the state directory and configures a new runner
|
||||
|
||||
You should only enable this option if `tokenFile` points to a file which contains a
|
||||
personal access token (PAT). If you're using the option with a registration token, restarting the
|
||||
service will fail as soon as the registration token expired.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
User under which to run the service.
|
||||
|
||||
If this option and the `group` option is set to `null`,
|
||||
the service runs as a dynamically allocated user.
|
||||
|
||||
Also see the `group` option for an overview on the effects of the `user` and `group` settings.
|
||||
'';
|
||||
default = null;
|
||||
defaultText = lib.literalExpression "username";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Group under which to run the service.
|
||||
|
||||
The effect of this option depends on the value of the `user` option:
|
||||
|
||||
- `group == null` and `user == null`:
|
||||
The service runs with a dynamically allocated user and group.
|
||||
- `group == null` and `user != null`:
|
||||
The service runs as the given user and its default group.
|
||||
- `group != null` and `user == null`:
|
||||
This configuration is invalid. In this case, the service would use the given group
|
||||
but run as root implicitly. If this is really what you want, set `user = "root"` explicitly.
|
||||
'';
|
||||
default = null;
|
||||
defaultText = lib.literalExpression "groupname";
|
||||
};
|
||||
|
||||
workDir = lib.mkOption {
|
||||
type = with lib.types; nullOr str;
|
||||
description = ''
|
||||
Working directory, available as `$GITHUB_WORKSPACE` during workflow runs
|
||||
and used as a default for [repository checkouts](https://github.com/actions/checkout).
|
||||
The service cleans this directory on every service start.
|
||||
|
||||
A value of `null` will default to the systemd `RuntimeDirectory`.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
|
||||
nodeRuntimes = lib.mkOption {
|
||||
type = with lib.types; nonEmptyListOf (enum [ "node20" ]);
|
||||
default = [ "node20" ];
|
||||
description = ''
|
||||
List of Node.js runtimes the runner should support.
|
||||
'';
|
||||
};
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
|
||||
serviceOverrides = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
description = ''
|
||||
Modify the systemd service. Can be used to, e.g., adjust the sandboxing options.
|
||||
See {manpage}`systemd.exec(5)` for more options.
|
||||
'';
|
||||
example = {
|
||||
ProtectHome = false;
|
||||
RestrictAddressFamilies = [ "AF_PACKET" ];
|
||||
};
|
||||
default = { };
|
||||
};
|
||||
|
||||
package = lib.mkPackageOption pkgs "github-runner" { };
|
||||
|
||||
ephemeral = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = ''
|
||||
If enabled, causes the following behavior:
|
||||
|
||||
- Passes the `--ephemeral` flag to the runner configuration script
|
||||
- De-registers and stops the runner with GitHub after it has processed one job
|
||||
- On stop, systemd wipes the runtime directory (this always happens, even without using the ephemeral option)
|
||||
- Restarts the service after its successful exit
|
||||
- On start, wipes the state directory and configures a new runner
|
||||
|
||||
You should only enable this option if `tokenFile` points to a file which contains a
|
||||
personal access token (PAT). If you're using the option with a registration token, restarting the
|
||||
service will fail as soon as the registration token expired.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = false;
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
User under which to run the service.
|
||||
|
||||
If this option and the `group` option is set to `null`,
|
||||
the service runs as a dynamically allocated user.
|
||||
|
||||
Also see the `group` option for an overview on the effects of the `user` and `group` settings.
|
||||
'';
|
||||
default = null;
|
||||
defaultText = lib.literalExpression "username";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = ''
|
||||
Group under which to run the service.
|
||||
|
||||
The effect of this option depends on the value of the `user` option:
|
||||
|
||||
- `group == null` and `user == null`:
|
||||
The service runs with a dynamically allocated user and group.
|
||||
- `group == null` and `user != null`:
|
||||
The service runs as the given user and its default group.
|
||||
- `group != null` and `user == null`:
|
||||
This configuration is invalid. In this case, the service would use the given group
|
||||
but run as root implicitly. If this is really what you want, set `user = "root"` explicitly.
|
||||
'';
|
||||
default = null;
|
||||
defaultText = lib.literalExpression "groupname";
|
||||
};
|
||||
|
||||
workDir = lib.mkOption {
|
||||
type = with lib.types; nullOr str;
|
||||
description = ''
|
||||
Working directory, available as `$GITHUB_WORKSPACE` during workflow runs
|
||||
and used as a default for [repository checkouts](https://github.com/actions/checkout).
|
||||
The service cleans this directory on every service start.
|
||||
|
||||
A value of `null` will default to the systemd `RuntimeDirectory`.
|
||||
|
||||
Changing this option triggers a new runner registration.
|
||||
'';
|
||||
default = null;
|
||||
};
|
||||
|
||||
nodeRuntimes = lib.mkOption {
|
||||
type = with lib.types; nonEmptyListOf (enum [ "node20" ]);
|
||||
default = [ "node20" ];
|
||||
description = ''
|
||||
List of Node.js runtimes the runner should support.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}));
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,300 +1,336 @@
|
|||
{ config
|
||||
, lib
|
||||
, pkgs
|
||||
, ...
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
config.assertions = lib.flatten (
|
||||
lib.flip lib.mapAttrsToList config.services.github-runners (name: cfg: map (lib.mkIf cfg.enable) [
|
||||
{
|
||||
assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
|
||||
message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
|
||||
}
|
||||
{
|
||||
assertion = cfg.group == null || cfg.user != null;
|
||||
message = ''`services.github-runners.${name}`: Setting `group` while leaving `user` unset runs the service as `root`. If this is really what you want, set `user = "root"` explicitly'';
|
||||
}
|
||||
])
|
||||
lib.flip lib.mapAttrsToList config.services.github-runners (
|
||||
name: cfg:
|
||||
map (lib.mkIf cfg.enable) [
|
||||
{
|
||||
assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
|
||||
message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
|
||||
}
|
||||
{
|
||||
assertion = cfg.group == null || cfg.user != null;
|
||||
message = ''`services.github-runners.${name}`: Setting `group` while leaving `user` unset runs the service as `root`. If this is really what you want, set `user = "root"` explicitly'';
|
||||
}
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
config.systemd.services =
|
||||
let enabledRunners = lib.filterAttrs (_: cfg: cfg.enable) config.services.github-runners;
|
||||
in (lib.flip lib.mapAttrs' enabledRunners (name: cfg:
|
||||
let
|
||||
svcName = "github-runner-${name}";
|
||||
systemdDir = "github-runner/${name}";
|
||||
|
||||
# %t: Runtime directory root (usually /run); see systemd.unit(5)
|
||||
runtimeDir = "%t/${systemdDir}";
|
||||
# %S: State directory root (usually /var/lib); see systemd.unit(5)
|
||||
stateDir = "%S/${systemdDir}";
|
||||
# %L: Log directory root (usually /var/log); see systemd.unit(5)
|
||||
logsDir = "%L/${systemdDir}";
|
||||
# Name of file stored in service state directory
|
||||
currentConfigTokenFilename = ".current-token";
|
||||
|
||||
workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
|
||||
# Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
|
||||
package = cfg.package.override (old: lib.optionalAttrs (lib.hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; });
|
||||
enabledRunners = lib.filterAttrs (_: cfg: cfg.enable) config.services.github-runners;
|
||||
in
|
||||
lib.nameValuePair svcName {
|
||||
description = "GitHub Actions runner";
|
||||
(lib.flip lib.mapAttrs' enabledRunners (
|
||||
name: cfg:
|
||||
let
|
||||
svcName = "github-runner-${name}";
|
||||
systemdDir = "github-runner/${name}";
|
||||
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network.target" "network-online.target" ];
|
||||
# %t: Runtime directory root (usually /run); see systemd.unit(5)
|
||||
runtimeDir = "%t/${systemdDir}";
|
||||
# %S: State directory root (usually /var/lib); see systemd.unit(5)
|
||||
stateDir = "%S/${systemdDir}";
|
||||
# %L: Log directory root (usually /var/log); see systemd.unit(5)
|
||||
logsDir = "%L/${systemdDir}";
|
||||
# Name of file stored in service state directory
|
||||
currentConfigTokenFilename = ".current-token";
|
||||
|
||||
environment = {
|
||||
HOME = workDir;
|
||||
RUNNER_ROOT = stateDir;
|
||||
} // cfg.extraEnvironment;
|
||||
workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
|
||||
# Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
|
||||
package = cfg.package.override (
|
||||
old: lib.optionalAttrs (lib.hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; }
|
||||
);
|
||||
in
|
||||
lib.nameValuePair svcName {
|
||||
description = "GitHub Actions runner";
|
||||
|
||||
path = (with pkgs; [
|
||||
bashInteractive
|
||||
coreutils
|
||||
git
|
||||
gnutar
|
||||
gzip
|
||||
]) ++ [
|
||||
config.nix.package
|
||||
] ++ cfg.extraPackages;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"network.target"
|
||||
"network-online.target"
|
||||
];
|
||||
|
||||
serviceConfig = lib.mkMerge [
|
||||
{
|
||||
ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
|
||||
environment = {
|
||||
HOME = workDir;
|
||||
RUNNER_ROOT = stateDir;
|
||||
} // cfg.extraEnvironment;
|
||||
|
||||
# Does the following, sequentially:
|
||||
# - If the module configuration or the token has changed, purge the state directory,
|
||||
# and create the current and the new token file with the contents of the configured
|
||||
# token. While both files have the same content, only the later is accessible by
|
||||
# the service user.
|
||||
# - Configure the runner using the new token file. When finished, delete it.
|
||||
# - Set up the directory structure by creating the necessary symlinks.
|
||||
ExecStartPre =
|
||||
let
|
||||
# Wrapper script which expects the full path of the state, working and logs
|
||||
# directory as arguments. Overrides the respective systemd variables to provide
|
||||
# unambiguous directory names. This becomes relevant, for example, if the
|
||||
# caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
|
||||
# to contain more than one directory. This causes systemd to set the respective
|
||||
# environment variables with the path of all of the given directories, separated
|
||||
# by a colon.
|
||||
writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
|
||||
set -euo pipefail
|
||||
path =
|
||||
(with pkgs; [
|
||||
bashInteractive
|
||||
coreutils
|
||||
git
|
||||
gnutar
|
||||
gzip
|
||||
])
|
||||
++ [
|
||||
config.nix.package
|
||||
]
|
||||
++ cfg.extraPackages;
|
||||
|
||||
STATE_DIRECTORY="$1"
|
||||
WORK_DIRECTORY="$2"
|
||||
LOGS_DIRECTORY="$3"
|
||||
serviceConfig = lib.mkMerge [
|
||||
{
|
||||
ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
|
||||
|
||||
${lines}
|
||||
'';
|
||||
runnerRegistrationConfig = lib.getAttrs [
|
||||
"ephemeral"
|
||||
"extraLabels"
|
||||
"name"
|
||||
"noDefaultLabels"
|
||||
"runnerGroup"
|
||||
"tokenFile"
|
||||
"url"
|
||||
"workDir"
|
||||
]
|
||||
cfg;
|
||||
newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
|
||||
currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
|
||||
newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
|
||||
currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
|
||||
# Does the following, sequentially:
|
||||
# - If the module configuration or the token has changed, purge the state directory,
|
||||
# and create the current and the new token file with the contents of the configured
|
||||
# token. While both files have the same content, only the later is accessible by
|
||||
# the service user.
|
||||
# - Configure the runner using the new token file. When finished, delete it.
|
||||
# - Set up the directory structure by creating the necessary symlinks.
|
||||
ExecStartPre =
|
||||
let
|
||||
# Wrapper script which expects the full path of the state, working and logs
|
||||
# directory as arguments. Overrides the respective systemd variables to provide
|
||||
# unambiguous directory names. This becomes relevant, for example, if the
|
||||
# caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
|
||||
# to contain more than one directory. This causes systemd to set the respective
|
||||
# environment variables with the path of all of the given directories, separated
|
||||
# by a colon.
|
||||
writeScript =
|
||||
name: lines:
|
||||
pkgs.writeShellScript "${svcName}-${name}.sh" ''
|
||||
set -euo pipefail
|
||||
|
||||
runnerCredFiles = [
|
||||
".credentials"
|
||||
".credentials_rsaparams"
|
||||
".runner"
|
||||
];
|
||||
unconfigureRunner = writeScript "unconfigure" ''
|
||||
copy_tokens() {
|
||||
# Copy the configured token file to the state dir and allow the service user to read the file
|
||||
install --mode=666 ${lib.escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
|
||||
# Also copy current file to allow for a diff on the next start
|
||||
install --mode=600 ${lib.escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
|
||||
}
|
||||
clean_state() {
|
||||
find "$STATE_DIRECTORY/" -mindepth 1 -delete
|
||||
copy_tokens
|
||||
}
|
||||
diff_config() {
|
||||
changed=0
|
||||
# Check for module config changes
|
||||
[[ -f "${currentConfigPath}" ]] \
|
||||
&& ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
|
||||
|| changed=1
|
||||
# Also check the content of the token file
|
||||
[[ -f "${currentConfigTokenPath}" ]] \
|
||||
&& ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${lib.escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
|
||||
|| changed=1
|
||||
# If the config has changed, remove old state and copy tokens
|
||||
if [[ "$changed" -eq 1 ]]; then
|
||||
echo "Config has changed, removing old runner state."
|
||||
echo "The old runner will still appear in the GitHub Actions UI." \
|
||||
"You have to remove it manually."
|
||||
STATE_DIRECTORY="$1"
|
||||
WORK_DIRECTORY="$2"
|
||||
LOGS_DIRECTORY="$3"
|
||||
|
||||
${lines}
|
||||
'';
|
||||
runnerRegistrationConfig = lib.getAttrs [
|
||||
"ephemeral"
|
||||
"extraLabels"
|
||||
"name"
|
||||
"noDefaultLabels"
|
||||
"runnerGroup"
|
||||
"tokenFile"
|
||||
"url"
|
||||
"workDir"
|
||||
] cfg;
|
||||
newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
|
||||
currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
|
||||
newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
|
||||
currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
|
||||
|
||||
runnerCredFiles = [
|
||||
".credentials"
|
||||
".credentials_rsaparams"
|
||||
".runner"
|
||||
];
|
||||
unconfigureRunner = writeScript "unconfigure" ''
|
||||
copy_tokens() {
|
||||
# Copy the configured token file to the state dir and allow the service user to read the file
|
||||
install --mode=666 ${lib.escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
|
||||
# Also copy current file to allow for a diff on the next start
|
||||
install --mode=600 ${lib.escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
|
||||
}
|
||||
clean_state() {
|
||||
find "$STATE_DIRECTORY/" -mindepth 1 -delete
|
||||
copy_tokens
|
||||
}
|
||||
diff_config() {
|
||||
changed=0
|
||||
# Check for module config changes
|
||||
[[ -f "${currentConfigPath}" ]] \
|
||||
&& ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
|
||||
|| changed=1
|
||||
# Also check the content of the token file
|
||||
[[ -f "${currentConfigTokenPath}" ]] \
|
||||
&& ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${lib.escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
|
||||
|| changed=1
|
||||
# If the config has changed, remove old state and copy tokens
|
||||
if [[ "$changed" -eq 1 ]]; then
|
||||
echo "Config has changed, removing old runner state."
|
||||
echo "The old runner will still appear in the GitHub Actions UI." \
|
||||
"You have to remove it manually."
|
||||
clean_state
|
||||
fi
|
||||
}
|
||||
if [[ "${lib.optionalString cfg.ephemeral "1"}" ]]; then
|
||||
# In ephemeral mode, we always want to start with a clean state
|
||||
clean_state
|
||||
fi
|
||||
}
|
||||
if [[ "${lib.optionalString cfg.ephemeral "1"}" ]]; then
|
||||
# In ephemeral mode, we always want to start with a clean state
|
||||
clean_state
|
||||
elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
|
||||
# There are state files from a previous run; diff them to decide if we need a new registration
|
||||
diff_config
|
||||
else
|
||||
# The state directory is entirely empty which indicates a first start
|
||||
copy_tokens
|
||||
fi
|
||||
# Always clean workDir
|
||||
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
|
||||
'';
|
||||
configureRunner = writeScript "configure" /*bash*/''
|
||||
if [[ -e "${newConfigTokenPath}" ]]; then
|
||||
echo "Configuring GitHub Actions Runner"
|
||||
# shellcheck disable=SC2054 # don't complain about commas in --labels
|
||||
args=(
|
||||
--unattended
|
||||
--disableupdate
|
||||
--work "$WORK_DIRECTORY"
|
||||
--url ${lib.escapeShellArg cfg.url}
|
||||
--labels ${lib.escapeShellArg (lib.concatStringsSep "," cfg.extraLabels)}
|
||||
${lib.optionalString (cfg.name != null ) "--name ${lib.escapeShellArg cfg.name}"}
|
||||
${lib.optionalString cfg.replace "--replace"}
|
||||
${lib.optionalString (cfg.runnerGroup != null) "--runnergroup ${lib.escapeShellArg cfg.runnerGroup}"}
|
||||
${lib.optionalString cfg.ephemeral "--ephemeral"}
|
||||
${lib.optionalString cfg.noDefaultLabels "--no-default-labels"}
|
||||
)
|
||||
# If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
|
||||
# if it is not a PAT, we assume it contains a registration token and use the --token option
|
||||
token=$(<"${newConfigTokenPath}")
|
||||
if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
|
||||
args+=(--pat "$token")
|
||||
elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
|
||||
# There are state files from a previous run; diff them to decide if we need a new registration
|
||||
diff_config
|
||||
else
|
||||
args+=(--token "$token")
|
||||
# The state directory is entirely empty which indicates a first start
|
||||
copy_tokens
|
||||
fi
|
||||
${package}/bin/Runner.Listener configure "''${args[@]}"
|
||||
# Move the automatically created _diag dir to the logs dir
|
||||
mkdir -p "$STATE_DIRECTORY/_diag"
|
||||
cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
|
||||
rm -rf "$STATE_DIRECTORY/_diag/"
|
||||
# Cleanup token from config
|
||||
rm "${newConfigTokenPath}"
|
||||
# Symlink to new config
|
||||
ln -s '${newConfigPath}' "${currentConfigPath}"
|
||||
fi
|
||||
'';
|
||||
setupWorkDir = writeScript "setup-work-dirs" ''
|
||||
# Link _diag dir
|
||||
ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
|
||||
# Always clean workDir
|
||||
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
|
||||
'';
|
||||
configureRunner =
|
||||
writeScript "configure" # bash
|
||||
''
|
||||
if [[ -e "${newConfigTokenPath}" ]]; then
|
||||
echo "Configuring GitHub Actions Runner"
|
||||
# shellcheck disable=SC2054 # don't complain about commas in --labels
|
||||
args=(
|
||||
--unattended
|
||||
--disableupdate
|
||||
--work "$WORK_DIRECTORY"
|
||||
--url ${lib.escapeShellArg cfg.url}
|
||||
--labels ${lib.escapeShellArg (lib.concatStringsSep "," cfg.extraLabels)}
|
||||
${lib.optionalString (cfg.name != null) "--name ${lib.escapeShellArg cfg.name}"}
|
||||
${lib.optionalString cfg.replace "--replace"}
|
||||
${lib.optionalString (
|
||||
cfg.runnerGroup != null
|
||||
) "--runnergroup ${lib.escapeShellArg cfg.runnerGroup}"}
|
||||
${lib.optionalString cfg.ephemeral "--ephemeral"}
|
||||
${lib.optionalString cfg.noDefaultLabels "--no-default-labels"}
|
||||
)
|
||||
# If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
|
||||
# if it is not a PAT, we assume it contains a registration token and use the --token option
|
||||
token=$(<"${newConfigTokenPath}")
|
||||
if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
|
||||
args+=(--pat "$token")
|
||||
else
|
||||
args+=(--token "$token")
|
||||
fi
|
||||
${package}/bin/Runner.Listener configure "''${args[@]}"
|
||||
# Move the automatically created _diag dir to the logs dir
|
||||
mkdir -p "$STATE_DIRECTORY/_diag"
|
||||
cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
|
||||
rm -rf "$STATE_DIRECTORY/_diag/"
|
||||
# Cleanup token from config
|
||||
rm "${newConfigTokenPath}"
|
||||
# Symlink to new config
|
||||
ln -s '${newConfigPath}' "${currentConfigPath}"
|
||||
fi
|
||||
'';
|
||||
setupWorkDir = writeScript "setup-work-dirs" ''
|
||||
# Link _diag dir
|
||||
ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
|
||||
|
||||
# Link the runner credentials to the work dir
|
||||
ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
|
||||
'';
|
||||
in
|
||||
map (x: "${x} ${lib.escapeShellArgs [ stateDir workDir logsDir ]}") [
|
||||
"+${unconfigureRunner}" # runs as root
|
||||
configureRunner
|
||||
setupWorkDir
|
||||
# Link the runner credentials to the work dir
|
||||
ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
|
||||
'';
|
||||
in
|
||||
map
|
||||
(
|
||||
x:
|
||||
"${x} ${
|
||||
lib.escapeShellArgs [
|
||||
stateDir
|
||||
workDir
|
||||
logsDir
|
||||
]
|
||||
}"
|
||||
)
|
||||
[
|
||||
"+${unconfigureRunner}" # runs as root
|
||||
configureRunner
|
||||
setupWorkDir
|
||||
];
|
||||
|
||||
# If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
|
||||
# to trigger a fresh registration.
|
||||
Restart = if cfg.ephemeral then "on-success" else "no";
|
||||
# If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
|
||||
# https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
|
||||
RestartForceExitStatus = [ 2 ];
|
||||
|
||||
# Contains _diag
|
||||
LogsDirectory = [ systemdDir ];
|
||||
# Default RUNNER_ROOT which contains ephemeral Runner data
|
||||
RuntimeDirectory = [ systemdDir ];
|
||||
# Home of persistent runner data, e.g., credentials
|
||||
StateDirectory = [ systemdDir ];
|
||||
StateDirectoryMode = "0700";
|
||||
WorkingDirectory = workDir;
|
||||
|
||||
InaccessiblePaths = [
|
||||
# Token file path given in the configuration, if visible to the service
|
||||
"-${cfg.tokenFile}"
|
||||
# Token file in the state directory
|
||||
"${stateDir}/${currentConfigTokenFilename}"
|
||||
];
|
||||
|
||||
# If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
|
||||
# to trigger a fresh registration.
|
||||
Restart = if cfg.ephemeral then "on-success" else "no";
|
||||
# If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
|
||||
# https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
|
||||
RestartForceExitStatus = [ 2 ];
|
||||
KillSignal = "SIGINT";
|
||||
|
||||
# Contains _diag
|
||||
LogsDirectory = [ systemdDir ];
|
||||
# Default RUNNER_ROOT which contains ephemeral Runner data
|
||||
RuntimeDirectory = [ systemdDir ];
|
||||
# Home of persistent runner data, e.g., credentials
|
||||
StateDirectory = [ systemdDir ];
|
||||
StateDirectoryMode = "0700";
|
||||
WorkingDirectory = workDir;
|
||||
# Hardening (may overlap with DynamicUser=)
|
||||
# The following options are only for optimizing:
|
||||
# systemd-analyze security github-runner
|
||||
AmbientCapabilities = lib.mkBefore [ "" ];
|
||||
CapabilityBoundingSet = lib.mkBefore [ "" ];
|
||||
# ProtectClock= adds DeviceAllow=char-rtc r
|
||||
DeviceAllow = lib.mkBefore [ "" ];
|
||||
NoNewPrivileges = lib.mkDefault true;
|
||||
PrivateDevices = lib.mkDefault true;
|
||||
PrivateMounts = lib.mkDefault true;
|
||||
PrivateTmp = lib.mkDefault true;
|
||||
PrivateUsers = lib.mkDefault true;
|
||||
ProtectClock = lib.mkDefault true;
|
||||
ProtectControlGroups = lib.mkDefault true;
|
||||
ProtectHome = lib.mkDefault true;
|
||||
ProtectHostname = lib.mkDefault true;
|
||||
ProtectKernelLogs = lib.mkDefault true;
|
||||
ProtectKernelModules = lib.mkDefault true;
|
||||
ProtectKernelTunables = lib.mkDefault true;
|
||||
ProtectSystem = lib.mkDefault "strict";
|
||||
RemoveIPC = lib.mkDefault true;
|
||||
RestrictNamespaces = lib.mkDefault true;
|
||||
RestrictRealtime = lib.mkDefault true;
|
||||
RestrictSUIDSGID = lib.mkDefault true;
|
||||
UMask = lib.mkDefault "0066";
|
||||
ProtectProc = lib.mkDefault "invisible";
|
||||
SystemCallFilter = lib.mkBefore [
|
||||
"~@clock"
|
||||
"~@cpu-emulation"
|
||||
"~@module"
|
||||
"~@mount"
|
||||
"~@obsolete"
|
||||
"~@raw-io"
|
||||
"~@reboot"
|
||||
"~capset"
|
||||
"~setdomainname"
|
||||
"~sethostname"
|
||||
];
|
||||
RestrictAddressFamilies = lib.mkBefore [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
"AF_UNIX"
|
||||
"AF_NETLINK"
|
||||
];
|
||||
|
||||
InaccessiblePaths = [
|
||||
# Token file path given in the configuration, if visible to the service
|
||||
"-${cfg.tokenFile}"
|
||||
# Token file in the state directory
|
||||
"${stateDir}/${currentConfigTokenFilename}"
|
||||
];
|
||||
BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
|
||||
|
||||
KillSignal = "SIGINT";
|
||||
# Needs network access
|
||||
PrivateNetwork = lib.mkDefault false;
|
||||
# Cannot be true due to Node
|
||||
MemoryDenyWriteExecute = lib.mkDefault false;
|
||||
|
||||
# Hardening (may overlap with DynamicUser=)
|
||||
# The following options are only for optimizing:
|
||||
# systemd-analyze security github-runner
|
||||
AmbientCapabilities = lib.mkBefore [ "" ];
|
||||
CapabilityBoundingSet = lib.mkBefore [ "" ];
|
||||
# ProtectClock= adds DeviceAllow=char-rtc r
|
||||
DeviceAllow = lib.mkBefore [ "" ];
|
||||
NoNewPrivileges = lib.mkDefault true;
|
||||
PrivateDevices = lib.mkDefault true;
|
||||
PrivateMounts = lib.mkDefault true;
|
||||
PrivateTmp = lib.mkDefault true;
|
||||
PrivateUsers = lib.mkDefault true;
|
||||
ProtectClock = lib.mkDefault true;
|
||||
ProtectControlGroups = lib.mkDefault true;
|
||||
ProtectHome = lib.mkDefault true;
|
||||
ProtectHostname = lib.mkDefault true;
|
||||
ProtectKernelLogs = lib.mkDefault true;
|
||||
ProtectKernelModules = lib.mkDefault true;
|
||||
ProtectKernelTunables = lib.mkDefault true;
|
||||
ProtectSystem = lib.mkDefault "strict";
|
||||
RemoveIPC = lib.mkDefault true;
|
||||
RestrictNamespaces = lib.mkDefault true;
|
||||
RestrictRealtime = lib.mkDefault true;
|
||||
RestrictSUIDSGID = lib.mkDefault true;
|
||||
UMask = lib.mkDefault "0066";
|
||||
ProtectProc = lib.mkDefault "invisible";
|
||||
SystemCallFilter = lib.mkBefore [
|
||||
"~@clock"
|
||||
"~@cpu-emulation"
|
||||
"~@module"
|
||||
"~@mount"
|
||||
"~@obsolete"
|
||||
"~@raw-io"
|
||||
"~@reboot"
|
||||
"~capset"
|
||||
"~setdomainname"
|
||||
"~sethostname"
|
||||
];
|
||||
RestrictAddressFamilies = lib.mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
|
||||
# The more restrictive "pid" option makes `nix` commands in CI emit
|
||||
# "GC Warning: Couldn't read /proc/stat"
|
||||
# You may want to set this to "pid" if not using `nix` commands
|
||||
ProcSubset = lib.mkDefault "all";
|
||||
# Coverage programs for compiled code such as `cargo-tarpaulin` disable
|
||||
# ASLR (address space layout randomization) which requires the
|
||||
# `personality` syscall
|
||||
# You may want to set this to `true` if not using coverage tooling on
|
||||
# compiled code
|
||||
LockPersonality = lib.mkDefault false;
|
||||
|
||||
BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
|
||||
|
||||
# Needs network access
|
||||
PrivateNetwork = lib.mkDefault false;
|
||||
# Cannot be true due to Node
|
||||
MemoryDenyWriteExecute = lib.mkDefault false;
|
||||
|
||||
# The more restrictive "pid" option makes `nix` commands in CI emit
|
||||
# "GC Warning: Couldn't read /proc/stat"
|
||||
# You may want to set this to "pid" if not using `nix` commands
|
||||
ProcSubset = lib.mkDefault "all";
|
||||
# Coverage programs for compiled code such as `cargo-tarpaulin` disable
|
||||
# ASLR (address space layout randomization) which requires the
|
||||
# `personality` syscall
|
||||
# You may want to set this to `true` if not using coverage tooling on
|
||||
# compiled code
|
||||
LockPersonality = lib.mkDefault false;
|
||||
|
||||
DynamicUser = lib.mkDefault true;
|
||||
}
|
||||
(lib.mkIf (cfg.user != null) {
|
||||
DynamicUser = false;
|
||||
User = cfg.user;
|
||||
})
|
||||
(lib.mkIf (cfg.group != null) {
|
||||
DynamicUser = false;
|
||||
Group = cfg.group;
|
||||
})
|
||||
cfg.serviceOverrides
|
||||
];
|
||||
}
|
||||
));
|
||||
DynamicUser = lib.mkDefault true;
|
||||
}
|
||||
(lib.mkIf (cfg.user != null) {
|
||||
DynamicUser = false;
|
||||
User = cfg.user;
|
||||
})
|
||||
(lib.mkIf (cfg.group != null) {
|
||||
DynamicUser = false;
|
||||
Group = cfg.group;
|
||||
})
|
||||
cfg.serviceOverrides
|
||||
];
|
||||
}
|
||||
));
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue