├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config-example.toml ├── docs ├── Makefile ├── mkdocs.yml ├── requirements.txt ├── runtime.txt ├── src │ ├── changelog.md │ ├── docs │ │ ├── config-comments.md │ │ ├── config.md │ │ ├── env.md │ │ └── index.md │ ├── features │ │ ├── health-endpoint.md │ │ ├── index.md │ │ ├── live-reload.md │ │ ├── providers.md │ │ ├── rate-limits.md │ │ └── status-hooks.md │ ├── index.md │ ├── install.md │ ├── providers │ │ ├── github.md │ │ ├── gitlab.md │ │ ├── index.md │ │ └── standalone.md │ ├── tutorial │ │ ├── auto-deploy.md │ │ ├── failure-email.md │ │ └── index.md │ └── why.md └── theme │ ├── css │ ├── snowflake.css │ └── theme.css │ ├── js │ └── redirect.js │ ├── main.html │ ├── page.html │ └── redirect.html ├── netlify.toml ├── rustfmt.toml ├── src ├── app.rs ├── bin │ └── fisher.rs ├── common │ ├── config.rs │ ├── errors.rs │ ├── mod.rs │ ├── prelude.rs │ ├── serial.rs │ ├── state.rs │ ├── structs.rs │ └── traits.rs ├── lib.rs ├── processor │ ├── api.rs │ ├── mod.rs │ ├── scheduled_job.rs │ ├── scheduler.rs │ ├── test_utils.rs │ ├── thread.rs │ └── types.rs ├── providers │ ├── github.rs │ ├── gitlab.rs │ ├── mod.rs │ ├── standalone.rs │ ├── status.rs │ └── testing.rs ├── requests.rs ├── scripts │ ├── collector.rs │ ├── jobs.rs │ ├── mod.rs │ ├── repository.rs │ ├── script.rs │ └── test_utils.rs ├── utils │ ├── hex.rs │ ├── mod.rs │ ├── net.rs │ ├── parse_env.rs │ ├── parse_time.rs │ └── testing.rs └── web │ ├── api.rs │ ├── app.rs │ ├── http.rs │ ├── mod.rs │ ├── proxies.rs │ ├── rate_limits.rs │ ├── requests.rs │ └── responses.rs ├── tests ├── binary_tests │ ├── basic_functionality.rs │ └── mod.rs ├── common │ ├── command.rs │ ├── config.rs │ ├── env.rs │ ├── macros.rs │ ├── mod.rs │ └── prelude.rs └── integration.rs └── tools ├── build-release-packages.sh └── release.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /docs/build 3 | /docs/env 4 | 5 | /target 6 | /fisher_common/target 7 | /fisher_processor/target 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - 1.31.1 5 | - beta 6 | - nightly 7 | 8 | matrix: 9 | fast_finish: true 10 | allow_failures: 11 | - rust: nightly 12 | 13 | cache: cargo 14 | 15 | script: 16 | - cargo build --release 17 | - cargo test --all --release 18 | - cargo test --all --release -- --ignored 19 | 20 | notifications: 21 | email: false 22 | irc: 23 | channels: 24 | - "chat.freenode.net#pietroalbini" 25 | template: 26 | - "Build %{result} for %{repository_slug} on branch %{branch} (%{commit})." 27 | - "More details: %{build_url}" 28 | use_notice: true 29 | skip_join: true 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Fisher changelog 2 | 3 | This page contains the full list of the changes in each Fisher release. 4 | Internal changes without any visible effect aren't documented, but you can find 5 | everything a user can notice. 6 | 7 | ## Fisher 1.0.x 8 | 9 | ### Fisher 1.0.0 10 | 11 | *Released on July 22nd, 2019.* 12 | 13 | * **New features:** 14 | 15 | * [Configuration files](docs/config.md) are now supported 16 | * The [Standalone provider](providers/standalone.md) now supports 17 | whitelisting IP addresses 18 | * The [GitHub provider](providers/github.md) now provides more environment 19 | variables for push events 20 | * [Rate limits](features/rate-limits.md) are now supported for invalid 21 | requests 22 | 23 | * **Changes and improvements:** 24 | 25 | * **BREAKING:** the `SHELL` environment variable is not present anymore 26 | * **BREAKING:** most of the CLI arguments are now removed 27 | * **BREAKING:** the `FISHER_STATUS_HOOK_NAME` env var is now 28 | called `FISHER_STATUS_SCRIPT_NAME` 29 | * **BREAKING:** the `job_completed` status hooks event is now called 30 | `job-completed` 31 | * **BREAKING:** the `job_failed` status hooks event is now called 32 | `job-failed` 33 | * **BREAKING:** data files are now located in a different directory, use 34 | the related environment variable to get them 35 | * The `USER` env var is now guaranteed to be correct 36 | 37 | ### Fisher 1.0.0-beta.7 38 | 39 | *Released on August 16th, 2017.* 40 | 41 | * Fix hooks with multiple providers not validated properly 42 | 43 | ### Fisher 1.0.0-beta.6 44 | 45 | *Released on May 10th, 2017.* 46 | 47 | * Update dependencies versions 48 | 49 | ### Fisher 1.0.0-beta.5 50 | 51 | *Released on April 23th, 2017.* 52 | 53 | * **New features:** 54 | 55 | * Add support for reloading the hooks on the fly with the SIGUSR1 signal 56 | 57 | ### Fisher 1.0.0-beta.4 58 | 59 | *Released on April 10th, 2017.* 60 | 61 | * **New features:** 62 | 63 | * Add the `max_threads` field to `GET /health` 64 | * Add the `label`, `milestone`, `organization`, `project_card`, 65 | `project_column`, `project`, `pull_request_review`, `team` GitHub events 66 | * Add the ability to provide extra environment variables with the `-e` flag 67 | * Add the ability to load hooks in subdirectories with the `-r` flag 68 | * Add the ability to set priorities for hooks 69 | * Add the ability to disable parallel execution for certain hooks 70 | 71 | * **Changes and improvements:** 72 | 73 | * **BREAKING:** `$FISHER_REQUEST_BODY` is not available anymore on status 74 | hooks 75 | * **BREAKING:** Rename `queue_size` to `queued_jobs` in `GET /health` for 76 | consistency 77 | * **BREAKING:** Rename `active_jobs` to `busy_threads` in `GET /health` for 78 | consistency 79 | * **BREAKING:** The extension of the files is needed when calling the hooks 80 | (for example you need to call `/hook/example.sh` instead of `/hook/example`) 81 | * Speed up status hooks processing 82 | * Replace the old processor with a faster one 83 | * Improve testing coverage of the project 84 | 85 | * **Bug fixes:** 86 | 87 | * Avoid killing the running jobs when a signal is received 88 | * Fix GitHub pings not being delivered if a events whitelist was present 89 | * Fix web server not replying to incoming requests while shutting down 90 | 91 | ### Fisher 1.0.0-beta.3 92 | 93 | *Released on January 5th, 2017.* 94 | 95 | * Add the `$FISHER_REQUEST_IP` environment variable 96 | * Add support for status hooks 97 | * Refactored a bunch of the code 98 | * Improve testing coverage of the project 99 | 100 | ### Fisher 1.0.0-beta.2 101 | 102 | *Released on September 24th, 2016.* 103 | 104 | * Add support for working behind proxies 105 | * Add support for receiving hooks from **GitLab** 106 | * Show the current Fisher configuration at startup 107 | * Improve unit testing coverage of the project 108 | 109 | ### Fisher 1.0.0-beta.1 110 | 111 | *Released on September 6th, 2016.* 112 | 113 | * Initial public release of Fisher 114 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Pietro Albini "] 3 | description = "Webhooks catcher written in Rust" 4 | license = "GPL-3.0" 5 | name = "fisher" 6 | readme = "README.md" 7 | repository = "https://github.com/pietroalbini/fisher" 8 | version = "1.0.0" 9 | 10 | [[bin]] 11 | doc = false 12 | name = "fisher" 13 | 14 | [dependencies] 15 | ansi_term = "0.11.0" 16 | error-chain = "0.12.0" 17 | lazy_static = "1.2.0" 18 | nix = "0.12.0" 19 | rand = "0.6.3" 20 | regex = "1.1.0" 21 | serde = "^1.0" 22 | serde_derive = "^1.0" 23 | serde_json = "^1.0" 24 | tempdir = "^0.3" 25 | tiny_http = "0.6.2" 26 | toml = "^0.4" 27 | url = "^1.2" 28 | users = "0.8.1" 29 | hmac = "0.7.1" 30 | sha-1 = "0.8.1" 31 | 32 | [dev-dependencies] 33 | hyper = "^0.10" 34 | reqwest = "^0.8" 35 | 36 | [profile.release] 37 | lto = true 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Fisher 2 | 3 | [![Build Status](https://travis-ci.org/pietroalbini/fisher.svg?branch=master)](https://travis-ci.org/pietroalbini/fisher) 4 | 5 | Fisher is a fast, simple webhooks catcher written in Rust. It's easy to set 6 | up, it features builtin security, and you can monitor its status with a simple 7 | HTTP request. Being a single binary, you can deploy it easily wherever you 8 | want. 9 | 10 | Fisher is released under the GNU GPL v3+ license, see LICENSE for more details. 11 | In order to build it, you need to have Rust 1.31.1 or greater installed. 12 | 13 | ### Usage 14 | 15 | Fist of all, you need to [download Fisher][download] and place the executable 16 | on your server (possibly in a directory in `$PATH`). 17 | 18 | Fisher doesn't have a configuration file, and you only need to place your hooks 19 | in a directory (make sure they're executable!): 20 | 21 | ``` 22 | $ mkdir /srv/hooks 23 | $ cat > /srv/hooks/example-hook.sh << EOF 24 | #!/bin/bash 25 | 26 | echo "I'm an hook!" 27 | EOF 28 | $ chmod +x /srv/hooks/example-hook.sh 29 | ``` 30 | 31 | Then, you start fisher and you're good to go! 32 | 33 | ``` 34 | $ fisher /srv/hooks 35 | Total hooks collected: 1 36 | Web API listening on 127.0.0.1:8000 37 | ``` 38 | 39 | You can now call your hook: the request will be queued by Fisher and the script 40 | will be executed. 41 | 42 | ``` 43 | $ curl http://127.0.0.1:8000/hook/example-hook 44 | ``` 45 | 46 | ### Building Fisher 47 | 48 | In order to build fisher, you need a stable Rust compiler and cargo installed. 49 | 50 | ``` 51 | $ git clone https://github.com/pietroalbini/fisher 52 | $ cd fisher 53 | $ cargo build --release 54 | ``` 55 | 56 | The compiled binary will be available in `target/release/fisher`. 57 | 58 | [download]: https://files.pietroalbini.io/releases/fisher 59 | -------------------------------------------------------------------------------- /config-example.toml: -------------------------------------------------------------------------------- 1 | [http] 2 | 3 | # The number of proxies Fisher sits behind. This is used to correctly parse the 4 | # X-Forwarded-For HTTP header in order to retrieve the correct origin IP. If 5 | # this value is zero, the header is ignored, otherwise it must be present with 6 | # the correct number of entries to avoid requests being rejected. 7 | behind-proxies = 0 8 | 9 | # The network address Fisher will listen on. By default, only requests coming 10 | # from the local machine are accepted (thus requiring a reverse proxy in front 11 | # of the instance). If you want to expose Fisher directly on the Internet you 12 | # should change the IP address to `0.0.0.0`. 13 | bind = "127.0.0.1:8000" 14 | 15 | # If this is set to false, the `/health` HTTP endpoint (used to monitor the 16 | # instance) is disabled. Disable this if you don't need monitoring and you 17 | # don't want the data to be publicly accessible. 18 | health-endpoint = true 19 | 20 | # Rate limit for failed requests (allowed requests / time period). The rate 21 | # limit only applies to webhooks that failed validation, so it doesn't impact 22 | # legit requests (while keeping brute force attempts away). 23 | rate-limit = "10/1m" 24 | 25 | 26 | [scripts] 27 | 28 | # The directory containing all the scripts Fisher will use. Scripts needs to be 29 | # executable in order to be called. 30 | path = "/srv/fisher-scripts" 31 | 32 | # If this is set to true, scripts in subdirectories of `scripts.path` will also 33 | # be loaded, including from symlinks (be sure to check permissions before 34 | # changing this option). 35 | recursive = false 36 | 37 | 38 | [jobs] 39 | 40 | # Maximum number of parallel jobs to run. 41 | threads = 1 42 | 43 | 44 | # Extra environment variables provided to the scripts Fisher starts. Since the 45 | # outside environment is filtered, this is the place to add every variable you 46 | # want to have available. 47 | [env] 48 | #TEST_VAR = "content" 49 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build update-snowflake 2 | 3 | SNOWFLAKE_COMPONENTS = typography navbar sidebar inline-list footer button vertical-fill 4 | 5 | build: env/.present 6 | env/bin/mkdocs build --clean 7 | 8 | update-snowflake: env/.present 9 | env/bin/snowflake-css -m -c "REPLACE_COLOR_HERE" -- $(SNOWFLAKE_COMPONENTS) > theme/css/snowflake.css 10 | 11 | env/.present: requirements.txt 12 | @rm -rf env 13 | virtualenv -p python3 env 14 | env/bin/pip install -r requirements.txt 15 | @touch env/.present 16 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | site_name: Fisher documentation 4 | copyright: Created by Pietro Albini 5 | 6 | nav: 7 | - "Home page": "index.md" 8 | - "Introduction": 9 | - "Why you should use Fisher": "why.md" 10 | - "Installing Fisher": "install.md" 11 | - "Tutorials": 12 | - "Automatic deploy from GitHub": "tutorial/auto-deploy.md" 13 | - "Send emails when scripts fails": "tutorial/failure-email.md" 14 | - "Features": 15 | - "Live reloading": "features/live-reload.md" 16 | - "Monitoring with status hooks": "features/status-hooks.md" 17 | - "Monitoring with the health endpoint": "features/health-endpoint.md" 18 | - "Rate limits": "features/rate-limits.md" 19 | - "Third-party providers": "features/providers.md" 20 | - "Documentation": 21 | - "The configuration file": "docs/config.md" 22 | - "Configuration comments": "docs/config-comments.md" 23 | - "Scripts execution context": "docs/env.md" 24 | - "Supported providers": 25 | - "Standalone provider": "providers/standalone.md" 26 | - "GitHub provider": "providers/github.md" 27 | - "GitLab provider": "providers/gitlab.md" 28 | - "Other information": 29 | - "Changelog": "changelog.md" 30 | 31 | plugins: [] 32 | markdown_extensions: 33 | - meta 34 | 35 | theme: 36 | name: null 37 | custom_dir: theme 38 | 39 | extra: 40 | color: "#00695c" 41 | navbar: 42 | "Source code": https://github.com/pietroalbini/fisher 43 | "Report an issue": https://github.com/pietroalbini/fisher/issues/new 44 | 45 | docs_dir: src 46 | site_dir: build 47 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.0.4 2 | snowflake-css==1.3.2 3 | -------------------------------------------------------------------------------- /docs/runtime.txt: -------------------------------------------------------------------------------- 1 | 3.6 2 | -------------------------------------------------------------------------------- /docs/src/changelog.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/src/docs/config-comments.md: -------------------------------------------------------------------------------- 1 | # Configuration comments 2 | 3 | *Configuration comments* are special comments, located at the top of the 4 | scripts, used by Fisher to determine the configuration of the script itself. 5 | 6 | All the configuration comments are optional, but if you want to take advantage 7 | of some Fisher features you have to use them. 8 | 9 | ## Syntax 10 | 11 | Configuration comments must be located at the top of the file, before any 12 | empty line. This means they can be located after the `#!shebang` or other 13 | comments at the top of the file. 14 | 15 | They must start with `##`, and are composed of a key and a JSON value. For 16 | example, this is a valid configuration comment: 17 | 18 | ``` 19 | ## Fisher: {"parallel": false} 20 | ``` 21 | 22 | ## The `Fisher` configuration comment 23 | 24 | The `Fisher` configuration comment allows you to configure the behavior of 25 | Fisher for this specific script. Its value must be valid JSON. 26 | 27 | ``` 28 | ## Fisher: {"parallel": false, "priority": 10} 29 | ``` 30 | 31 | ### `priority` 32 | 33 | The priority of the script. Scripts with higher priority will always be 34 | executed before scripts with lower priority, even if the lower priority ones 35 | are first in the queue. 36 | 37 | Status hooks have a default priority of `1000`: if you choose a priority higher 38 | than that, be advised that if you have a lot of scripts in the queue the 39 | execution of status hooks might be delayed, or they might not be executed at 40 | all. 41 | 42 | It must be a signed integer, and its default value is `0`. 43 | 44 | ### `parallel` 45 | 46 | This configuration key tells Fisher if the script can be executed in parallel. 47 | 48 | Fisher can support executing multiple scripts at the same time to work through 49 | the queue faster, but not every script might support it. For example, if a 50 | script runs a database migration you don't want to execute two of them at the 51 | same time. 52 | 53 | With this configuration key you can tell the scheduler this script doesn't 54 | support being executed in parallel and the scheduler will avoid doing that, 55 | while continuing to executing the other ones in parallel. 56 | 57 | It must be a boolean, and its default value is `true`. 58 | -------------------------------------------------------------------------------- /docs/src/docs/config.md: -------------------------------------------------------------------------------- 1 | # The configuration file 2 | 3 | Fisher uses a configuration file to store its settings. The configuration file 4 | uses the TOML syntax (a serialization format similar to INI files), and has 5 | sensible defaults for every option (so you can omit the ones you don't want to 6 | change). 7 | 8 | This page contains a description for each available setting. You can also find 9 | a commented configuration file distributed with all the official packages, and 10 | in [the source code repository][src]. 11 | 12 | [src]: https://github.com/pietroalbini/fisher/blob/master/config-example.toml 13 | 14 | ----- 15 | 16 | ## `[http]` section 17 | 18 | The `[http]` section contains the configuration for the built-in HTTP server 19 | and API. 20 | 21 | ### `behind-proxies` 22 | 23 | The number of proxies Fisher sits behind. This is used to correctly parse the 24 | X-Forwarded-For HTTP header in order to retrieve the correct origin IP. If this 25 | value is zero, the header is ignored, otherwise it must be present with the 26 | correct number of entries to avoid requests being rejected. 27 | 28 | **Type**: integer - **Default**: `0` 29 | 30 | ### `bind` 31 | 32 | The network address Fisher will listen on. By default, only requests coming 33 | from the local machine are accepted (thus requiring a reverse proxy in front of 34 | the instance). If you want to expose Fisher directly on the Internet you should 35 | change the IP address to `0.0.0.0`. 36 | 37 | **Type**: string - **Default**: `127.0.0.1:8000` 38 | 39 | ### `health-endpoint` 40 | 41 | If this is set to false, the `/health` HTTP endpoint (used to monitor the 42 | instance) is disabled. Disable this if you don't need monitoring and you don't 43 | want the data to be publicly accessible. 44 | 45 | **Type**: boolean - **Default**: `true` 46 | 47 | ### `rate-limit` 48 | 49 | Rate limit for failed requests (allowed requests / time period). The rate limit 50 | only applies to webhooks that failed validation, so it doesn't impact legit 51 | requests (while keeping brute force attempts away). [Check out the rate limits 52 | documentation](../features/rate-limits.md). 53 | 54 | **Type**: string - **Default**: `10/1m` 55 | 56 | ----- 57 | 58 | ## `[scripts]` section 59 | 60 | The `[scripts]` section configures how Fisher looks for scripts in the 61 | filesystem. 62 | 63 | ### `path` 64 | 65 | The directory containing all the scripts Fisher will use. Scripts needs to be 66 | executable in order to be called. 67 | 68 | **Type**: string - **Default**: `/srv/fisher-scripts` 69 | 70 | ### `recursive` 71 | 72 | If this is set to true, scripts in subdirectories of `scripts.path` will also 73 | be loaded, including from symlinks (be sure to check permissions before 74 | changing this option). 75 | 76 | **Type**: boolean - **Default**: `false` 77 | 78 | ----- 79 | 80 | ## `[jobs]` section 81 | 82 | The `[jobs]` section configures how Fisher runs jobs (for example incoming 83 | hooks). 84 | 85 | ### `threads` 86 | 87 | Maximum number of parallel jobs you want to run. 88 | 89 | **Type**: integer - **Default**: `1` 90 | 91 | ----- 92 | 93 | ## `[env]` section 94 | 95 | Extra environment variables provided to the scripts Fisher starts. Since the 96 | outside environment is filtered, this is the place to add every variable you 97 | want to have available. You can add environment variables by adding extra 98 | key-value pairs under this section, for example: 99 | 100 | ```toml 101 | [env] 102 | VAR_1 = "value" 103 | VAR_2 = "1" 104 | ``` 105 | -------------------------------------------------------------------------------- /docs/src/docs/env.md: -------------------------------------------------------------------------------- 1 | # Scripts execution context 2 | 3 | Fisher executes all the scripts in a clean environment, to remove every 4 | chance of misbehaving on different machines. Here is described everything 5 | guaranteed about the environment. 6 | 7 | ## Working directory 8 | 9 | Scripts are executed in a temporary directory created by Fisher: this allows 10 | you to download and build things without worrying about cleaning up after the 11 | execution of your script. 12 | 13 | This directory is usually located in `/tmp`, and it has a random name. It's 14 | also set as the current working directory when the script is started, and as 15 | the `$HOME`. Being the home directory means most of the dotfiles and caches 16 | created during the execution are cleared out after the build. 17 | 18 | ## Environment variables 19 | 20 | Fisher provides only a subset of environment variables to the processes. 21 | 22 | ### System environment variables 23 | 24 | Most of the environment variables provided by the system are removed by Fisher. 25 | A few of them are not, though: you can change them being assured all the 26 | changes will be available to the scripts. 27 | 28 | - `$LC_ALL` and `$LANG`: the system language 29 | - `$PATH`: the system path used to search binaries 30 | 31 | Also, those system environment variables are overridden by Fisher: 32 | 33 | - `$HOME`: this is set to the build directory 34 | - `$USER`: this is set to the current user name 35 | 36 | ### Fisher environment variables 37 | 38 | Fisher adds its own environment variables to the mix. These variables allows 39 | you to get more information about the incoming request: 40 | 41 | - `$FISHER_REQUEST_IP`: the IP address of the client that sent the webhook 42 | - `$FISHER_REQUEST_BODY`: the path to the file containing the raw request body 43 | 44 | Other than these variable, each provider can add its own environment variables. 45 | Check out the documentation for the providers you're using to learn more about 46 | that. 47 | -------------------------------------------------------------------------------- /docs/src/docs/index.md: -------------------------------------------------------------------------------- 1 | redirect: . 2 | -------------------------------------------------------------------------------- /docs/src/features/health-endpoint.md: -------------------------------------------------------------------------------- 1 | # Monitoring with the `/health` endpoint 2 | 3 | While [status hooks](status-hooks.md) allow you to record what happened after 4 | the execution of your scripts, they can't be used to monitor what's happening 5 | *right now* on your Fisher instance. If you need to know that though (for 6 | example to have graphs on your favourite monitoring solution) the `/health` 7 | HTTP endpoint provides an easy way to retrieve that data. 8 | 9 | ## API reference 10 | 11 | The endpoint can be accessed with a GET HTTP request to the `/health` URL. The 12 | endpoint returns a JSON response, with the following schema (keep in mind new 13 | fields can be added in future Fisher releases): 14 | 15 | ``` 16 | { 17 | "result": { 18 | "busy_threads": 2, 19 | "max_threads": 2, 20 | "queued_jobs": 42 21 | }, 22 | "status": "ok" 23 | } 24 | ``` 25 | 26 | The `status` field returns if the request was successful: it can be `ok` if 27 | there is some data available or `forbidden` if the endpoint is disabled in the 28 | configuration. The returned data is contained in the `result` field, and 29 | contains: 30 | 31 | * `busy_threads`: the number of threads currently processing webhooks 32 | * `max_threads`: the number of threads allocated to processing webhooks 33 | * `queued_jobs`: the number of jobs waiting to be processed in the queue 34 | 35 | ## Configuration 36 | 37 | If you don't plan to use the endpoint on your instance, you can disable it in 38 | the [configuration file](../docs/config.md). This won't affect the performance 39 | at all, but avoids exposing the information to the outside world. When 40 | disabled, the endpoint returns a 403 HTTP status code when called, and contains 41 | `forbidden` in the `status` field of the returned JSON. 42 | 43 | To disable the endpoint, set the `http.health-endpoint` configuration to `false`: 44 | 45 | ``` 46 | [http] 47 | health-endpoint = false 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/src/features/index.md: -------------------------------------------------------------------------------- 1 | redirect: . 2 | -------------------------------------------------------------------------------- /docs/src/features/live-reload.md: -------------------------------------------------------------------------------- 1 | # Live reloading 2 | 3 | One of the design decisions of Fisher is to rely on an in-memory queue to store 4 | all the jobs waiting to be processed. While this allows Fisher to be faster 5 | (and avoids the need to configure an external database), having no persistency 6 | means if the Fisher process is restarted all the jobs in the queue will be 7 | lost. 8 | 9 | Fisher needs to be restarted from time to time though, mainly if you need to 10 | add new scripts or the configuration has to be changed. In order to avoid 11 | losing the queue in those cases, Fisher has support for reloading the scripts 12 | list and its configuration at runtime, without restarting the process. 13 | 14 | ## Reloading with signals 15 | 16 | In order to reload Fisher, you need to send a `SIGUSR1` to the main Fisher 17 | process: if you don't use any process manager on your machine, a simple 18 | `killall` should do the trick: 19 | 20 | ``` 21 | $ killall -USR1 fisher 22 | ``` 23 | 24 | This command will reload **all** the Fisher instances the current user has the 25 | rights to send signals to: if you have multiple instances running it might be 26 | best to get the right PID and send the signal only to that one (with the `kill` 27 | command). 28 | 29 | ## What happens when you reload a Fisher instance 30 | 31 | When you tell a Fisher instance to reload, multiple things happens to ensure 32 | everything is reloaded properly. Here is a list of all the steps. Please note 33 | there are no guarantees this list will be accurate in past or future releases. 34 | 35 | * First of all, the Fisher instance is locked, to prevent errors with new jobs 36 | coming in while updating the configuration: this means no new queued jobs 37 | will be processed, and the webhook endpoint will reply with *503 Unavailable*. 38 | 39 | * Then, if any setting in the `[http]` configuration section is changed, the 40 | entire internal HTTP server is restarted. 41 | 42 | * Then, if any of the other configuration entries is changed, their value is 43 | updated. 44 | 45 | * Then, all the scripts will be reloaded from disk. 46 | 47 | * Finally, the Fisher instance is unlocked, even if the reload fails. 48 | -------------------------------------------------------------------------------- /docs/src/features/providers.md: -------------------------------------------------------------------------------- 1 | # Introduction to providers 2 | 3 | If you want to integrate your scripts with third-party websites and services, 4 | providers are the way to go. They allow you to filter only the requests coming 5 | from them, and they also give your scripts more information specific to that 6 | provider. 7 | 8 | Fisher has currently native support for these providers: 9 | 10 | * [Standalone](../providers/standalone.md) - for scripts not tied to a specific 11 | third-party website 12 | * [GitHub](../providers/github.md) - for webhooks coming from 13 | [GitHub.com](https://github.com) 14 | * [GitLab](../providers/gitlab.md) - for webhooks coming from a 15 | [GitLab](https://about.gitlab.com) instance 16 | 17 | ## Applying a provider to a script 18 | 19 | In order to apply a provider to a script, you need to add its [configuration 20 | comment](../docs/config-comments.md) to the top of the script. After Fisher is 21 | started/reloaded, it will start filtering requests according to that provider. 22 | You can also add multiple providers to a single script, and they will be 23 | validated according to the ordering they're wrote in the script. 24 | -------------------------------------------------------------------------------- /docs/src/features/rate-limits.md: -------------------------------------------------------------------------------- 1 | # Rate limits 2 | 3 | The Internet is a nasty place, with a bunch of people trying to break into 4 | others' stuff: some of them constantly try to log into things by brute-forcing 5 | passwords and access keys, and that could be a problem if you protect your 6 | hooks with secrets (for example with the [Standalone 7 | provider](../providers/standalone.md)). 8 | 9 | To prevent attackers guessing the right secret key by trying all the possible 10 | ones, Fisher supports rate limiting requests built-in. Rate limits are enabled 11 | by default, but they **only affect invalid requests**: you can rest assured all 12 | the legit webhooks will be processed, while the bad ones are automatically 13 | limited. 14 | 15 | ## Customizing rate limits 16 | 17 | By default, Fisher accepts a maximum of 10 invalid requests each minute. While 18 | the default limit should be enough even when you're testing things, you might 19 | need to tweak that. 20 | 21 | You can change the default limit with the `http.rate-limit` key in the 22 | configuration file: 23 | 24 | ```toml 25 | [http] 26 | rate-limit = "10/1m" 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/src/features/status-hooks.md: -------------------------------------------------------------------------------- 1 | # Monitoring with status hooks 2 | 3 | Fisher doesn't have any monitoring or reporting solution built-in, for a simple 4 | reason: there are countless ways you could do that, and integrating all of them 5 | would be a daunting task. Instead, Fisher provides **status hooks**, a way to 6 | build the integration yourself in a simple way. 7 | 8 | [Check out the tutorial for an hands-on introduction.][tutorial] 9 | 10 | [tutorial]: ../tutorial/failure-email.md 11 | 12 | ## Status hooks execution 13 | 14 | Status hooks are executed when an event happens inside of Fisher, allowing you 15 | to react to it. The following events are supported: 16 | 17 | * `job-completed`: a job completed without any error 18 | * `job-failed`: a job failed to execute, probably due to an error 19 | 20 | Status hooks are executed in the scheduler along with the normal jobs, but with 21 | a priority of `1000`. This means they will be executed before any other job, 22 | but you can override this behavior by giving the most important scripts an 23 | higher priority. 24 | 25 | ## Creating status hooks 26 | 27 | To create a status hook, you just need to create a script that uses the 28 | `Status` provider: 29 | 30 | ```plain 31 | ## Fisher-Status: {"events": ["job-failed"], "scripts": ["hook1.sh"]} 32 | ``` 33 | 34 | The status hook is configured with a [configuration 35 | comment](../docs/config-comments.md), and supports the following keys: 36 | 37 | * `events`: the list of events you want to catch 38 | * `scripts`: execute the status hook only for these hooks *(optional)* 39 | 40 | ## Execution environment 41 | 42 | Status hooks are executed with the following environment variables: 43 | 44 | * `FISHER_STATUS_EVENT`: the name of the current event 45 | * `FISHER_STATUS_SCRIPT_NAME`: the name of the script that triggered the event 46 | * `FISHER_STATUS_SUCCESS`: `0` if the script failed, or `1` if it completed 47 | * `FISHER_STATUS_EXIT_CODE`: the script exit code (if it wasn't killed) 48 | * `FISHER_STATUS_SIGNAL`: the signal that killed the script (if it was killed) 49 | * `FISHER_STATUS_STDOUT`: path to the file containing the stdout of the script 50 | * `FISHER_STATUS_STDERR`: path to the file containing the stderr of the script 51 | -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | 45 | 46 |
47 | 48 |
49 |

Simple yet powerful webhooks catcher

50 |

Meet Fisher

51 |
52 | 53 | Fisher is the easy way to automate things: receive webhooks from the services 54 | you use, schedule the execution of scripts in response to each request, give 55 | priority to the ones you care about, and be notified when something goes wrong. 56 | All of this in a simple software, designed to be simple to use and operate. 57 | 58 |
59 | 60 | Why you should use it 61 | 62 | 63 | Read the tutorial 64 | 65 |
66 | You can also go ahead and install it now! 67 |
68 |
69 | 70 |
71 | -------------------------------------------------------------------------------- /docs/src/install.md: -------------------------------------------------------------------------------- 1 | # Installing Fisher 2 | 3 | If you want to use Fisher in a new machine you need to install it. Fisher is 4 | written in Rust, and it's available as a single binary you can drop into your 5 | path. 6 | 7 | Unfortunately, no precompiled packages for any Linux distribution are available 8 | yet. In the future they might become available. 9 | 10 | ## Precompiled binaries 11 | 12 | Official precompiled binaries are available from 13 | [files.pietroalbini.org](https://files.pietroalbini.org/releases/fisher). You 14 | can download the latest version from it and extract the binary contained in it 15 | in your `${PATH}` (usually `/usr/local/bin`). There are also GPG signatures 16 | available if you want to check them. 17 | 18 | ## Install from source 19 | 20 | If you want to build Fisher from source, you need to have the Rust 1.17 (or 21 | greater) toolchain installed on the target machine. Keep in mind this might 22 | take a while to complete. 23 | 24 | The easiest way to build from source is to build the package uploaded in the 25 | Rust's package registry, [crates.io](https://crates.io/crates/fisher): 26 | 27 | ``` 28 | $ cargo install fisher 29 | ``` 30 | 31 | Instead, if you want to compile directly from the source code you need to fetch 32 | the code from the git repository, and then build it with Cargo. 33 | 34 | ``` 35 | $ git clone https://github.com/pietroalbini/fisher 36 | $ cd fisher 37 | $ cargo build --release 38 | ``` 39 | 40 | The binary will be available in `target/release/fisher`. 41 | 42 | ## Starting Fisher at boot time 43 | 44 | If you want to start Fisher at boot, you should create a new systemd service 45 | (if your distribution uses systemd as the init). Place the following file in 46 | `/etc/systemd/system/fisher.service`: 47 | 48 | ``` 49 | [Unit] 50 | Description=The Fisher webhooks catcher 51 | 52 | [Service] 53 | ExecStart=/usr/local/bin/fisher /srv/webhooks/config.toml 54 | ExecReload=/bin/kill -USR1 $MAINPID 55 | 56 | User=fisher 57 | Group=fisher 58 | 59 | PrivateTmp=yes 60 | 61 | [Install] 62 | WantedBy=multi-user.target 63 | ``` 64 | 65 | This service assumes your system is configured this way: 66 | 67 | - The Fisher binary is located in `/usr/local/bin/fisher` 68 | - The configuration file is located in `/srv/webhooks/config.toml` 69 | - Fisher is executed by the `fisher` user 70 | 71 | If those things don't match your server configuration, you must change them in 72 | the service file. Then, you can manage Fisher like every other systemd service: 73 | 74 | ``` 75 | $ systemctl start fisher 76 | $ systemctl stop fisher 77 | $ systemctl restart fisher 78 | $ systemctl reload fisher 79 | $ systemctl status fisher 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/src/providers/github.md: -------------------------------------------------------------------------------- 1 | # The `GitHub` provider 2 | 3 | The GitHub provider allows you to integrate with [GitHub](https://github.com), 4 | a popular code hosting platform. GitHub supports a wide range of different 5 | webhooks, spanning from code pushes to comments. 6 | 7 | The provider performs some consistency checks on the incoming webhooks, to 8 | ensure they come from GitHub. It also ignores incoming pings from GitHub (such 9 | as the ones sent when the webhook is created), so the script will be executed 10 | only when something really happens. 11 | 12 | If you need to ensure no one can send fake webhooks, you can configure GitHub 13 | to sign all outgoing webhooks with a secret key you provide: if you put it in 14 | the configuration comment the provider will reject every incoming webhook with 15 | an invalid signature. 16 | 17 | ## Configuration 18 | 19 | ```plain 20 | ## Fisher-GitHub: {"secret": "secret key", "events": ["push", "pull_request"]} 21 | ``` 22 | 23 | The provider is configured with a [configuration 24 | comment](../docs/config-comments.md), and supports the following keys: 25 | 26 | * `secret`: the secret key used to sign webhooks 27 | * `events`: a whitelist of GitHub events you want to accept 28 | 29 | ## Environment variables 30 | 31 | The provider sets the following environment variables during the execution of 32 | the script: 33 | 34 | * `FISHER_GITHUB_EVENT`: the name of the event of this webhook 35 | * `FISHER_GITHUB_DELIVERY_ID`: the ID of the webhook delivery 36 | 37 | Also, if the `push` event is **whitelisted**, the following environment 38 | variables might be present: 39 | 40 | * `FISHER_GITHUB_PUSH_REF`: the git ref of the pushed commit (for example 41 | `refs/heads/master`) 42 | * `FISHER_GITHUB_PUSH_HEAD`: the sha1 ID of the pushed commit 43 | -------------------------------------------------------------------------------- /docs/src/providers/gitlab.md: -------------------------------------------------------------------------------- 1 | ## The `GitLab` provider 2 | 3 | The GitLab provider allows you to integrate with 4 | [GitLab](https://about.gitlab.com), a code hosting platform also available 5 | self-hosted. GitLab supports a few webhooks, mostly related to its main 6 | features. 7 | 8 | The provider perform some consistency checks on the incoming webhooks, to 9 | ensure they come from a GitLab instance. It can also check if the secret key 10 | sent by GitLab along with the webhook matches the one configured in the script 11 | (using configuration comments), rejecting invalid requests. 12 | 13 | ## Configuration 14 | 15 | ```plain 16 | ## Fisher-GitLab: {"secret": "secret key", "events": ["Push", "Issue"]} 17 | ``` 18 | 19 | The provider is configured with a [configuration 20 | comment](../docs/config-comments.md), and supports the following keys: 21 | 22 | * `secret`: the secret key used to sign webhooks 23 | * `events`: a whitelist of GitLab events you want to accept 24 | 25 | ## Environment varialbles 26 | 27 | The provider sets the following environment variables during the execution of 28 | the script: 29 | 30 | * `FISHER_GITLAB_EVENT`: the name of the event of this webhook 31 | -------------------------------------------------------------------------------- /docs/src/providers/index.md: -------------------------------------------------------------------------------- 1 | redirect: features/providers/ 2 | -------------------------------------------------------------------------------- /docs/src/providers/standalone.md: -------------------------------------------------------------------------------- 1 | # The `Standalone` provider 2 | 3 | The standalone provider is probably the most useful one, because it can be used 4 | without a third-party website and also to integrate with an external website 5 | not directly supported by Fisher. 6 | 7 | This provider validates if the incoming requests have a secret value in them 8 | (either in the query string param `secret` or the header `X-Fisher-Secret`). If 9 | they don't have them they will be rejected. Both the query string argument name 10 | and the header name are configurable on a per-script basis. 11 | 12 | This provider also supports whitelisting the IP addresses allowed to call the 13 | webhook. This way you can provide a basic level of authorization without 14 | sharing secret keys around. 15 | 16 | This provider doesn't provide any environment variable to the executing script. 17 | 18 | ## Configuration 19 | 20 | ``` 21 | ## Fisher-Standalone: {"secret": "secret key", "from": ["127.0.0.1"]} 22 | ``` 23 | 24 | The provider is configured with a [configuration 25 | comment](../docs/config-comments.md), and supports the following keys: 26 | 27 | * `from` *(optional)*: a list of IP addresses to whitelist 28 | * `secret` *(optional)*: the secret key the request must contain 29 | * `param_name` *(optional)*: the custom name of the query string param 30 | containing the secret key 31 | * `header_name` *(optional)*: the custom name of the header containing the 32 | secret key 33 | -------------------------------------------------------------------------------- /docs/src/tutorial/auto-deploy.md: -------------------------------------------------------------------------------- 1 | # Tutorial: automatic deploy from GitHub 2 | 3 | Automatically deploying your project from its git repository every time you 4 | push is a great time saver, and one of the most common things done with 5 | webhooks. 6 | 7 | In this tutorial we're going to configure Fisher to deploy your project on your 8 | server automatically, every time you push some changes. You need to have 9 | [Fisher installed](../install.md) on the machine, and a repository you own on 10 | GitHub. 11 | 12 | ## Creating the build script 13 | 14 | First of all, you need a script that fetches the git repository and deploys it. 15 | This script will be called by Fisher when a new commit is pushed, and its 16 | content depends on how both your repository and your server are structured. 17 | 18 | We'll create an example one, which assumes a static site built by running 19 | `make`: 20 | 21 | ``` 22 | #!/bin/bash 23 | 24 | git clone https://github.com/username/repository.git src 25 | 26 | cd src 27 | make 28 | 29 | cp -r build/ /srv/www/example.com 30 | ``` 31 | 32 | While the exact script content will change based on your setup, you might have 33 | noticed no cleanup is done: the cloned repository isn't deleted by the script. 34 | That's intentional, because Fisher automatically runs each script in a 35 | temporary directory, deleting it after. 36 | 37 | ## Starting Fisher 38 | 39 | Now you can start Fisher and play with it! Make sure the build script created 40 | in the previous paragraph is located in the scripts directory specified in your 41 | configuration file, and check if the current user has the permissions to 42 | execute it. You can then start Fisher pointing to the configuration file: 43 | 44 | ``` 45 | $ fisher /path/to/config.toml 46 | ``` 47 | 48 | If you see the script name in the loaded hooks list, you're good to go! You can 49 | execute the script by doing an HTTP request: 50 | 51 | ``` 52 | $ curl http://localhost:8000/hook/script-name.sh 53 | ``` 54 | 55 | ## Integrating with GitHub 56 | 57 | Now it's time to integrate your script with GitHub. 58 | 59 | Go to your GitHub repository settings and add a new webhook for the `push` 60 | event, pointing to the public URL of Fisher. For example, if the script is 61 | named `deploy.sh` and the public URL of the Fisher instance is 62 | `https://hooks.example.com`, you need to put this URL: 63 | 64 | ``` 65 | https://hooks.example.com/hook/deploy.sh 66 | ``` 67 | 68 | Now it's time to tell Fisher it's integrating with GitHub. The way to do this 69 | is to add a *configuration comment* to the script, a special comment located at 70 | the top of the script, just below the shebang: 71 | 72 | ``` 73 | ## Fisher-GitHub: {} 74 | ``` 75 | 76 | The full source code of the deploy script is now: 77 | 78 | ``` 79 | #!/bin/bash 80 | ## Fisher-GitHub: {} 81 | 82 | git clone https://github.com/username/repository.git src 83 | 84 | cd src 85 | make 86 | 87 | cp -r build/ /srv/www/example.com 88 | ``` 89 | 90 | After you restart Fisher, it will start recognizing incoming requests from 91 | GitHub, adding during the execution a few useful environment variables. 92 | 93 | ## Rejecting invalid requests 94 | 95 | Right now everyone can start a new deploy, and that might cause issues if 96 | someone finds the URL and starts calling it. Of course you can create a long 97 | and random script name to avoid that, but it's not the cleanest solution. 98 | 99 | A better way to fix this is to let GitHub sign requests with a secret key you 100 | provide (I recommend generating a 32 characters random string), and if you tell 101 | Fisher about it all the invalid requests will be rejected. 102 | 103 | After you generated the key, go to the GitHub settings for the webhook and copy 104 | the secret key in its field. Then, in the Fisher script, change the 105 | *configuration comment* to this: 106 | 107 | ``` 108 | ## Fisher-GitHub: {"secret": "YOUR-SECRET"} 109 | ``` 110 | 111 | After you restart Fisher, all the invalid requests will be rejected! 112 | -------------------------------------------------------------------------------- /docs/src/tutorial/failure-email.md: -------------------------------------------------------------------------------- 1 | # Tutorial: send emails when scripts fails 2 | 3 | This isn't a perfect world, and even the best scripts can fail: a way to know 4 | when one of them doesn't execute correctly and why it failed is invaluable. 5 | 6 | Fisher doesn't have a ready to be used solution for this though: there are 7 | countless way you can be notified about a problem, and supporting all of them 8 | would be an impossible task. Instead, Fisher provides you a simple but powerful 9 | interface to script how notifications are sent: [status 10 | hooks](../features/status-hooks.md). 11 | 12 | A status hook is a normal script Fisher starts every time a job completed its 13 | execution, that receives all the availabe information about the job execution. 14 | This means it can then report those information to your monitoring software of 15 | choice, or notify you about what happened the way you like. 16 | 17 | In this tutorial we're going to create a status hook that sends an email with 18 | the script output if an execution fails, so you're alerted when something goes 19 | wrong and why. 20 | 21 | ## Installing dependencies 22 | 23 | Since we're going to send out emails when something bad happens, the `mail` 24 | command has to be installed on the server running Fisher. How to do so depends 25 | on which operative system you have installed, but in Debian/Ubuntu you can 26 | install the tool with the command: 27 | 28 | ```plain 29 | $ sudo apt install mailutils 30 | ``` 31 | 32 | ## Creating the status hook 33 | 34 | First of all, you need to create the script that Fisher will call when a job 35 | fails. To do so, create an executable file in the directory loaded by Fisher 36 | with this content: 37 | 38 | ```bash 39 | #!/bin/bash 40 | ## Fisher-Status: {"events": ["job-failed"]} 41 | ``` 42 | 43 | All the status hooks needs to use the special [Status 44 | provider](../features/status-hooks.md), which allows to filter the events the 45 | status hooks will handle. In this case, our status hook will handle just the 46 | `job_failed` event. 47 | 48 | Then we can edit the script to send the scary email to our address: 49 | 50 | ```bash 51 | #!/bin/bash 52 | ## Fisher-Status: {"events": ["job-failed"]} 53 | 54 | NOTIFY_ADDRESS="bob@example.com" 55 | 56 | echo "A Fisher script failed." | mail -s "A script failed" "${NOTIFY_ADDRESS}" 57 | ``` 58 | 59 | ## Retrieving the job details 60 | 61 | The script we just wrote works fine, but doesn't tell you anything other than 62 | "a script failed". In order to be really useful, the email needs to contain 63 | more details about the execution. Fisher provides all the available information 64 | through the environment, so you can retrieve them in every scripting language 65 | you use. Check out the [status hooks 66 | documentation](../features/status-hooks.md) for a list of all the environment 67 | variables. 68 | 69 | Let's change the script to be more useful then: 70 | 71 | ```bash 72 | #!/bin/bash 73 | ## Fisher-Status: {"events": ["job-failed"]} 74 | 75 | NOTIFY_ADDRESS="bob@example.com" 76 | 77 | echo "Script ${FISHER_STATUS_SCRIPT_NAME} failed to execute!" > m 78 | echo >> m 79 | echo "Host: $(hostname)" >> m 80 | echo "Exit code: ${FISHER_STATUS_EXIT_CODE:-none}" >> m 81 | echo "Killed with signal: ${FISHER_STATUS_SIGNAL:-none}" >> m 82 | echo >> m 83 | echo "Standard output" >> m 84 | echo "===============" >> m 85 | cat "${FISHER_STATUS_STDOUT}" >> m 86 | echo >> m 87 | echo "Standard error" >> m 88 | echo "==============" >> m 89 | cat "${FISHER_STATUS_STDERR}" >> m 90 | 91 | cat m | mail -s "Script ${FISHER_STATUS_SCRIPT_NAME} failed" "${NOTIFY_ADDRESS}" 92 | ``` 93 | 94 | Now you'll know everything when something goes wrong! 95 | -------------------------------------------------------------------------------- /docs/src/tutorial/index.md: -------------------------------------------------------------------------------- 1 | redirect: . 2 | -------------------------------------------------------------------------------- /docs/src/why.md: -------------------------------------------------------------------------------- 1 | # Why you should use Fisher 2 | 3 | There's a lot of ways to catch and act on incoming webhooks out there, such 4 | as simple CGI scripts, small dedicated web applications or even other projects 5 | similar to Fisher. Fisher has multiple advantages over them, though. 6 | 7 | ## Simple to operate 8 | 9 | Fisher is made to be simple to operate and monitor. 10 | 11 | There is no need to create a configuration file listing all the available 12 | webhooks, or to use a specific naming convention for the scripts in the 13 | filesystem: you just need to put all the scripts in a directory, and Fisher 14 | will automatically scan them and load all the *executable* files as new 15 | webhooks. 16 | 17 | Because there is no centralized configuration file, each script is configured 18 | by adding comments to the top of its source file: this allows you to keep all 19 | the scripts along with their configuration in source control. For example, to 20 | create a webhook that receives `push` events from GitHub you can write this 21 | script: 22 | 23 | ```bash 24 | #!/bin/bash 25 | # Fisher-GitHub: {"events": ["push"]} 26 | 27 | echo "I'm the script!" 28 | ``` 29 | 30 | ## Support for multiple third-party providers 31 | 32 | Fisher supports a wide range of third-party providers you can use to validate 33 | if incoming webhooks are actually valid. This means Fisher is not tied to a 34 | single external service like other projects, and you can even use it in a 35 | standalone way. 36 | 37 | You can add one or multiple providers to each script, and when a webhook 38 | arrives, its request is checked against all of them. This allows a single 39 | script to be triggered by both GitHub and GitLab, for example. 40 | 41 | ## Clean scripts execution context 42 | 43 | After a webhook is received and validated, Fisher executes the related script 44 | in a clean environment, to avoid strange errors caused by different 45 | environments. 46 | 47 | All the scripts are executed in a temporary directory deleted after the 48 | execution, which is set also as the `$HOME` to avoid dotfiles being written 49 | somewhere else. This allows, for example, to execute multiple instances of the 50 | same script in parallel. 51 | 52 | To guarantee reproducibility, the execution environment is stripped out of most 53 | of the existing environment variables: only a few of them are kept by default, 54 | and you can manually add custom ones if you need them. 55 | 56 | ## Advanced scheduling 57 | 58 | Fisher includes a scheduler to execute the validated webhooks. It's 59 | deterministic, and it has multiple features to control the order in which the 60 | scripts are executed. 61 | 62 | One of those features is scripts priority: you can specify what's the priority 63 | of each script, and higher-priority scripts in the queue will be executed 64 | before the others. This ensures the most important webhooks will be processed 65 | right away! 66 | 67 | Fisher has also the ability to execute multiple scripts in parallel, to work 68 | through the queue faster. Unfortunately, not every script can be parallelized 69 | (think about a deploy script executing some migrations in the database). To 70 | avoid this the scheduler allows to mark single scripts as "non parallel", and 71 | it will never schedule that script multiple times, while running the other ones 72 | in parallel. 73 | 74 | ## Easy monitoring 75 | 76 | Fisher can be easily monitored, to give you all the diagnostics information you 77 | need. 78 | 79 | There is [an endpoint](features/health-endpoint.md), `/health`, which you can 80 | use to do black-box monitoring: it returns a few facts about the instance (such 81 | as the number of webhooks in the queue) you can use to build graphs or 82 | trigger monitoring alerts. 83 | 84 | If you need insights why a webhook failed, you can also create *status hooks*, 85 | special scripts executed after a script is run. Status hooks receive all the 86 | details about the previous execution, such as standard output/error and exit 87 | code. You can use them to log jobs into your existing systems, or to alert you 88 | when something fails. 89 | -------------------------------------------------------------------------------- /docs/theme/css/snowflake.css: -------------------------------------------------------------------------------- 1 | *{box-sizing:border-box}body{color:#333;font-family:sans-serif;line-height:1.5em;margin:0}ul{padding-left:1.25em}ul li{list-style:square;margin:0.3em 0}a{color:REPLACE_COLOR_HERE;text-decoration:none}a:hover{text-decoration:underline}.wrapper{max-width:52em;margin:auto;padding:0 1em}h1,h2,h3,h4,h5,h6{margin:1em 0 0.5em 0;line-height:1.2em;font-size:1.2em;font-weight:400;color:#000}h1,h2{padding-bottom:0.3em;border-bottom:1px solid #eee}h1{font-size:2.2em}h2{font-size:1.8em}h3{font-size:1.4em}p{margin:0.5em 0}code,pre{font-family:monospace;font-size:1.1em;background:#fff;border:0.1em solid #e1e1e1}code{display:inline-block;padding:0 0.4em;border-radius:0.2em}pre{display:block;margin:1em 0;padding:0.5em 1em;border-radius:0.3em;box-shadow:0 0.1em 0.1em rgba(0,0,0,0.1);overflow-x:auto}pre>code{font-size:1em;padding:0;border-radius:0;border:0}@media all and (max-width:52em){.wrapper pre{border-radius:0;border-left:0;border-right:0;margin:1em -1.1em}}hr{border:0;border-bottom:0.1em solid #eee;margin:2.5em 25%}.navbar{background:REPLACE_COLOR_HERE;color:#fff;margin-bottom:2em;box-shadow:0 0.2em 0.2em rgba(0,0,0,0.2);position:relative;z-index:100;width:100%;overflow:auto;white-space:nowrap}.navbar:after{content:"";display:block;visibility:hidden;height:0;clear:both}.navbar ul{margin:0;padding:0}.navbar ul.left{float:left}.navbar ul.right{float:right}.navbar ul li{display:inline-block;margin:0;padding:0.75em 1em}.navbar ul li a{display:block;margin:-0.75em -1em;padding:0.75em 1em;text-decoration:none;color:rgba(255,255,255,0.7);transition:0.2s color ease-out}.navbar ul li a:hover{color:#fff}.navbar ul li a.active{color:#fff;background:rgba(255,255,255,0.15)}.navbar .wrapper{padding:0}.navbar.sticky{position:sticky;top:0;z-index:200}@media all and (max-width:52em){.navbar ul{display:inline-block}.navbar ul.left,.navbar ul.right{float:none}.navbar .wrapper{display:inline-block}}.sidebar-container{display:flex;flex-direction:row;align-content:stretch;align-items:stretch}.sidebar-container .sidebar{box-shadow:inset 0 0 0.2em rgba(0,0,0,0.15);background:#eee;width:20em;padding:1px}.sidebar-container .sidebar .nav{margin:0.5em 0;padding:0}.sidebar-container .sidebar .nav li{margin:0;display:block;list-style:none}.sidebar-container .sidebar .nav li.section{padding:0.8em 0.8em 0.3em 0.8em;color:#888;text-transform:uppercase}.sidebar-container .sidebar .nav li.section:hover{background:transparent}.sidebar-container .sidebar .nav li a{display:block;padding:0.3em 0.8em}.sidebar-container .sidebar .nav li:hover,.sidebar-container .sidebar .nav li.active{background:rgba(0,0,0,0.05)}.sidebar-container .sidebar .nav li:hover a,.sidebar-container .sidebar .nav li.active a{color:#333;text-decoration:none}.sidebar-container .content{flex:1}.sidebar-container>label{display:none}.sidebar-container>input[type=checkbox]{display:none}@media all and (max-width:67em){.sidebar-container{display:block}.sidebar-container>label{display:block;cursor:pointer;color:REPLACE_COLOR_HERE;text-align:center;border-bottom:0.1em solid #eee;padding:0.5em}.sidebar-container>input[type=checkbox] + .sidebar{display:none}.sidebar-container>input[type=checkbox]:checked + .sidebar{display:block;width:100%}}.navbar + .sidebar-container{margin-top:-2em}.inline-list{margin:0;padding:0;text-align:center}.inline-list>*{display:inline-block;margin:0;padding:0.2em 0 0.2em 1.2em}.inline-list>*:first-child{padding-left:0}.footer{color:#888;margin:1.5em 0}.footer a{color:#555}.button{user-select:none;display:inline-block;margin:0.3em;padding:0.4em 0.8em;border:0;border-radius:0.2em;font-size:1em;font-family:sans-serif;line-height:1.4em;background:REPLACE_COLOR_HERE;color:#fff;cursor:pointer;transition:opacity 0.2s ease-out;box-shadow:0 0.2em 0.2em rgba(0,0,0,0.2)}.button::-moz-focus-inner{padding:0}.button:hover{opacity:0.9;text-decoration:none}.button.button-big{font-size:1.4em}.button.button-large{font-size:1.2em}.button.button-normal{font-size:1em}.button.button-small{font-size:0.8em}.vertical-fill{display:flex;flex-direction:column}.vertical-fill>*{flex:0}.vertical-fill>*:last-child{flex:1}body.vertical-fill{min-height:100vh} 2 | -------------------------------------------------------------------------------- /docs/theme/css/theme.css: -------------------------------------------------------------------------------- 1 | body, .button { 2 | font-family: "Source Sans Pro", sans-serif; 3 | } 4 | 5 | h1, h2, h3, h4, h5, h6, .site-name { 6 | font-family: "Bitter", serif; 7 | } 8 | 9 | .navbar ul li.site-name a { 10 | color: #fff; 11 | } 12 | 13 | @media all and (max-width: 75em) { 14 | .next-page-title { 15 | display: none; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/theme/js/redirect.js: -------------------------------------------------------------------------------- 1 | // Redirect to the configured URL 2 | document.location = document.head.getAttribute("data-redirect-to"); 3 | -------------------------------------------------------------------------------- /docs/theme/main.html: -------------------------------------------------------------------------------- 1 | {%- if page.meta.redirect -%} 2 | {%- include "redirect.html" -%} 3 | {%- else -%} 4 | {%- include "page.html" -%} 5 | {%- endif -%} 6 | -------------------------------------------------------------------------------- /docs/theme/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {%- if page.title %}{{ page.title }}{% endif %} - {{ config.site_name -}} 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 39 | 40 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /docs/theme/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Redirecting... 8 | 9 | 10 |

If the redirect doesn't work click here.

11 | 12 | 13 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "docs/" 3 | publish = "docs/build/" 4 | command = "make" 5 | 6 | # Permanent redirect from the netlify domain to the canonical domain 7 | [[redirects]] 8 | from = "https://org-pietroalbini-fisher.netlify.com/*" 9 | to = "https://fisher.pietroalbini.org/:splat" 10 | status = 301 11 | force = true 12 | 13 | # Security headers 14 | [[headers]] 15 | for = "/*" 16 | [headers.values] 17 | X-Frame-Options = "DENY" 18 | X-Xss-Protection = "1; mode=block" 19 | X-Content-Type-Options = "nosniff" 20 | Referrer-Policy = "no-referrer" 21 | Content-Security-Policy = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' assets.pietroalbini.org; font-src assets.pietroalbini.org; img-src 'self' assets.pietroalbini.org" 22 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::net::SocketAddr; 17 | use std::path::Path; 18 | use std::sync::Arc; 19 | use std::collections::HashMap; 20 | 21 | use common::prelude::*; 22 | use common::state::State; 23 | use common::config::{Config, HttpConfig}; 24 | 25 | use scripts::{Blueprint, Repository, JobContext}; 26 | use processor::{Processor, ProcessorApi}; 27 | use web::WebApp; 28 | 29 | 30 | struct InnerApp { 31 | locked: bool, 32 | scripts_blueprint: Blueprint, 33 | processor: Processor, 34 | http: Option>>, 35 | } 36 | 37 | impl InnerApp { 38 | fn new() -> Result { 39 | let state = Arc::new(State::new()); 40 | let blueprint = Blueprint::new(state.clone()); 41 | 42 | let processor = Processor::new( 43 | 0, 44 | Arc::new(blueprint.repository()), 45 | JobContext::default(), 46 | state.clone(), 47 | )?; 48 | 49 | Ok(InnerApp { 50 | locked: false, 51 | scripts_blueprint: blueprint, 52 | http: None, 53 | processor, 54 | }) 55 | } 56 | 57 | fn restart_http_server(&mut self, config: &HttpConfig) -> Result<()> { 58 | // Stop the server if it's already running 59 | if let Some(http) = self.http.take() { 60 | http.stop(); 61 | } 62 | 63 | let http = WebApp::new( 64 | Arc::new(self.scripts_blueprint.repository()), 65 | config, 66 | self.processor.api(), 67 | )?; 68 | 69 | // Lock the server if it was locked before 70 | if self.locked { 71 | http.lock(); 72 | } 73 | 74 | self.http = Some(http); 75 | 76 | Ok(()) 77 | } 78 | 79 | fn set_scripts_path>( 80 | &mut self, path: P, recursive: bool, 81 | ) -> Result<()> { 82 | self.scripts_blueprint.clear(); 83 | self.scripts_blueprint.collect_path(path, recursive)?; 84 | self.processor.api().cleanup()?; 85 | 86 | Ok(()) 87 | } 88 | 89 | fn set_job_environment(&self, env: HashMap) -> Result<()> { 90 | self.processor.api().update_context(JobContext { 91 | environment: env, 92 | .. JobContext::default() 93 | })?; 94 | Ok(()) 95 | } 96 | 97 | fn set_threads_count(&self, count: u16) -> Result<()> { 98 | self.processor.api().set_threads_count(count)?; 99 | Ok(()) 100 | } 101 | 102 | fn http_addr(&self) -> Option<&SocketAddr> { 103 | if let Some(ref http) = self.http { 104 | Some(http.addr()) 105 | } else { 106 | None 107 | } 108 | } 109 | 110 | fn lock(&mut self) -> Result<()> { 111 | if let Some(ref http) = self.http { 112 | http.lock(); 113 | } 114 | self.processor.api().lock()?; 115 | 116 | self.locked = true; 117 | 118 | Ok(()) 119 | } 120 | 121 | fn unlock(&mut self) -> Result<()> { 122 | self.processor.api().unlock()?; 123 | if let Some(ref http) = self.http { 124 | http.unlock(); 125 | } 126 | 127 | self.locked = false; 128 | 129 | Ok(()) 130 | } 131 | 132 | fn stop(mut self) -> Result<()> { 133 | if let Some(ref http) = self.http { 134 | http.lock(); 135 | } 136 | 137 | self.processor.stop()?; 138 | 139 | if let Some(http) = self.http.take() { 140 | http.stop(); 141 | } 142 | 143 | Ok(()) 144 | } 145 | } 146 | 147 | 148 | pub struct Fisher { 149 | config: Config, 150 | inner: InnerApp, 151 | } 152 | 153 | impl Fisher { 154 | pub fn new(config: Config) -> Result { 155 | let mut inner = InnerApp::new()?; 156 | inner.set_scripts_path( 157 | &config.scripts.path, config.scripts.recursive, 158 | )?; 159 | inner.set_job_environment(config.env.clone())?; 160 | inner.set_threads_count(config.jobs.threads)?; 161 | inner.restart_http_server(&config.http)?; 162 | 163 | Ok(Fisher { 164 | config, 165 | inner, 166 | }) 167 | } 168 | 169 | pub fn web_address(&self) -> Option<&SocketAddr> { 170 | self.inner.http_addr() 171 | } 172 | 173 | pub fn reload(&mut self, new_config: Config) -> Result<()> { 174 | // Ensure Fisher is unlocked even if the reload fails 175 | self.inner.lock()?; 176 | let result = self.reload_inner(new_config); 177 | self.inner.unlock()?; 178 | 179 | result 180 | } 181 | 182 | fn reload_inner(&mut self, new_config: Config) -> Result<()> { 183 | // Restart the HTTP server if its configuration changed 184 | if self.config.http != new_config.http { 185 | self.inner.restart_http_server(&new_config.http)?; 186 | } 187 | 188 | // Update the job context if the environment is different 189 | if self.config.env != new_config.env { 190 | self.inner.set_job_environment(new_config.env.clone())?; 191 | } 192 | 193 | // Update the threads count if it's different 194 | if self.config.jobs.threads != new_config.jobs.threads { 195 | self.inner.set_threads_count(new_config.jobs.threads)?; 196 | } 197 | 198 | // Reload hooks, changing the script path 199 | self.inner.set_scripts_path( 200 | &new_config.scripts.path, 201 | new_config.scripts.recursive, 202 | )?; 203 | 204 | self.config = new_config; 205 | 206 | Ok(()) 207 | } 208 | 209 | pub fn stop(self) -> Result<()> { 210 | self.inner.stop() 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/bin/fisher.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | extern crate fisher; 17 | extern crate nix; 18 | extern crate toml; 19 | 20 | use std::fs; 21 | use std::io::Read; 22 | use std::path::Path; 23 | 24 | use fisher::*; 25 | use nix::sys::signal::{Signal, SigSet}; 26 | 27 | 28 | static VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); 29 | 30 | 31 | fn show_version() { 32 | if let Some(version) = VERSION { 33 | println!("Fisher {}", version); 34 | } else { 35 | println!("Fisher (version unknown)"); 36 | } 37 | } 38 | 39 | 40 | fn usage(exit_code: i32, error_msg: &str) -> ! { 41 | if error_msg.len() > 0 { 42 | println!("Error: {}\n", error_msg); 43 | } 44 | println!("Usage: fisher "); 45 | println!("Execute `fisher --help` for more details"); 46 | ::std::process::exit(exit_code); 47 | } 48 | 49 | 50 | fn parse_cli() -> String { 51 | // Parse the CLI args 52 | let mut only_args = false; 53 | let mut flag_help = false; 54 | let mut flag_version = false; 55 | let mut config_path = None; 56 | 57 | for arg in ::std::env::args().skip(1) { 58 | if !only_args && arg.chars().next() == Some('-') { 59 | match arg.as_str() { 60 | "--" => only_args = true, 61 | "-h" | "--help" => flag_help = true, 62 | "--version" => flag_version = true, 63 | _ => usage(1, &format!("invalid flag: {}", arg)), 64 | } 65 | } else if config_path.is_none() { 66 | config_path = Some(arg); 67 | } else { 68 | usage(1, &format!("unexpected argument: {}", arg)); 69 | } 70 | } 71 | 72 | if flag_help { 73 | show_version(); 74 | println!("Simple webhooks catcher\n"); 75 | 76 | println!("ARGUMENTS"); 77 | println!(" config_path The path to the configuration file"); 78 | println!(); 79 | 80 | println!("OPTIONS"); 81 | println!(" -h | --help Show this message"); 82 | println!(" --version Show the Fisher version"); 83 | 84 | ::std::process::exit(0); 85 | } else if flag_version { 86 | show_version(); 87 | ::std::process::exit(0); 88 | } else if let Some(path) = config_path { 89 | path 90 | } else { 91 | usage(1, "too few arguments"); 92 | } 93 | } 94 | 95 | 96 | fn read_config>(path: P) -> Result { 97 | // Read the configuration from a file 98 | let mut file = fs::File::open(path)?; 99 | let mut buffer = String::new(); 100 | file.read_to_string(&mut buffer)?; 101 | 102 | Ok(toml::from_str(&buffer).map_err(|e| { 103 | Error::from_kind(ErrorKind::BoxedError(Box::new(e)).into()) 104 | })?) 105 | } 106 | 107 | 108 | fn app() -> Result<()> { 109 | // Capture only the signals Fisher uses 110 | let mut signals = SigSet::empty(); 111 | signals.add(Signal::SIGINT); 112 | signals.add(Signal::SIGTERM); 113 | signals.add(Signal::SIGUSR1); 114 | signals.thread_block()?; 115 | 116 | let config_path = parse_cli(); 117 | 118 | let mut app = Fisher::new(read_config(&config_path)?)?; 119 | println!("HTTP server listening on {}", app.web_address().unwrap()); 120 | 121 | // Wait for signals while the other threads execute the application 122 | loop { 123 | match signals.wait()? { 124 | Signal::SIGINT | Signal::SIGTERM => break, 125 | Signal::SIGUSR1 => { 126 | println!("Reloading configuration and scripts..."); 127 | 128 | // Don't crash if the reload fails, just show errors 129 | // No changes are applied if the reload fails 130 | match read_config(&config_path) { 131 | Ok(new_config) => { 132 | if let Err(err) = app.reload(new_config) { 133 | err.pretty_print() 134 | } 135 | } 136 | Err(err) => err.pretty_print(), 137 | } 138 | } 139 | _ => {} 140 | } 141 | } 142 | 143 | // Stop Fisher 144 | app.stop()?; 145 | 146 | Ok(()) 147 | } 148 | 149 | 150 | fn main() { 151 | if let Err(err) = app() { 152 | err.pretty_print(); 153 | std::process::exit(1); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/common/config.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! This module contains the deserializable configuration structs used by 17 | //! Fisher. 18 | 19 | use std::collections::HashMap; 20 | use std::str::FromStr; 21 | use std::net::SocketAddr; 22 | use std::fmt; 23 | use std::result::Result as StdResult; 24 | 25 | use serde::de::{Error as DeError, Visitor, Deserialize, Deserializer}; 26 | 27 | use common::prelude::*; 28 | use utils; 29 | 30 | 31 | macro_rules! default { 32 | ($struct:ident {$( $key:ident: $value:expr, )*}) => { 33 | impl Default for $struct { 34 | fn default() -> Self { 35 | $struct { 36 | $( $key: $value ),* 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | macro_rules! default_fn { 44 | ($name:ident: $type:ty = $val:expr) => { 45 | fn $name() -> $type { 46 | $val 47 | } 48 | } 49 | } 50 | 51 | 52 | /// The Fisher configuration. 53 | #[derive(Debug, Default, PartialEq, Eq, Deserialize)] 54 | pub struct Config { 55 | /// Configuration for the built-in HTTP webhooks receiver. 56 | #[serde(default)] 57 | pub http: HttpConfig, 58 | /// Configuration for the scripts loading. 59 | #[serde(default)] 60 | pub scripts: ScriptsConfig, 61 | /// Configuration for running jobs. 62 | #[serde(default)] 63 | pub jobs: JobsConfig, 64 | /// Extra environment variables. 65 | #[serde(default)] 66 | pub env: HashMap, 67 | } 68 | 69 | 70 | /// Configuration for the built-in HTTP webhooks receiver. 71 | #[derive(Debug, PartialEq, Eq, Deserialize)] 72 | pub struct HttpConfig { 73 | /// The number of proxies Fisher is behind. 74 | #[serde(rename="behind-proxies", default="default_behind_proxies")] 75 | pub behind_proxies: u8, 76 | /// The socket address to bind. 77 | #[serde(default="default_bind")] 78 | pub bind: SocketAddr, 79 | /// The rate limit for bad requests 80 | #[serde(rename="rate-limit", default)] 81 | pub rate_limit: RateLimitConfig, 82 | /// Enable or disable the health endpoint 83 | #[serde(rename="health-endpoint", default="default_health_endpoint")] 84 | pub health_endpoint: bool, 85 | } 86 | 87 | default_fn!(default_behind_proxies: u8 = 0); 88 | default_fn!(default_bind: SocketAddr = "127.0.0.1:8000".parse().unwrap()); 89 | default_fn!(default_health_endpoint: bool = true); 90 | 91 | default!(HttpConfig { 92 | behind_proxies: default_behind_proxies(), 93 | bind: default_bind(), 94 | rate_limit: RateLimitConfig::default(), 95 | health_endpoint: default_health_endpoint(), 96 | }); 97 | 98 | 99 | /// Configuration for rate limiting. 100 | #[derive(Debug, PartialEq, Eq)] 101 | pub struct RateLimitConfig { 102 | /// The number of allowed requests in the interval. 103 | pub allowed: u64, 104 | /// The interval of time to consider. 105 | pub interval: utils::TimeString, 106 | } 107 | 108 | default!(RateLimitConfig { 109 | allowed: 10, 110 | interval: 60.into(), 111 | }); 112 | 113 | impl RateLimitConfig { 114 | fn from_str_internal(s: &str) -> Result { 115 | let slash_pos = s.char_indices() 116 | .filter(|ci| ci.1 == '/') 117 | .map(|ci| ci.0) 118 | .collect::>(); 119 | 120 | match slash_pos.len() { 121 | 0 => Ok(RateLimitConfig { 122 | allowed: s.parse()?, 123 | interval: 60.into(), 124 | }), 125 | 1 => { 126 | let (requests, interval) = s.split_at(slash_pos[0]); 127 | Ok(RateLimitConfig { 128 | allowed: requests.parse()?, 129 | interval: (&interval[1..]).parse()?, 130 | }) 131 | }, 132 | _ => Err(ErrorKind::RateLimitConfigTooManySlashes.into()), 133 | } 134 | } 135 | } 136 | 137 | impl FromStr for RateLimitConfig { 138 | type Err = Error; 139 | 140 | fn from_str(s: &str) -> Result { 141 | Self::from_str_internal(s) 142 | .chain_err(|| ErrorKind::RateLimitConfigError(s.into())) 143 | } 144 | } 145 | 146 | struct RateLimitConfigVisitor; 147 | 148 | impl<'de> Visitor<'de> for RateLimitConfigVisitor { 149 | type Value = RateLimitConfig; 150 | 151 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 152 | formatter.write_str("a number of seconds, a time string or a map") 153 | } 154 | 155 | fn visit_str(self, s: &str) -> StdResult { 156 | match s.parse() { 157 | Ok(parsed) => Ok(parsed), 158 | Err(e) => Err(E::custom(e.to_string())), 159 | } 160 | } 161 | 162 | fn visit_i64(self, num: i64) -> StdResult { 163 | Ok(RateLimitConfig { 164 | allowed: num as u64, 165 | interval: 60.into(), 166 | }) 167 | } 168 | } 169 | 170 | impl<'de> Deserialize<'de> for RateLimitConfig { 171 | fn deserialize>( 172 | deserializer: D, 173 | ) -> StdResult { 174 | deserializer.deserialize_any(RateLimitConfigVisitor) 175 | } 176 | } 177 | 178 | 179 | /// Configuration for running jobs. 180 | #[derive(Debug, PartialEq, Eq, Deserialize)] 181 | pub struct JobsConfig { 182 | /// The number of execution threads to use. 183 | #[serde(default = "default_threads")] 184 | pub threads: u16, 185 | } 186 | 187 | default_fn!(default_threads: u16 = 1); 188 | 189 | default!(JobsConfig { 190 | threads: default_threads(), 191 | }); 192 | 193 | 194 | /// Configuration for looking scripts up. 195 | #[derive(Debug, PartialEq, Eq, Deserialize)] 196 | pub struct ScriptsConfig { 197 | /// The path to search for hooks 198 | #[serde(default = "default_path")] 199 | pub path: String, 200 | /// Search subdirectories or not. 201 | #[serde(default = "default_recursive")] 202 | pub recursive: bool, 203 | } 204 | 205 | default_fn!(default_path: String = ".".into()); 206 | default_fn!(default_recursive: bool = false); 207 | 208 | default!(ScriptsConfig { 209 | path: default_path(), 210 | recursive: default_recursive(), 211 | }); 212 | -------------------------------------------------------------------------------- /src/common/errors.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2018 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::path::{Path, PathBuf}; 17 | use std::fs; 18 | use std::env; 19 | 20 | 21 | /// Convert a path relative to the current directory, if possible. 22 | /// 23 | /// This is used to display prettier error messages, and returns the original 24 | /// path if it's not a subdirectory the current one. 25 | fn relative_to_current>(original_path: P) -> PathBuf { 26 | let mut result = PathBuf::new(); 27 | 28 | let current = if let Ok(curr) = env::current_dir(){ 29 | curr 30 | } else { 31 | return original_path.as_ref().to_path_buf(); 32 | }; 33 | let path = if let Ok(path) = fs::canonicalize(&original_path) { 34 | path 35 | } else { 36 | return original_path.as_ref().to_path_buf(); 37 | }; 38 | 39 | let mut current_iter = current.iter(); 40 | for segment in path.iter() { 41 | if let Some(current_segment) = current_iter.next() { 42 | if segment != current_segment { 43 | return original_path.as_ref().to_path_buf(); 44 | } 45 | } else { 46 | result.push(segment); 47 | } 48 | } 49 | 50 | result 51 | } 52 | 53 | 54 | error_chain! { 55 | foreign_links { 56 | Io(::std::io::Error); 57 | ParseInt(::std::num::ParseIntError); 58 | AddrParse(::std::net::AddrParseError); 59 | Json(::serde_json::Error); 60 | Nix(::nix::Error); 61 | } 62 | 63 | errors { 64 | // Hex parsing errors 65 | HexInvalidChar(chr: char) { 66 | description("invalid char in hex"), 67 | display("invalid char in hex: {}", chr), 68 | } 69 | HexInvalidLength { 70 | description("odd length for hex string"), 71 | display("odd length for hex string"), 72 | } 73 | 74 | // Time strings 75 | TimeStringInvalid(string: String) { 76 | description("invalid time string"), 77 | display("invalid time string: {}", string), 78 | } 79 | TimeStringInvalidChar(chr: char) { 80 | description("time string contains an invalid char"), 81 | display("the char '{}' isn't allowed in time strings", chr), 82 | } 83 | TimeStringExpectedNumber(pos: usize) { 84 | description("expected a number in the time string"), 85 | display("expected a number in position {}", pos), 86 | } 87 | 88 | // Requests errors 89 | NotBehindProxy { 90 | description("not behind enough proxies"), 91 | display("not behind enough proxies"), 92 | } 93 | WrongRequestKind { 94 | description("wrong request kind"), 95 | display("wrong request kind"), 96 | } 97 | 98 | // Rate limit config 99 | RateLimitConfigTooManySlashes { 100 | description("too many slashes present"), 101 | display("too many slashes present"), 102 | } 103 | 104 | // Providers errors 105 | ProviderNotFound(name: String) { 106 | description("provider not found"), 107 | display("unknown provider: {}", name), 108 | } 109 | ProviderGitHubInvalidEventName(name: String) { 110 | description("invalid GitHub event name"), 111 | display("invalid GitHub event name: {}", name), 112 | } 113 | ProviderGitLabInvalidEventName(name: String) { 114 | description("invalid GitLab event name"), 115 | display("invalid GitLab event name: {}", name), 116 | } 117 | 118 | // Broken things 119 | BrokenChannel { 120 | description("an internal communication channel is broken"), 121 | display("an internal communication channel is broken"), 122 | } 123 | PoisonedLock { 124 | description("an internal lock is poisoned"), 125 | display("an internal lock is poisoned"), 126 | } 127 | 128 | // Other errors 129 | BoxedError(boxed: Box<::std::error::Error + Send + Sync>) { 130 | description("generic error"), 131 | display("{}", boxed), 132 | } 133 | 134 | // Chained errors 135 | ScriptExecutionFailed(name: String) { 136 | description("script execution failed"), 137 | display("execution of the '{}' script failed", name), 138 | } 139 | ScriptParsingError(file: String, line: u32) { 140 | description("script parsing error"), 141 | display( 142 | "parsing of the script '{}' failed (at line {})", 143 | relative_to_current(file).to_string_lossy(), line, 144 | ), 145 | } 146 | RateLimitConfigError(string: String) { 147 | description("error while parsing the rate limit config"), 148 | display("error while parsing rate limit config '{}'", string), 149 | } 150 | } 151 | } 152 | 153 | impl Error { 154 | pub fn pretty_print(&self) { 155 | println!("Error: {}", self); 156 | for chain in self.iter().skip(1) { 157 | println!(" caused by: {}", chain); 158 | } 159 | } 160 | } 161 | 162 | impl From<::std::sync::mpsc::SendError> for Error { 163 | fn from(_: ::std::sync::mpsc::SendError) -> Error { 164 | ErrorKind::BrokenChannel.into() 165 | } 166 | } 167 | 168 | impl From<::std::sync::mpsc::RecvError> for Error { 169 | fn from(_: ::std::sync::mpsc::RecvError) -> Error { 170 | ErrorKind::BrokenChannel.into() 171 | } 172 | } 173 | 174 | impl From<::std::sync::PoisonError> for Error { 175 | fn from(_: ::std::sync::PoisonError) -> Error { 176 | ErrorKind::PoisonedLock.into() 177 | } 178 | } 179 | 180 | impl From> for Error { 181 | fn from(err: Box<::std::error::Error + Send + Sync>) -> Error { 182 | ErrorKind::BoxedError(err).into() 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! This module contains all the common code used by the Fisher application. 17 | //! All the other modules then depends on this crate to get access to 18 | //! the features. 19 | 20 | pub mod config; 21 | pub mod errors; 22 | pub mod prelude; 23 | pub mod serial; 24 | pub mod state; 25 | pub mod structs; 26 | pub mod traits; 27 | -------------------------------------------------------------------------------- /src/common/prelude.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! Prelude for Fisher. 17 | //! 18 | //! This module re-exports useful things used by all the Fisher code, to be 19 | //! easily included. 20 | 21 | pub use super::errors::{Error, ErrorKind, Result, ResultExt}; 22 | pub use super::traits::*; 23 | -------------------------------------------------------------------------------- /src/common/serial.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! Opaque, infinite serial. 17 | //! 18 | //! This module provides an opaque struct, [`Serial`](struct.Serial.html), 19 | //! that can be incremented indefinitely. The only limitation is, you can only 20 | //! compare values with a maximum difference of 2^32 increments between them. 21 | //! 22 | //! Due to the limits of the implementation, it's not possible to access 23 | //! the actual value of a [`Serial`](struct.Serial.html), but you can compare 24 | //! multiple instances of it to get the greatest or check if they're the same 25 | //! one. 26 | 27 | use std::cmp::Ordering; 28 | use std::fmt; 29 | 30 | 31 | /// Opaque, infinite serial. 32 | /// 33 | /// Check out the [module documentation](index.html) for more details. 34 | #[derive(Copy, Clone, Eq, PartialEq)] 35 | pub struct Serial { 36 | increment: u32, 37 | alternate: bool, 38 | } 39 | 40 | impl Serial { 41 | /// Create a new Serial object, starting from zero. 42 | pub fn zero() -> Self { 43 | Serial { 44 | increment: 0, 45 | alternate: false, 46 | } 47 | } 48 | 49 | /// Return the Serial object following this one, without incrementing the 50 | /// current object in place. 51 | /// 52 | /// ``` 53 | /// # use fisher::common::serial::Serial; 54 | /// let serial = Serial::zero(); 55 | /// assert!(serial.next() > serial); 56 | /// ``` 57 | pub fn next(&self) -> Serial { 58 | let mut serial = self.clone(); 59 | 60 | let (new, overflowed) = serial.increment.overflowing_add(1); 61 | 62 | serial.increment = new; 63 | if overflowed { 64 | serial.alternate = !serial.alternate; 65 | } 66 | 67 | serial 68 | } 69 | 70 | /// Increment the current instance of Serial by one, and return the 71 | /// incremented value. 72 | /// 73 | /// ``` 74 | /// # use fisher::common::serial::Serial; 75 | /// let mut serial = Serial::zero(); 76 | /// let old = serial.clone(); 77 | /// assert!(serial.incr() > old); 78 | /// ``` 79 | pub fn incr(&mut self) -> Serial { 80 | let next = self.next(); 81 | *self = next; 82 | *self 83 | } 84 | } 85 | 86 | impl fmt::Debug for Serial { 87 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 88 | write!(f, "Serial") 89 | } 90 | } 91 | 92 | impl Ord for Serial { 93 | fn cmp(&self, other: &Serial) -> Ordering { 94 | let cmp = self.increment.cmp(&other.increment); 95 | 96 | if self.alternate != other.alternate { 97 | cmp.reverse() 98 | } else { 99 | cmp 100 | } 101 | } 102 | } 103 | 104 | impl PartialOrd for Serial { 105 | fn partial_cmp(&self, other: &Serial) -> Option { 106 | Some(self.cmp(other)) 107 | } 108 | } 109 | 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::Serial; 114 | 115 | 116 | #[test] 117 | fn test_basic_ordering() { 118 | let old = Serial::zero(); 119 | let new = old.next(); 120 | 121 | assert!(old < new); 122 | } 123 | 124 | 125 | #[test] 126 | fn test_overflowing() { 127 | let mut old1 = Serial::zero(); 128 | old1.increment = ::std::u32::MAX - 1; 129 | 130 | let old2 = old1.next(); 131 | let old3 = old2.next(); 132 | let new = old3.next(); 133 | 134 | assert_eq!(old1.increment, ::std::u32::MAX - 1); 135 | assert_eq!(old2.increment, ::std::u32::MAX); 136 | assert_eq!(old3.increment, 0); 137 | assert_eq!(new.increment, 1); 138 | 139 | assert!(old1 < old2); 140 | assert!(old2 < old3); 141 | assert!(old3 < new); 142 | assert!(old1 < new); 143 | } 144 | 145 | 146 | #[test] 147 | fn test_incr() { 148 | let mut serial = Serial::zero(); 149 | 150 | let original = serial.clone(); 151 | serial.incr(); 152 | 153 | assert!(serial > original); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/common/state.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! Fisher's global state. 17 | //! 18 | //! This module contains the code that keeps the Fisher global state. This is 19 | //! used for example to generate unique IDs across the codebase. The main 20 | //! [`State`](struct.State.html) struct is also marked as Sync and Send, so 21 | //! it can be used across threads without locking. 22 | 23 | use std::sync::atomic::{AtomicUsize, Ordering}; 24 | use std::cmp::PartialOrd; 25 | use std::cmp::Ordering as CmpOrdering; 26 | 27 | 28 | /// This enum represents a kind of ID. 29 | /// 30 | /// You should use this to specify which ID you do want. 31 | 32 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 33 | pub enum IdKind { 34 | /// This kind should be used to identify hooks. 35 | HookId, 36 | 37 | /// This kind should be used to identify threads. 38 | ThreadId, 39 | 40 | #[doc(hidden)] __NonExaustiveMatch, 41 | } 42 | 43 | 44 | /// This struct contains an unique ID. 45 | /// 46 | /// The struct is intentionally opaque, so you won't be able to get the actual 47 | /// value of the ID, but you can compare multiple IDs to get which one is 48 | /// greater, and check if multiple IDs are equal. This is done to be able to 49 | /// swap the inner implementation without breaking any code. 50 | 51 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] 52 | pub struct UniqueId { 53 | id: usize, 54 | kind: IdKind, 55 | } 56 | 57 | impl PartialOrd for UniqueId { 58 | fn partial_cmp(&self, other: &Self) -> Option { 59 | if self.kind == other.kind { 60 | self.id.partial_cmp(&other.id) 61 | } else { 62 | None 63 | } 64 | } 65 | } 66 | 67 | 68 | /// This struct keeps the global state of Fisher. 69 | 70 | #[derive(Debug)] 71 | pub struct State { 72 | counter: AtomicUsize, 73 | } 74 | 75 | impl State { 76 | /// Create a new instance of the struct. 77 | pub fn new() -> Self { 78 | State { 79 | counter: AtomicUsize::new(0), 80 | } 81 | } 82 | 83 | /// Get the next ID for a specific [`IdKind`](enum.IdKind.html). The ID is 84 | /// guaranteed to be unique and greater than the last ID. 85 | pub fn next_id(&self, kind: IdKind) -> UniqueId { 86 | UniqueId { 87 | id: self.counter.fetch_add(1, Ordering::SeqCst), 88 | kind: kind, 89 | } 90 | } 91 | } 92 | 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::{IdKind, State}; 97 | 98 | 99 | #[test] 100 | fn test_next_id() { 101 | // State must always increment 102 | let state = State::new(); 103 | let id1 = state.next_id(IdKind::HookId); 104 | let id2 = state.next_id(IdKind::HookId); 105 | let id3 = state.next_id(IdKind::ThreadId); 106 | 107 | assert!(id1 < id2); 108 | assert!(id1 == id1); 109 | assert!(id1 != id2); 110 | assert!(id1 != id3); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/common/structs.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! Structs used by Fisher. 17 | 18 | 19 | /// This struct contains some information about how the processor is feeling. 20 | 21 | #[derive(Copy, Clone, Debug, Serialize)] 22 | pub struct HealthDetails { 23 | /// The number of jobs in the queue, waiting to be processed. 24 | pub queued_jobs: usize, 25 | 26 | /// The number of threads currently processing some jobs. 27 | pub busy_threads: u16, 28 | 29 | /// The total number of threads running, either waiting or working. 30 | pub max_threads: u16, 31 | } 32 | -------------------------------------------------------------------------------- /src/common/traits.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! Traits used by Fisher. 17 | 18 | use std::hash::Hash; 19 | use std::sync::Arc; 20 | use std::fmt::Debug; 21 | 22 | use super::prelude::*; 23 | use super::structs::HealthDetails; 24 | 25 | 26 | /// This trait represents a script that can be run by Fisher. 27 | pub trait ScriptTrait { 28 | /// The type of the ID of the script. Must be hashable. 29 | type Id: Debug + Hash + PartialEq + Eq + Send + Copy + Clone; 30 | 31 | /// This method returns the unique ID of this script. The ID must be 32 | /// the same between calls to the same script. 33 | fn id(&self) -> Self::Id; 34 | 35 | /// This method returns if multiple instances of the script can be safely 36 | /// run in parallel. 37 | fn can_be_parallel(&self) -> bool; 38 | } 39 | 40 | 41 | /// This trait represents a repository of scripts. 42 | pub trait ScriptsRepositoryTrait: Send + Sync { 43 | /// The type of the scripts. Must implement 44 | /// [`ScriptTrait`](trait.ScriptTrait.html). 45 | type Script: ScriptTrait + Send + Sync; 46 | 47 | /// The type of the jobs. Must implement [`JobTrait`](trait.JobTrait.html). 48 | type Job: JobTrait + Debug + Send + Sync + Clone; 49 | 50 | /// The iterator returned by the `iter` method. 51 | type ScriptsIter: Iterator>; 52 | 53 | /// The iterator returned by the `jobs_after_output` method 54 | type JobsIter: Iterator; 55 | 56 | /// Get a script by its ID. 57 | fn id_exists(&self, id: &::Id) -> bool; 58 | 59 | /// Get an iterator over all the scripts. 60 | fn iter(&self) -> Self::ScriptsIter; 61 | 62 | /// Return all the jobs generated as a conseguence of the result of another 63 | /// job. 64 | /// 65 | /// In Fisher, this is used to spawn status hooks when another job 66 | /// completes, but it can also return nothing. 67 | fn jobs_after_output( 68 | &self, 69 | output: >::Output, 70 | ) -> Option; 71 | } 72 | 73 | 74 | /// This trait represents a Job that can be processed by Fisher. 75 | pub trait JobTrait { 76 | /// The context that will be provided to the job. 77 | type Context: Debug + Send + Sync; 78 | 79 | /// The output that will be returned by the job. 80 | type Output: Clone + Send + Sync; 81 | 82 | /// Execute the job and return the output of it. 83 | fn execute(&self, ctx: &Self::Context) -> Result; 84 | 85 | /// Get the ID of the underlying script. 86 | fn script_id(&self) -> S::Id; 87 | 88 | /// Get the name of the underlying script. 89 | fn script_name(&self) -> &str; 90 | } 91 | 92 | 93 | /// This trait represents the API of the processor 94 | pub trait ProcessorApiTrait: Send { 95 | /// Queue a new job into the processor. 96 | fn queue(&self, job: S::Job, priority: isize) -> Result<()>; 97 | 98 | /// Get some insights about the health of the processor. 99 | fn health_details(&self) -> Result; 100 | 101 | /// Execute periodic cleanup tasks on the processor. 102 | fn cleanup(&self) -> Result<()>; 103 | 104 | /// Lock the processor, preventing new jobs to be run. 105 | fn lock(&self) -> Result<()>; 106 | 107 | /// Unlock the processor, allowing new jobs to be run. 108 | fn unlock(&self) -> Result<()>; 109 | } 110 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | #![recursion_limit="256"] 17 | 18 | extern crate ansi_term; 19 | #[macro_use] 20 | extern crate error_chain; 21 | #[cfg(test)] 22 | extern crate hyper; 23 | #[macro_use] 24 | extern crate lazy_static; 25 | extern crate nix; 26 | extern crate rand; 27 | extern crate regex; 28 | extern crate hmac; 29 | extern crate sha1; 30 | extern crate serde; 31 | #[macro_use] 32 | extern crate serde_derive; 33 | #[macro_use] 34 | extern crate serde_json; 35 | extern crate tempdir; 36 | extern crate tiny_http; 37 | extern crate url; 38 | extern crate users; 39 | 40 | #[macro_use] 41 | mod utils; 42 | mod app; 43 | mod processor; 44 | mod providers; 45 | mod requests; 46 | mod scripts; 47 | mod web; 48 | pub mod common; 49 | 50 | // Public API 51 | pub use app::Fisher; 52 | pub use common::config::Config; 53 | pub use common::errors::*; 54 | -------------------------------------------------------------------------------- /src/processor/api.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::sync::{mpsc, Arc}; 17 | 18 | use common::prelude::*; 19 | use common::state::State; 20 | use common::structs::HealthDetails; 21 | 22 | use processor::scheduler::{Scheduler, SchedulerInput}; 23 | #[cfg(test)] 24 | use processor::scheduler::DebugDetails; 25 | use processor::types::{Job, JobContext}; 26 | 27 | 28 | /// This struct allows you to spawn a new processor, stop it and get its 29 | /// [`ProcessorApi`](struct.ProcessorApi.html). 30 | 31 | #[derive(Debug)] 32 | pub struct Processor { 33 | input: mpsc::Sender>, 34 | wait: mpsc::Receiver<()>, 35 | } 36 | 37 | impl Processor { 38 | /// Create a new processor with the provided configuration. The returned 39 | /// struct allows you to control it. 40 | pub fn new( 41 | max_threads: u16, 42 | hooks: Arc, 43 | ctx: JobContext, 44 | state: Arc, 45 | ) -> Result { 46 | // Retrieve wanted information from the spawned thread 47 | let (input_send, input_recv) = mpsc::sync_channel(0); 48 | let (wait_send, wait_recv) = mpsc::channel(); 49 | 50 | ::std::thread::spawn(move || { 51 | let inner = Scheduler::new(max_threads, hooks, ctx, state); 52 | input_send.send(inner.input()).unwrap(); 53 | 54 | inner.run().unwrap(); 55 | 56 | // Notify the main thread this exited 57 | wait_send.send(()).unwrap(); 58 | }); 59 | 60 | Ok(Processor { 61 | input: input_recv.recv()?, 62 | wait: wait_recv, 63 | }) 64 | } 65 | 66 | /// Stop this processor, and return only when the processor is stopped. 67 | pub fn stop(self) -> Result<()> { 68 | // Ask the processor to stop 69 | self.input.send(SchedulerInput::StopSignal)?; 70 | self.wait.recv()?; 71 | 72 | Ok(()) 73 | } 74 | 75 | /// Get a struct allowing you to control the processor. 76 | pub fn api(&self) -> ProcessorApi { 77 | ProcessorApi { 78 | input: self.input.clone(), 79 | } 80 | } 81 | } 82 | 83 | 84 | /// This struct allows you to interact with a running processor. 85 | 86 | #[derive(Debug, Clone)] 87 | pub struct ProcessorApi { 88 | input: mpsc::Sender>, 89 | } 90 | 91 | impl ProcessorApi { 92 | #[cfg(test)] 93 | pub fn debug_details(&self) -> Result> { 94 | let (res_send, res_recv) = mpsc::channel(); 95 | self.input.send(SchedulerInput::DebugDetails(res_send))?; 96 | Ok(res_recv.recv()?) 97 | } 98 | 99 | pub fn update_context(&self, ctx: JobContext) -> Result<()> { 100 | self.input.send(SchedulerInput::UpdateContext(ctx))?; 101 | Ok(()) 102 | } 103 | 104 | pub fn set_threads_count(&self, count: u16) -> Result<()> { 105 | self.input.send(SchedulerInput::SetThreadsCount(count))?; 106 | Ok(()) 107 | } 108 | } 109 | 110 | impl ProcessorApiTrait for ProcessorApi { 111 | fn queue(&self, job: Job, priority: isize) -> Result<()> { 112 | self.input.send(SchedulerInput::Job(job, priority))?; 113 | Ok(()) 114 | } 115 | 116 | fn health_details(&self) -> Result { 117 | let (res_send, res_recv) = mpsc::channel(); 118 | self.input.send(SchedulerInput::HealthStatus(res_send))?; 119 | Ok(res_recv.recv()?) 120 | } 121 | 122 | fn cleanup(&self) -> Result<()> { 123 | self.input.send(SchedulerInput::Cleanup)?; 124 | Ok(()) 125 | } 126 | 127 | fn lock(&self) -> Result<()> { 128 | self.input.send(SchedulerInput::Lock)?; 129 | Ok(()) 130 | } 131 | 132 | fn unlock(&self) -> Result<()> { 133 | self.input.send(SchedulerInput::Unlock)?; 134 | Ok(()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/processor/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | //! This crate contains the processor used by the Fisher application, and its 17 | //! public API. 18 | 19 | #![warn(missing_docs)] 20 | 21 | mod api; 22 | mod scheduled_job; 23 | mod scheduler; 24 | mod thread; 25 | mod types; 26 | #[cfg(test)] 27 | mod test_utils; 28 | 29 | pub use processor::api::{Processor, ProcessorApi}; 30 | -------------------------------------------------------------------------------- /src/processor/scheduled_job.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::cmp::Ordering; 17 | 18 | use common::prelude::*; 19 | use common::serial::Serial; 20 | 21 | use super::types::{Job, JobContext, JobOutput, ScriptId}; 22 | 23 | 24 | #[derive(Debug)] 25 | pub struct ScheduledJob { 26 | job: Job, 27 | priority: isize, 28 | serial: Serial, 29 | } 30 | 31 | impl ScheduledJob { 32 | pub fn new(job: Job, priority: isize, serial: Serial) -> Self { 33 | ScheduledJob { 34 | job: job, 35 | priority: priority, 36 | serial: serial, 37 | } 38 | } 39 | 40 | pub fn execute(&self, ctx: &JobContext) -> Result> { 41 | self.job.execute(ctx) 42 | .chain_err(|| { 43 | ErrorKind::ScriptExecutionFailed(self.hook_name().into()) 44 | }) 45 | } 46 | 47 | pub fn hook_id(&self) -> ScriptId { 48 | self.job.script_id() 49 | } 50 | 51 | pub fn hook_name(&self) -> &str { 52 | self.job.script_name() 53 | } 54 | } 55 | 56 | impl Ord for ScheduledJob { 57 | fn cmp(&self, other: &ScheduledJob) -> Ordering { 58 | let priority_ord = self.priority.cmp(&other.priority); 59 | 60 | if priority_ord == Ordering::Equal { 61 | self.serial.cmp(&other.serial).reverse() 62 | } else { 63 | priority_ord 64 | } 65 | } 66 | } 67 | 68 | impl PartialOrd for ScheduledJob { 69 | fn partial_cmp(&self, other: &ScheduledJob) -> Option { 70 | Some(self.cmp(other)) 71 | } 72 | } 73 | 74 | impl PartialEq for ScheduledJob { 75 | fn eq(&self, other: &ScheduledJob) -> bool { 76 | self.priority == other.priority 77 | } 78 | } 79 | 80 | impl Eq for ScheduledJob {} 81 | -------------------------------------------------------------------------------- /src/processor/test_utils.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::collections::{HashMap, VecDeque}; 17 | use std::fmt::{self, Debug}; 18 | use std::sync::{Arc, Mutex, RwLock}; 19 | use std::sync::atomic::{AtomicUsize, Ordering}; 20 | 21 | use common::prelude::*; 22 | 23 | 24 | pub struct Script { 25 | id: usize, 26 | name: String, 27 | can_be_parallel: bool, 28 | func: Arc Result<()> + Send>>>, 29 | } 30 | 31 | impl ScriptTrait for Script { 32 | type Id = usize; 33 | 34 | fn id(&self) -> usize { 35 | self.id 36 | } 37 | 38 | fn can_be_parallel(&self) -> bool { 39 | self.can_be_parallel 40 | } 41 | } 42 | 43 | impl Debug for Script { 44 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 45 | write!( 46 | f, 47 | "Script {{ id: {}, name: {}, can_be_parallel: {} }}", 48 | self.id, 49 | self.name, 50 | self.can_be_parallel, 51 | ) 52 | } 53 | } 54 | 55 | 56 | #[derive(Debug, Clone)] 57 | pub struct Job { 58 | script: Arc>, 59 | args: I, 60 | } 61 | 62 | impl JobTrait> for Job { 63 | type Context = (); 64 | type Output = (); 65 | 66 | fn execute(&self, _: &()) -> Result<()> { 67 | (self.script.func.lock().unwrap())(self.args.clone()) 68 | } 69 | 70 | fn script_id(&self) -> usize { 71 | self.script.id 72 | } 73 | 74 | fn script_name(&self) -> &str { 75 | &self.script.name 76 | } 77 | } 78 | 79 | 80 | pub struct Repository { 81 | last_id: AtomicUsize, 82 | scripts: RwLock>>>, 83 | ids: RwLock>, 84 | } 85 | 86 | impl Repository { 87 | pub fn new() -> Self { 88 | Repository { 89 | last_id: AtomicUsize::new(0), 90 | ids: RwLock::new(Vec::new()), 91 | scripts: RwLock::new(HashMap::new()), 92 | } 93 | } 94 | 95 | pub fn add_script Result<()> + 'static + Send>( 96 | &self, 97 | name: &str, 98 | parallel: bool, 99 | func: F, 100 | ) { 101 | self.ids 102 | .write() 103 | .unwrap() 104 | .push(self.last_id.load(Ordering::SeqCst)); 105 | self.scripts.write().unwrap().insert( 106 | name.to_string(), 107 | Arc::new(Script { 108 | id: self.last_id.fetch_add(1, Ordering::SeqCst), 109 | name: name.to_string(), 110 | can_be_parallel: parallel, 111 | func: Arc::new(Mutex::new(Box::new(func))), 112 | }), 113 | ); 114 | } 115 | 116 | pub fn job(&self, name: &str, args: I) -> Option> { 117 | self.scripts 118 | .read() 119 | .unwrap() 120 | .get(name) 121 | .cloned() 122 | .map(|script| Job { script, args }) 123 | } 124 | 125 | pub fn script_id_of(&self, name: &str) -> Option { 126 | self.scripts 127 | .read() 128 | .unwrap() 129 | .get(name) 130 | .map(|script| script.id()) 131 | } 132 | 133 | pub fn recreate_scripts(&self) { 134 | let mut scripts: Vec<_> = 135 | self.scripts.read().unwrap().values().cloned().collect(); 136 | 137 | self.ids.write().unwrap().clear(); 138 | self.scripts.write().unwrap().clear(); 139 | 140 | for script in scripts.drain(..) { 141 | self.add_script(&script.name, script.can_be_parallel, |_| Ok(())); 142 | } 143 | } 144 | } 145 | 146 | impl ScriptsRepositoryTrait for Repository { 147 | type Script = Script; 148 | type Job = Job; 149 | type ScriptsIter = SimpleIter>>; 150 | type JobsIter = SimpleIter>; 151 | 152 | fn id_exists(&self, id: &usize) -> bool { 153 | self.ids.read().unwrap().contains(id) 154 | } 155 | 156 | fn iter(&self) -> Self::ScriptsIter { 157 | SimpleIter::new( 158 | self.scripts.read().unwrap().values().cloned().collect(), 159 | ) 160 | } 161 | 162 | fn jobs_after_output(&self, _: ()) -> Option { 163 | None 164 | } 165 | } 166 | 167 | 168 | pub struct SimpleIter { 169 | values: VecDeque, 170 | } 171 | 172 | impl SimpleIter { 173 | fn new(values: VecDeque) -> Self { 174 | SimpleIter { values } 175 | } 176 | } 177 | 178 | impl Iterator for SimpleIter { 179 | type Item = T; 180 | 181 | fn next(&mut self) -> Option { 182 | self.values.pop_front() 183 | } 184 | } 185 | 186 | 187 | pub fn test_wrapper Result<()>>(func: F) { 188 | let result = func(); 189 | if let Err(error) = result { 190 | panic!("{}", error); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/processor/types.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use common::prelude::*; 17 | 18 | 19 | pub type Job = ::Job; 20 | 21 | pub type JobContext = 22 | <::Job as JobTrait< 23 | ::Script, 24 | >>::Context; 25 | 26 | pub type JobOutput = 27 | <::Job as JobTrait< 28 | ::Script, 29 | >>::Output; 30 | 31 | pub type ScriptId = < 32 | ::Script as ScriptTrait 33 | >::Id; 34 | -------------------------------------------------------------------------------- /src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | mod status; 17 | mod standalone; 18 | mod github; 19 | mod gitlab; 20 | #[cfg(test)] 21 | pub mod testing; 22 | 23 | 24 | pub mod prelude { 25 | pub use providers::ProviderTrait; 26 | pub use requests::{Request, RequestType}; 27 | pub use common::prelude::*; 28 | pub use scripts::EnvBuilder; 29 | } 30 | 31 | 32 | pub use self::status::{StatusEvent, StatusEventKind, StatusProvider}; 33 | 34 | 35 | use requests::{Request, RequestType}; 36 | use common::prelude::*; 37 | use scripts::EnvBuilder; 38 | 39 | 40 | /// This trait should be implemented by every Fisher provider 41 | /// The objects implementing this trait must also implement Clone and Debug 42 | pub trait ProviderTrait: ::std::fmt::Debug { 43 | /// This method should create a new instance of the provider, from a 44 | /// given configuration string 45 | fn new(&str) -> Result 46 | where 47 | Self: Sized; 48 | 49 | /// This method should validate an incoming request, returning its 50 | /// type if the request is valid 51 | fn validate(&self, &Request) -> RequestType; 52 | 53 | /// This method should build the environment to process an incoming 54 | /// request 55 | fn build_env(&self, req: &Request, builder: &mut EnvBuilder) -> Result<()>; 56 | 57 | /// This method tells the scheduler if the hook should trigger status hooks 58 | /// after the request is processed. By default this returns true, change it 59 | /// only if you really know what you're doing 60 | fn trigger_status_hooks(&self, _req: &Request) -> bool { 61 | true 62 | } 63 | } 64 | 65 | 66 | macro_rules! ProviderEnum { 67 | ($($cfg:meta | $name:ident => $provider:path),*) => { 68 | 69 | #[derive(Debug)] 70 | pub enum Provider { 71 | $( 72 | #[cfg($cfg)] 73 | $name($provider), 74 | )* 75 | } 76 | 77 | impl Provider { 78 | 79 | pub fn new(name: &str, config: &str) -> Result { 80 | match name { 81 | $( 82 | #[cfg($cfg)] 83 | stringify!($name) => { 84 | use $provider as InnerProvider; 85 | match InnerProvider::new(config) { 86 | Ok(prov) => Ok(Provider::$name(prov)), 87 | Err(err) => Err(err), 88 | } 89 | }, 90 | )* 91 | _ => Err( 92 | ErrorKind::ProviderNotFound(name.to_string()).into() 93 | ), 94 | } 95 | } 96 | 97 | pub fn validate(&self, req: &Request) -> RequestType { 98 | match *self { 99 | $( 100 | #[cfg($cfg)] 101 | Provider::$name(ref prov) => { 102 | (prov as &ProviderTrait).validate(req) 103 | }, 104 | )* 105 | } 106 | } 107 | 108 | pub fn build_env( 109 | &self, req: &Request, builder: &mut EnvBuilder, 110 | ) -> Result<()> { 111 | match *self { 112 | $( 113 | #[cfg($cfg)] 114 | Provider::$name(ref prov) => { 115 | (prov as &ProviderTrait).build_env(req, builder) 116 | }, 117 | )* 118 | } 119 | } 120 | 121 | pub fn trigger_status_hooks(&self, req: &Request) -> bool { 122 | match *self { 123 | $( 124 | #[cfg($cfg)] 125 | Provider::$name(ref prov) => { 126 | (prov as &ProviderTrait).trigger_status_hooks(req) 127 | } 128 | )* 129 | } 130 | } 131 | 132 | #[allow(dead_code)] 133 | pub fn name(&self) -> &str { 134 | match *self { 135 | $( 136 | #[cfg($cfg)] 137 | Provider::$name(..) => stringify!($name), 138 | )* 139 | } 140 | } 141 | } 142 | }; 143 | } 144 | 145 | 146 | ProviderEnum! { 147 | any(test, not(test)) | Standalone => self::standalone::StandaloneProvider, 148 | any(test, not(test)) | Status => self::status::StatusProvider, 149 | any(test, not(test)) | GitHub => self::github::GitHubProvider, 150 | any(test, not(test)) | GitLab => self::gitlab::GitLabProvider, 151 | test | Testing => self::testing::TestingProvider 152 | } 153 | -------------------------------------------------------------------------------- /src/providers/standalone.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::net::IpAddr; 17 | 18 | use serde_json; 19 | 20 | use providers::prelude::*; 21 | 22 | 23 | #[derive(Debug, Deserialize)] 24 | pub struct StandaloneProvider { 25 | secret: Option, 26 | from: Option>, 27 | 28 | param_name: Option, 29 | header_name: Option, 30 | } 31 | 32 | impl StandaloneProvider { 33 | fn param_name(&self) -> String { 34 | match self.param_name { 35 | Some(ref name) => name.clone(), 36 | None => "secret".into(), 37 | } 38 | } 39 | 40 | fn header_name(&self) -> String { 41 | match self.header_name { 42 | Some(ref name) => name.clone(), 43 | None => "X-Fisher-Secret".into(), 44 | } 45 | } 46 | } 47 | 48 | impl ProviderTrait for StandaloneProvider { 49 | fn new(config: &str) -> Result { 50 | // Check if it's possible to create a new instance and return it 51 | let inst = serde_json::from_str(config)?; 52 | Ok(inst) 53 | } 54 | 55 | fn validate(&self, request: &Request) -> RequestType { 56 | let req; 57 | if let Request::Web(ref inner) = *request { 58 | req = inner; 59 | } else { 60 | return RequestType::Invalid; 61 | } 62 | 63 | // Check if the secret code is valid 64 | if let Some(ref correct_secret) = self.secret { 65 | let secret = if let Some(found) = req.params.get(&self.param_name()) { 66 | // Secret in the request parameters 67 | found 68 | } else if let Some(found) = req.headers.get(&self.header_name()) { 69 | // Secret in the HTTP headers 70 | found 71 | } else { 72 | // No secret present, abort! 73 | return RequestType::Invalid; 74 | }; 75 | 76 | // Abort if the secret doesn't match 77 | if secret != correct_secret { 78 | return RequestType::Invalid; 79 | } 80 | } 81 | 82 | // Check if the IP address is allowed 83 | if let Some(ref allowed) = self.from { 84 | if !allowed.contains(&req.source) { 85 | return RequestType::Invalid; 86 | } 87 | } 88 | 89 | RequestType::ExecuteHook 90 | } 91 | 92 | fn build_env(&self, _: &Request, _: &mut EnvBuilder) -> Result<()> { 93 | Ok(()) 94 | } 95 | } 96 | 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use std::collections::HashMap; 101 | 102 | use utils::testing::*; 103 | use requests::RequestType; 104 | use providers::ProviderTrait; 105 | use scripts::EnvBuilder; 106 | 107 | use super::StandaloneProvider; 108 | 109 | 110 | #[test] 111 | fn test_new() { 112 | // Check if valid config is accepted 113 | let right = vec![ 114 | r#"{}"#, 115 | r#"{"secret": "abcde"}"#, 116 | r#"{"secret": "abcde", "param_name": "a"}"#, 117 | r#"{"secret": "abcde", "header_name": "X-b"}"#, 118 | r#"{"secret": "abcde", "param_name": "a", "header_name": "b"}"#, 119 | r#"{"from": ["127.0.0.1", "192.168.1.1", "10.0.0.2"]}"#, 120 | r#"{"from": ["127.0.0.1"], "secret": "abcde"}"#, 121 | ]; 122 | for one in &right { 123 | assert!(StandaloneProvider::new(one).is_ok(), "Should be valid: {}", one); 124 | } 125 | 126 | let wrong = vec![ 127 | r#"{"secret": 123}"#, 128 | r#"{"secret": true}"#, 129 | r#"{"secret": ["a", "b"]}"#, 130 | r#"{"secret": {"a": "b"}}"#, 131 | r#"{"from": "127.0.0.1"}"#, 132 | r#"{"from": ["256.0.0.1"]}"#, 133 | ]; 134 | for one in &wrong { 135 | assert!(StandaloneProvider::new(one).is_err(), "Should be invalid: {}", one); 136 | } 137 | } 138 | 139 | #[test] 140 | fn test_validate_secret() { 141 | let config = r#"{"secret": "abcde"}"#; 142 | let config_custom = concat!( 143 | r#"{"secret": "abcde", "param_name": "a","#, 144 | r#" "header_name": "X-A"}"# 145 | ); 146 | 147 | test_validate_inner_secret(config, "secret", "X-Fisher-Secret"); 148 | test_validate_inner_secret(config_custom, "a", "X-A"); 149 | } 150 | 151 | fn test_validate_inner_secret(config: &str, param_name: &str, header_name: &str) { 152 | let p = StandaloneProvider::new(config).unwrap(); 153 | 154 | // Test a request with no headers or params 155 | // It should not be validate 156 | assert_eq!( 157 | p.validate(&dummy_web_request().into()), 158 | RequestType::Invalid 159 | ); 160 | 161 | // Test a request with the secret param, but the wrong secret key 162 | // It should not be validated 163 | let mut req = dummy_web_request(); 164 | req.params 165 | .insert(param_name.to_string(), "12345".to_string()); 166 | assert_eq!(p.validate(&req.into()), RequestType::Invalid); 167 | 168 | // Test a request with the secret param and the correct secret key 169 | // It should be validated 170 | let mut req = dummy_web_request(); 171 | req.params 172 | .insert(param_name.to_string(), "abcde".to_string()); 173 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 174 | 175 | // Test a request with the secret header, but the wrong secret key 176 | // It should not be validated 177 | let mut req = dummy_web_request(); 178 | req.headers 179 | .insert(header_name.to_string(), "12345".to_string()); 180 | assert_eq!(p.validate(&req.into()), RequestType::Invalid); 181 | 182 | // Test a request with the secret header and the correct secret key 183 | // It should be validated 184 | let mut req = dummy_web_request(); 185 | req.headers 186 | .insert(header_name.to_string(), "abcde".to_string()); 187 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 188 | } 189 | 190 | #[test] 191 | fn test_validate_from() { 192 | let config = r#"{"from": ["192.168.1.1", "10.0.0.1"]}"#; 193 | let p = StandaloneProvider::new(config).unwrap(); 194 | 195 | let mut req = dummy_web_request(); 196 | req.source = "127.0.0.1".parse().unwrap(); 197 | assert_eq!(p.validate(&req.into()), RequestType::Invalid); 198 | 199 | for ip in &["192.168.1.1", "10.0.0.1"] { 200 | let mut req = dummy_web_request(); 201 | req.source = ip.parse().unwrap(); 202 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 203 | } 204 | } 205 | 206 | 207 | #[test] 208 | fn test_build_env() { 209 | let p = StandaloneProvider::new(r#"{"secret": "abcde"}"#).unwrap(); 210 | let mut b = EnvBuilder::dummy(); 211 | p.build_env(&dummy_web_request().into(), &mut b).unwrap(); 212 | 213 | assert_eq!(b.dummy_data().env, HashMap::new()); 214 | assert_eq!(b.dummy_data().files, HashMap::new()); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/providers/testing.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::net::IpAddr; 17 | use std::str::FromStr; 18 | 19 | use providers::prelude::*; 20 | use common::prelude::*; 21 | 22 | 23 | #[derive(Debug)] 24 | pub struct TestingProvider { 25 | config: String, 26 | } 27 | 28 | impl ProviderTrait for TestingProvider { 29 | fn new(config: &str) -> Result { 30 | // If the configuration is "yes", then it's correct 31 | if config != "FAIL" { 32 | Ok(TestingProvider { 33 | config: config.into(), 34 | }) 35 | } else { 36 | // This error doesn't make any sense, but it's still an error 37 | Err(ErrorKind::ProviderNotFound(String::new()).into()) 38 | } 39 | } 40 | 41 | fn validate(&self, request: &Request) -> RequestType { 42 | let req; 43 | if let &Request::Web(ref inner) = request { 44 | req = inner; 45 | } else { 46 | return RequestType::Invalid; 47 | } 48 | 49 | // If the secret param is provided, validate it 50 | if let Some(secret) = req.params.get("secret") { 51 | if secret != "testing" { 52 | return RequestType::Invalid; 53 | } 54 | } 55 | 56 | // If the ip param is provided, validate it 57 | if let Some(ip) = req.params.get("ip") { 58 | if req.source != IpAddr::from_str(ip).unwrap() { 59 | return RequestType::Invalid; 60 | } 61 | } 62 | 63 | // Allow to override the result of this 64 | if let Some(request_type) = req.params.get("request_type") { 65 | match request_type.as_ref() { 66 | // "ping" will return RequestType::Ping 67 | "ping" => { 68 | return RequestType::Ping; 69 | } 70 | _ => {} 71 | } 72 | } 73 | 74 | RequestType::ExecuteHook 75 | } 76 | 77 | fn build_env(&self, r: &Request, b: &mut EnvBuilder) -> Result<()> { 78 | let req; 79 | if let &Request::Web(ref inner) = r { 80 | req = inner; 81 | } else { 82 | return Ok(()); 83 | } 84 | 85 | if let Some(env) = req.params.get("env") { 86 | b.add_env("ENV", env); 87 | } 88 | 89 | writeln!(b.data_file("prepared")?, "prepared")?; 90 | 91 | Ok(()) 92 | } 93 | 94 | fn trigger_status_hooks(&self, request: &Request) -> bool { 95 | if let &Request::Web(ref inner) = request { 96 | !inner.params.contains_key("ignore_status_hooks") 97 | } else { 98 | true 99 | } 100 | } 101 | } 102 | 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use std::net::IpAddr; 107 | use std::str::FromStr; 108 | 109 | use utils::testing::*; 110 | use requests::RequestType; 111 | use providers::ProviderTrait; 112 | use scripts::EnvBuilder; 113 | 114 | use super::TestingProvider; 115 | 116 | 117 | #[test] 118 | fn test_new() { 119 | assert!(TestingProvider::new("").is_ok()); 120 | assert!(TestingProvider::new("SOMETHING").is_ok()); 121 | assert!(TestingProvider::new("FAIL").is_err()); 122 | } 123 | 124 | 125 | #[test] 126 | fn test_validate() { 127 | let p = TestingProvider::new("").unwrap(); 128 | 129 | // Without any secret 130 | assert_eq!( 131 | p.validate(&dummy_web_request().into()), 132 | RequestType::ExecuteHook 133 | ); 134 | 135 | // With the wrong secret 136 | let mut req = dummy_web_request(); 137 | req.params 138 | .insert("secret".to_string(), "wrong!!!".to_string()); 139 | assert_eq!(p.validate(&req.into()), RequestType::Invalid); 140 | 141 | // With the correct secret 142 | let mut req = dummy_web_request(); 143 | req.params 144 | .insert("secret".to_string(), "testing".to_string()); 145 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 146 | 147 | // With the wrong IP address 148 | let mut req = dummy_web_request(); 149 | req.params.insert("ip".into(), "127.1.1.1".into()); 150 | req.source = IpAddr::from_str("127.2.2.2").unwrap(); 151 | assert_eq!(p.validate(&req.into()), RequestType::Invalid); 152 | 153 | // With the right IP address 154 | let mut req = dummy_web_request(); 155 | req.params.insert("ip".into(), "127.1.1.1".into()); 156 | req.source = IpAddr::from_str("127.1.1.1").unwrap(); 157 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 158 | 159 | // With the request_type param but with no meaningful value 160 | let mut req = dummy_web_request(); 161 | req.params 162 | .insert("request_type".to_string(), "something".to_string()); 163 | assert_eq!(p.validate(&req.into()), RequestType::ExecuteHook); 164 | 165 | // With the request_type param and the "ping" value 166 | let mut req = dummy_web_request(); 167 | req.params 168 | .insert("request_type".to_string(), "ping".to_string()); 169 | assert_eq!(p.validate(&req.into()), RequestType::Ping); 170 | } 171 | 172 | 173 | #[test] 174 | fn test_build_env() { 175 | let p = TestingProvider::new("").unwrap(); 176 | 177 | // Without the env param 178 | let mut b = EnvBuilder::dummy(); 179 | p.build_env(&dummy_web_request().into(), &mut b).unwrap(); 180 | 181 | assert_eq!(b.dummy_data().env, hashmap! { 182 | "PREPARED".into() => "prepared".into(), 183 | }); 184 | assert_eq!(b.dummy_data().files, hashmap! { 185 | "prepared".into() => "prepared\n".bytes().collect::>(), 186 | }); 187 | 188 | // With the env param 189 | let mut req = dummy_web_request(); 190 | req.params.insert("env".to_string(), "test".to_string()); 191 | 192 | let mut b = EnvBuilder::dummy(); 193 | p.build_env(&req.into(), &mut b).unwrap(); 194 | 195 | assert_eq!(b.dummy_data().env, hashmap! { 196 | "PREPARED".into() => "prepared".into(), 197 | "ENV".into() => "test".into(), 198 | }); 199 | assert_eq!(b.dummy_data().files, hashmap! { 200 | "prepared".into() => "prepared\n".bytes().collect::>(), 201 | }); 202 | } 203 | 204 | #[test] 205 | fn test_trigger_status_hooks() { 206 | let p = TestingProvider::new("").unwrap(); 207 | 208 | assert!(p.trigger_status_hooks(&dummy_web_request().into())); 209 | 210 | let mut req = dummy_web_request(); 211 | req.params 212 | .insert("ignore_status_hooks".into(), "yes".into()); 213 | 214 | assert!(!p.trigger_status_hooks(&req.into())); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/requests.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use common::prelude::*; 17 | use web::WebRequest; 18 | use providers::StatusEvent; 19 | 20 | 21 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 22 | pub enum RequestType { 23 | ExecuteHook, 24 | Ping, 25 | Invalid, 26 | } 27 | 28 | 29 | #[derive(Debug, Clone)] 30 | pub enum Request { 31 | Web(WebRequest), 32 | Status(StatusEvent), 33 | } 34 | 35 | impl Request { 36 | pub fn web(&self) -> Result<&WebRequest> { 37 | if let Request::Web(ref req) = *self { 38 | Ok(req) 39 | } else { 40 | Err(ErrorKind::WrongRequestKind.into()) 41 | } 42 | } 43 | } 44 | 45 | 46 | impl From for Request { 47 | fn from(from: WebRequest) -> Request { 48 | Request::Web(from) 49 | } 50 | } 51 | 52 | 53 | impl From for Request { 54 | fn from(from: StatusEvent) -> Request { 55 | Request::Status(from) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/scripts/collector.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::fs::{canonicalize, read_dir, ReadDir}; 17 | use std::path::{Path, PathBuf}; 18 | use std::collections::VecDeque; 19 | use std::os::unix::fs::PermissionsExt; 20 | use std::sync::Arc; 21 | 22 | use common::prelude::*; 23 | use common::state::State; 24 | 25 | use scripts::Script; 26 | 27 | 28 | pub(in scripts) struct Collector { 29 | dirs: VecDeque, 30 | state: Arc, 31 | base: PathBuf, 32 | recursive: bool, 33 | } 34 | 35 | impl Collector { 36 | pub(in scripts) fn new>( 37 | base: P, 38 | state: Arc, 39 | recursive: bool, 40 | ) -> Result { 41 | let mut dirs = VecDeque::new(); 42 | dirs.push_front(read_dir(&base)?); 43 | 44 | Ok(Collector { 45 | dirs: dirs, 46 | state: state, 47 | base: base.as_ref().to_path_buf(), 48 | recursive: recursive, 49 | }) 50 | } 51 | 52 | fn collect_file(&mut self, e: PathBuf) -> Result>> { 53 | if e.is_dir() { 54 | if self.recursive { 55 | self.dirs.push_back(read_dir(&e)?); 56 | } 57 | return Ok(None); 58 | } 59 | 60 | // Check if the file is executable and readable 61 | let mode = e.metadata()?.permissions().mode(); 62 | if !((mode & 0o111) != 0 && (mode & 0o444) != 0) { 63 | // Skip files with wrong permissions 64 | return Ok(None); 65 | } 66 | 67 | // Try to remove the prefix from the path 68 | let name = match e.strip_prefix(&self.base) { 69 | Ok(stripped) => stripped, 70 | Err(_) => &e, 71 | }.to_str() 72 | .unwrap() 73 | .to_string(); 74 | 75 | let exec = canonicalize(&e)?.to_str().unwrap().into(); 76 | 77 | Ok(Some(Arc::new(Script::load(name, exec, &self.state)?))) 78 | } 79 | } 80 | 81 | impl Iterator for Collector { 82 | type Item = Result>; 83 | 84 | fn next(&mut self) -> Option { 85 | loop { 86 | let entry = if let Some(iter) = self.dirs.get_mut(0) { 87 | iter.next() 88 | } else { 89 | // No more directories to search in 90 | return None; 91 | }; 92 | 93 | match entry { 94 | // Found an entry 95 | Some(Ok(entry)) => { 96 | match self.collect_file(entry.path()) { 97 | Ok(result) => { 98 | if let Some(script) = result { 99 | return Some(Ok(script)); 100 | } 101 | // If None is returned get another one 102 | } 103 | Err(err) => { 104 | return Some(Err(err)); 105 | } 106 | } 107 | } 108 | // I/O error while getting the next entry 109 | Some(Err(err)) => { 110 | return Some(Err(err.into())); 111 | } 112 | // No more entries in the directory 113 | None => { 114 | // Don't search in this directory anymore 115 | let _ = self.dirs.pop_front(); 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use std::os::unix::fs::OpenOptionsExt; 126 | use std::fs; 127 | 128 | use common::prelude::*; 129 | use scripts::test_utils::*; 130 | 131 | use super::Collector; 132 | 133 | 134 | fn assert_collected( 135 | env: &TestEnv, 136 | recurse: bool, 137 | expected: &[&str], 138 | ) -> Result<()> { 139 | let mut found = 0; 140 | 141 | let c = Collector::new(&env.scripts_dir(), env.state(), recurse)?; 142 | for script in c { 143 | found += 1; 144 | 145 | let script = script?; 146 | if !expected.contains(&script.name()) { 147 | panic!("Unexpected script collected: {}", script.name()); 148 | } 149 | } 150 | 151 | assert_eq!(found, expected.len()); 152 | Ok(()) 153 | } 154 | 155 | 156 | #[test] 157 | fn test_scripts_collection_collects_all_the_valid_scripts() { 158 | test_wrapper(|env| { 159 | // Create two scripts in the top level 160 | env.create_script("first.sh", &[])?; 161 | env.create_script("second.sh", &[])?; 162 | 163 | // Create a non-executable script 164 | fs::OpenOptions::new() 165 | .create(true) 166 | .write(true) 167 | .mode(0o644) 168 | .open(env.scripts_dir().join("third.sh"))?; 169 | 170 | // Create a directory with another script 171 | let dir = env.scripts_dir().join("subdir"); 172 | fs::create_dir(&dir)?; 173 | env.create_script_into(&dir, "fourth.sh", &[])?; 174 | 175 | // Ensure the collected scripts are the right ones 176 | assert_collected(&env, false, &["first.sh", "second.sh"])?; 177 | assert_collected( 178 | &env, 179 | true, 180 | &["first.sh", "second.sh", "subdir/fourth.sh"], 181 | )?; 182 | 183 | Ok(()) 184 | }); 185 | } 186 | 187 | 188 | #[test] 189 | fn test_scripts_collection_with_invalid_scripts_fails() { 190 | test_wrapper(|env| { 191 | // Create a valid script 192 | env.create_script( 193 | "valid.sh", 194 | &[ 195 | r#"#!/bin/bash"#, 196 | r#"## Fisher-Testing: {}"#, 197 | r#"echo "I'm valid!""#, 198 | ], 199 | )?; 200 | 201 | // Ensure the scripts collection succedes 202 | assert_collected(&env, false, &["valid.sh"])?; 203 | 204 | // Create an additional invalid script 205 | env.create_script( 206 | "invalid.sh", 207 | &[ 208 | r#"#!/bin/bash"#, 209 | r#"## Fisher-InvalidProviderDoNotReallyCreateThis: {}"#, 210 | r#"echo "I'm not valid :(""#, 211 | ], 212 | )?; 213 | 214 | // Ensure the scripts collection fails 215 | assert_collected(&env, false, &["valid.sh", "invalid.sh"]) 216 | .err() 217 | .expect("The collection should return an error"); 218 | 219 | Ok(()) 220 | }) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/scripts/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | #[cfg(test)] 17 | mod test_utils; 18 | mod collector; 19 | mod jobs; 20 | mod repository; 21 | mod script; 22 | 23 | pub use self::repository::{Blueprint, Repository}; 24 | pub use self::repository::{ScriptsIter, StatusJobsIter}; 25 | pub use self::script::{Script, ScriptProvider}; 26 | pub use self::jobs::{Job, JobOutput, Context as JobContext, EnvBuilder}; 27 | -------------------------------------------------------------------------------- /src/scripts/test_utils.rs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017 Pietro Albini 2 | // 3 | // This program is free software: you can redistribute it and/or modify 4 | // it under the terms of the GNU General Public License as published by 5 | // the Free Software Foundation, either version 3 of the License, or 6 | // (at your option) any later version. 7 | // 8 | // This program is distributed in the hope that it will be useful, 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | // GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License 14 | // along with this program. If not, see . 15 | 16 | use std::collections::HashMap; 17 | use std::fs; 18 | use std::io::Write; 19 | use std::net::{IpAddr, Ipv4Addr}; 20 | use std::os::unix::fs::OpenOptionsExt; 21 | use std::path::PathBuf; 22 | use std::sync::Arc; 23 | 24 | use tempdir::TempDir; 25 | 26 | use common::prelude::*; 27 | use common::state::State; 28 | use scripts::Script; 29 | use web::WebRequest; 30 | 31 | 32 | pub struct TestEnv { 33 | state: Arc, 34 | scripts_dir: PathBuf, 35 | temp_dirs: Vec, 36 | } 37 | 38 | impl TestEnv { 39 | fn new() -> Result { 40 | let scripts_dir = TempDir::new("fisher-tests")?; 41 | 42 | Ok(TestEnv { 43 | state: Arc::new(State::new()), 44 | scripts_dir: scripts_dir.path().to_path_buf(), 45 | temp_dirs: vec![scripts_dir], 46 | }) 47 | } 48 | 49 | pub fn state(&self) -> Arc { 50 | self.state.clone() 51 | } 52 | 53 | pub fn tempdir(&mut self) -> Result { 54 | let dir = TempDir::new("fisher-tests")?; 55 | let owned = dir.path().to_path_buf(); 56 | 57 | self.temp_dirs.push(dir); 58 | Ok(owned) 59 | } 60 | 61 | pub fn scripts_dir(&self) -> PathBuf { 62 | self.scripts_dir.clone() 63 | } 64 | 65 | pub fn create_script(&self, name: &str, content: &[&str]) -> Result<()> { 66 | self.create_script_into(&self.scripts_dir, name, content) 67 | } 68 | 69 | pub fn create_script_into( 70 | &self, 71 | path: &PathBuf, 72 | name: &str, 73 | content: &[&str], 74 | ) -> Result<()> { 75 | let path = path.join(name); 76 | 77 | let mut to_write = String::new(); 78 | for line in content { 79 | to_write.push_str(line); 80 | to_write.push('\n'); 81 | } 82 | 83 | fs::OpenOptions::new() 84 | .create(true) 85 | .write(true) 86 | .mode(0o755) 87 | .open(&path)? 88 | .write(to_write.as_bytes())?; 89 | 90 | Ok(()) 91 | } 92 | 93 | 94 | pub fn load_script(&self, name: &str) -> Result