diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c05d752..790e7565 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,11 +7,21 @@ on: - master jobs: tests: - runs-on: ubuntu-latest + strategy: + matrix: + include: + - system: x86_64-linux + runner: ubuntu-latest + - system: aarch64-linux + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: cachix/install-nix-action@v17 - #- run: nix flake check - - run: nix-build -A checks.x86_64-linux.build -A checks.x86_64-linux.validate-openapi + - uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + extra-systems = ${{ matrix.system }} + - uses: DeterminateSystems/magic-nix-cache-action@main + - run: nix-build -A checks.${{ matrix.system }}.build -A checks.${{ matrix.system }}.validate-openapi diff --git a/.github/workflows/update-flakes.yml b/.github/workflows/update-flakes.yml new file mode 100644 index 00000000..b5c0c2dd --- /dev/null +++ b/.github/workflows/update-flakes.yml @@ -0,0 +1,28 @@ +name: "Update Flakes" +on: + schedule: + # Run weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + workflow_dispatch: +jobs: + update-flakes: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v3 + - uses: cachix/install-nix-action@v31 + - name: Update flake inputs + run: nix flake update + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + commit-message: "flake.lock: Update" + title: "Update flake inputs" + body: | + Automated flake input updates. + + This PR was automatically created by the update-flakes workflow. + branch: update-flakes + delete-branch: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 12df926f..ea9c2985 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ *~ +/.direnv/ .test_info.* +/src/root/static/bootstrap +/src/root/static/fontawesome +/src/root/static/js/flot /src/sql/hydra-postgresql.sql /src/sql/hydra-sqlite.sql /src/sql/tmp.sqlite diff --git a/.perlcriticrc b/.perlcriticrc index 42ac1c77..77d9e724 100644 --- a/.perlcriticrc +++ b/.perlcriticrc @@ -2,3 +2,9 @@ theme = community # 5 is the least complainy, 1 is the most complainy severity = 1 + +# Disallow backticks - use IPC::Run3 instead for better security +include = InputOutput::ProhibitBacktickOperators + +# Prohibit shell-invoking system() and exec() - use list form or IPC::Run3 instead +include = Hydra::ProhibitShellInvokingSystemCalls diff --git a/README.md b/README.md index 54b95549..5ff24eb2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Running Hydra is currently only supported on NixOS. The [hydra module](https://g } ``` ### Creating An Admin User -Once the Hydra service has been configured as above and activate you should already be able to access the UI interface at the specified URL. However some actions require an admin user which has to be created first: +Once the Hydra service has been configured as above and activated, you should already be able to access the UI interface at the specified URL. However some actions require an admin user which has to be created first: ``` $ su - hydra @@ -80,10 +80,15 @@ $ nix build You can use the provided shell.nix to get a working development environment: ``` $ nix develop -$ mesonConfigurePhase -$ ninja +$ ln -svf ../../../build/src/bootstrap src/root/static/bootstrap +$ ln -svf ../../../build/src/fontawesome src/root/static/fontawesome +$ ln -svf ../../../../build/src/flot src/root/static/js/flot +$ meson setup build +$ ninja -C build ``` +The development environment can also automatically be established using [nix-direnv](https://github.com/nix-community/nix-direnv). + ### Executing Hydra During Development When working on new features or bug fixes you need to be able to run Hydra from your working copy. This @@ -100,7 +105,7 @@ Have a look at the [Procfile](./Procfile) if you want to see how the processes a conflicts with services that might be running on your host, hydra and postgress are started on custom ports: - hydra-server: 63333 with the username "alice" and the password "foobar" -- postgresql: 64444 +- postgresql: 64444, can be connected to using `psql -p 64444 -h localhost hydra` Note that this is only ever meant as an ad-hoc way of executing Hydra during development. Please make use of the NixOS module for actually running Hydra in production. diff --git a/doc/manual/src/SUMMARY.md b/doc/manual/src/SUMMARY.md index 357e795a..5ef8ac90 100644 --- a/doc/manual/src/SUMMARY.md +++ b/doc/manual/src/SUMMARY.md @@ -10,6 +10,7 @@ - [RunCommand](./plugins/RunCommand.md) - [Using the external API](api.md) - [Webhooks](webhooks.md) + - [Webhook Authentication Migration Guide](webhook-migration-guide.md) - [Monitoring Hydra](./monitoring/README.md) ## Developer's Guide diff --git a/doc/manual/src/configuration.md b/doc/manual/src/configuration.md index 856d314c..491376b3 100644 --- a/doc/manual/src/configuration.md +++ b/doc/manual/src/configuration.md @@ -51,10 +51,12 @@ base_uri example.com `base_uri` should be your hydra servers proxied URL. If you are using Hydra nixos module then setting `hydraURL` option should be enough. -If you want to serve Hydra with a prefix path, for example -[http://example.com/hydra]() then you need to configure your reverse -proxy to pass `X-Request-Base` to hydra, with prefix path as value. For -example if you are using nginx, then use configuration similar to +You also need to configure your reverse proxy to pass `X-Request-Base` +to hydra, with the same value as `base_uri`. +This also covers the case of serving Hydra with a prefix path, +as in [http://example.com/hydra](). + +For example if you are using nginx, then use configuration similar to following: server { @@ -264,6 +266,40 @@ default role mapping: Note that configuring both the LDAP parameters in the hydra.conf and via the environment variable is a fatal error. +Webhook Authentication +--------------------- + +Hydra supports authenticating webhook requests from GitHub and Gitea to prevent unauthorized job evaluations. +Webhook secrets should be stored in separate files outside the Nix store for security using Config::General's include mechanism. + +In your main `hydra.conf`: +```apache + + Include /var/lib/hydra/secrets/webhook-secrets.conf + +``` + +Then create `/var/lib/hydra/secrets/webhook-secrets.conf` with your actual secrets: +```apache + + secret = your-github-webhook-secret + + + secret = your-gitea-webhook-secret + +``` + +For multiple secrets (useful for rotation or multiple environments), use an array: +```apache + + secret = your-github-webhook-secret-prod + secret = your-github-webhook-secret-staging + +``` + +**Important**: The secrets file should have restricted permissions (e.g., 0600) to prevent unauthorized access. +See the [Webhooks documentation](webhooks.md) for detailed setup instructions. + Embedding Extra HTML -------------------- diff --git a/doc/manual/src/hacking.md b/doc/manual/src/hacking.md index 8b2b13ba..6815197a 100644 --- a/doc/manual/src/hacking.md +++ b/doc/manual/src/hacking.md @@ -46,6 +46,16 @@ $ meson test $ YATH_JOB_COUNT=$NIX_BUILD_CORES meson test ``` +To run individual tests: + +```console +# Run a specific test file +$ PERL5LIB=t/lib:$PERL5LIB perl t/test.pl t/Hydra/Controller/API/checks.t + +# Run all tests in a directory +$ PERL5LIB=t/lib:$PERL5LIB perl t/test.pl t/Hydra/Controller/API/ +``` + **Warning**: Currently, the tests can fail if run with high parallelism [due to an issue in `Test::PostgreSQL`](https://github.com/TJC/Test-postgresql/issues/40) diff --git a/doc/manual/src/installation.md b/doc/manual/src/installation.md index cbf3f907..39a86885 100644 --- a/doc/manual/src/installation.md +++ b/doc/manual/src/installation.md @@ -48,7 +48,7 @@ Getting Nix If your server runs NixOS you are all set to continue with installation of Hydra. Otherwise you first need to install Nix. The latest stable version can be found one [the Nix web -site](http://nixos.org/nix/download.html), along with a manual, which +site](https://nixos.org/download/), along with a manual, which includes installation instructions. Installation diff --git a/doc/manual/src/webhook-migration-guide.md b/doc/manual/src/webhook-migration-guide.md new file mode 100644 index 00000000..ddce6feb --- /dev/null +++ b/doc/manual/src/webhook-migration-guide.md @@ -0,0 +1,168 @@ +# Webhook Authentication Migration Guide + +This guide helps Hydra administrators migrate from unauthenticated webhooks to authenticated webhooks to secure their Hydra instances against unauthorized job evaluations. + +## Why Migrate? + +Currently, Hydra's webhook endpoints (`/api/push-github` and `/api/push-gitea`) accept any POST request without authentication. This vulnerability allows: +- Anyone to trigger expensive job evaluations +- Potential denial of service through repeated requests +- Manipulation of build timing and scheduling + +## Step-by-Step Migration for NixOS + +### 1. Create Webhook Configuration + +Create a webhook secrets configuration file with the generated secrets: + +```bash +# Create the secrets configuration file with inline secret generation +cat > /var/lib/hydra/secrets/webhook-secrets.conf < + secret = $(openssl rand -hex 32) + + + secret = $(openssl rand -hex 32) + +EOF + +# Set secure permissions +chmod 0440 /var/lib/hydra/secrets/webhook-secrets.conf +chown hydra:hydra /var/lib/hydra/secrets/webhook-secrets.conf +``` + +**Important**: Save the generated secrets to configure them in GitHub/Gitea later. You can view them with: +```bash +cat /var/lib/hydra/secrets/webhook-secrets.conf +``` + +Then update your NixOS configuration to include the webhook configuration: + +```nix +{ + services.hydra-dev = { + enable = true; + hydraURL = "https://hydra.example.com"; + notificationSender = "hydra@example.com"; + + extraConfig = '' + + Include /var/lib/hydra/secrets/webhook-secrets.conf + + ''; + }; +} +``` + +For multiple secrets (useful for rotation or multiple environments), update your webhook-secrets.conf: + +```apache + + secret = your-github-webhook-secret-prod + secret = your-github-webhook-secret-staging + + + secret = your-gitea-webhook-secret + +``` + +### 2. Deploy Configuration + +Apply the NixOS configuration: + +```bash +nixos-rebuild switch +``` + +This will automatically restart Hydra services with the new configuration. + +### 3. Verify Configuration + +Check Hydra's logs to ensure secrets were loaded successfully: + +```bash +journalctl -u hydra-server | grep -i webhook +``` + +You should not see warnings about webhook authentication not being configured. + +### 4. Update Your Webhooks + +#### GitHub +1. Navigate to your repository settings: `https://github.com///settings/hooks` +2. Edit your existing Hydra webhook +3. In the "Secret" field, paste the content of `/var/lib/hydra/secrets/github-webhook-secret` +4. Click "Update webhook" +5. GitHub will send a ping event to verify the configuration + +#### Gitea +1. Navigate to your repository webhook settings +2. Edit your existing Hydra webhook +3. In the "Secret" field, paste the content of `/var/lib/hydra/secrets/gitea-webhook-secret` +4. Click "Update Webhook" +5. Use the "Test Delivery" button to verify the configuration + +### 5. Test the Configuration + +After updating each webhook: +1. Make a test commit to trigger the webhook +2. Check Hydra's logs for successful authentication +3. Verify the evaluation was triggered in Hydra's web interface + +## Troubleshooting + +### 401 Unauthorized Errors + +If webhooks start failing with 401 errors: +- Verify the secret in the Git forge matches the file content exactly +- Check file permissions: `ls -la /var/lib/hydra/secrets/` +- Ensure no extra whitespace in secret files +- Check Hydra logs for specific error messages + +### Webhook Still Unauthenticated + +If you see warnings about unauthenticated webhooks after configuration: +- Verify the configuration syntax in your NixOS module +- Ensure the NixOS configuration was successfully applied +- Check that the webhook-secrets.conf file exists and is readable by the Hydra user +- Verify the Include path is correct in your hydra.conf +- Check the syntax of your webhook-secrets.conf file + +### Testing Without Git Forge + +You can test webhook authentication using curl: + +```bash +# Read the secret +SECRET=$(cat /var/lib/hydra/secrets/github-webhook-secret) + +# Create test payload +PAYLOAD='{"ref":"refs/heads/main","repository":{"clone_url":"https://github.com/test/repo.git"}}' + +# Calculate signature +SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)" + +# Send authenticated request +curl -X POST https://your-hydra/api/push-github \ + -H "Content-Type: application/json" \ + -H "X-Hub-Signature-256: $SIGNATURE" \ + -d "$PAYLOAD" +``` + +For Gitea (no prefix in signature): +```bash +# Read the secret +SECRET=$(cat /var/lib/hydra/secrets/gitea-webhook-secret) + +# Create test payload +PAYLOAD='{"ref":"refs/heads/main","repository":{"clone_url":"https://gitea.example.com/test/repo.git"}}' + +# Calculate signature +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) + +# Send authenticated request +curl -X POST https://your-hydra/api/push-gitea \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Signature: $SIGNATURE" \ + -d "$PAYLOAD" +``` diff --git a/doc/manual/src/webhooks.md b/doc/manual/src/webhooks.md index 674e1064..7a211788 100644 --- a/doc/manual/src/webhooks.md +++ b/doc/manual/src/webhooks.md @@ -3,6 +3,58 @@ Hydra can be notified by github or gitea with webhooks to trigger a new evaluation when a jobset has a github repo in its input. +## Webhook Authentication + +Hydra supports webhook signature verification for both GitHub and Gitea using HMAC-SHA256. This ensures that webhook +requests are coming from your configured Git forge and haven't been tampered with. + +### Configuring Webhook Authentication + +1. **Create webhook configuration**: Generate and store webhook secrets securely: + ```bash + # Create directory and generate secrets in one step + mkdir -p /var/lib/hydra/secrets + cat > /var/lib/hydra/secrets/webhook-secrets.conf < + secret = $(openssl rand -hex 32) + + + secret = $(openssl rand -hex 32) + + EOF + + # Set secure permissions + chmod 0600 /var/lib/hydra/secrets/webhook-secrets.conf + chown hydra:hydra /var/lib/hydra/secrets/webhook-secrets.conf + ``` + +2. **Configure Hydra**: Add the following to your `hydra.conf`: + ```apache + + Include /var/lib/hydra/secrets/webhook-secrets.conf + + ``` + +3. **Configure your Git forge**: View the generated secrets and configure them in GitHub/Gitea: + ```bash + grep "secret =" /var/lib/hydra/secrets/webhook-secrets.conf + ``` + +### Multiple Secrets Support + +Hydra supports configuring multiple secrets for each platform, which is useful for: +- Zero-downtime secret rotation +- Supporting multiple environments (production/staging) +- Gradual migration of webhooks + +To configure multiple secrets, use array syntax: +```apache + + secret = current-webhook-secret + secret = previous-webhook-secret + +``` + ## GitHub To set up a webhook for a GitHub repository go to `https://github.com///settings` @@ -10,11 +62,16 @@ and in the `Webhooks` tab click on `Add webhook`. - In `Payload URL` fill in `https:///api/push-github`. - In `Content type` switch to `application/json`. -- The `Secret` field can stay empty. +- In the `Secret` field, enter the content of your GitHub webhook secret file (if authentication is configured). - For `Which events would you like to trigger this webhook?` keep the default option for events on `Just the push event.`. Then add the hook with `Add webhook`. +### Verifying GitHub Webhook Security + +After configuration, GitHub will send webhook requests with an `X-Hub-Signature-256` header containing the HMAC-SHA256 +signature of the request body. Hydra will verify this signature matches the configured secret. + ## Gitea To set up a webhook for a Gitea repository go to the settings of the repository in your Gitea instance @@ -22,6 +79,23 @@ and in the `Webhooks` tab click on `Add Webhook` and choose `Gitea` in the drop - In `Target URL` fill in `https:///api/push-gitea`. - Keep HTTP method `POST`, POST Content Type `application/json` and Trigger On `Push Events`. +- In the `Secret` field, enter the content of your Gitea webhook secret file (if authentication is configured). - Change the branch filter to match the git branch hydra builds. Then add the hook with `Add webhook`. + +### Verifying Gitea Webhook Security + +After configuration, Gitea will send webhook requests with an `X-Gitea-Signature` header containing the HMAC-SHA256 +signature of the request body. Hydra will verify this signature matches the configured secret. + +## Troubleshooting + +If you receive 401 Unauthorized errors: +- Verify the webhook secret in your Git forge matches the content of the secret file exactly +- Check that the secret file has proper permissions (should be 0600) +- Look at Hydra's logs for specific error messages +- Ensure the correct signature header is being sent by your Git forge + +If you see warnings about webhook authentication not being configured: +- Configure webhook authentication as described above to secure your endpoints diff --git a/flake.lock b/flake.lock index 2679eecb..0ca074f3 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "nix": { "flake": false, "locked": { - "lastModified": 1748154947, - "narHash": "sha256-rCpANMHFIlafta6J/G0ILRd+WNSnzv/lzi40Y8f1AR8=", + "lastModified": 1750777360, + "narHash": "sha256-nDWFxwhT+fQNgi4rrr55EKjpxDyVKSl1KaNmSXtYj40=", "owner": "NixOS", "repo": "nix", - "rev": "d761dad79c79af17aa476a29749bd9d69747548f", + "rev": "7bb200199705eddd53cb34660a76567c6f1295d9", "type": "github" }, "original": { @@ -20,11 +20,11 @@ "nix-eval-jobs": { "flake": false, "locked": { - "lastModified": 1748211873, - "narHash": "sha256-AJ22q6yWc1hPkqssXMxQqD6QUeJ6hbx52xWHhKsmuP0=", + "lastModified": 1748680938, + "narHash": "sha256-TQk6pEMD0mFw7jZXpg7+2qNKGbAluMQgc55OMgEO8bM=", "owner": "nix-community", "repo": "nix-eval-jobs", - "rev": "d9262e535e35454daebcebd434bdb9c1486bb998", + "rev": "974a4af3d4a8fd242d8d0e2608da4be87a62b83f", "type": "github" }, "original": { @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1748124805, - "narHash": "sha256-8A7HjmnvCpDjmETrZY1QwzKunR63LiP7lHu1eA5q6JI=", + "lastModified": 1750736827, + "narHash": "sha256-UcNP7BR41xMTe0sfHBH8R79+HdCw0OwkC/ZKrQEuMeo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "db1aed32009f408e4048c1dd0beaf714dd34ed93", + "rev": "b4a30b08433ad7b6e1dfba0833fb0fe69d43dfec", "type": "github" }, "original": { diff --git a/foreman/start-evaluator.sh b/foreman/start-evaluator.sh index a2858003..73e0fe42 100755 --- a/foreman/start-evaluator.sh +++ b/foreman/start-evaluator.sh @@ -1,5 +1,7 @@ #!/bin/sh +export PATH=$(pwd)/src/script:$PATH + # wait for hydra-server to listen while ! nc -z localhost 63333; do sleep 1; done diff --git a/foreman/start-hydra.sh b/foreman/start-hydra.sh index bb8cc909..f5af310f 100755 --- a/foreman/start-hydra.sh +++ b/foreman/start-hydra.sh @@ -1,5 +1,7 @@ #!/bin/sh +export PATH=$(pwd)/src/script:$PATH + # wait for postgresql to listen while ! pg_isready -h $(pwd)/.hydra-data/postgres -p 64444; do sleep 1; done diff --git a/foreman/start-notify.sh b/foreman/start-notify.sh index 6a647e54..454f1461 100755 --- a/foreman/start-notify.sh +++ b/foreman/start-notify.sh @@ -1,5 +1,7 @@ #!/bin/sh +export PATH=$(pwd)/src/script:$PATH + # wait for hydra-server to listen while ! nc -z localhost 63333; do sleep 1; done diff --git a/hydra-api.yaml b/hydra-api.yaml index a2fdea28..aa89b015 100644 --- a/hydra-api.yaml +++ b/hydra-api.yaml @@ -78,6 +78,11 @@ paths: description: project and jobset formatted as ":" to evaluate schema: type: string + - in: query + name: force + description: when set to true the jobset gets evaluated even when it did not change + schema: + type: boolean responses: '200': description: jobset trigger response diff --git a/nixos-modules/hydra.nix b/nixos-modules/hydra.nix index 283a9b8d..544da7eb 100644 --- a/nixos-modules/hydra.nix +++ b/nixos-modules/hydra.nix @@ -340,7 +340,7 @@ in requires = [ "hydra-init.service" ]; wants = [ "network-online.target" ]; after = [ "hydra-init.service" "network.target" "network-online.target" ]; - path = [ cfg.package pkgs.nettools pkgs.openssh pkgs.bzip2 config.nix.package ]; + path = [ cfg.package pkgs.hostname-debian pkgs.openssh pkgs.bzip2 config.nix.package ]; restartTriggers = [ hydraConf ]; environment = env // { PGPASSFILE = "${baseDir}/pgpass-queue-runner"; # grrr @@ -364,7 +364,7 @@ in requires = [ "hydra-init.service" ]; restartTriggers = [ hydraConf ]; after = [ "hydra-init.service" "network.target" ]; - path = with pkgs; [ nettools cfg.package jq ]; + path = with pkgs; [ hostname-debian cfg.package jq ]; environment = env // { HYDRA_DBI = "${env.HYDRA_DBI};application_name=hydra-evaluator"; }; @@ -463,12 +463,12 @@ in '' set -eou pipefail compression=$(sed -nr 's/compress_build_logs_compression = ()/\1/p' ${baseDir}/hydra.conf) - if [[ $compression == "" ]]; then - compression="bzip2" + if [[ $compression == "" || $compression == bzip2 ]]; then + compressionCmd=(bzip2) elif [[ $compression == zstd ]]; then - compression="zstd --rm" + compressionCmd=(zstd --rm) fi - find ${baseDir}/build-logs -ignore_readdir_race -type f -name "*.drv" -mtime +3 -size +0c | xargs -r "$compression" --force --quiet + find ${baseDir}/build-logs -ignore_readdir_race -type f -name "*.drv" -mtime +3 -size +0c -print0 | xargs -0 -r "''${compressionCmd[@]}" --force --quiet ''; startAt = "Sun 01:45"; }; diff --git a/package.nix b/package.nix index 5c1a7860..2ea21b1e 100644 --- a/package.nix +++ b/package.nix @@ -110,6 +110,7 @@ let NetAmazonS3 NetPrometheus NetStatsd + NumberBytesHuman PadWalker ParallelForkManager PerlCriticCommunity @@ -238,7 +239,7 @@ stdenv.mkDerivation (finalAttrs: { shellHook = '' pushd $(git rev-parse --show-toplevel) >/dev/null - PATH=$(pwd)/build/src/hydra-evaluator:$(pwd)/build/src/script:$(pwd)/build/src/hydra-queue-runner:$PATH + PATH=$(pwd)/build/src/hydra-evaluator:$(pwd)/src/script:$(pwd)/build/src/hydra-queue-runner:$PATH PERL5LIB=$(pwd)/src/lib:$PERL5LIB export HYDRA_HOME="$(pwd)/src/" mkdir -p .hydra-data diff --git a/src/hydra-evaluator/hydra-evaluator.cc b/src/hydra-evaluator/hydra-evaluator.cc index 52664188..10cd2233 100644 --- a/src/hydra-evaluator/hydra-evaluator.cc +++ b/src/hydra-evaluator/hydra-evaluator.cc @@ -180,10 +180,8 @@ struct Evaluator { auto conn(dbPool.get()); pqxx::work txn(*conn); - txn.exec_params0 - ("update Jobsets set startTime = $1 where id = $2", - now, - jobset.name.id); + txn.exec("update Jobsets set startTime = $1 where id = $2", + pqxx::params{now, jobset.name.id}).no_rows(); txn.commit(); } @@ -234,7 +232,7 @@ struct Evaluator pqxx::work txn(*conn); if (jobset.evaluation_style == EvaluationStyle::ONE_AT_A_TIME) { - auto evaluation_res = txn.exec_params + auto evaluation_res = txn.exec ("select id from JobsetEvals " "where jobset_id = $1 " "order by id desc limit 1" @@ -250,7 +248,7 @@ struct Evaluator auto evaluation_id = evaluation_res[0][0].as(); - auto unfinished_build_res = txn.exec_params + auto unfinished_build_res = txn.exec ("select id from Builds " "join JobsetEvalMembers " " on (JobsetEvalMembers.build = Builds.id) " @@ -420,21 +418,18 @@ struct Evaluator /* Clear the trigger time to prevent this jobset from getting stuck in an endless failing eval loop. */ - txn.exec_params0 + txn.exec ("update Jobsets set triggerTime = null where id = $1 and startTime is not null and triggerTime <= startTime", - jobset.name.id); + jobset.name.id).no_rows(); /* Clear the start time. */ - txn.exec_params0 + txn.exec ("update Jobsets set startTime = null where id = $1", - jobset.name.id); + jobset.name.id).no_rows(); if (!WIFEXITED(status) || WEXITSTATUS(status) > 1) { - txn.exec_params0 - ("update Jobsets set errorMsg = $1, lastCheckedTime = $2, errorTime = $2, fetchErrorMsg = null where id = $3", - fmt("evaluation %s", statusToString(status)), - now, - jobset.name.id); + txn.exec("update Jobsets set errorMsg = $1, lastCheckedTime = $2, errorTime = $2, fetchErrorMsg = null where id = $3", + pqxx::params{fmt("evaluation %s", statusToString(status)), now, jobset.name.id}).no_rows(); } txn.commit(); @@ -459,7 +454,7 @@ struct Evaluator { auto conn(dbPool.get()); pqxx::work txn(*conn); - txn.exec("update Jobsets set startTime = null"); + txn.exec("update Jobsets set startTime = null").no_rows(); txn.commit(); } diff --git a/src/hydra-queue-runner/build-result.cc b/src/hydra-queue-runner/build-result.cc index b0695e8b..aa98acbb 100644 --- a/src/hydra-queue-runner/build-result.cc +++ b/src/hydra-queue-runner/build-result.cc @@ -51,8 +51,8 @@ BuildOutput getBuildOutput( "[[:space:]]+" "([a-zA-Z0-9_-]+)" // subtype (e.g. "readme") "[[:space:]]+" - "(\"[^\"]+\"|[^[:space:]\"]+)" // path (may be quoted) - "([[:space:]]+([^[:space:]]+))?" // entry point + "(\"[^\"]+\"|[^[:space:]<>\"]+)" // path (may be quoted) + "([[:space:]]+([^[:space:]<>]+))?" // entry point , std::regex::extended); for (auto & output : outputs) { @@ -78,7 +78,7 @@ BuildOutput getBuildOutput( product.type = match[1]; product.subtype = match[2]; std::string s(match[3]); - product.path = s[0] == '"' ? std::string(s, 1, s.size() - 2) : s; + product.path = s[0] == '"' && s.back() == '"' ? std::string(s, 1, s.size() - 2) : s; product.defaultPath = match[5]; /* Ensure that the path exists and points into the Nix @@ -93,6 +93,8 @@ BuildOutput getBuildOutput( if (file == narMembers.end()) continue; product.name = product.path == store->printStorePath(output) ? "" : baseNameOf(product.path); + if (!std::regex_match(product.name, std::regex("[a-zA-Z0-9.@:_ -]*"))) + product.name = ""; if (file->second.type == SourceAccessor::Type::tRegular) { product.isRegular = true; @@ -127,8 +129,9 @@ BuildOutput getBuildOutput( if (file == narMembers.end() || file->second.type != SourceAccessor::Type::tRegular) continue; - res.releaseName = trim(file->second.contents.value()); - // FIXME: validate release name + auto contents = trim(file->second.contents.value()); + if (std::regex_match(contents, std::regex("[a-zA-Z0-9.@:_-]+"))) + res.releaseName = contents; } /* Get metrics. */ @@ -140,10 +143,18 @@ BuildOutput getBuildOutput( for (auto & line : tokenizeString(file->second.contents.value(), "\n")) { auto fields = tokenizeString>(line); if (fields.size() < 2) continue; + if (!std::regex_match(fields[0], std::regex("[a-zA-Z0-9._-]+"))) + continue; BuildMetric metric; - metric.name = fields[0]; // FIXME: validate - metric.value = atof(fields[1].c_str()); // FIXME + metric.name = fields[0]; + try { + metric.value = std::stod(fields[1]); + } catch (...) { + continue; // skip this metric + } metric.unit = fields.size() >= 3 ? fields[2] : ""; + if (!std::regex_match(metric.unit, std::regex("[a-zA-Z0-9._%-]+"))) + metric.unit = ""; res.metrics[metric.name] = metric; } } diff --git a/src/hydra-queue-runner/builder.cc b/src/hydra-queue-runner/builder.cc index ff0634b1..85f1c8d3 100644 --- a/src/hydra-queue-runner/builder.cc +++ b/src/hydra-queue-runner/builder.cc @@ -458,13 +458,12 @@ void State::failStep( for (auto & build : indirect) { if (build->finishedInDB) continue; printError("marking build %1% as failed", build->id); - txn.exec_params0 - ("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, isCachedBuild = $5, notificationPendingSince = $4 where id = $1 and finished = 0", - build->id, + txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, isCachedBuild = $5, notificationPendingSince = $4 where id = $1 and finished = 0", + pqxx::params{build->id, (int) (build->drvPath != step->drvPath && result.buildStatus() == bsFailed ? bsDepFailed : result.buildStatus()), result.startTime, result.stopTime, - result.stepStatus == bsCachedFailure ? 1 : 0); + result.stepStatus == bsCachedFailure ? 1 : 0}).no_rows(); nrBuildsDone++; } @@ -473,7 +472,7 @@ void State::failStep( if (result.stepStatus != bsCachedFailure && result.canCache) for (auto & i : step->drv->outputsAndOptPaths(*localStore)) if (i.second.second) - txn.exec_params0("insert into FailedPaths values ($1)", localStore->printStorePath(*i.second.second)); + txn.exec("insert into FailedPaths values ($1)", pqxx::params{localStore->printStorePath(*i.second.second)}).no_rows(); txn.commit(); } diff --git a/src/hydra-queue-runner/hydra-queue-runner.cc b/src/hydra-queue-runner/hydra-queue-runner.cc index a4a7f0a7..7cdc04b3 100644 --- a/src/hydra-queue-runner/hydra-queue-runner.cc +++ b/src/hydra-queue-runner/hydra-queue-runner.cc @@ -276,17 +276,16 @@ void State::monitorMachinesFile() void State::clearBusy(Connection & conn, time_t stopTime) { pqxx::work txn(conn); - txn.exec_params0 - ("update BuildSteps set busy = 0, status = $1, stopTime = $2 where busy != 0", - (int) bsAborted, - stopTime != 0 ? std::make_optional(stopTime) : std::nullopt); + txn.exec("update BuildSteps set busy = 0, status = $1, stopTime = $2 where busy != 0", + pqxx::params{(int) bsAborted, + stopTime != 0 ? std::make_optional(stopTime) : std::nullopt}).no_rows(); txn.commit(); } unsigned int State::allocBuildStep(pqxx::work & txn, BuildID buildId) { - auto res = txn.exec_params1("select max(stepnr) from BuildSteps where build = $1", buildId); + auto res = txn.exec("select max(stepnr) from BuildSteps where build = $1", buildId).one_row(); return res[0].is_null() ? 1 : res[0].as() + 1; } @@ -297,9 +296,8 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID restart: auto stepNr = allocBuildStep(txn, buildId); - auto r = txn.exec_params - ("insert into BuildSteps (build, stepnr, type, drvPath, busy, startTime, system, status, propagatedFrom, errorMsg, stopTime, machine) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) on conflict do nothing", - buildId, + auto r = txn.exec("insert into BuildSteps (build, stepnr, type, drvPath, busy, startTime, system, status, propagatedFrom, errorMsg, stopTime, machine) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) on conflict do nothing", + pqxx::params{buildId, stepNr, 0, // == build localStore->printStorePath(step->drvPath), @@ -310,17 +308,16 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID propagatedFrom != 0 ? std::make_optional(propagatedFrom) : std::nullopt, // internal::params errorMsg != "" ? std::make_optional(errorMsg) : std::nullopt, startTime != 0 && status != bsBusy ? std::make_optional(startTime) : std::nullopt, - machine); + machine}); if (r.affected_rows() == 0) goto restart; for (auto & [name, output] : getDestStore()->queryPartialDerivationOutputMap(step->drvPath, &*localStore)) - txn.exec_params0 - ("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)", - buildId, stepNr, name, + txn.exec("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)", + pqxx::params{buildId, stepNr, name, output ? std::optional { localStore->printStorePath(*output)} - : std::nullopt); + : std::nullopt}).no_rows(); if (status == bsBusy) txn.exec(fmt("notify step_started, '%d\t%d'", buildId, stepNr)); @@ -331,11 +328,10 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID void State::updateBuildStep(pqxx::work & txn, BuildID buildId, unsigned int stepNr, StepState stepState) { - if (txn.exec_params - ("update BuildSteps set busy = $1 where build = $2 and stepnr = $3 and busy != 0 and status is null", - (int) stepState, + if (txn.exec("update BuildSteps set busy = $1 where build = $2 and stepnr = $3 and busy != 0 and status is null", + pqxx::params{(int) stepState, buildId, - stepNr).affected_rows() != 1) + stepNr}).affected_rows() != 1) throw Error("step %d of build %d is in an unexpected state", stepNr, buildId); } @@ -345,29 +341,27 @@ void State::finishBuildStep(pqxx::work & txn, const RemoteResult & result, { assert(result.startTime); assert(result.stopTime); - txn.exec_params0 - ("update BuildSteps set busy = 0, status = $1, errorMsg = $4, startTime = $5, stopTime = $6, machine = $7, overhead = $8, timesBuilt = $9, isNonDeterministic = $10 where build = $2 and stepnr = $3", - (int) result.stepStatus, buildId, stepNr, + txn.exec("update BuildSteps set busy = 0, status = $1, errorMsg = $4, startTime = $5, stopTime = $6, machine = $7, overhead = $8, timesBuilt = $9, isNonDeterministic = $10 where build = $2 and stepnr = $3", + pqxx::params{(int) result.stepStatus, buildId, stepNr, result.errorMsg != "" ? std::make_optional(result.errorMsg) : std::nullopt, result.startTime, result.stopTime, machine != "" ? std::make_optional(machine) : std::nullopt, result.overhead != 0 ? std::make_optional(result.overhead) : std::nullopt, result.timesBuilt > 0 ? std::make_optional(result.timesBuilt) : std::nullopt, - result.timesBuilt > 1 ? std::make_optional(result.isNonDeterministic) : std::nullopt); + result.timesBuilt > 1 ? std::make_optional(result.isNonDeterministic) : std::nullopt}).no_rows(); assert(result.logFile.find('\t') == std::string::npos); txn.exec(fmt("notify step_finished, '%d\t%d\t%s'", buildId, stepNr, result.logFile)); if (result.stepStatus == bsSuccess) { // Update the corresponding `BuildStepOutputs` row to add the output path - auto res = txn.exec_params1("select drvPath from BuildSteps where build = $1 and stepnr = $2", buildId, stepNr); + auto res = txn.exec("select drvPath from BuildSteps where build = $1 and stepnr = $2", pqxx::params{buildId, stepNr}).one_row(); assert(res.size()); StorePath drvPath = localStore->parseStorePath(res[0].as()); // If we've finished building, all the paths should be known for (auto & [name, output] : getDestStore()->queryDerivationOutputMap(drvPath, &*localStore)) - txn.exec_params0 - ("update BuildStepOutputs set path = $4 where build = $1 and stepnr = $2 and name = $3", - buildId, stepNr, name, localStore->printStorePath(output)); + txn.exec("update BuildStepOutputs set path = $4 where build = $1 and stepnr = $2 and name = $3", + pqxx::params{buildId, stepNr, name, localStore->printStorePath(output)}).no_rows(); } } @@ -378,23 +372,21 @@ int State::createSubstitutionStep(pqxx::work & txn, time_t startTime, time_t sto restart: auto stepNr = allocBuildStep(txn, build->id); - auto r = txn.exec_params - ("insert into BuildSteps (build, stepnr, type, drvPath, busy, status, startTime, stopTime) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict do nothing", - build->id, + auto r = txn.exec("insert into BuildSteps (build, stepnr, type, drvPath, busy, status, startTime, stopTime) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict do nothing", + pqxx::params{build->id, stepNr, 1, // == substitution (localStore->printStorePath(drvPath)), 0, 0, startTime, - stopTime); + stopTime}); if (r.affected_rows() == 0) goto restart; - txn.exec_params0 - ("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)", - build->id, stepNr, outputName, - localStore->printStorePath(storePath)); + txn.exec("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)", + pqxx::params{build->id, stepNr, outputName, + localStore->printStorePath(storePath)}).no_rows(); return stepNr; } @@ -461,35 +453,32 @@ void State::markSucceededBuild(pqxx::work & txn, Build::ptr build, { if (build->finishedInDB) return; - if (txn.exec_params("select 1 from Builds where id = $1 and finished = 0", build->id).empty()) return; + if (txn.exec("select 1 from Builds where id = $1 and finished = 0", pqxx::params{build->id}).empty()) return; - txn.exec_params0 - ("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, size = $5, closureSize = $6, releaseName = $7, isCachedBuild = $8, notificationPendingSince = $4 where id = $1", - build->id, + txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, size = $5, closureSize = $6, releaseName = $7, isCachedBuild = $8, notificationPendingSince = $4 where id = $1", + pqxx::params{build->id, (int) (res.failed ? bsFailedWithOutput : bsSuccess), startTime, stopTime, res.size, res.closureSize, res.releaseName != "" ? std::make_optional(res.releaseName) : std::nullopt, - isCachedBuild ? 1 : 0); + isCachedBuild ? 1 : 0}).no_rows(); for (auto & [outputName, outputPath] : res.outputs) { - txn.exec_params0 - ("update BuildOutputs set path = $3 where build = $1 and name = $2", - build->id, + txn.exec("update BuildOutputs set path = $3 where build = $1 and name = $2", + pqxx::params{build->id, outputName, - localStore->printStorePath(outputPath) - ); + localStore->printStorePath(outputPath)} + ).no_rows(); } - txn.exec_params0("delete from BuildProducts where build = $1", build->id); + txn.exec("delete from BuildProducts where build = $1", pqxx::params{build->id}).no_rows(); unsigned int productNr = 1; for (auto & product : res.products) { - txn.exec_params0 - ("insert into BuildProducts (build, productnr, type, subtype, fileSize, sha256hash, path, name, defaultPath) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - build->id, + txn.exec("insert into BuildProducts (build, productnr, type, subtype, fileSize, sha256hash, path, name, defaultPath) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + pqxx::params{build->id, productNr++, product.type, product.subtype, @@ -497,22 +486,21 @@ void State::markSucceededBuild(pqxx::work & txn, Build::ptr build, product.sha256hash ? std::make_optional(product.sha256hash->to_string(HashFormat::Base16, false)) : std::nullopt, product.path, product.name, - product.defaultPath); + product.defaultPath}).no_rows(); } - txn.exec_params0("delete from BuildMetrics where build = $1", build->id); + txn.exec("delete from BuildMetrics where build = $1", pqxx::params{build->id}).no_rows(); for (auto & metric : res.metrics) { - txn.exec_params0 - ("insert into BuildMetrics (build, name, unit, value, project, jobset, job, timestamp) values ($1, $2, $3, $4, $5, $6, $7, $8)", - build->id, + txn.exec("insert into BuildMetrics (build, name, unit, value, project, jobset, job, timestamp) values ($1, $2, $3, $4, $5, $6, $7, $8)", + pqxx::params{build->id, metric.second.name, metric.second.unit != "" ? std::make_optional(metric.second.unit) : std::nullopt, metric.second.value, build->projectName, build->jobsetName, build->jobName, - build->timestamp); + build->timestamp}).no_rows(); } nrBuildsDone++; @@ -524,7 +512,7 @@ bool State::checkCachedFailure(Step::ptr step, Connection & conn) pqxx::work txn(conn); for (auto & i : step->drv->outputsAndOptPaths(*localStore)) if (i.second.second) - if (!txn.exec_params("select 1 from FailedPaths where path = $1", localStore->printStorePath(*i.second.second)).empty()) + if (!txn.exec("select 1 from FailedPaths where path = $1", pqxx::params{localStore->printStorePath(*i.second.second)}).empty()) return true; return false; } @@ -736,8 +724,8 @@ void State::dumpStatus(Connection & conn) auto mc = startDbUpdate(); pqxx::work txn(conn); // FIXME: use PostgreSQL 9.5 upsert. - txn.exec("delete from SystemStatus where what = 'queue-runner'"); - txn.exec_params0("insert into SystemStatus values ('queue-runner', $1)", statusJson.dump()); + txn.exec("delete from SystemStatus where what = 'queue-runner'").no_rows(); + txn.exec("insert into SystemStatus values ('queue-runner', $1)", pqxx::params{statusJson.dump()}).no_rows(); txn.exec("notify status_dumped"); txn.commit(); } @@ -802,7 +790,7 @@ void State::unlock() { pqxx::work txn(*conn); - txn.exec("delete from SystemStatus where what = 'queue-runner'"); + txn.exec("delete from SystemStatus where what = 'queue-runner'").no_rows(); txn.commit(); } } @@ -880,11 +868,10 @@ void State::run(BuildID buildOne) pqxx::work txn(*conn); for (auto & step : steps) { printMsg(lvlError, "cleaning orphaned step %d of build %d", step.second, step.first); - txn.exec_params0 - ("update BuildSteps set busy = 0, status = $1 where build = $2 and stepnr = $3 and busy != 0", - (int) bsAborted, + txn.exec("update BuildSteps set busy = 0, status = $1 where build = $2 and stepnr = $3 and busy != 0", + pqxx::params{(int) bsAborted, step.first, - step.second); + step.second}).no_rows(); } txn.commit(); } catch (std::exception & e) { diff --git a/src/hydra-queue-runner/queue-monitor.cc b/src/hydra-queue-runner/queue-monitor.cc index 0785be6f..d6d87e71 100644 --- a/src/hydra-queue-runner/queue-monitor.cc +++ b/src/hydra-queue-runner/queue-monitor.cc @@ -5,6 +5,7 @@ #include #include +#include using namespace nix; @@ -108,8 +109,7 @@ bool State::getQueuedBuilds(Connection & conn, { pqxx::work txn(conn); - auto res = txn.exec_params - ("select builds.id, builds.jobset_id, jobsets.project as project, " + auto res = txn.exec("select builds.id, builds.jobset_id, jobsets.project as project, " "jobsets.name as jobset, job, drvPath, maxsilent, timeout, timestamp, " "globalPriority, priority from Builds " "inner join jobsets on builds.jobset_id = jobsets.id " @@ -158,11 +158,10 @@ bool State::getQueuedBuilds(Connection & conn, if (!build->finishedInDB) { auto mc = startDbUpdate(); pqxx::work txn(conn); - txn.exec_params0 - ("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3 where id = $1 and finished = 0", - build->id, + txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3 where id = $1 and finished = 0", + pqxx::params{build->id, (int) bsAborted, - time(0)); + time(0)}).no_rows(); txn.commit(); build->finishedInDB = true; nrBuildsDone++; @@ -192,22 +191,20 @@ bool State::getQueuedBuilds(Connection & conn, derivation path, then by output path. */ BuildID propagatedFrom = 0; - auto res = txn.exec_params1 - ("select max(build) from BuildSteps where drvPath = $1 and startTime != 0 and stopTime != 0 and status = 1", - localStore->printStorePath(ex.step->drvPath)); + auto res = txn.exec("select max(build) from BuildSteps where drvPath = $1 and startTime != 0 and stopTime != 0 and status = 1", + pqxx::params{localStore->printStorePath(ex.step->drvPath)}).one_row(); if (!res[0].is_null()) propagatedFrom = res[0].as(); if (!propagatedFrom) { for (auto & [outputName, optOutputPath] : destStore->queryPartialDerivationOutputMap(ex.step->drvPath, &*localStore)) { constexpr std::string_view common = "select max(s.build) from BuildSteps s join BuildStepOutputs o on s.build = o.build where startTime != 0 and stopTime != 0 and status = 1"; auto res = optOutputPath - ? txn.exec_params( + ? txn.exec( std::string { common } + " and path = $1", - localStore->printStorePath(*optOutputPath)) - : txn.exec_params( + pqxx::params{localStore->printStorePath(*optOutputPath)}) + : txn.exec( std::string { common } + " and drvPath = $1 and name = $2", - localStore->printStorePath(ex.step->drvPath), - outputName); + pqxx::params{localStore->printStorePath(ex.step->drvPath), outputName}); if (!res[0][0].is_null()) { propagatedFrom = res[0][0].as(); break; @@ -216,12 +213,11 @@ bool State::getQueuedBuilds(Connection & conn, } createBuildStep(txn, 0, build->id, ex.step, "", bsCachedFailure, "", propagatedFrom); - txn.exec_params - ("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3, isCachedBuild = 1, notificationPendingSince = $3 " + txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3, isCachedBuild = 1, notificationPendingSince = $3 " "where id = $1 and finished = 0", - build->id, + pqxx::params{build->id, (int) (ex.step->drvPath == build->drvPath ? bsFailed : bsDepFailed), - time(0)); + time(0)}).no_rows(); notifyBuildFinished(txn, build->id, {}); txn.commit(); build->finishedInDB = true; @@ -653,10 +649,8 @@ Jobset::ptr State::createJobset(pqxx::work & txn, if (i != jobsets_->end()) return i->second; } - auto res = txn.exec_params1 - ("select schedulingShares from Jobsets where id = $1", - jobsetID); - if (res.empty()) throw Error("missing jobset - can't happen"); + auto res = txn.exec("select schedulingShares from Jobsets where id = $1", + pqxx::params{jobsetID}).one_row(); auto shares = res["schedulingShares"].as(); @@ -664,11 +658,10 @@ Jobset::ptr State::createJobset(pqxx::work & txn, jobset->setShares(shares); /* Load the build steps from the last 24 hours. */ - auto res2 = txn.exec_params - ("select s.startTime, s.stopTime from BuildSteps s join Builds b on build = id " + auto res2 = txn.exec("select s.startTime, s.stopTime from BuildSteps s join Builds b on build = id " "where s.startTime is not null and s.stopTime > $1 and jobset_id = $2", - time(0) - Jobset::schedulingWindow * 10, - jobsetID); + pqxx::params{time(0) - Jobset::schedulingWindow * 10, + jobsetID}); for (auto const & row : res2) { time_t startTime = row["startTime"].as(); time_t stopTime = row["stopTime"].as(); @@ -705,11 +698,10 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref pqxx::work txn(conn); for (auto & [name, output] : derivationOutputs) { - auto r = txn.exec_params - ("select id, buildStatus, releaseName, closureSize, size from Builds b " + auto r = txn.exec("select id, buildStatus, releaseName, closureSize, size from Builds b " "join BuildOutputs o on b.id = o.build " "where finished = 1 and (buildStatus = 0 or buildStatus = 6) and path = $1", - localStore->printStorePath(output)); + pqxx::params{localStore->printStorePath(output)}); if (r.empty()) continue; BuildID id = r[0][0].as(); @@ -721,9 +713,8 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref res.closureSize = r[0][3].is_null() ? 0 : r[0][3].as(); res.size = r[0][4].is_null() ? 0 : r[0][4].as(); - auto products = txn.exec_params - ("select type, subtype, fileSize, sha256hash, path, name, defaultPath from BuildProducts where build = $1 order by productnr", - id); + auto products = txn.exec("select type, subtype, fileSize, sha256hash, path, name, defaultPath from BuildProducts where build = $1 order by productnr", + pqxx::params{id}); for (auto row : products) { BuildProduct product; @@ -745,9 +736,8 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref res.products.emplace_back(product); } - auto metrics = txn.exec_params - ("select name, unit, value from BuildMetrics where build = $1", - id); + auto metrics = txn.exec("select name, unit, value from BuildMetrics where build = $1", + pqxx::params{id}); for (auto row : metrics) { BuildMetric metric; diff --git a/src/lib/Hydra/Controller/API.pm b/src/lib/Hydra/Controller/API.pm index 9f8b7cba..f74bacbb 100644 --- a/src/lib/Hydra/Controller/API.pm +++ b/src/lib/Hydra/Controller/API.pm @@ -12,6 +12,9 @@ use DateTime; use Digest::SHA qw(sha256_hex); use Text::Diff; use IPC::Run qw(run); +use Digest::SHA qw(hmac_sha256_hex); +use String::Compare::ConstantTime qw(equals); +use IPC::Run3; sub api : Chained('/') PathPart('api') CaptureArgs(0) { @@ -216,8 +219,13 @@ sub scmdiff : Path('/api/scmdiff') Args(0) { } elsif ($type eq "git") { my $clonePath = getSCMCacheDir . "/git/" . sha256_hex($uri); die if ! -d $clonePath; - $diff .= `(cd $clonePath; git --git-dir .git log $rev1..$rev2)`; - $diff .= `(cd $clonePath; git --git-dir .git diff $rev1..$rev2)`; + my ($stdout1, $stderr1); + run3(['git', '-C', $clonePath, 'log', "$rev1..$rev2"], \undef, \$stdout1, \$stderr1); + $diff .= $stdout1 if $? == 0; + + my ($stdout2, $stderr2); + run3(['git', '-C', $clonePath, 'diff', "$rev1..$rev2"], \undef, \$stdout2, \$stderr2); + $diff .= $stdout2 if $? == 0; } $c->stash->{'plain'} = { data => (scalar $diff) || " " }; @@ -274,13 +282,84 @@ sub push : Chained('api') PathPart('push') Args(0) { ); } +sub verifyWebhookSignature { + my ($c, $platform, $header_name, $signature_prefix) = @_; + + # Get secrets from config + my $webhook_config = $c->config->{webhooks} // {}; + my $platform_config = $webhook_config->{$platform} // {}; + my $secrets = $platform_config->{secret}; + + # Normalize to array + $secrets = [] unless defined $secrets; + $secrets = [$secrets] unless ref($secrets) eq 'ARRAY'; + + # Trim whitespace from secrets + my @secrets = grep { defined && length } map { s/^\s+|\s+$//gr } @$secrets; + + if (@secrets) { + my $signature = $c->request->header($header_name); + + if (!$signature) { + $c->log->warn("Webhook authentication failed for $platform: Missing signature from IP " . $c->request->address); + $c->response->status(401); + $c->stash->{json} = { error => "Missing webhook signature" }; + $c->forward('View::JSON'); + return 0; + } + + # Get the raw body content from the buffered PSGI input + # For JSON requests, Catalyst will have already read and buffered the body + my $input = $c->request->env->{'psgi.input'}; + $input->seek(0, 0); + local $/; + my $payload = <$input>; + $input->seek(0, 0); # Reset for any other consumers + + unless (defined $payload && length $payload) { + $c->log->warn("Webhook authentication failed for $platform: Empty request body from IP " . $c->request->address); + $c->response->status(400); + $c->stash->{json} = { error => "Empty request body" }; + $c->forward('View::JSON'); + return 0; + } + + my $valid = 0; + for my $secret (@secrets) { + my $expected = $signature_prefix . hmac_sha256_hex($payload, $secret); + if (equals($signature, $expected)) { + $valid = 1; + last; + } + } + + if (!$valid) { + $c->log->warn("Webhook authentication failed for $platform: Invalid signature from IP " . $c->request->address); + $c->response->status(401); + $c->stash->{json} = { error => "Invalid webhook signature" }; + $c->forward('View::JSON'); + return 0; + } + + return 1; + } else { + $c->log->warn("Webhook authentication failed for $platform: Unable to validate signature from IP " . $c->request->address . " because no secrets are configured"); + $c->response->status(401); + $c->stash->{json} = { error => "Invalid webhook signature" }; + $c->forward('View::JSON'); + return 0; + } +} + sub push_github : Chained('api') PathPart('push-github') Args(0) { my ($self, $c) = @_; $c->{stash}->{json}->{jobsetsTriggered} = []; + return unless verifyWebhookSignature($c, 'github', 'X-Hub-Signature-256', 'sha256='); + my $in = $c->request->{data}; - my $owner = $in->{repository}->{owner}->{name} or die; + my $owner = ($in->{repository}->{owner}->{name} // $in->{repository}->{owner}->{login}) or die; my $repo = $in->{repository}->{name} or die; print STDERR "got push from GitHub repository $owner/$repo\n"; @@ -297,6 +376,9 @@ sub push_gitea : Chained('api') PathPart('push-gitea') Args(0) { $c->{stash}->{json}->{jobsetsTriggered} = []; + # Note: Gitea doesn't use sha256= prefix + return unless verifyWebhookSignature($c, 'gitea', 'X-Gitea-Signature', ''); + my $in = $c->request->{data}; my $url = $in->{repository}->{clone_url} or die; $url =~ s/.git$//; diff --git a/src/lib/Hydra/Controller/Build.pm b/src/lib/Hydra/Controller/Build.pm index 5e7b6f24..ac31cc5b 100644 --- a/src/lib/Hydra/Controller/Build.pm +++ b/src/lib/Hydra/Controller/Build.pm @@ -13,6 +13,8 @@ use Data::Dump qw(dump); use List::SomeUtils qw(all); use Encode; use JSON::PP; +use IPC::Run qw(run); +use IPC::Run3; use WWW::Form::UrlEncoded::PP qw(); use feature 'state'; @@ -348,19 +350,21 @@ sub contents : Chained('buildChain') PathPart Args(1) { notFound($c, "Product $path has disappeared.") unless -e $path; - # Sanitize $path to prevent shell injection attacks. - $path =~ /^\/[\/[A-Za-z0-9_\-\.=+:]+$/ or die "Filename contains illegal characters.\n"; - - # FIXME: don't use shell invocations below. - # FIXME: use nix store cat my $res; if ($product->type eq "nix-build" && -d $path) { # FIXME: use nix ls-store -R --json - $res = `cd '$path' && find . -print0 | xargs -0 ls -ld --`; - error($c, "`ls -lR' error: $?") if $? != 0; + # We need to use a pipe between find and xargs, so we'll use IPC::Run + my $error; + # Run find with absolute path and post-process to get relative paths + my $success = run(['find', $path, '-print0'], '|', ['xargs', '-0', 'ls', '-ld', '--'], \$res, \$error); + error($c, "`find $path -print0 | xargs -0 ls -ld --' error: $error") unless $success; + + # Strip the base path to show relative paths + my $escaped_path = quotemeta($path); + $res =~ s/^(.*\s)$escaped_path(\/|$)/$1.$2/mg; #my $baseuri = $c->uri_for('/build', $c->stash->{build}->id, 'download', $product->productnr); #$baseuri .= "/".$product->name if $product->name; @@ -368,34 +372,59 @@ sub contents : Chained('buildChain') PathPart Args(1) { } elsif ($path =~ /\.rpm$/) { - $res = `rpm --query --info --package '$path'`; - error($c, "RPM error: $?") if $? != 0; + my ($stdout1, $stderr1); + run3(['rpm', '--query', '--info', '--package', $path], \undef, \$stdout1, \$stderr1); + error($c, "RPM error: $stderr1") if $? != 0; + $res = $stdout1; + $res .= "===\n"; - $res .= `rpm --query --list --verbose --package '$path'`; - error($c, "RPM error: $?") if $? != 0; + + my ($stdout2, $stderr2); + run3(['rpm', '--query', '--list', '--verbose', '--package', $path], \undef, \$stdout2, \$stderr2); + error($c, "RPM error: $stderr2") if $? != 0; + $res .= $stdout2; } elsif ($path =~ /\.deb$/) { - $res = `dpkg-deb --info '$path'`; - error($c, "`dpkg-deb' error: $?") if $? != 0; + my ($stdout1, $stderr1); + run3(['dpkg-deb', '--info', $path], \undef, \$stdout1, \$stderr1); + error($c, "`dpkg-deb' error: $stderr1") if $? != 0; + $res = $stdout1; + $res .= "===\n"; - $res .= `dpkg-deb --contents '$path'`; - error($c, "`dpkg-deb' error: $?") if $? != 0; + + my ($stdout2, $stderr2); + run3(['dpkg-deb', '--contents', $path], \undef, \$stdout2, \$stderr2); + error($c, "`dpkg-deb' error: $stderr2") if $? != 0; + $res .= $stdout2; } elsif ($path =~ /\.(tar(\.gz|\.bz2|\.xz|\.lzma)?|tgz)$/ ) { - $res = `tar tvfa '$path'`; - error($c, "`tar' error: $?") if $? != 0; + my ($stdout, $stderr); + run3(['tar', 'tvfa', $path], \undef, \$stdout, \$stderr); + error($c, "`tar' error: $stderr") if $? != 0; + $res = $stdout; } elsif ($path =~ /\.(zip|jar)$/ ) { - $res = `unzip -v '$path'`; - error($c, "`unzip' error: $?") if $? != 0; + my ($stdout, $stderr); + run3(['unzip', '-v', $path], \undef, \$stdout, \$stderr); + error($c, "`unzip' error: $stderr") if $? != 0; + $res = $stdout; } elsif ($path =~ /\.iso$/ ) { - $res = `isoinfo -d -i '$path' && isoinfo -l -R -i '$path'`; - error($c, "`isoinfo' error: $?") if $? != 0; + # Run first isoinfo command + my ($stdout1, $stderr1); + run3(['isoinfo', '-d', '-i', $path], \undef, \$stdout1, \$stderr1); + error($c, "`isoinfo' error: $stderr1") if $? != 0; + $res = $stdout1; + + # Run second isoinfo command + my ($stdout2, $stderr2); + run3(['isoinfo', '-l', '-R', '-i', $path], \undef, \$stdout2, \$stderr2); + error($c, "`isoinfo' error: $stderr2") if $? != 0; + $res .= $stdout2; } else { diff --git a/src/lib/Hydra/Controller/Root.pm b/src/lib/Hydra/Controller/Root.pm index adb5ad44..264b4bbb 100644 --- a/src/lib/Hydra/Controller/Root.pm +++ b/src/lib/Hydra/Controller/Root.pm @@ -9,9 +9,12 @@ use Hydra::Helper::CatalystUtils; use Hydra::View::TT; use Nix::Store; use Nix::Config; +use Number::Bytes::Human qw(format_bytes); use Encode; use File::Basename; use JSON::MaybeXS; +use HTML::Entities; +use IPC::Run3; use List::Util qw[min max]; use List::SomeUtils qw{any}; use Net::Prometheus; @@ -57,6 +60,7 @@ sub begin :Private { $c->stash->{tracker} = defined $c->config->{tracker} ? $c->config->{tracker} : ""; $c->stash->{flashMsg} = $c->flash->{flashMsg}; $c->stash->{successMsg} = $c->flash->{successMsg}; + $c->stash->{localStore} = isLocalStore; $c->stash->{isPrivateHydra} = $c->config->{private} // "0" ne "0"; @@ -174,8 +178,14 @@ sub queue_runner_status_GET { my ($self, $c) = @_; #my $status = from_json($c->model('DB::SystemStatus')->find('queue-runner')->status); - my $status = decode_json(`hydra-queue-runner --status`); - if ($?) { $status->{status} = "unknown"; } + my ($stdout, $stderr); + run3(['hydra-queue-runner', '--status'], \undef, \$stdout, \$stderr); + my $status; + if ($? != 0) { + $status = { status => "unknown" }; + } else { + $status = decode_json($stdout); + } my $json = JSON->new->pretty()->canonical(); $c->stash->{template} = 'queue-runner-status.tt'; @@ -188,8 +198,10 @@ sub machines :Local Args(0) { my ($self, $c) = @_; my $machines = getMachines; - # Add entry for localhost. - $machines->{''} //= {}; + # Add entry for localhost. The implicit addition is not needed with queue runner v2 + if (not $c->config->{'queue_runner_endpoint'}) { + $machines->{''} //= {}; + } delete $machines->{'localhost'}; my $status = $c->model('DB::SystemStatus')->find("queue-runner"); @@ -197,9 +209,11 @@ sub machines :Local Args(0) { my $ms = decode_json($status->status)->{"machines"}; foreach my $name (keys %{$ms}) { $name = "" if $name eq "localhost"; - $machines->{$name} //= {disabled => 1}; - $machines->{$name}->{nrStepsDone} = $ms->{$name}->{nrStepsDone}; - $machines->{$name}->{avgStepBuildTime} = $ms->{$name}->{avgStepBuildTime} // 0; + my $outName = $name; + $outName = "" if $name eq "ssh://localhost"; + $machines->{$outName} //= {disabled => 1}; + $machines->{$outName}->{nrStepsDone} = $ms->{$name}->{nrStepsDone}; + $machines->{$outName}->{avgStepBuildTime} = $ms->{$name}->{avgStepBuildTime} // 0; } } @@ -212,6 +226,19 @@ sub machines :Local Args(0) { "where busy != 0 order by machine, stepnr", { Slice => {} }); $c->stash->{template} = 'machine-status.tt'; + $c->stash->{human_bytes} = sub { + my ($bytes) = @_; + return format_bytes($bytes, si => 1); + }; + $c->stash->{pretty_load} = sub { + my ($load) = @_; + return sprintf('%.2f', $load); + }; + $c->stash->{pretty_percent} = sub { + my ($percent) = @_; + my $ret = sprintf('%.2f', $percent); + return (' ' x (6 - length($ret))) . encode_entities($ret); + }; $self->status_ok($c, entity => $c->stash->{machines}); } diff --git a/src/lib/Hydra/Helper/Nix.pm b/src/lib/Hydra/Helper/Nix.pm index 134b8b7e..b9354092 100644 --- a/src/lib/Hydra/Helper/Nix.pm +++ b/src/lib/Hydra/Helper/Nix.pm @@ -12,10 +12,14 @@ use Nix::Store; use Encode; use Sys::Hostname::Long; use IPC::Run; +use IPC::Run3; +use LWP::UserAgent; +use JSON::MaybeXS; use UUID4::Tiny qw(is_uuid4_string); our @ISA = qw(Exporter); our @EXPORT = qw( + addToStore cancelBuilds constructRunCommandLogPath findLog @@ -340,37 +344,68 @@ sub getEvals { sub getMachines { my %machines = (); + my $config = getHydraConfig(); - my @machinesFiles = split /:/, ($ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix/machines"); + if ($config->{'queue_runner_endpoint'}) { + my $ua = LWP::UserAgent->new(); + my $resp = $ua->get($config->{'queue_runner_endpoint'} . "/status/machines"); + if (not $resp->is_success) { + print STDERR "Unable to ask queue runner for machines\n"; + return \%machines; + } - for my $machinesFile (@machinesFiles) { - next unless -e $machinesFile; - open(my $conf, "<", $machinesFile) or die; - while (my $line = <$conf>) { - chomp($line); - $line =~ s/\#.*$//g; - next if $line =~ /^\s*$/; - my @tokens = split /\s+/, $line; + my $data = decode_json($resp->decoded_content) or return \%machines; + my $machinesData = $data->{machines}; - if (!defined($tokens[5]) || $tokens[5] eq "-") { - $tokens[5] = ""; - } - my @supportedFeatures = split(/,/, $tokens[5] || ""); - - if (!defined($tokens[6]) || $tokens[6] eq "-") { - $tokens[6] = ""; - } - my @mandatoryFeatures = split(/,/, $tokens[6] || ""); - $machines{$tokens[0]} = - { systemTypes => [ split(/,/, $tokens[1]) ] - , sshKeys => $tokens[2] - , maxJobs => int($tokens[3]) - , speedFactor => 1.0 * (defined $tokens[4] ? int($tokens[4]) : 1) - , supportedFeatures => [ @supportedFeatures, @mandatoryFeatures ] - , mandatoryFeatures => [ @mandatoryFeatures ] + foreach my $machineName (keys %$machinesData) { + my $machine = %$machinesData{$machineName}; + $machines{$machineName} = + { systemTypes => $machine->{systems} + , maxJobs => $machine->{maxJobs} + , speedFactor => $machine->{speedFactor} + , supportedFeatures => [ @{$machine->{supportedFeatures}}, @{$machine->{mandatoryFeatures}} ] + , mandatoryFeatures => [ @{$machine->{mandatoryFeatures}} ] + # New fields for the machine status + , primarySystemType => $machine->{systems}[0] + , hasCapacity => $machine->{hasCapacity} + , hasDynamicCapacity => $machine->{hasDynamicCapacity} + , hasStaticCapacity => $machine->{hasStaticCapacity} + , score => $machine->{score} + , stats => $machine->{stats} + , memTotal => $machine->{totalMem} }; } - close $conf; + } else { + my @machinesFiles = split /:/, ($ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix/machines"); + + for my $machinesFile (@machinesFiles) { + next unless -e $machinesFile; + open(my $conf, "<", $machinesFile) or die; + while (my $line = <$conf>) { + chomp($line); + $line =~ s/\#.*$//g; + next if $line =~ /^\s*$/; + my @tokens = split /\s+/, $line; + + if (!defined($tokens[5]) || $tokens[5] eq "-") { + $tokens[5] = ""; + } + my @supportedFeatures = split(/,/, $tokens[5] || ""); + + if (!defined($tokens[6]) || $tokens[6] eq "-") { + $tokens[6] = ""; + } + my @mandatoryFeatures = split(/,/, $tokens[6] || ""); + $machines{$tokens[0]} = + { systemTypes => [ split(/,/, $tokens[1]) ] + , maxJobs => int($tokens[3]) + , speedFactor => 1.0 * (defined $tokens[4] ? int($tokens[4]) : 1) + , supportedFeatures => [ @supportedFeatures, @mandatoryFeatures ] + , mandatoryFeatures => [ @mandatoryFeatures ] + }; + } + close $conf; + } } return \%machines; @@ -581,4 +616,14 @@ sub constructRunCommandLogPath { return "$hydra_path/runcommand-logs/$bucket/$uuid"; } + +sub addToStore { + my ($path) = @_; + + my ($stdout, $stderr); + run3(['nix-store', '--add', $path], \undef, \$stdout, \$stderr); + die "cannot add path $path to the Nix store: $stderr\n" if $? != 0; + return trim($stdout); +} + 1; diff --git a/src/lib/Hydra/Plugin/BitBucketPulls.pm b/src/lib/Hydra/Plugin/BitBucketPulls.pm index 3a082ac9..8e7a087a 100644 --- a/src/lib/Hydra/Plugin/BitBucketPulls.pm +++ b/src/lib/Hydra/Plugin/BitBucketPulls.pm @@ -7,8 +7,10 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); +use IPC::Run qw(run); sub supportedInputTypes { my ($self, $inputTypes) = @_; @@ -47,10 +49,8 @@ sub fetchInput { open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; print $fh encode_json \%pulls; close $fh; - system("jq -S . < $filename > $tempdir/bitbucket-pulls-sorted.json"); - my $storePath = trim(`nix-store --add "$tempdir/bitbucket-pulls-sorted.json"` - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + run(["jq", "-S", "."], '<', $filename, '>', "$tempdir/bitbucket-pulls-sorted.json") or die "jq command failed: $?"; + my $storePath = addToStore("$tempdir/bitbucket-pulls-sorted.json"); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/DarcsInput.pm b/src/lib/Hydra/Plugin/DarcsInput.pm index a8df6396..bb8962a6 100644 --- a/src/lib/Hydra/Plugin/DarcsInput.pm +++ b/src/lib/Hydra/Plugin/DarcsInput.pm @@ -7,6 +7,7 @@ use Digest::SHA qw(sha256_hex); use File::Path; use Hydra::Helper::Exec; use Hydra::Helper::Nix; +use IPC::Run3; sub supportedInputTypes { my ($self, $inputTypes) = @_; @@ -70,8 +71,11 @@ sub fetchInput { (system "darcs", "get", "--lazy", $clonePath, "$tmpDir/export", "--quiet", "--to-match", "hash $revision") == 0 or die "darcs export failed"; - $revCount = `darcs changes --count --repodir $tmpDir/export`; chomp $revCount; - die "darcs changes --count failed" if $? != 0; + my ($stdout, $stderr); + run3(['darcs', 'changes', '--count', '--repodir', "$tmpDir/export"], \undef, \$stdout, \$stderr); + die "darcs changes --count failed: $stderr\n" if $? != 0; + $revCount = $stdout; + chomp $revCount; system "rm", "-rf", "$tmpDir/export/_darcs"; $storePath = $MACHINE_LOCAL_STORE->addToStore("$tmpDir/export", 1, "sha256"); diff --git a/src/lib/Hydra/Plugin/GiteaPulls.pm b/src/lib/Hydra/Plugin/GiteaPulls.pm index c43d207d..0e30e448 100644 --- a/src/lib/Hydra/Plugin/GiteaPulls.pm +++ b/src/lib/Hydra/Plugin/GiteaPulls.pm @@ -16,6 +16,7 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); @@ -26,19 +27,18 @@ sub supportedInputTypes { sub _iterate { my ($url, $auth, $pulls, $ua) = @_; - my $req = HTTP::Request->new('GET', $url); + $req->header('Accept' => 'application/json'); $req->header('Authorization' => 'token ' . $auth) if defined $auth; my $res = $ua->request($req); my $content = $res->decoded_content; die "Error pulling from the gitea pulls API: $content\n" - unless $res->is_success; - + unless $res->is_success; my $pulls_list = decode_json $content; - + # TODO Stream out the json instead foreach my $pull (@$pulls_list) { - $pulls->{$pull->{number}} = $pull; + $pulls->{$pull->{number}} = $pull; } # TODO Make Link header parsing more robust!!! @@ -71,12 +71,10 @@ sub fetchInput { my $tempdir = File::Temp->newdir("gitea-pulls" . "XXXXX", TMPDIR => 1); my $filename = "$tempdir/gitea-pulls.json"; open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; - print $fh encode_json \%pulls; + print $fh JSON->new->utf8->canonical->encode(\%pulls); close $fh; - my $storePath = trim(`nix-store --add "$filename"` - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + my $storePath = addToStore($filename); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/GiteaRefs.pm b/src/lib/Hydra/Plugin/GiteaRefs.pm index 1b728009..3e0ba458 100644 --- a/src/lib/Hydra/Plugin/GiteaRefs.pm +++ b/src/lib/Hydra/Plugin/GiteaRefs.pm @@ -7,8 +7,10 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); +use IPC::Run qw(run); =head1 NAME @@ -118,10 +120,8 @@ sub fetchInput { open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; print $fh encode_json \%refs; close $fh; - system("jq -S . < $filename > $tempdir/gitea-refs-sorted.json"); - my $storePath = trim(qx{nix-store --add "$tempdir/gitea-refs-sorted.json"} - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + run(["jq", "-S", "."], '<', $filename, '>', "$tempdir/gitea-refs-sorted.json") or die "jq command failed: $?"; + my $storePath = addToStore("$tempdir/gitea-refs-sorted.json"); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/GithubPulls.pm b/src/lib/Hydra/Plugin/GithubPulls.pm index 9d8412c3..0ac7a08b 100644 --- a/src/lib/Hydra/Plugin/GithubPulls.pm +++ b/src/lib/Hydra/Plugin/GithubPulls.pm @@ -7,6 +7,7 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); @@ -58,9 +59,7 @@ sub fetchInput { print $fh JSON->new->utf8->canonical->encode(\%pulls); close $fh; - my $storePath = trim(`nix-store --add "$filename"` - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + my $storePath = addToStore($filename); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/GithubRefs.pm b/src/lib/Hydra/Plugin/GithubRefs.pm index 7d6e303f..105c70ae 100644 --- a/src/lib/Hydra/Plugin/GithubRefs.pm +++ b/src/lib/Hydra/Plugin/GithubRefs.pm @@ -7,8 +7,10 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); +use IPC::Run qw(run); =head1 NAME @@ -114,10 +116,8 @@ sub fetchInput { open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; print $fh encode_json \%refs; close $fh; - system("jq -S . < $filename > $tempdir/github-refs-sorted.json"); - my $storePath = trim(qx{nix-store --add "$tempdir/github-refs-sorted.json"} - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + run(["jq", "-S", "."], '<', $filename, '>', "$tempdir/github-refs-sorted.json") or die "jq command failed: $?"; + my $storePath = addToStore("$tempdir/github-refs-sorted.json"); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/GitlabPulls.pm b/src/lib/Hydra/Plugin/GitlabPulls.pm index 13bf1c46..b53d387e 100644 --- a/src/lib/Hydra/Plugin/GitlabPulls.pm +++ b/src/lib/Hydra/Plugin/GitlabPulls.pm @@ -21,8 +21,10 @@ use HTTP::Request; use LWP::UserAgent; use JSON::MaybeXS; use Hydra::Helper::CatalystUtils; +use Hydra::Helper::Nix; use File::Temp; use POSIX qw(strftime); +use IPC::Run qw(run); sub supportedInputTypes { my ($self, $inputTypes) = @_; @@ -85,10 +87,8 @@ sub fetchInput { open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; print $fh encode_json \%pulls; close $fh; - system("jq -S . < $filename > $tempdir/gitlab-pulls-sorted.json"); - my $storePath = trim(`nix-store --add "$tempdir/gitlab-pulls-sorted.json"` - or die "cannot copy path $filename to the Nix store.\n"); - chomp $storePath; + run(["jq", "-S", "."], '<', $filename, '>', "$tempdir/gitlab-pulls-sorted.json") or die "jq command failed: $?"; + my $storePath = addToStore("$tempdir/gitlab-pulls-sorted.json"); my $timestamp = time; return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; } diff --git a/src/lib/Hydra/Plugin/PathInput.pm b/src/lib/Hydra/Plugin/PathInput.pm index c923a03c..73d10201 100644 --- a/src/lib/Hydra/Plugin/PathInput.pm +++ b/src/lib/Hydra/Plugin/PathInput.pm @@ -5,6 +5,7 @@ use warnings; use parent 'Hydra::Plugin'; use POSIX qw(strftime); use Hydra::Helper::Nix; +use IPC::Run3; sub supportedInputTypes { my ($self, $inputTypes) = @_; @@ -37,11 +38,16 @@ sub fetchInput { print STDERR "copying input ", $name, " from $uri\n"; if ( $uri =~ /^\// ) { - $storePath = `nix-store --add "$uri"` - or die "cannot copy path $uri to the Nix store.\n"; + $storePath = addToStore($uri); } else { - $storePath = `PRINT_PATH=1 nix-prefetch-url "$uri" | tail -n 1` - or die "cannot fetch $uri to the Nix store.\n"; + # Run nix-prefetch-url with PRINT_PATH=1 + my ($stdout, $stderr); + local $ENV{PRINT_PATH} = 1; + run3(['nix-prefetch-url', $uri], \undef, \$stdout, \$stderr); + die "cannot fetch $uri to the Nix store: $stderr\n" if $? != 0; + # Get the last line (which is the store path) + my @output_lines = split /\n/, $stdout; + $storePath = $output_lines[-1] if @output_lines; } chomp $storePath; diff --git a/src/lib/Hydra/Plugin/S3Backup.pm b/src/lib/Hydra/Plugin/S3Backup.pm index f1f50754..133b62d1 100644 --- a/src/lib/Hydra/Plugin/S3Backup.pm +++ b/src/lib/Hydra/Plugin/S3Backup.pm @@ -7,6 +7,8 @@ use File::Temp; use File::Basename; use Fcntl; use IO::File; +use IPC::Run qw(run); +use IPC::Run3; use Net::Amazon::S3; use Net::Amazon::S3::Client; use Digest::SHA; @@ -27,11 +29,11 @@ my %compressors = (); $compressors{"none"} = ""; if (defined($Nix::Config::bzip2)) { - $compressors{"bzip2"} = "| $Nix::Config::bzip2", + $compressors{"bzip2"} = "$Nix::Config::bzip2", } if (defined($Nix::Config::xz)) { - $compressors{"xz"} = "| $Nix::Config::xz", + $compressors{"xz"} = "$Nix::Config::xz", } my $lockfile = Hydra::Model::DB::getHydraPath . "/.hydra-s3backup.lock"; @@ -111,7 +113,16 @@ sub buildFinished { } next unless @incomplete_buckets; my $compressor = $compressors{$compression_type}; - system("$Nix::Config::binDir/nix-store --dump $path $compressor > $tempdir/nar") == 0 or die; + if ($compressor eq "") { + # No compression - use IPC::Run3 to redirect stdout to file + run3(["$Nix::Config::binDir/nix-store", "--dump", $path], + \undef, "$tempdir/nar", \undef) or die "nix-store --dump failed: $!"; + } else { + # With compression - use IPC::Run to pipe nix-store output to compressor + my $dump_cmd = ["$Nix::Config::binDir/nix-store", "--dump", $path]; + my $compress_cmd = [$compressor]; + run($dump_cmd, '|', $compress_cmd, '>', "$tempdir/nar") or die "Pipeline failed: $?"; + } my $digest = Digest::SHA->new(256); $digest->addfile("$tempdir/nar"); my $file_hash = $digest->hexdigest; diff --git a/src/lib/Perl/Critic/Policy/Hydra/ProhibitShellInvokingSystemCalls.pm b/src/lib/Perl/Critic/Policy/Hydra/ProhibitShellInvokingSystemCalls.pm new file mode 100644 index 00000000..325866c8 --- /dev/null +++ b/src/lib/Perl/Critic/Policy/Hydra/ProhibitShellInvokingSystemCalls.pm @@ -0,0 +1,103 @@ +package Perl::Critic::Policy::Hydra::ProhibitShellInvokingSystemCalls; + +use strict; +use warnings; +use constant; + +use Perl::Critic::Utils qw{ :severities :classification :ppi }; +use base 'Perl::Critic::Policy'; + +our $VERSION = '1.000'; + +use constant DESC => q{Shell-invoking system calls are prohibited}; +use constant EXPL => q{Use list form system() or IPC::Run3 for better security. String form invokes shell and is vulnerable to injection}; + +sub supported_parameters { return () } +sub default_severity { return $SEVERITY_HIGHEST } +sub default_themes { return qw( hydra security ) } +sub applies_to { return 'PPI::Token::Word' } + +sub violates { + my ( $self, $elem, undef ) = @_; + + # Only check system() and exec() calls + return () unless $elem->content() =~ /^(system|exec)$/; + return () unless is_function_call($elem); + + # Skip method calls (->system or ->exec) + my $prev = $elem->sprevious_sibling(); + return () if $prev && $prev->isa('PPI::Token::Operator') && $prev->content() eq '->'; + + # Get first argument after function name, skipping whitespace + my $args = $elem->snext_sibling(); + return () unless $args; + $args = $args->snext_sibling() while $args && $args->isa('PPI::Token::Whitespace'); + + # For parenthesized calls, look inside + my $search_elem = $args; + if ($args && $args->isa('PPI::Structure::List')) { + $search_elem = $args->schild(0); + return () unless $search_elem; + } + + # Check if it's list form (has comma) + my $current = $search_elem; + if ($current && $current->isa('PPI::Statement')) { + # Look through statement children + for my $child ($current->schildren()) { + return () if $child->isa('PPI::Token::Operator') && $child->content() eq ','; + } + } else { + # Look through siblings for non-parenthesized calls + while ($current) { + return () if $current->isa('PPI::Token::Operator') && $current->content() eq ','; + last if $current->isa('PPI::Token::Structure') && $current->content() eq ';'; + $current = $current->snext_sibling(); + } + } + + # Check if first arg is array variable + my $first = $search_elem->isa('PPI::Statement') ? + $search_elem->schild(0) : $search_elem; + return () if $first && $first->isa('PPI::Token::Symbol') && $first->content() =~ /^[@]/; + + # Check if it's a safe single-word command + if ($first && $first->isa('PPI::Token::Quote')) { + my $content = $first->string(); + return () if $content =~ /^[a-zA-Z0-9_\-\.\/]+$/; + } + + return $self->violation( DESC, EXPL, $elem ); +} + +1; + +__END__ + +=pod + +=head1 NAME + +Perl::Critic::Policy::Hydra::ProhibitShellInvokingSystemCalls - Prohibit shell-invoking system() and exec() calls + +=head1 DESCRIPTION + +This policy prohibits the use of C and C functions when called with a single string argument, +which invokes the shell and is vulnerable to injection attacks. + +The list form (e.g., C) is allowed as it executes directly without shell interpretation. +For better error handling and output capture, consider using C. + +=head1 CONFIGURATION + +This Policy is not configurable except for the standard options. + +=head1 AUTHOR + +Hydra Development Team + +=head1 COPYRIGHT + +Copyright (c) 2025 Hydra Development Team. All rights reserved. + +=cut diff --git a/src/libhydra/db.hh b/src/libhydra/db.hh index c664a01d..acdf3b8c 100644 --- a/src/libhydra/db.hh +++ b/src/libhydra/db.hh @@ -27,19 +27,20 @@ struct Connection : pqxx::connection }; -class receiver : public pqxx::notification_receiver +class receiver { std::optional status; + pqxx::connection & conn; public: receiver(pqxx::connection_base & c, const std::string & channel) - : pqxx::notification_receiver(c, channel) { } - - void operator() (const std::string & payload, int pid) override + : conn(static_cast(c)) { - status = payload; - }; + conn.listen(channel, [this](pqxx::notification n) { + status = std::string(n.payload); + }); + } std::optional get() { auto s = status; diff --git a/src/meson.build b/src/meson.build index 52b821bc..c2ffc075 100644 --- a/src/meson.build +++ b/src/meson.build @@ -57,20 +57,12 @@ fontawesome = custom_target( command: ['unzip', '-u', '-d', '@OUTDIR@', '@INPUT@'], ) custom_target( - 'name-fontawesome-css', + 'name-fontawesome', input: fontawesome, - output: 'css', - command: ['cp', '-r', '@INPUT@/css', '@OUTPUT@'], + output: 'fontawesome', + command: ['cp', '-r', '@INPUT@' , '@OUTPUT@'], install: true, - install_dir: hydra_libexecdir_static / 'fontawesome', -) -custom_target( - 'name-fontawesome-webfonts', - input: fontawesome, - output: 'webfonts', - command: ['cp', '-r', '@INPUT@/webfonts', '@OUTPUT@'], - install: true, - install_dir: hydra_libexecdir_static / 'fontawesome', + install_dir: hydra_libexecdir_static, ) # Scripts diff --git a/src/root/all.tt b/src/root/all.tt index e877f5b5..eee4488e 100644 --- a/src/root/all.tt +++ b/src/root/all.tt @@ -11,7 +11,7 @@ titleHTML="Latest builds" _ "") %] [% PROCESS common.tt %] -

Showing builds [% (page - 1) * resultsPerPage + 1 %] - [% (page - 1) * resultsPerPage + builds.size %] out of [% total %] in order of descending finish time.

+

Showing builds [% (page - 1) * resultsPerPage + 1 %] - [% (page - 1) * resultsPerPage + builds.size %] out of [% HTML.escape(total) %] in order of descending finish time.

[% INCLUDE renderBuildList hideProjectName=project hideJobsetName=jobset hideJobName=job %] [% INCLUDE renderPager %] diff --git a/src/root/build.tt b/src/root/build.tt index 18ff6f01..8c52b489 100644 --- a/src/root/build.tt +++ b/src/root/build.tt @@ -37,7 +37,7 @@ END; seen.${step.drvpath} = 1; log = c.uri_for('/build' build.id 'nixlog' step.stepnr); %] - [% step.stepnr %] + [% HTML.escape(step.stepnr) %] [% IF step.type == 0 %] Build of [% INCLUDE renderOutputs outputs=step.buildstepoutputs %] @@ -86,7 +86,7 @@ END; [% ELSIF step.status == 11 %] Output limit exceeded [% ELSIF step.status == 12 %] - Non-determinism detected [% IF step.timesbuilt %] after [% step.timesbuilt %] times[% END %] + Non-determinism detected [% IF step.timesbuilt %] after [% HTML.escape(step.timesbuilt) %] times[% END %] [% ELSIF step.errormsg %] Failed: [% HTML.escape(step.errormsg) %] [% ELSE %] @@ -112,16 +112,16 @@ END; [% IF c.user_exists %] [% IF available %] [% IF build.keep %] - Unkeep + c.uri_for('/build' build.id 'keep' 0)) %]>Unkeep [% ELSE %] - Keep + c.uri_for('/build' build.id 'keep' 1)) %]>Keep [% END %] [% END %] [% IF build.finished %] - Restart + c.uri_for('/build' build.id 'restart')) %]>Restart [% ELSE %] - Cancel - Bump up + c.uri_for('/build' build.id 'cancel')) %]>Cancel + c.uri_for('/build' build.id 'bump')) %]>Bump up [% END %] [% END %] @@ -132,7 +132,7 @@ END; [% IF steps.size() > 0 %][% END %] - [% IF build.dependents %][% END%] + [% IF build.dependents %][% END %] [% IF drvAvailable %][% END %] [% IF localStore && available %][% END %] [% IF runcommandlogProblem || runcommandlogs.size() > 0 %][% END %] @@ -151,7 +151,7 @@ END; - + @@ -168,9 +168,9 @@ END; END; %]; [%+ IF nrFinished == nrConstituents && nrFailedConstituents == 0 %] - all [% nrConstituents %] constituent builds succeeded + all [% HTML.escape(nrConstituents) %] constituent builds succeeded [% ELSE %] - [% nrFailedConstituents %] out of [% nrConstituents %] constituent builds failed + [% HTML.escape(nrFailedConstituents) %] out of [% HTML.escape(nrConstituents) %] constituent builds failed [% IF nrFinished < nrConstituents %] ([% nrConstituents - nrFinished %] still pending) [% END %] @@ -180,25 +180,25 @@ END; - + [% IF build.releasename %] - + [% ELSE %] - + [% END %] [% IF eval %] [% END %] @@ -226,9 +226,9 @@ END; [% END %] @@ -336,12 +336,12 @@ END; [% IF eval.nixexprinput %] - + [% END %] - + @@ -361,11 +361,11 @@ END; - + - + @@ -376,14 +376,14 @@ END; + ( chartsURL) %]>history) [% END %] [% IF build.finished && build.closuresize %] + ( chartsURL) %]>history) [% END %] [% IF build.finished && build.buildproducts %] @@ -412,9 +412,9 @@ END; [% FOREACH metric IN build.buildmetrics %] - - - + + + [% END %] @@ -456,8 +456,8 @@ END; [% FOREACH input IN build.dependents %] - - + + [% END %] @@ -484,7 +484,7 @@ END; [% ELSIF runcommandlogProblem == "disabled-jobset" %] This jobset does not enable Dynamic RunCommand support. [% ELSE %] - Dynamic RunCommand is not enabled: [% runcommandlogProblem %]. + Dynamic RunCommand is not enabled: [% HTML.escape(runcommandlogProblem) %]. [% END %] [% END %] @@ -503,18 +503,18 @@ END;
-
[% runcommandlog.command | html%]
+
[% runcommandlog.command | html %]
[% IF not runcommandlog.is_running() %] [% IF runcommandlog.did_fail_with_signal() %] - Exit signal: [% runcommandlog.signal %] + Exit signal: [% runcommandlog.signal | html %] [% IF runcommandlog.core_dumped %] (Core Dumped) [% END %] [% ELSIF runcommandlog.did_fail_with_exec_error() %] - Exec error: [% runcommandlog.error_number %] + Exec error: [% runcommandlog.error_number | html %] [% ELSIF not runcommandlog.did_succeed() %] - Exit code: [% runcommandlog.exit_code %] + Exit code: [% runcommandlog.exit_code | html %] [% END %] [% END %]
@@ -532,9 +532,9 @@ END; [% IF runcommandlog.uuid != undef %] [% runLog = c.uri_for('/build', build.id, 'runcommandlog', runcommandlog.uuid) %] [% END %]
@@ -563,7 +563,7 @@ END; [% IF eval.flake %] -

If you have Nix +

If you have Nix installed, you can reproduce this build on your own machine by running the following command:

@@ -573,7 +573,7 @@ END; [% ELSE %] -

If you have Nix +

If you have Nix installed, you can reproduce this build on your own machine by downloading url) %]>a script that checks out all inputs of the build and then invokes Nix to diff --git a/src/root/channel-contents.tt b/src/root/channel-contents.tt index 083d6ae5..11d0323d 100644 --- a/src/root/channel-contents.tt +++ b/src/root/channel-contents.tt @@ -7,7 +7,7 @@ href="http://nixos.org/">Nix package manager. If you have Nix installed, you can subscribe to this channel by once executing

-$ nix-channel --add [% curUri +%]
+$ nix-channel --add [% HTML.escape(curUri) +%]
 $ nix-channel --update
 
@@ -49,9 +49,9 @@ installed, you can subscribe to this channel by once executing

[% b = pkg.build %] - - - + + + [% END %] - + [% IF !hideJobName %] [% END %] - - + + [% IF showDescription %] - + [% END %] [% END; IF linkToAll %] - + [% END; END; @@ -176,11 +176,11 @@ BLOCK renderBuildList; END; -BLOCK renderLink %][% title %][% END; +BLOCK renderLink %] uri) %]>[% HTML.escape(title) %][% END; BLOCK maybeLink; - IF uri %] uri, class => class); IF confirmmsg +%] onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>[% content %][% ELSE; content; END; + IF uri %] uri, class => class); IF confirmmsg +%] onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>[% HTML.escape(content) %][% ELSE; HTML.escape(content); END; END; @@ -192,7 +192,7 @@ BLOCK renderSelection; [% END %] @@ -200,7 +200,7 @@ BLOCK renderSelection; [% ELSE %] [% END; @@ -216,12 +216,12 @@ BLOCK editString; %] BLOCK renderFullBuildLink; - INCLUDE renderFullJobNameOfBuild build=build %] build [% build.id %][% + INCLUDE renderFullJobNameOfBuild build=build %] c.uri_for('/build' build.id)) %]>build [% HTML.escape(build.id) %][% END; BLOCK renderBuildIdLink; %] -build [% id %] + c.uri_for('/build' id)) %]>build [% HTML.escape(id) %] [% END; @@ -320,7 +320,7 @@ END; BLOCK renderShortInputValue; IF input.type == "build" || input.type == "sysbuild" %] - [% input.dependency.id %] + c.uri_for('/build' input.dependency.id)) %]>[% HTML.escape(input.dependency.id) %] [% ELSIF input.type == "string" %] "[% HTML.escape(input.value) %]" [% ELSIF input.type == "nix" || input.type == "boolean" %] @@ -338,7 +338,7 @@ BLOCK renderDiffUri; url = bi1.uri; path = url.replace(base, ''); IF url.match(base) %] - [% contents %] + m.uri.replace('_path_', path).replace('_1_', bi1.revision).replace('_2_', bi2.revision)) %]>[% HTML.escape(contents) %] [% nouri = 0; END; END; @@ -347,13 +347,13 @@ BLOCK renderDiffUri; url = res.0; branch = res.1; IF bi1.type == "hg" || bi1.type == "git" %] - c.uri_for('/api/scmdiff', { uri = url, rev1 = bi1.revision, rev2 = bi2.revision, type = bi1.type, branch = branch - })) %]">[% contents %] + })) %]>[% HTML.escape(contents) %] [% ELSE; contents; END; @@ -369,8 +369,8 @@ BLOCK renderInputs; %] [% FOREACH input IN inputs %] - - + + - + [% END %] @@ -407,33 +407,33 @@ BLOCK renderInputDiff; %] IF bi1.name == bi2.name; IF bi1.type == bi2.type; IF bi1.value != bi2.value || bi1.uri != bi2.uri %] - + [% ELSIF bi1.uri == bi2.uri && bi1.revision != bi2.revision %] [% IF bi1.type == "git" %] [% ELSE %] [% END %] [% ELSIF bi1.dependency.id != bi2.dependency.id || bi1.path != bi2.path %] [% END %] [% ELSE %] - + [% END; deletedInput = 0; END; END; IF deletedInput == 1 %] - + [% END; END; END %] @@ -443,10 +443,10 @@ BLOCK renderInputDiff; %] BLOCK renderPager %] [% END; @@ -455,13 +455,13 @@ BLOCK renderShortEvalInput; IF input.type == "svn" || input.type == "svn-checkout" || input.type == "bzr" || input.type == "bzr-checkout" %] r[% input.revision %] [% ELSIF input.type == "git" %] - [% input.revision.substr(0, 7) %] + [% input.revision.substr(0, 7) | html %] [% ELSIF input.type == "hg" %] - [% input.revision.substr(0, 12) %] + [% input.revision.substr(0, 12) | html %] [% ELSIF input.type == "build" || input.type == "sysbuild" %] - [% input.get_column('dependency') %] + c.uri_for('/build' input.get_column('dependency'))) %]>[% HTML.escape(input.get_column('dependency')) %] [% ELSE %] - [% input.revision %] + [% input.revision | html %] [% END; END; @@ -498,7 +498,7 @@ BLOCK renderEvals %] eval = e.eval; link = c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id]) %] - + [% IF !jobset && !build %] [% END %] @@ -507,7 +507,7 @@ BLOCK renderEvals %] [% IF e.changedInputs.size > 0; sep=''; FOREACH input IN e.changedInputs; - sep; %] [% input.name %] → [% INCLUDE renderShortEvalInput input=input; + sep; %] [% HTML.escape(input.name) %] → [% INCLUDE renderShortEvalInput input=input; sep=', '; END; ELSE %] @@ -518,29 +518,29 @@ BLOCK renderEvals %] [% END %] [% END; IF linkToAll %] - + [% END %]
Build ID:[% build.id %][% HTML.escape(build.id) %]
Status:
System:[% build.system %][% build.system | html %]
Release name:[% HTML.escape(build.releasename) %][% build.releasename | html %]
Nix name:[% build.nixname %][% build.nixname | html %]
Part of: - evaluation [% eval.id %] - [% IF nrEvals > 1 +%] (and [% nrEvals - 1 %] others)[% END %] + c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id])) %]>evaluation [% HTML.escape(eval.id) %] + [% IF nrEvals > 1 +%] (and c.uri_for('/build' build.id 'evals')) %]>[% nrEvals - 1 %] others)[% END %]
Logfile: [% actualLog = cachedBuildStep ? c.uri_for('/build' cachedBuild.id 'nixlog' cachedBuildStep.stepnr) : c.uri_for('/build' build.id 'log') %] - pretty - raw - tail + actualLog) %]>pretty + actualLog _ "/raw") %]>raw + actualLog _ "/tail") %]>tail
Nix expression:file [% HTML.escape(eval.nixexprpath) %] in input [% HTML.escape(eval.nixexprinput) %]file [% eval.nixexprpath | html %] in input [% eval.nixexprinput | html %]
Nix name:[% build.nixname %][% build.nixname | html %]
Short description:
System:[% build.system %][% build.system | html %]
Derivation store path:[% build.drvpath %][% build.drvpath | html %]
Output store paths:
Closure size: [% mibs(build.closuresize / (1024 * 1024)) %] MiB - (history)
Output size: [% mibs(build.size / (1024 * 1024)) %] MiB - (history)
c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]">[%HTML.escape(metric.name)%][%metric.value%][%metric.unit%] c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]">[% metric.name | html %][% HTML.escape(metric.value) %][% HTML.escape(metric.unit) %]
[% INCLUDE renderFullBuildLink build=input.build %][% input.name %][% input.build.system %][% input.name | html %][% input.build.system | html %] [% INCLUDE renderDateTime timestamp = input.build.timestamp %]
[% b.id %][% b.get_column('releasename') || b.nixname %][% b.system %] c.uri_for('/build' b.id)) %]>[% HTML.escape(b.id) %][% b.get_column('releasename') || b.nixname | html %][% b.system | html %] [% IF b.homepage %] b.homepage) %]>[% HTML.escape(b.description) %] diff --git a/src/root/common.tt b/src/root/common.tt index 86335a74..b18d33f7 100644 --- a/src/root/common.tt +++ b/src/root/common.tt @@ -55,17 +55,17 @@ BLOCK renderRelativeDate %] [% END; BLOCK renderProjectName %] -[% project %] + c.uri_for('/project' project)) %]>[% project | html %] [% END; BLOCK renderJobsetName %] -[% jobset %] + c.uri_for('/jobset' project jobset)) %]>[% jobset | html %] [% END; BLOCK renderJobName %] -[% job %] + c.uri_for('/job' project jobset job)) %]>[% job | html %] [% END; @@ -98,7 +98,7 @@ BLOCK renderDrvInfo; .substr(0, -4); # strip `.drv` IF drvname != releasename; IF step.type == 0; action = "Build"; ELSE; action = "Substitution"; END; - IF drvname; %] ([% action %] of [% drvname %])[% END; + IF drvname; %] ([% HTML.escape(action) %] of [% HTML.escape(drvname) %])[% END; END; END; @@ -140,25 +140,25 @@ BLOCK renderBuildListBody; [% IF showSchedulingInfo %] [% IF busy %]Started[% ELSE %]Queued[% END %][% build.id %] link) %]>[% HTML.escape(build.id) %] - [% IF !hideJobsetName %][%build.jobset.get_column("project")%]:[%build.jobset.get_column("name")%]:[% END %][%build.get_column("job")%] + link) %]>[% IF !hideJobsetName %][% HTML.escape(build.jobset.get_column("project")) %]:[% HTML.escape(build.jobset.get_column("name")) %]:[% END %][% HTML.escape(build.get_column("job")) %] [% IF showStepName %] [% INCLUDE renderDrvInfo step=build.buildsteps releasename=build.nixname %] [% END %] [% t = showSchedulingInfo ? build.timestamp : build.stoptime; IF t; INCLUDE renderRelativeDate timestamp=(showSchedulingInfo ? build.timestamp : build.stoptime); ELSE; "-"; END %][% !showSchedulingInfo and build.get_column('releasename') ? build.get_column('releasename') : build.nixname %][% build.system %][% !showSchedulingInfo and build.get_column('releasename') ? HTML.escape(build.get_column('releasename')) : HTML.escape(build.nixname) %][% build.system | html %][% build.description %][% HTML.escape(build.description) %]
More...
linkToAll) %]>More...
[% input.name %][% type = input.type; inputTypes.$type %][% input.name | html %][% type = input.type; HTML.escape(inputTypes.$type) %] [% IF input.type == "build" || input.type == "sysbuild" %] [% INCLUDE renderFullBuildLink build=input.dependency %] @@ -383,7 +383,7 @@ BLOCK renderInputs; %] [% END %] [% IF input.revision %][% HTML.escape(input.revision) %][% END %][% input.path %][% input.path | html %]
[% bi1.name %][% INCLUDE renderShortInputValue input=bi1 %] to [% INCLUDE renderShortInputValue input=bi2 %]
[% HTML.escape(bi1.name) %][% INCLUDE renderShortInputValue input=bi1 %] to [% INCLUDE renderShortInputValue input=bi2 %]
- [% bi1.name %][% INCLUDE renderDiffUri contents=(bi1.revision.substr(0, 12) _ ' to ' _ bi2.revision.substr(0, 12)) %] + [% HTML.escape(bi1.name) %][% INCLUDE renderDiffUri contents=(bi1.revision.substr(0, 12) _ ' to ' _ bi2.revision.substr(0, 12)) %]
- [% bi1.name %][% INCLUDE renderDiffUri contents=(bi1.revision _ ' to ' _ bi2.revision) %] + [% HTML.escape(bi1.name) %][% INCLUDE renderDiffUri contents=(bi1.revision _ ' to ' _ bi2.revision) %]
- [% bi1.name %][% INCLUDE renderShortInputValue input=bi1 %] to [% INCLUDE renderShortInputValue input=bi2 %] + [% HTML.escape(bi1.name) %][% INCLUDE renderShortInputValue input=bi1 %] to [% INCLUDE renderShortInputValue input=bi2 %]

[% INCLUDE renderInputDiff inputs1=bi1.dependency.inputs inputs2=bi2.dependency.inputs nestedDiff=1 nestLevel=nestLevel+1 %]
[% bi1.name %]Changed input type from '[% type = bi1.type; inputTypes.$type %]' to '[% type = bi2.type; inputTypes.$type %]'
[% HTML.escape(bi1.name) %]Changed input type from '[% type = bi1.type; HTML.escape(inputTypes.$type) %]' to '[% type = bi2.type; HTML.escape(inputTypes.$type) %]'
[% bi1.name %]Input not present in this build.
[% HTML.escape(bi1.name) %]Input not present in this build.
[% eval.id %] link) %]>[% HTML.escape(eval.id) %][% INCLUDE renderFullJobsetName project=eval.jobset.project.name jobset=eval.jobset.name %] - [% e.nrSucceeded %] + [% HTML.escape(e.nrSucceeded) %] [% IF e.nrFailed > 0 %] - [% e.nrFailed %] + [% HTML.escape(e.nrFailed) %] [% END %] [% IF e.nrScheduled > 0 %] - [% e.nrScheduled %] + [% HTML.escape(e.nrScheduled) %] [% END %] [% IF e.diff > 0 %] - +[% e.diff %] + +[% HTML.escape(e.diff) %] [% ELSIF e.diff < 0 && e.nrScheduled == 0 %] - [% e.diff %] + [% HTML.escape(e.diff) %] [% END %]
More...
linkToAll) %]>More...
@@ -548,19 +548,19 @@ BLOCK renderEvals %] BLOCK renderLogLinks %] -(log, raw, tail) +( url) %]>log, "$url/raw") %]>raw, "$url/tail") %]>tail) [% END; BLOCK makeLazyTab %] -
+
tabName) %] class="tab-pane">
[% END; @@ -587,7 +587,7 @@ BLOCK navItem %] [% END; @@ -657,17 +657,17 @@ BLOCK renderJobsetOverview %] [% successrate FILTER format('%d') %]% [% IF j.get_column('nrsucceeded') > 0 %] - [% j.get_column('nrsucceeded') %] + [% HTML.escape(j.get_column('nrsucceeded')) %] [% END %] [% IF j.get_column('nrfailed') > 0 %] - [% j.get_column('nrfailed') %] + [% HTML.escape(j.get_column('nrfailed')) %] [% END %] [% IF j.get_column('nrscheduled') > 0 %] - [% j.get_column('nrscheduled') %] + [% HTML.escape(j.get_column('nrscheduled')) %] [% END %] @@ -685,14 +685,22 @@ BLOCK includeFlot %] [% END; +BLOCK renderYesNo %] +[% IF value %] +Yes +[% ELSE %] +No +[% END %] +[% END; + BLOCK createChart %] -
-
+
+
diff --git a/src/root/dashboard-my-jobs-tab.tt b/src/root/dashboard-my-jobs-tab.tt index a1e82612..470c174c 100644 --- a/src/root/dashboard-my-jobs-tab.tt +++ b/src/root/dashboard-my-jobs-tab.tt @@ -9,7 +9,7 @@ [% ELSE %] -

Below are the most recent builds of the [% builds.size %] jobs of which you +

Below are the most recent builds of the [% HTML.escape(builds.size) %] jobs of which you ([% HTML.escape(user.emailaddress) %]) are a maintainer.

[% INCLUDE renderBuildList %] diff --git a/src/root/dashboard.tt b/src/root/dashboard.tt index 06b8de15..0daf3dad 100644 --- a/src/root/dashboard.tt +++ b/src/root/dashboard.tt @@ -24,7 +24,7 @@ [% INCLUDE renderFullJobName project=j.job.get_column('project') jobset=j.job.get_column('jobset') job=j.job.job %] [% FOREACH b IN j.builds %] - [% INCLUDE renderBuildStatusIcon size=16 build=b %] + c.uri_for('/build' b.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=b %] [% END %] [% END %] diff --git a/src/root/deps.tt b/src/root/deps.tt index 6daa9725..4cb49af4 100644 --- a/src/root/deps.tt +++ b/src/root/deps.tt @@ -3,20 +3,20 @@ [% BLOCK renderNode %]
  • [% IF done.${node.path} %] - [% node.name %] (repeated) + [% node.name | html %] ( "#" _ done.${node.path}) %]>repeated) [% ELSE %] [% done.${node.path} = global.nodeId; global.nodeId = global.nodeId + 1; %] [% IF node.refs.size > 0 %] [% END %] - + done.${node.path}) %]> [% IF node.buildStep %] - [% node.name %] [% + c.uri_for('/build' node.buildStep.get_column('build'))) %]>[% node.name %] [% IF buildStepLogExists(node.buildStep); INCLUDE renderLogLinks url=c.uri_for('/build' node.buildStep.get_column('build') 'nixlog' node.buildStep.stepnr); END %] [% ELSE %] - [% node.name %] (no info) + [% node.name | html %] (no info) [% END %] [% IF isRoot %] diff --git a/src/root/edit-jobset.tt b/src/root/edit-jobset.tt index 61e3636f..a3c1c9c5 100644 --- a/src/root/edit-jobset.tt +++ b/src/root/edit-jobset.tt @@ -7,17 +7,17 @@ [% USE format %] [% BLOCK renderJobsetInput %] - + id) %][% END %]> - input.name) %]/> + baseName _ "-name", name => baseName _ "-name", value => input.name) %] /> [% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes edit=1 %] - + baseName) %]> [% IF createFromEval %] [% value = (input.uri or input.value); IF input.revision; value = value _ " " _ input.revision; END; warn = input.altnr != 0; @@ -36,7 +36,7 @@ value, id => "$baseName-value", name => "$baseName-value") %]/> - + "$baseName-emailresponsible", name => "$baseName-emailresponsible") %] [% IF input.emailresponsible; 'checked="checked"'; END %]/> [% END %] @@ -149,7 +149,7 @@
    @@ -195,7 +195,7 @@ [% INCLUDE renderJobsetInputs %] - + [% INCLUDE renderJobsetInput input="" extraClass="template" id="input-template" baseName="input-template" %] diff --git a/src/root/edit-project.tt b/src/root/edit-project.tt index bb850e5c..7ee5331b 100644 --- a/src/root/edit-project.tt +++ b/src/root/edit-project.tt @@ -86,7 +86,7 @@ diff --git a/src/root/evals.tt b/src/root/evals.tt index c12079d1..b65fe15d 100644 --- a/src/root/evals.tt +++ b/src/root/evals.tt @@ -10,7 +10,7 @@ [% PROCESS common.tt %]

    Showing evaluations [% (page - 1) * resultsPerPage + 1 %] - [% -(page - 1) * resultsPerPage + evals.size %] out of [% total %].

    +(page - 1) * resultsPerPage + evals.size %] out of [% HTML.escape(total) %].

    [% INCLUDE renderEvals %] diff --git a/src/root/job-metrics-tab.tt b/src/root/job-metrics-tab.tt index 123a00f1..c7433264 100644 --- a/src/root/job-metrics-tab.tt +++ b/src/root/job-metrics-tab.tt @@ -16,7 +16,7 @@ [% FOREACH metric IN metrics %] -

    Metric: c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]>[%HTML.escape(metric.name)%]

    +

    Metric: c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]>[% HTML.escape(metric.name) %]

    [% id = metricDivId(metric.name); INCLUDE createChart dataUrl=c.uri_for('/job' project.name jobset.name job 'metric' metric.name); %] diff --git a/src/root/job.tt b/src/root/job.tt index 7e475f69..1a82a86b 100644 --- a/src/root/job.tt +++ b/src/root/job.tt @@ -10,8 +10,8 @@ [% IF !jobExists(jobset, job) %]
    This job is not a member of the latest evaluation of its jobset. This means it was +[% HTML.attributes(href => c.uri_for('/jobset' project.name jobset.name +'evals')) %]>latest evaluation of its jobset. This means it was removed or had an evaluation error.
    [% END %] @@ -46,7 +46,7 @@ removed or had an evaluation error. its success or failure is determined entirely by the result of building its constituent jobs. The table below shows the status of each constituent job for the [% - aggregates.keys.size %] most recent builds of the + HTML.escape(aggregates.keys.size) %] most recent builds of the aggregate. [% aggs = aggregates.keys.nsort.reverse %] @@ -58,7 +58,7 @@ removed or had an evaluation error. [% END %] @@ -70,7 +70,7 @@ removed or had an evaluation error. [% FOREACH agg IN aggs %] [% END %] @@ -22,9 +22,9 @@ [% FOREACH chan IN channels-%] - + [% FOREACH eval IN evalIds %] - + [% END %] [% END %] diff --git a/src/root/jobset-eval.tt b/src/root/jobset-eval.tt index 12086d85..245a2b0a 100644 --- a/src/root/jobset-eval.tt +++ b/src/root/jobset-eval.tt @@ -13,25 +13,23 @@ This jobset one month earlier [% IF project.jobsets_rs.count > 1 %] - [% FOREACH j IN project.jobsets.sort('name'); IF j.name != jobset.name %] - Jobset [% project.name %]:[% j.name %] + [% FOREACH j IN project.jobsets.sort('name'); IF j.name != jobset.name && j.enabled == 1 %] + Jobset [% project.name | html %]:[% j.name | html %] [% END; END %] [% END %]

    This evaluation was performed [% IF eval.flake %]from the flake -[%HTML.escape(eval.flake)%][%END%] on [% INCLUDE renderDateTime +[% HTML.escape(eval.flake) %][% END %] on [% INCLUDE renderDateTime timestamp=eval.timestamp %]. Fetching the dependencies took [% -eval.checkouttime %]s and evaluation took [% eval.evaltime %]s.

    +eval.checkouttime %]s and evaluation took [% HTML.escape(eval.evaltime) %]s.

    [% IF otherEval %]

    Comparisons are relative to [% INCLUDE renderFullJobsetName -project=otherEval.jobset.project.name jobset=otherEval.jobset.name %] evaluation [% otherEval.id %].

    -[% ELSE %] -
    Couldn't find an evaluation to compare to.
    +project=otherEval.jobset.project.name jobset=otherEval.jobset.name %] evaluation c.uri_for(c.controller('JobsetEval').action_for('view'), +[otherEval.id])) %]>[% HTML.escape(otherEval.id) %].

    [% END %] @@ -47,46 +45,46 @@ c.uri_for(c.controller('JobsetEval').action_for('view'), [% END %] [% IF aborted.size > 0 %] - + [% END %] [% IF nowFail.size > 0 %] - + [% END %] [% IF nowSucceed.size > 0 %] - + [% END %] [% IF new.size > 0 %] - + [% END %] [% IF removed.size > 0 %] - + [% END %] [% IF stillFail.size > 0 %] - + [% END %] [% IF stillSucceed.size > 0 %] - + [% END %] [% IF unfinished.size > 0 %] - + [% END %] @@ -101,7 +99,7 @@ c.uri_for(c.controller('JobsetEval').action_for('view'), [% INCLUDE renderBuildListBody builds=builds.slice(0, (size > max ? max : size) - 1) hideProjectName=1 hideJobsetName=1 busy=0 %] [% IF size > max; params = c.req.params; params.full = 1 %] - + [% END %] [% INCLUDE renderBuildListFooter %] [% END %] @@ -134,11 +132,11 @@ c.uri_for(c.controller('JobsetEval').action_for('view'), [% FOREACH j IN removed.slice(0,(size > max ? max : size) - 1) %] - + [% END %] [% IF size > max; params = c.req.params; params.full = 1 %] - + [% END %]
    [% agg_ = aggregates.$agg %]
    [% r = aggregates.$agg.constituents.$j; IF r.id %] - + c.uri_for('/build' r.id)) %]> [% INCLUDE renderBuildStatusIcon size=16 build=r %] [% END %] @@ -89,8 +89,8 @@ removed or had an evaluation error. diff --git a/src/root/jobset-channels-tab.tt b/src/root/jobset-channels-tab.tt index 692f2682..5ea8ce1b 100644 --- a/src/root/jobset-channels-tab.tt +++ b/src/root/jobset-channels-tab.tt @@ -14,7 +14,7 @@ [% FOREACH eval IN evalIds %]
    [% chan %] c.uri_for('/channel/custom' project.name jobset.name chan)) %]>[% HTML.escape(chan) %][% r = evals.$eval.builds.$chan; IF r.id %][% INCLUDE renderBuildStatusIcon size=16 build=r %][% END %][% r = evals.$eval.builds.$chan; IF r.id %] c.uri_for('/build' r.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=r %][% END %]
    ([% size - max %] more builds omitted)
    c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id], params) _ tabname) %]>([% size - max %] more builds omitted)
    [% INCLUDE renderJobName project=project.name jobset=jobset.name job=j.job %][% j.system %][% j.system | html %]
    ([% size - max %] more jobs omitted)
    ([% size - max %] more jobs omitted)
    diff --git a/src/root/jobset-jobs-tab.tt b/src/root/jobset-jobs-tab.tt index 707d329e..ddb63caa 100644 --- a/src/root/jobset-jobs-tab.tt +++ b/src/root/jobset-jobs-tab.tt @@ -41,7 +41,7 @@ [% ELSE %] [% IF nrJobs > jobs.size %] -
    Showing the first [% jobs.size %] jobs. Show all [% nrJobs %] jobs...
    +
    Showing the first [% HTML.escape(jobs.size) %] jobs. Show all [% HTML.escape(nrJobs) %] jobs...
    [% END %] [% evalIds = evals.keys.nsort.reverse %] @@ -52,7 +52,7 @@ [% FOREACH eval IN evalIds %] [% END %] @@ -62,7 +62,7 @@ [% INCLUDE renderJobName project=project.name jobset=jobset.name job=j %] [% FOREACH eval IN evalIds %] - [% r = evals.$eval.builds.$j; IF r.id %][% INCLUDE renderBuildStatusIcon size=16 build=r %][% END %] + [% r = evals.$eval.builds.$j; IF r.id %] c.uri_for('/build' r.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=r %][% END %] [% END %] [% END %] diff --git a/src/root/jobset.tt b/src/root/jobset.tt index 3e594756..c0b6e4cd 100644 --- a/src/root/jobset.tt +++ b/src/root/jobset.tt @@ -6,14 +6,14 @@ [% BLOCK renderJobsetInput %] - + id) %][% END %]> [% HTML.escape(input.name) %] [% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes %] - + baseName) %]> [% FOREACH alt IN input.search_related('jobsetinputalts', {}, { order_by => 'altnr' }) %] [% IF input.type == "string" %] @@ -153,11 +153,11 @@ [% END %] Check interval: - [% jobset.checkinterval || "disabled" %] + [% HTML.escape(jobset.checkinterval) || "disabled" %] Scheduling shares: - [% jobset.schedulingshares %] [% IF totalShares %] ([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% totalShares %] shares)[% END %] + [% HTML.escape(jobset.schedulingshares) %] [% IF totalShares %] ([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% HTML.escape(totalShares) %] shares)[% END %] Enable Dynamic RunCommand Hooks: @@ -175,7 +175,7 @@ [% END %] Number of evaluations to keep: - [% jobset.keepnr %] + [% HTML.escape(jobset.keepnr) %] @@ -188,7 +188,7 @@ diff --git a/src/root/layout.tt b/src/root/layout.tt index b520b455..37f4cb7f 100644 --- a/src/root/layout.tt +++ b/src/root/layout.tt @@ -24,7 +24,7 @@