├── .envrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── ci ├── jobset.nix ├── jobsets-declarative.nix └── spec.json ├── default.nix ├── hydra-cli ├── Cargo.lock ├── Cargo.toml ├── data │ ├── eval-1525352.json │ └── search-build.json └── src │ ├── hydra │ ├── client.rs │ ├── example.rs │ ├── mod.rs │ ├── reqwest_client.rs │ └── types.rs │ ├── lib.rs │ ├── main.rs │ ├── ops │ ├── jobset_create.rs │ ├── jobset_delete.rs │ ├── jobset_eval.rs │ ├── jobset_wait.rs │ ├── mod.rs │ ├── project.rs │ ├── project_create.rs │ ├── project_list.rs │ ├── reproduce.rs │ └── search.rs │ └── pretty.rs ├── logo.png ├── nix ├── nixpkgs.nix ├── sources.json └── sources.nix ├── package.nix ├── shell.nix └── tests └── vm.nix /.envrc: -------------------------------------------------------------------------------- 1 | eval "$(lorri direnv)" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | **/*.rs.bk 3 | *~ 4 | result 5 | result-* 6 | .gitignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - language: nix 4 | nix: 2.2.2 5 | if: type = pull_request 6 | script: 7 | # This is because the unstable channel has not been updated yet 8 | - nix-env -f https://github.com/NixOS/nixpkgs/archive/1f5fa9a8298ec7411431da981b4f1a79e10f2a8e.tar.gz -i hydra-cli 9 | - hydra-cli -H https://hydra.nix.corp.cloudwatt.com jobset-wait hydra-cli $TRAVIS_PULL_REQUEST 10 | - language: rust 11 | rust: 12 | - stable 13 | cache: cargo 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Orange 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hydra-cli 2 | 3 | [![Build Status](https://travis-ci.com/nlewo/hydra-cli.svg?branch=master)](https://travis-ci.com/nlewo/hydra-cli) 4 | 5 | `hydra-cli` lets you talk to the JSON API of [hydra](https://nixos.org/hydra) on the command line. It 6 | includes commands for querying and creating projects and jobsets. 7 | 8 |

9 | 12 |

13 | 14 | ## Installation 15 | 16 | Use `nix-env` to install: 17 | 18 | ``` 19 | nix-env -f https://github.com/nlewo/hydra-cli/archive/master.tar.gz -iA hydra-cli 20 | ``` 21 | 22 | ## Build 23 | 24 | Inside the provided nix shell: 25 | 26 | ```bash 27 | $ cargo build 28 | ``` 29 | 30 | ## Usage 31 | 32 | `hydra-cli` talks to the JSON API of hydra and allows you to query, create and wait for 33 | projects and jobsets: 34 | 35 | - [project-create](#project-create) Creates a project. 36 | - [project-list](#project-list) Lists existing projects. 37 | - [project-show](#project-show) Shows the projects' information. 38 | - [jobset-create](#jobset-create) Creates a jobset in a project. 39 | - [jobset-wait](#jobset-wait) Waits for a jobset's completion. 40 | - [reproduce](#reproduce) Retrieves information for reproducing an output path. 41 | - [search](#search) Searches for an output path. 42 | 43 | By default `hydra-cli` talks to https://hydra.nixos.org. The default can be 44 | overwritten by setting the `HYDRA_HOST` environment variable or by passing `--host ` on the 45 | command line. 46 | 47 | ### Commands 48 | 49 | `$ hydra-cli --help` 50 | ``` 51 | hydra-cli 0.3.0 52 | lewo 53 | CLI Hydra client 54 | 55 | USAGE: 56 | hydra-cli [FLAGS] [OPTIONS] [SUBCOMMAND] 57 | 58 | FLAGS: 59 | -h, --help Prints help information 60 | --no-check-certificate Disable TLS certificate check for the Hydra host 61 | -V, --version Prints version information 62 | 63 | OPTIONS: 64 | -H, --host Hydra host URL [env: HYDRA_HOST=] [default: https://hydra.nixos.org] 65 | 66 | SUBCOMMANDS: 67 | help Prints this message or the help of the given subcommand(s) 68 | jobset-create Add a jobset to a project 69 | jobset-eval Evaluate a jobset 70 | jobset-wait Wait for jobset completion 71 | project-create Create a new project 72 | project-list List projects 73 | project-show Get information of a project 74 | reproduce Retrieve information to reproduce an output path 75 | search Search by output paths 76 | 77 | A client to query Hydra through its JSON API. 78 | ``` 79 | #### project-create 80 | 81 | The `project-create` command creates a new project under the name specified. The created project 82 | will be _enabled_ and _visible_. _Note_: this command requires user authentication. 83 | 84 | `$ hydra-cli project-create --help` 85 | ``` 86 | hydra-cli-project-create 87 | Create a new project 88 | 89 | USAGE: 90 | hydra-cli project-create --password --user 91 | 92 | FLAGS: 93 | -h, --help Prints help information 94 | -V, --version Prints version information 95 | 96 | OPTIONS: 97 | --password A user password [env: HYDRA_PASSWORD=] 98 | --user A user name [env: HYDRA_USER=] 99 | 100 | ARGS: 101 | The name of the project in which to create the jobset 102 | ``` 103 | #### project-list 104 | 105 | The `project-list` command retrieves a list of all configured projects. 106 | 107 | `$ hydra-cli project-list --help` 108 | ``` 109 | hydra-cli-project-list 110 | List projects 111 | 112 | USAGE: 113 | hydra-cli project-list [FLAGS] 114 | 115 | FLAGS: 116 | -h, --help Prints help information 117 | -j JSON output 118 | -V, --version Prints version information 119 | ``` 120 | #### project-show 121 | 122 | The `project-show` command displays information on a given project. 123 | 124 | `$ hydra-cli project-show --help` 125 | ``` 126 | hydra-cli-project-show 127 | Get information of a project 128 | 129 | USAGE: 130 | hydra-cli project-show [FLAGS] 131 | 132 | FLAGS: 133 | -h, --help Prints help information 134 | -j JSON output 135 | -V, --version Prints version information 136 | 137 | ARGS: 138 | A project name 139 | ``` 140 | #### jobset-create 141 | 142 | The `jobset-create` command creates a new jobset and adds it to the project specified. 143 | 144 | `$ hydra-cli jobset-create --help` 145 | ``` 146 | hydra-cli-jobset-create 147 | Add a jobset to a project 148 | 149 | USAGE: 150 | hydra-cli jobset-create --password --user 151 | 152 | FLAGS: 153 | -h, --help Prints help information 154 | -V, --version Prints version information 155 | 156 | OPTIONS: 157 | --password A user password [env: HYDRA_PASSWORD=] 158 | --user A user name [env: HYDRA_USER=] 159 | 160 | ARGS: 161 | The project to add the jobset to 162 | The name of the jobset to create 163 | Project configuration JSON filepath 164 | 165 | Here is an example JSON config: 166 | 167 | { 168 | "description": "hydra-cli master jobset", 169 | "checkinterval": 60, 170 | "enabled": 1, 171 | "visible": true, 172 | "keepnr": 3, 173 | "nixexprinput": "src", 174 | "nixexprpath": "default.nix", 175 | "inputs": { 176 | "src": { 177 | "value": "https://github.com/nlewo/hydra-cli.git master", 178 | "type": "git", 179 | "revision": null, 180 | "uri": null 181 | } 182 | } 183 | } 184 | ``` 185 | #### jobset-eval 186 | 187 | The `jobset-eval` command starts the evaluation of a jobset. 188 | 189 | `$ hydra-cli jobset-eval --help` 190 | ``` 191 | hydra-cli-jobset-eval 192 | Evaluate a jobset 193 | 194 | USAGE: 195 | hydra-cli jobset-eval 196 | 197 | FLAGS: 198 | -h, --help Prints help information 199 | -V, --version Prints version information 200 | 201 | ARGS: 202 | The project to evaluate the jobset from 203 | The jobset to evaluate 204 | ``` 205 | #### jobset-wait 206 | 207 | The `jobset-create` command waits until the specified jobset has been evaluated. 208 | 209 | `$ hydra-cli jobset-wait --help` 210 | ``` 211 | hydra-cli-jobset-wait 212 | Wait for jobset completion 213 | 214 | USAGE: 215 | hydra-cli jobset-wait [OPTIONS] 216 | 217 | FLAGS: 218 | -h, --help Prints help information 219 | -V, --version Prints version information 220 | 221 | OPTIONS: 222 | --timeout Maximum time to wait for (in seconds and infinite by default) 223 | 224 | ARGS: 225 | The project of the jobset to wait for 226 | The name of the jobset to wait for 227 | ``` 228 | #### reproduce 229 | 230 | The `reproduce` command retrieves information on how to reproduce a given output-path. 231 | 232 | `$ hydra-cli reproduce --help` 233 | ``` 234 | hydra-cli-reproduce 235 | Retrieve information to reproduce an output path 236 | 237 | USAGE: 238 | hydra-cli reproduce [FLAGS] 239 | 240 | FLAGS: 241 | -h, --help Prints help information 242 | -j JSON output 243 | -V, --version Prints version information 244 | 245 | ARGS: 246 | Piece of an output path (hash, name,...) 247 | ``` 248 | #### search 249 | 250 | The `search` command searches for a jobset based on the output-path specified. 251 | 252 | `$ hydra-cli search --help` 253 | ``` 254 | hydra-cli-search 255 | Search by output paths 256 | 257 | USAGE: 258 | hydra-cli search [limit] 259 | 260 | FLAGS: 261 | -h, --help Prints help information 262 | -V, --version Prints version information 263 | 264 | ARGS: 265 | Piece of an output path (hash, name,...) 266 | How many results to return [default: 10] 267 | ``` 268 | ### Contributing 269 | 270 | Contributions to the project are welcome in the form of GitHub PRs. Please consider 271 | the following guidelines before creating PRs: 272 | 273 | - Please make sure to format your code using `rustfmt` 274 | - If you are planning to make any considerable changes, you should first present your plans in a GitHub issue so it can be discussed. 275 | - If you are adding features please consider the possibility of adding a test in [tests/vm.nix](./tests/vm.nix) 276 | 277 | #### Updating the README 278 | 279 | `hydra-cli` uses [mdsh](https://github.com/zimbatm/mdsh) to create and verify the README.md you are looking at. Changes to the `hydra-cli` 280 | command syntax will failing tests on [hydra](https://hydra.nix.corp.cloudwatt.com/project/hydra-cli). 281 | 282 | You can execute the tests locally by running: 283 | 284 | ``` 285 | nix-build -A tests.readme 286 | ``` 287 | 288 | If you want to update the README.md after having made relevant changes to `hydra-cli` you can do so as follows: 289 | 290 | ``` 291 | cp $(nix-build -A readme) ./README.md 292 | ``` 293 | 294 | 295 | ### License 296 | 297 | - Licensed under [MIT](./LICENSE). 298 | - The `hydra-cli` logo has been created using [https://game-icons.net](https://game-icons.net/1x1/lorc/hydra.html). 299 | -------------------------------------------------------------------------------- /ci/jobset.nix: -------------------------------------------------------------------------------- 1 | import ../default.nix {} 2 | -------------------------------------------------------------------------------- /ci/jobsets-declarative.nix: -------------------------------------------------------------------------------- 1 | { nixpkgs, declInput, pulls }: 2 | let 3 | pkgs = import nixpkgs {}; 4 | prs = builtins.fromJSON (builtins.readFile pulls); 5 | prJobsets = pkgs.lib.mapAttrs (num: info: 6 | { enabled = 1; 7 | hidden = false; 8 | description = "PR ${num}: ${info.title}"; 9 | nixexprinput = "nixexpr"; 10 | nixexprpath = "ci/jobset.nix"; 11 | checkinterval = 60; 12 | schedulingshares = 20; 13 | enableemail = false; 14 | emailoverride = ""; 15 | keepnr = 1; 16 | inputs = { 17 | nixexpr = { 18 | type = "git"; 19 | value = "git://github.com/${info.base.repo.owner.login}/${info.base.repo.name}.git pull/${num}/head"; 20 | emailresponsible = false; 21 | }; 22 | nixpkgs = { 23 | value = "https://github.com/NixOS/nixpkgs f52505fac8c82716872a616c501ad9eff188f97f"; 24 | type = "git"; 25 | emailresponsible = false; 26 | }; 27 | }; 28 | } 29 | ) prs; 30 | desc = prJobsets // { 31 | trunk = { 32 | description = "Build master of nlewo/hydra-cli"; 33 | checkinterval = 60; 34 | enabled = 1; 35 | nixexprinput = "nixexpr"; 36 | nixexprpath = "ci/jobset.nix"; 37 | schedulingshares = 100; 38 | enableemail = false; 39 | emailoverride = ""; 40 | keepnr = 3; 41 | hidden = false; 42 | inputs = { 43 | nixexpr = { 44 | value = "https://github.com/nlewo/hydra-cli"; 45 | type = "git"; 46 | emailresponsible = false; 47 | }; 48 | # Only used by Niv to get its fetchers. 49 | # Belongs to branch 19.03. 50 | nixpkgs = { 51 | value = "https://github.com/NixOS/nixpkgs f52505fac8c82716872a616c501ad9eff188f97f"; 52 | type = "git"; 53 | emailresponsible = false; 54 | }; 55 | }; 56 | }; 57 | }; 58 | 59 | in { 60 | jobsets = pkgs.runCommand "spec.json" {} '' 61 | cat >$out <", "Tobias Pflug "] 5 | edition = "2018" 6 | license = "MIT" 7 | 8 | [dependencies] 9 | clap = "2.33.3" 10 | reqwest = { version = "0.10.8", features = ["blocking", "json", "cookies"] } 11 | serde = { version = "1.0", features = ["derive"] } 12 | serde_json = "1.0" 13 | chrono = "0.4.19" 14 | log = "0.4.11" 15 | prettytable-rs = "0.10.0" 16 | mockito = "0.27.0" 17 | -------------------------------------------------------------------------------- /hydra-cli/data/eval-1525352.json: -------------------------------------------------------------------------------- 1 | { 2 | "jobsetevalinputs": { 3 | "nix": { 4 | "value": null, 5 | "type": "git", 6 | "revision": "26bc876ae6e7ceff8b37ba5c2c1043ce216cf384", 7 | "dependency": null, 8 | "uri": "https://github.com/NixOS/nix.git" 9 | }, 10 | "officialRelease": { 11 | "uri": null, 12 | "dependency": null, 13 | "revision": null, 14 | "value": "false", 15 | "type": "boolean" 16 | }, 17 | "nixpkgs": { 18 | "revision": "a7e559a5504572008567383c3dc8e142fa7a8633", 19 | "dependency": null, 20 | "uri": "https://github.com/NixOS/nixpkgs-channels.git", 21 | "type": "git", 22 | "value": null 23 | } 24 | }, 25 | "id": 1525352, 26 | "hasnewbuilds": 1, 27 | "builds": [ 28 | 94858417, 29 | 94858423, 30 | 94858416, 31 | 94858427, 32 | 94858420, 33 | 94858413, 34 | 94858426, 35 | 94858433, 36 | 94858430, 37 | 94858415, 38 | 94858418, 39 | 94858412, 40 | 94858424, 41 | 94858425, 42 | 94858431, 43 | 94858428, 44 | 94858422, 45 | 94858414, 46 | 94858434, 47 | 94858419, 48 | 94858421, 49 | 94858432, 50 | 94858429 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /hydra-cli/data/search-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "builds": [ 3 | { 4 | "buildstatus": 2, 5 | "jobset": "trunk", 6 | "jobsetevals": [ 7 | 1523141, 8 | 1523060, 9 | 1523028, 10 | 1523021, 11 | 1523006, 12 | 1522960, 13 | 1522914, 14 | 1522875, 15 | 1522829, 16 | 1522819, 17 | 1522786, 18 | 1522735, 19 | 1522658, 20 | 1522611, 21 | 1522605, 22 | 1522575, 23 | 1522529, 24 | 1522491, 25 | 1522453, 26 | 1522436, 27 | 1522405, 28 | 1522394, 29 | 1522367, 30 | 1522333, 31 | 1522292, 32 | 1522272, 33 | 1522256, 34 | 1522230, 35 | 1522201, 36 | 1522177 37 | ], 38 | "system": "x86_64-darwin", 39 | "buildmetrics": {}, 40 | "project": "nixpkgs", 41 | "drvpath": "/nix/store/ppihpm4vvkcr49m7nr9q8ikxpfgkx005-afew-1.3.0.drv", 42 | "starttime": 1559613988, 43 | "id": 94250155, 44 | "buildoutputs": { 45 | "out": { 46 | "path": "/nix/store/dx2i55cj2avkl1hhigz08kv5iy2r62k7-afew-1.3.0" 47 | } 48 | }, 49 | "finished": 1, 50 | "stoptime": 1559614425, 51 | "releasename": null, 52 | "nixname": "afew-1.3.0", 53 | "buildproducts": {}, 54 | "timestamp": 1559122416, 55 | "job": "afew.x86_64-darwin", 56 | "priority": 100 57 | }, 58 | { 59 | "priority": 100, 60 | "job": "afew.x86_64-darwin", 61 | "timestamp": 1558862192, 62 | "buildproducts": {}, 63 | "nixname": "afew-1.3.0", 64 | "releasename": null, 65 | "finished": 1, 66 | "stoptime": 1559053538, 67 | "buildoutputs": { 68 | "out": { 69 | "path": "/nix/store/dx2i55cj2avkl1hhigz08kv5iy2r62k7-afew-1.3.0" 70 | } 71 | }, 72 | "id": 94098485, 73 | "starttime": 1559053054, 74 | "drvpath": "/nix/store/ppihpm4vvkcr49m7nr9q8ikxpfgkx005-afew-1.3.0.drv", 75 | "project": "nixpkgs", 76 | "buildmetrics": {}, 77 | "system": "x86_64-darwin", 78 | "jobsetevals": [ 79 | 1521962, 80 | 1521636 81 | ], 82 | "buildstatus": 2, 83 | "jobset": "staging-next" 84 | } 85 | ], 86 | "buildsdrv": [], 87 | "jobsets": [], 88 | "projects": [] 89 | } 90 | -------------------------------------------------------------------------------- /hydra-cli/src/hydra/client.rs: -------------------------------------------------------------------------------- 1 | //! Hydra CI REST API Library 2 | //! 3 | //! The HydraClient trait includes operations for querying and creating resources on 4 | //! a Hydra endpoint. 5 | 6 | pub use crate::hydra::types::{ 7 | Build, Eval, Jobset, JobsetConfig, JobsetOverview, Project, ProjectConfig, Reproduce, Search, 8 | }; 9 | use crate::ops::OpError; 10 | use serde::{Deserialize, Serialize}; 11 | 12 | /// Stores combination of user and password as required by login 13 | #[derive(Serialize, Deserialize)] 14 | pub struct Creds { 15 | pub username: String, 16 | pub password: String, 17 | } 18 | 19 | /// Errors occuring while talking to the hydra endpoint 20 | #[derive(PartialEq, Debug)] 21 | pub enum ClientError { 22 | /// Generic error 23 | Error(String), 24 | /// Received data that could not be parsed 25 | InvalidResponse(String), 26 | } 27 | 28 | impl From for OpError { 29 | fn from(e: ClientError) -> Self { 30 | match e { 31 | ClientError::Error(s) => OpError::RequestError(s), 32 | ClientError::InvalidResponse(s) => OpError::RequestError(s), 33 | } 34 | } 35 | } 36 | 37 | pub trait HydraClient { 38 | /// Authenticates with the server using username and password provided by `Creds` 39 | fn login(&self, creds: Creds) -> Result<(), ClientError>; 40 | 41 | /// Returns the Hydra host 42 | fn host(&self) -> String; 43 | 44 | /// Searches the host for the nix store path `query` 45 | fn search(&self, query: &str) -> Result; 46 | 47 | /// Retrieves evaluation information for the build specified by `number` 48 | fn eval(&self, number: i64) -> Result; 49 | 50 | /// Retrieves the jobset specified by `project` / `jobset` 51 | fn jobset(&self, project: &str, jobset: &str) -> Result; 52 | 53 | /// Creates a jobset called `jobset_name` in the project `project_name` using 54 | /// the configuration from `jobset_config` 55 | fn jobset_create( 56 | &self, 57 | project_name: &str, 58 | jobset_name: &str, 59 | jobset_config: &JobsetConfig, 60 | ) -> Result<(), ClientError>; 61 | 62 | /// Delete a jobset 63 | fn jobset_delete(&self, project_name: &str, jobset_name: &str) -> Result<(), ClientError>; 64 | 65 | /// Evaluate a jobset called `jobset_name` in the project `project_name` 66 | fn jobset_eval(&self, project_name: &str, jobset_name: &str) -> Result<(), ClientError>; 67 | 68 | /// Retrieves information on all jobsets belonging to `project` 69 | fn jobset_overview(&self, project: &str) -> Result, ClientError>; 70 | 71 | /// Retrieves all configured projects 72 | fn projects(&self) -> Result, ClientError>; 73 | 74 | /// Retrieves a project given by `name` 75 | fn project_create(&self, name: &str) -> Result<(), ClientError>; 76 | } 77 | -------------------------------------------------------------------------------- /hydra-cli/src/hydra/example.rs: -------------------------------------------------------------------------------- 1 | //! Examples intended for the CLI help sections 2 | 3 | pub use crate::hydra::types::{Input, JobsetConfig, JobsetEnabled}; 4 | use std::collections::HashMap; 5 | 6 | pub fn jobset_config() -> JobsetConfig { 7 | JobsetConfig { 8 | description: "hydra-cli master jobset".to_string(), 9 | checkinterval: 60, 10 | enabled: JobsetEnabled::Enabled, 11 | visible: true, 12 | keepnr: 3, 13 | nixexprinput: "src".to_string(), 14 | nixexprpath: "default.nix".to_string(), 15 | inputs: { 16 | let mut map = HashMap::::new(); 17 | let input = Input { 18 | value: Some("https://github.com/nlewo/hydra-cli.git master".to_string()), 19 | input_type: "git".to_string(), 20 | revision: None, 21 | uri: None, 22 | }; 23 | map.insert("src".to_string(), input); 24 | map 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /hydra-cli/src/hydra/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod example; 3 | pub mod reqwest_client; 4 | mod types; 5 | -------------------------------------------------------------------------------- /hydra-cli/src/hydra/reqwest_client.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::*; 2 | 3 | use reqwest::blocking::Client as ReqwestClient; 4 | use reqwest::header::{CONTENT_TYPE, REFERER}; 5 | use serde::de::DeserializeOwned; 6 | use serde_json::Value; 7 | #[cfg(test)] 8 | use std::collections::HashMap; 9 | 10 | #[cfg(test)] 11 | use mockito; 12 | 13 | #[cfg(test)] 14 | use crate::hydra::types::JobsetEnabled; 15 | 16 | impl From for ClientError { 17 | fn from(e: reqwest::Error) -> Self { 18 | let msg = format!("{}", e); 19 | ClientError::Error(msg) 20 | } 21 | } 22 | 23 | #[derive(Clone)] 24 | pub struct Client { 25 | pub host: String, 26 | pub client: ReqwestClient, 27 | } 28 | 29 | impl Client { 30 | pub fn new(client: ReqwestClient, host: String) -> Client { 31 | Client { client, host } 32 | } 33 | } 34 | 35 | /// Performs a GET request retrieving a deserializable response 36 | fn get_json(client: &ReqwestClient, url: &str) -> Result { 37 | let res = client 38 | .get(url) 39 | .header(reqwest::header::CONTENT_TYPE, "application/json") 40 | .send()?; 41 | 42 | if res.status().is_success() { 43 | let v: Value = res.json()?; 44 | match serde_json::from_value(v) { 45 | Ok(x) => Ok(x), 46 | Err(x) => Err(ClientError::InvalidResponse(format!("{}", x))), 47 | } 48 | } else { 49 | Err(ClientError::Error(format!("{}", res.status()))) 50 | } 51 | } 52 | 53 | impl HydraClient for Client { 54 | fn project_create(&self, name: &str) -> Result<(), ClientError> { 55 | let create_proj_url = format!("{}/project/{}", &self.host, name); 56 | let proj: ProjectConfig = ProjectConfig { 57 | displayname: String::from(name), 58 | enabled: true, 59 | visible: true, 60 | }; 61 | let res = self 62 | .client 63 | .put(&create_proj_url) 64 | .header(REFERER, self.host.as_str()) 65 | .json(&proj) 66 | .send()?; 67 | 68 | if res.status().is_success() { 69 | Ok(()) 70 | } else { 71 | Err(ClientError::Error(format!("{}", res.status()))) 72 | } 73 | } 74 | 75 | fn host(&self) -> String { 76 | self.host.clone() 77 | } 78 | fn projects(&self) -> Result, ClientError> { 79 | get_json(&self.client, &self.host) 80 | } 81 | 82 | fn search(&self, query: &str) -> Result { 83 | let request_url = format!("{}/search?query={}", &self.host, query); 84 | get_json(&self.client, &request_url) 85 | } 86 | 87 | fn jobset_overview(&self, project: &str) -> Result, ClientError> { 88 | let request_url = format!("{}/api/jobsets?project={}", &self.host, project); 89 | get_json(&self.client, &request_url) 90 | } 91 | 92 | fn jobset(&self, project: &str, jobset: &str) -> Result { 93 | let request_url = format!("{}/jobset/{}/{}", &self.host, project, jobset); 94 | get_json(&self.client, &request_url) 95 | } 96 | 97 | fn eval(&self, number: i64) -> Result { 98 | let request_url = format!("{}/eval/{}", &self.host, number); 99 | get_json(&self.client, &request_url) 100 | } 101 | 102 | fn jobset_create( 103 | &self, 104 | project_name: &str, 105 | jobset_name: &str, 106 | jobset_config: &JobsetConfig, 107 | ) -> Result<(), ClientError> { 108 | let request_url = format!("{}/jobset/{}/{}", &self.host, project_name, jobset_name); 109 | let res = self 110 | .client 111 | .put(&request_url) 112 | .header(REFERER, self.host.as_str()) 113 | .json(&jobset_config) 114 | .send()?; 115 | 116 | if res.status().is_success() { 117 | Ok(()) 118 | } else { 119 | Err(ClientError::Error(format!("{}", res.status()))) 120 | } 121 | } 122 | 123 | fn jobset_delete(&self, project_name: &str, jobset_name: &str) -> Result<(), ClientError> { 124 | let request_url = format!("{}/jobset/{}/{}", &self.host, project_name, jobset_name); 125 | let res = self 126 | .client 127 | .delete(&request_url) 128 | .header(REFERER, self.host.as_str()) 129 | .header(CONTENT_TYPE, "application/json") 130 | .send()?; 131 | 132 | if res.status().is_success() { 133 | Ok(()) 134 | } else { 135 | Err(ClientError::Error(format!("{}", res.status()))) 136 | } 137 | } 138 | 139 | fn jobset_eval(&self, project_name: &str, jobset_name: &str) -> Result<(), ClientError> { 140 | let request_url = format!( 141 | "{}/api/push?jobsets={}:{}", 142 | &self.host, project_name, jobset_name 143 | ); 144 | let res = self 145 | .client 146 | .put(&request_url) 147 | .header(REFERER, self.host.as_str()) 148 | .send()?; 149 | 150 | if res.status().is_success() { 151 | Ok(()) 152 | } else { 153 | Err(ClientError::Error(format!("{}", res.status()))) 154 | } 155 | } 156 | 157 | fn login(&self, creds: Creds) -> Result<(), ClientError> { 158 | let login_request_url = format!("{}/login", &self.host); 159 | let login_res = self 160 | .client 161 | .post(&login_request_url) 162 | .header(REFERER, self.host.as_str()) 163 | .json(&creds) 164 | .send(); 165 | 166 | match login_res { 167 | Ok(r) => { 168 | if r.status().is_success() { 169 | Ok(()) 170 | } else if r.status().is_redirection() { 171 | Ok(()) 172 | } else { 173 | Err(ClientError::Error(format!("Response Error: {}", r.status()))) 174 | } 175 | } 176 | Err(err) => Err(ClientError::Error(format!("Request Error: {}", err))), 177 | } 178 | } 179 | } 180 | 181 | #[cfg(test)] 182 | mod tests { 183 | use super::*; 184 | use mockito::{mock, Matcher}; 185 | 186 | fn client() -> Client { 187 | let url = &mockito::server_url(); 188 | let c = reqwest::blocking::Client::builder() 189 | .cookie_store(true) 190 | .build() 191 | .unwrap(); 192 | 193 | Client::new(c, String::from(url)) 194 | } 195 | 196 | #[test] 197 | fn get_json_yields_err_on_non_200_response() { 198 | let _m = mock("GET", "/") 199 | .with_status(500) 200 | .with_header("content-type", "application/json") 201 | .with_body("[]") 202 | .create(); 203 | 204 | let c = client(); 205 | let res: Result = get_json(&c.client, &c.host); 206 | assert_eq!( 207 | res, 208 | Err(ClientError::Error("500 Internal Server Error".to_string())) 209 | ); 210 | } 211 | 212 | #[test] 213 | fn get_json_yields_invalid_response_on_invalid_json() { 214 | let _m = mock("GET", "/") 215 | .with_status(200) 216 | .with_header("content-type", "application/json") 217 | .with_body("a{x") 218 | .create(); 219 | 220 | let c = client(); 221 | let res: Result, ClientError> = get_json(&c.client, &c.host); 222 | 223 | assert_eq!( 224 | res, 225 | Err(ClientError::Error( 226 | "error decoding response body: expected value at line 1 column 1".to_string() 227 | )) 228 | ) 229 | } 230 | 231 | #[test] 232 | fn projects_lists_single_project() { 233 | let _m = mock("GET", "/") 234 | .with_status(200) 235 | .with_header("content-type", "application/json") 236 | .with_body("[{ \"owner\": \"admin\", \"displayname\": \"hydra-cli\", \"hidden\": 0, \"description\": \"Hydra Command Line Interface\", \"jobsets\": [ \"20\", \"21\"], \"releases\": [], \"enabled\": 1, \"name\": \"hydra-cli\" } ]") 237 | .create(); 238 | 239 | let ps = client().projects(); 240 | assert_eq!( 241 | ps.unwrap(), 242 | vec![Project { 243 | enabled: true, 244 | name: "hydra-cli".to_string(), 245 | displayname: "hydra-cli".to_string(), 246 | hidden: false, 247 | owner: "admin".to_string(), 248 | description: Some("Hydra Command Line Interface".to_string()), 249 | jobsets: vec!["20".to_string(), "21".to_string()] 250 | }] 251 | ); 252 | } 253 | 254 | #[test] 255 | fn login_posts_creds_to_login_path() { 256 | let _m = mock("POST", "/login") 257 | .with_status(200) 258 | .match_body(Matcher::JsonString( 259 | "{\"username\": \"user\", \"password\": \"pw\"}".to_string(), 260 | )) 261 | .create(); 262 | 263 | let res = client().login(Creds { 264 | username: "user".to_string(), 265 | password: "pw".to_string(), 266 | }); 267 | assert_eq!(res.unwrap(), ()) 268 | } 269 | 270 | #[test] 271 | fn login_yields_err_when_response_status_is_not_200() { 272 | let _m = mock("POST", "/login") 273 | .with_status(500) 274 | .match_body(Matcher::JsonString( 275 | "{\"username\": \"user\", \"password\": \"pw\"}".to_string(), 276 | )) 277 | .create(); 278 | 279 | let res = client().login(Creds { 280 | username: "user".to_string(), 281 | password: "pw".to_string(), 282 | }); 283 | 284 | assert_eq!( 285 | res, 286 | Err(ClientError::Error("Response Error: 500 Internal Server Error".to_string())) 287 | ); 288 | } 289 | 290 | #[test] 291 | fn jobset_create_posts_config_to_correct_path() { 292 | let jobset = JobsetConfig { 293 | description: "desc".to_string(), 294 | checkinterval: 100, 295 | enabled: JobsetEnabled::Enabled, 296 | visible: true, 297 | nixexprinput: "input".to_string(), 298 | nixexprpath: "path".to_string(), 299 | keepnr: 10, 300 | inputs: HashMap::new(), 301 | }; 302 | let _m = mock("PUT", "/jobset/foo-project/foo-jobset") 303 | .with_status(200) 304 | .match_body(Matcher::JsonString(serde_json::to_string(&jobset).unwrap())) 305 | .create(); 306 | 307 | let res = client().jobset_create("foo-project", "foo-jobset", &jobset); 308 | assert_eq!(res.unwrap(), ()) 309 | } 310 | 311 | #[test] 312 | fn jobset_create_yields_err_when_response_status_is_not_200() { 313 | let jobset = JobsetConfig { 314 | description: "desc".to_string(), 315 | checkinterval: 100, 316 | enabled: JobsetEnabled::Enabled, 317 | visible: true, 318 | nixexprinput: "input".to_string(), 319 | nixexprpath: "path".to_string(), 320 | keepnr: 10, 321 | inputs: HashMap::new(), 322 | }; 323 | let _m = mock("PUT", "/jobset/foo-project/foo-jobset") 324 | .with_status(500) 325 | .match_body(Matcher::JsonString(serde_json::to_string(&jobset).unwrap())) 326 | .create(); 327 | 328 | let res = client().jobset_create("foo-project", "foo-jobset", &jobset); 329 | assert_eq!( 330 | res, 331 | Err(ClientError::Error("500 Internal Server Error".to_string())) 332 | ); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /hydra-cli/src/hydra/types.rs: -------------------------------------------------------------------------------- 1 | use serde::de::{self, Deserializer, Unexpected}; 2 | use serde::{Deserialize, Serialize, Serializer}; 3 | pub use serde_json::Value; 4 | use std::collections::HashMap; 5 | 6 | #[derive(Serialize, Deserialize, Debug)] 7 | pub struct Input { 8 | pub value: Option, 9 | #[serde(rename = "type")] 10 | pub input_type: String, 11 | pub revision: Option, 12 | pub uri: Option, 13 | } 14 | 15 | #[derive(Serialize, Deserialize, Debug)] 16 | pub struct Eval { 17 | pub jobsetevalinputs: HashMap, 18 | } 19 | 20 | #[derive(Serialize, Deserialize, PartialEq, Debug)] 21 | pub struct Project { 22 | #[serde(deserialize_with = "bool_from_int")] 23 | pub enabled: bool, 24 | pub name: String, 25 | pub description: Option, 26 | #[serde(deserialize_with = "bool_from_int")] 27 | pub hidden: bool, 28 | pub owner: String, 29 | pub displayname: String, 30 | pub jobsets: Vec, 31 | } 32 | 33 | struct BoolFromInt {} 34 | 35 | impl de::Visitor<'_> for BoolFromInt { 36 | type Value = bool; 37 | 38 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 39 | formatter.write_str("Expecting a boolean either encoded as an unsigned int or as a bool") 40 | } 41 | 42 | fn visit_u64(self, v: u64) -> Result 43 | where 44 | E: de::Error, 45 | { 46 | match v { 47 | 0 => Ok(false), 48 | 1 => Ok(true), 49 | other => Err(de::Error::invalid_value( 50 | Unexpected::Unsigned(u64::from(other)), 51 | &"zero or one", 52 | )), 53 | } 54 | } 55 | 56 | fn visit_bool(self, v: bool) -> Result 57 | where 58 | E: de::Error, 59 | { 60 | Ok(v) 61 | } 62 | } 63 | 64 | fn bool_from_int<'de, D>(deserializer: D) -> Result 65 | where 66 | D: Deserializer<'de>, 67 | { 68 | deserializer.deserialize_any(BoolFromInt {}) 69 | } 70 | 71 | #[derive(Serialize, Deserialize, Debug)] 72 | pub struct Jobset { 73 | pub nixexprpath: String, 74 | pub nixexprinput: String, 75 | } 76 | 77 | #[derive(Serialize, Deserialize, Debug, Clone)] 78 | pub struct Path { 79 | pub path: String, 80 | } 81 | 82 | #[derive(Serialize, Deserialize, Debug, Clone)] 83 | pub struct Build { 84 | pub id: i64, 85 | pub project: String, 86 | pub drvpath: String, 87 | pub job: String, 88 | pub jobset: String, 89 | pub buildoutputs: HashMap, 90 | pub stoptime: i64, 91 | pub jobsetevals: Vec, 92 | } 93 | 94 | #[derive(Serialize, Deserialize, Debug)] 95 | pub struct Search { 96 | pub builds: Vec, 97 | } 98 | 99 | #[derive(Serialize, Deserialize, Debug)] 100 | pub struct Reproduce { 101 | pub build: Build, 102 | pub eval: Eval, 103 | pub jobset: Jobset, 104 | } 105 | 106 | pub type PosixTimestamp = u64; 107 | 108 | #[derive(Serialize, Deserialize, Debug)] 109 | pub struct JobsetOverview { 110 | pub nrscheduled: i64, 111 | pub nrtotal: i64, 112 | pub nrsucceeded: i64, 113 | pub project: String, 114 | pub name: String, 115 | pub nrfailed: i64, 116 | pub starttime: Option, 117 | pub lastcheckedtime: Option, 118 | pub haserrormsg: Option, 119 | } 120 | 121 | #[derive(PartialEq, Debug)] 122 | #[repr(u8)] 123 | pub enum JobsetEnabled { 124 | Disabled = 0, 125 | Enabled = 1, 126 | OneShot = 2, 127 | } 128 | 129 | impl Serialize for JobsetEnabled { 130 | fn serialize(&self, serializer: S) -> Result 131 | where 132 | S: Serializer, 133 | { 134 | serializer.serialize_u8(match self { 135 | JobsetEnabled::Disabled => 0, 136 | JobsetEnabled::Enabled => 1, 137 | JobsetEnabled::OneShot => 2, 138 | }) 139 | } 140 | } 141 | 142 | impl<'de> Deserialize<'de> for JobsetEnabled { 143 | fn deserialize(deserializer: D) -> Result 144 | where 145 | D: Deserializer<'de>, 146 | { 147 | match u8::deserialize(deserializer)? { 148 | 0 => Ok(JobsetEnabled::Disabled), 149 | 1 => Ok(JobsetEnabled::Enabled), 150 | 2 => Ok(JobsetEnabled::OneShot), 151 | other => Err(de::Error::invalid_value( 152 | Unexpected::Unsigned(u64::from(other)), 153 | &"zero, one, or two", 154 | )), 155 | } 156 | } 157 | } 158 | 159 | #[derive(Serialize, Deserialize, Debug)] 160 | pub struct JobsetConfig { 161 | pub description: String, 162 | pub checkinterval: i64, 163 | pub enabled: JobsetEnabled, 164 | #[serde(skip_serializing_if = "is_not_visible")] 165 | pub visible: bool, 166 | pub keepnr: i64, 167 | pub nixexprinput: String, 168 | pub nixexprpath: String, 169 | pub inputs: HashMap, 170 | } 171 | 172 | fn is_not_visible(visible: &bool) -> bool { 173 | !visible 174 | } 175 | 176 | #[derive(Serialize, Deserialize, Debug)] 177 | pub struct ProjectConfig { 178 | pub displayname: String, 179 | pub enabled: bool, 180 | pub visible: bool, 181 | } 182 | -------------------------------------------------------------------------------- /hydra-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate chrono; 2 | extern crate clap; 3 | extern crate reqwest; 4 | 5 | #[macro_use] 6 | extern crate prettytable; 7 | 8 | pub mod hydra; 9 | pub mod ops; 10 | pub mod pretty; 11 | -------------------------------------------------------------------------------- /hydra-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate hydra_cli; 2 | 3 | use clap::{App, Arg, SubCommand}; 4 | use reqwest::redirect; 5 | use std::str::FromStr; 6 | use std::time::Duration; 7 | 8 | use hydra_cli::hydra::example::jobset_config; 9 | use hydra_cli::hydra::reqwest_client::Client as ReqwestHydraClient; 10 | use hydra_cli::ops::{ 11 | jobset_create, jobset_delete, jobset_eval, jobset_wait, project, project_create, project_list, 12 | reproduce, search, OpError, OpResult, 13 | }; 14 | 15 | fn main() { 16 | let config_after_help = &format!( 17 | "Here is an example JSON config:\n\n{}", 18 | serde_json::to_string_pretty(&jobset_config()).unwrap() 19 | )[..]; 20 | let app = App::new("hydra-cli") 21 | .version("0.3.0") 22 | .about("CLI Hydra client") 23 | .author("lewo") 24 | .after_help("A client to query Hydra through its JSON API.") 25 | .arg( 26 | Arg::with_name("host") 27 | .short("H") 28 | .long("host") 29 | .default_value("https://hydra.nixos.org") 30 | .env("HYDRA_HOST") 31 | .help("Hydra host URL"), 32 | ) 33 | .arg( 34 | Arg::with_name("no-check-certificate") 35 | .long("no-check-certificate") 36 | .help("Disable TLS certificate check for the Hydra host"), 37 | ) 38 | .subcommand( 39 | SubCommand::with_name("search") 40 | .about("Search by output paths") 41 | .arg( 42 | Arg::with_name("query") 43 | .required(true) 44 | .help("Piece of an output path (hash, name,...)"), 45 | ) 46 | .arg( 47 | Arg::with_name("limit") 48 | .default_value("10") 49 | .help("How many results to return"), 50 | ), 51 | ) 52 | .subcommand( 53 | SubCommand::with_name("reproduce") 54 | .about("Retrieve information to reproduce an output path") 55 | .arg( 56 | Arg::with_name("query") 57 | .required(true) 58 | .help("Piece of an output path (hash, name,...)"), 59 | ) 60 | .arg(Arg::with_name("json").short("j").help("JSON output")), 61 | ) 62 | .subcommand( 63 | SubCommand::with_name("project-list") 64 | .about("List projects") 65 | .arg(Arg::with_name("json").short("j").help("JSON output")), 66 | ) 67 | .subcommand( 68 | SubCommand::with_name("project-show") 69 | .about("Get information of a project") 70 | .arg( 71 | Arg::with_name("project") 72 | .required(true) 73 | .help("A project name"), 74 | ) 75 | .arg(Arg::with_name("json").short("j").help("JSON output")), 76 | ) 77 | .subcommand( 78 | SubCommand::with_name("project-create") 79 | .about("Create a new project") 80 | .arg( 81 | Arg::with_name("project") 82 | .required(true) 83 | .help("The name of the project in which to create the jobset"), 84 | ) 85 | .arg( 86 | Arg::with_name("user") 87 | .takes_value(true) 88 | .required(true) 89 | .long("user") 90 | .env("HYDRA_USER") 91 | .help("A user name"), 92 | ) 93 | .arg( 94 | Arg::with_name("password") 95 | .takes_value(true) 96 | .required(true) 97 | .long("password") 98 | .env("HYDRA_PASSWORD") 99 | .help("A user password"), 100 | ), 101 | ) 102 | .subcommand( 103 | SubCommand::with_name("jobset-create") 104 | .about("Add a jobset to a project") 105 | .arg( 106 | Arg::with_name("project") 107 | .required(true) 108 | .help("The project to add the jobset to"), 109 | ) 110 | .arg( 111 | Arg::with_name("jobset") 112 | .required(true) 113 | .help("The name of the jobset to create"), 114 | ) 115 | .arg( 116 | Arg::with_name("config") 117 | .required(true) 118 | .help("Project configuration JSON filepath"), 119 | ) 120 | .arg( 121 | Arg::with_name("user") 122 | .takes_value(true) 123 | .required(true) 124 | .long("user") 125 | .env("HYDRA_USER") 126 | .help("A user name"), 127 | ) 128 | .arg( 129 | Arg::with_name("password") 130 | .takes_value(true) 131 | .required(true) 132 | .long("password") 133 | .env("HYDRA_PASSWORD") 134 | .help("A user password"), 135 | ) 136 | .after_help(config_after_help), 137 | ) 138 | .subcommand( 139 | SubCommand::with_name("jobset-delete") 140 | .about("Delete a jobset") 141 | .arg( 142 | Arg::with_name("project") 143 | .required(true) 144 | .help("The project to add the jobset to"), 145 | ) 146 | .arg( 147 | Arg::with_name("jobset") 148 | .required(true) 149 | .help("The name of the jobset to create"), 150 | ) 151 | .arg( 152 | Arg::with_name("user") 153 | .takes_value(true) 154 | .required(true) 155 | .long("user") 156 | .env("HYDRA_USER") 157 | .help("A user name"), 158 | ) 159 | .arg( 160 | Arg::with_name("password") 161 | .takes_value(true) 162 | .required(true) 163 | .long("password") 164 | .env("HYDRA_PASSWORD") 165 | .help("A user password"), 166 | ) 167 | .after_help(config_after_help), 168 | ) 169 | .subcommand( 170 | SubCommand::with_name("jobset-eval") 171 | .about("Evaluate a jobset") 172 | .arg( 173 | Arg::with_name("project") 174 | .required(true) 175 | .help("The project to evaluate the jobset from"), 176 | ) 177 | .arg( 178 | Arg::with_name("jobset") 179 | .required(true) 180 | .help("The jobset to evaluate"), 181 | ), 182 | ) 183 | .subcommand( 184 | SubCommand::with_name("jobset-wait") 185 | .about("Wait for jobset completion") 186 | .arg( 187 | Arg::with_name("project") 188 | .required(true) 189 | .help("The project of the jobset to wait for"), 190 | ) 191 | .arg( 192 | Arg::with_name("jobset") 193 | .required(true) 194 | .help("The name of the jobset to wait for"), 195 | ) 196 | .arg( 197 | Arg::with_name("timeout") 198 | .long("timeout") 199 | .takes_value(true) 200 | .help("Maximum time to wait for (in seconds and infinite by default)"), 201 | ), 202 | ); 203 | 204 | let mut help_buffer = Vec::new(); 205 | app.write_help(&mut help_buffer).unwrap(); 206 | let help_string = String::from_utf8(help_buffer).unwrap(); 207 | 208 | let matches = app.get_matches(); 209 | let host = matches.value_of("host").unwrap(); 210 | let no_check_certs = matches.is_present("no-check-certificate"); 211 | 212 | let custom = redirect::Policy::none(); 213 | 214 | let c = reqwest::blocking::Client::builder() 215 | .cookie_store(true) 216 | .danger_accept_invalid_certs(no_check_certs) 217 | .redirect(custom) 218 | .build() 219 | .unwrap(); 220 | let client = ReqwestHydraClient::new(c, String::from(host)); 221 | 222 | let cmd_res: OpResult = match matches.subcommand() { 223 | ("search", Some(args)) => search::run( 224 | &client, 225 | args.value_of("query").unwrap(), 226 | args.value_of("limit").unwrap().parse().unwrap(), 227 | ), 228 | 229 | ("reproduce", Some(args)) => reproduce::run( 230 | &client, 231 | host, 232 | args.value_of("query").unwrap(), 233 | args.is_present("json"), 234 | ), 235 | 236 | ("project-list", Some(args)) => project_list::run(&client, args.is_present("json")), 237 | 238 | ("project-show", Some(args)) => project::run( 239 | &client, 240 | args.value_of("project").unwrap(), 241 | args.is_present("json"), 242 | ), 243 | 244 | ("project-create", Some(args)) => project_create::run( 245 | &client, 246 | args.value_of("project").unwrap(), 247 | args.value_of("user").unwrap(), 248 | args.value_of("password").unwrap(), 249 | ), 250 | 251 | ("jobset-create", Some(args)) => jobset_create::run( 252 | &client, 253 | args.value_of("config").unwrap(), 254 | args.value_of("project").unwrap(), 255 | args.value_of("jobset").unwrap(), 256 | args.value_of("user").unwrap(), 257 | args.value_of("password").unwrap(), 258 | ), 259 | 260 | ("jobset-delete", Some(args)) => jobset_delete::run( 261 | &client, 262 | args.value_of("project").unwrap(), 263 | args.value_of("jobset").unwrap(), 264 | args.value_of("user").unwrap(), 265 | args.value_of("password").unwrap(), 266 | ), 267 | 268 | ("jobset-eval", Some(args)) => jobset_eval::run( 269 | &client, 270 | args.value_of("project").unwrap(), 271 | args.value_of("jobset").unwrap(), 272 | ), 273 | 274 | ("jobset-wait", Some(args)) => jobset_wait::run( 275 | &client, 276 | args.value_of("project").unwrap(), 277 | args.value_of("jobset").unwrap(), 278 | args.value_of("timeout") 279 | .map(|t| Duration::from_secs(u64::from_str(t).unwrap())), 280 | ), 281 | 282 | _ => { 283 | println!("{}", help_string); 284 | Err(OpError::CmdErr) 285 | } 286 | }; 287 | 288 | match cmd_res { 289 | Ok(_) => std::process::exit(0), 290 | Err(OpError::AuthError) => { 291 | eprintln!("ERROR: Failed to login. Please check your credentials"); 292 | std::process::exit(1) 293 | } 294 | Err(OpError::CmdErr) => { 295 | eprintln!("ERROR: hydra-cli called with invalid arguments"); 296 | std::process::exit(1) 297 | } 298 | Err(OpError::RequestError(m)) => { 299 | eprintln!("ERROR: {}", m); 300 | std::process::exit(1) 301 | } 302 | Err(OpError::TimeoutError) => { 303 | eprintln!("ERROR: Timeout"); 304 | std::process::exit(124) 305 | } 306 | Err(OpError::Error(m)) => { 307 | eprintln!("ERROR: {}", m); 308 | std::process::exit(1) 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/jobset_create.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{Creds, HydraClient, JobsetConfig}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | use std::fs::read_to_string; 4 | 5 | fn load_config(config_path: &str) -> JobsetConfig { 6 | let cfg = read_to_string(config_path).expect("Failed to read config file"); 7 | serde_json::from_str(&cfg).expect("Failed to parse jobset configuration") 8 | } 9 | 10 | pub fn run( 11 | client: &dyn HydraClient, 12 | config_path: &str, 13 | project_name: &str, 14 | jobset_name: &str, 15 | user: &str, 16 | password: &str, 17 | ) -> OpResult { 18 | let jobset_cfg = load_config(config_path); 19 | let creds = Creds { 20 | username: String::from(user), 21 | password: String::from(password), 22 | }; 23 | 24 | client.login(creds)?; 25 | client.jobset_create(project_name, jobset_name, &jobset_cfg)?; 26 | 27 | ok_msg("jobset__create") 28 | } 29 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/jobset_delete.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{Creds, HydraClient}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | 4 | pub fn run( 5 | client: &dyn HydraClient, 6 | project_name: &str, 7 | jobset_name: &str, 8 | user: &str, 9 | password: &str, 10 | ) -> OpResult { 11 | let creds = Creds { 12 | username: String::from(user), 13 | password: String::from(password), 14 | }; 15 | 16 | client.login(creds)?; 17 | client.jobset_delete(project_name, jobset_name)?; 18 | 19 | ok_msg("jobset_delete") 20 | } 21 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/jobset_eval.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::HydraClient; 2 | use crate::ops::{ok_msg, OpResult}; 3 | 4 | pub fn run(client: &dyn HydraClient, project_name: &str, jobset_name: &str) -> OpResult { 5 | client.jobset_eval(project_name, jobset_name)?; 6 | 7 | ok_msg("jobset_eval") 8 | } 9 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/jobset_wait.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::HydraClient; 2 | use crate::hydra::client::JobsetOverview; 3 | use crate::ops::{ok, OpError, OpResult}; 4 | use std::io; 5 | use std::io::Write; 6 | use std::option::Option; 7 | use std::thread; 8 | use std::time::SystemTime; 9 | use std::time::{Duration, UNIX_EPOCH}; 10 | 11 | fn evaluation_started_since(jobset: &JobsetOverview) -> Option { 12 | let starttime = jobset.starttime?; 13 | SystemTime::now() 14 | .duration_since(UNIX_EPOCH + Duration::from_secs(starttime)) 15 | .ok() 16 | } 17 | 18 | fn is_evaluation_finished_after(jobset: &JobsetOverview, start: SystemTime) -> bool { 19 | match jobset.lastcheckedtime { 20 | None => false, 21 | Some(t) => (UNIX_EPOCH + Duration::from_secs(t)) > start, 22 | } 23 | } 24 | 25 | fn is_jobset_built(client: &dyn HydraClient, jobset: &JobsetOverview) -> Result { 26 | if jobset.haserrormsg.unwrap_or(false) { 27 | println!(); 28 | Err(OpError::Error(format!( 29 | "evaluation of jobset {} failed; for details: {}/jobset/{}/{}", 30 | jobset.name, 31 | client.host(), 32 | jobset.project, 33 | jobset.name 34 | ))) 35 | } else if jobset.nrfailed != 0 { 36 | println!(); 37 | Err(OpError::Error(format!( 38 | "Jobset {} has {} failed jobs; for details: {}/jobset/{}/{}", 39 | jobset.name, 40 | jobset.nrfailed.to_string(), 41 | client.host(), 42 | jobset.project, 43 | jobset.name 44 | ))) 45 | } else if jobset.nrsucceeded == jobset.nrtotal { 46 | println!( 47 | "\nall jobs of jobset {} have been built", 48 | jobset.name.to_string() 49 | ); 50 | Ok(true) 51 | } else { 52 | Ok(false) 53 | } 54 | } 55 | 56 | fn jobset_find( 57 | client: &dyn HydraClient, 58 | project_name: &str, 59 | jobset_name: &str, 60 | ) -> Result { 61 | let jobsets = client.jobset_overview(project_name)?; 62 | jobsets 63 | .into_iter() 64 | .find(|j| j.name == jobset_name) 65 | .ok_or_else(|| { 66 | OpError::Error(format!( 67 | "Project {} doesn't have a jobset {}", 68 | project_name, jobset_name 69 | )) 70 | }) 71 | } 72 | 73 | // To know if a jobset has been successfully built, five steps are required: 74 | // 0. wait for the jobset creation : WaitingForJobset 75 | // 1. ensure a evaluation is not running : WaitingForPreviousEval 76 | // 2. wait for a new evaluation to start : WaitingForNewEval 77 | // 3. wait for this evaluation to be terminated : Evaluation 78 | // 4. wait for all scheduled builds to terminate : Building 79 | // 80 | // If a build or the evaluation fails, it immediately returns an error. 81 | // 82 | // There are several improvements, such as 83 | // - use the checkinterval to know when the next evaluation will start 84 | // - use the push Hydra API to trigger an evaluation (but this needs credentials) 85 | pub fn run( 86 | client: &dyn HydraClient, 87 | project_name: &str, 88 | jobset_name: &str, 89 | timeout: Option, 90 | ) -> OpResult { 91 | #[derive(PartialEq)] 92 | enum State { 93 | WaitingForJobset, 94 | WaitingForPreviousEval, 95 | WaitingForNewEval, 96 | Evaluating, 97 | Building, 98 | } 99 | let sleep = Duration::from_secs(2); 100 | let mut state = State::WaitingForJobset; 101 | let mut start = SystemTime::now(); 102 | let timeout_start = start; 103 | let mut nrscheduled = 0; 104 | 105 | println!( 106 | "waiting for jobset {}/{} ({}/jobset/{}/{})", 107 | project_name, 108 | jobset_name, 109 | client.host(), 110 | project_name, 111 | jobset_name 112 | ); 113 | loop { 114 | match timeout { 115 | Some(t) if SystemTime::now().duration_since(timeout_start).unwrap() > t => { 116 | return Err(OpError::TimeoutError) 117 | } 118 | _ => {} 119 | } 120 | 121 | match jobset_find(client, project_name, jobset_name) { 122 | Err(_) => { 123 | // The jobset should not disappear if it has already been seen! 124 | if state != State::WaitingForJobset { 125 | return Err(OpError::Error(format!( 126 | "jobset {}/{} failed", 127 | project_name, jobset_name 128 | ))); 129 | } 130 | } 131 | Ok(jobset) => match state { 132 | State::WaitingForJobset => { 133 | state = State::WaitingForPreviousEval; 134 | println!("waiting for a potential evaluation to terminate"); 135 | } 136 | State::WaitingForPreviousEval => match evaluation_started_since(&jobset) { 137 | Some(_) => {} 138 | None => { 139 | println!("\nwaiting for an new evaluation"); 140 | start = SystemTime::now(); 141 | state = State::WaitingForNewEval; 142 | } 143 | }, 144 | State::WaitingForNewEval => { 145 | if is_evaluation_finished_after(&jobset, start) { 146 | println!("\njobset has been evaluated"); 147 | // we skip the evaluation step since the evaluation is already finished 148 | state = State::Building; 149 | } else if let Some(d) = evaluation_started_since(&jobset) { 150 | println!( 151 | "\nevaluation is started since {} seconds", 152 | d.as_secs().to_string() 153 | ); 154 | state = State::Evaluating; 155 | } 156 | } 157 | State::Evaluating => { 158 | if is_evaluation_finished_after(&jobset, start) { 159 | println!("\njobset has been evaluated"); 160 | state = State::Building; 161 | } 162 | } 163 | State::Building => { 164 | if is_jobset_built(client, &jobset)? { 165 | break; 166 | } else if nrscheduled != jobset.nrscheduled { 167 | nrscheduled = jobset.nrscheduled; 168 | println!( 169 | "\njobset {} has still {} jobs scheduled", 170 | jobset_name.to_string(), 171 | jobset.nrscheduled.to_string() 172 | ); 173 | } 174 | } 175 | }, 176 | }; 177 | print!("."); 178 | io::stdout().flush().unwrap(); 179 | thread::sleep(sleep); 180 | } 181 | ok() 182 | } 183 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod jobset_create; 2 | pub mod jobset_delete; 3 | pub mod jobset_eval; 4 | pub mod jobset_wait; 5 | pub mod project; 6 | pub mod project_create; 7 | pub mod project_list; 8 | pub mod reproduce; 9 | pub mod search; 10 | 11 | pub enum OpError { 12 | AuthError, 13 | CmdErr, 14 | TimeoutError, 15 | Error(String), 16 | RequestError(String), 17 | } 18 | 19 | pub type OpResult = Result, OpError>; 20 | 21 | impl From for OpError { 22 | fn from(error: reqwest::Error) -> Self { 23 | let info = if error.is_timeout() { 24 | "timeout error: " 25 | } else { 26 | "" 27 | }; 28 | let msg = format!("{info} {err}", info = info, err = error); 29 | OpError::RequestError(msg) 30 | } 31 | } 32 | 33 | pub fn ok() -> OpResult { 34 | Ok(None) 35 | } 36 | 37 | pub fn ok_msg(message: T) -> OpResult 38 | where 39 | T: Into, 40 | { 41 | Ok(Some(message.into())) 42 | } 43 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/project.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{HydraClient, JobsetOverview}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | use prettytable::format; 4 | 5 | pub fn render_response(res: std::vec::Vec) { 6 | let mut table = table!(["Jobset", "Succeeded", "Scheduled", "Failed"]); 7 | table.set_format(*format::consts::FORMAT_CLEAN); 8 | for j in res { 9 | let mut nrfailed = j.nrfailed.to_string(); 10 | let mut nrscheduled = j.nrscheduled.to_string(); 11 | let name = j.name; 12 | if j.nrfailed == 0 { 13 | nrfailed = "".to_string(); 14 | } 15 | if j.nrscheduled == 0 { 16 | nrscheduled = "".to_string(); 17 | } 18 | table.add_row(row![name, j.nrsucceeded, nrscheduled, nrfailed]); 19 | } 20 | table.printstd(); 21 | } 22 | 23 | pub fn run(client: &dyn HydraClient, project: &str, to_json: bool) -> OpResult { 24 | let res = client.jobset_overview(project)?; 25 | if to_json { 26 | println!("{}", serde_json::to_string_pretty(&res).unwrap()) 27 | } else { 28 | render_response(res) 29 | }; 30 | ok_msg("overview created") 31 | } 32 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/project_create.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{Creds, HydraClient}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | 4 | pub fn run(client: &dyn HydraClient, project_name: &str, user: &str, password: &str) -> OpResult { 5 | let creds = Creds { 6 | username: String::from(user), 7 | password: String::from(password), 8 | }; 9 | 10 | client.login(creds)?; 11 | client.project_create(project_name)?; 12 | 13 | ok_msg("project_create") 14 | } 15 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/project_list.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{HydraClient, Project}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | use prettytable::format; 4 | 5 | pub fn run(client: &dyn HydraClient, to_json: bool) -> OpResult { 6 | let res: Vec = client.projects()?; 7 | if to_json { 8 | println!("{}", serde_json::to_string_pretty(&res).unwrap()) 9 | } else { 10 | let mut table = table!(["Name", "Enable", "Visible", "#Jobsets", "Description"]); 11 | table.set_format(*format::consts::FORMAT_CLEAN); 12 | for p in res { 13 | table.add_row(row![ 14 | p.name, 15 | p.enabled, 16 | !p.hidden, 17 | p.jobsets.len(), 18 | p.description.unwrap_or_else(|| "".to_string()) 19 | ]); 20 | } 21 | table.printstd(); 22 | }; 23 | ok_msg("projects listed") 24 | } 25 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/reproduce.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{HydraClient, Reproduce, Search}; 2 | use crate::ops::{ok_msg, OpResult}; 3 | use crate::pretty::{build_pretty_print, evaluation_pretty_print}; 4 | 5 | pub fn run(client: &dyn HydraClient, host: &str, query: &str, to_json: bool) -> OpResult { 6 | let mut res: Search = client.search(query)?; 7 | 8 | if res.builds.is_empty() { 9 | println!("No builds found. Exiting."); 10 | return ok_msg("no builds found"); 11 | } else if res.builds.len() > 1 { 12 | eprintln!( 13 | "Warning: the query matches {} builds, considering the first one.", 14 | res.builds.len() 15 | ); 16 | } 17 | let eval = client.eval(res.builds[0].jobsetevals[0])?; 18 | 19 | let jobset = client.jobset(&res.builds[0].project, &res.builds[0].jobset)?; 20 | let reproduce = Reproduce { 21 | build: res.builds.swap_remove(0), 22 | eval, 23 | jobset, 24 | }; 25 | 26 | if to_json { 27 | println!("{}", serde_json::to_string_pretty(&reproduce).unwrap()); 28 | } else { 29 | build_pretty_print(&reproduce.build); 30 | let input = &reproduce.eval.jobsetevalinputs[&reproduce.jobset.nixexprinput]; 31 | if input.input_type == "git" { 32 | println!("{:14} {}", "Repository", input.uri.as_ref().unwrap()); 33 | println!("{:14} {}", "Revision", input.revision.as_ref().unwrap()); 34 | } 35 | println!("{:14} {}", "Attribute name", reproduce.build.job); 36 | println!("{:14} {}", "Nix expr path", reproduce.jobset.nixexprpath); 37 | 38 | println!("Inputs:"); 39 | evaluation_pretty_print(&reproduce.eval); 40 | println!("{:14} {}/build/{}", "Hydra url", host, reproduce.build.id); 41 | } 42 | ok_msg("reproduce info created") 43 | } 44 | -------------------------------------------------------------------------------- /hydra-cli/src/ops/search.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{HydraClient, Search}; 2 | use crate::ops::{ok_msg, OpError, OpResult}; 3 | use crate::pretty::build_pretty_print; 4 | use std::cmp::min; 5 | 6 | fn print_result(s: Search, limit: usize) { 7 | let range = min(s.builds.len(), limit); 8 | for i in 0..range { 9 | build_pretty_print(&s.builds[i]); 10 | } 11 | } 12 | pub fn run(client: &dyn HydraClient, query: &str, limit: usize) -> OpResult { 13 | let res = client.search(query); 14 | match res { 15 | Ok(x) => print_result(x, limit), 16 | Err(_) => return Err(OpError::Error(String::from(""))), 17 | } 18 | ok_msg("search") 19 | } 20 | -------------------------------------------------------------------------------- /hydra-cli/src/pretty.rs: -------------------------------------------------------------------------------- 1 | use crate::hydra::client::{Build, Eval}; 2 | use chrono::NaiveDateTime; 3 | 4 | pub fn evaluation_pretty_print(e: &Eval) { 5 | for (k, v) in &e.jobsetevalinputs { 6 | println!(" {}", k); 7 | println!(" {:10} {}", "type", v.input_type); 8 | if let Some(t) = &v.value { 9 | println!(" {:10} {}", "value", t); 10 | } 11 | if let Some(t) = &v.uri { 12 | println!(" {:10} {}", "uri", t); 13 | } 14 | if let Some(t) = &v.revision { 15 | println!(" {:10} {}", "revision", t); 16 | } 17 | } 18 | } 19 | 20 | pub fn build_pretty_print(b: &Build) { 21 | println!("{:14} {}/{}/{}", "Job", b.project, b.jobset, b.job); 22 | 23 | match NaiveDateTime::from_timestamp_opt(b.stoptime, 0) { 24 | Some(t) => println!( 25 | "{:14} {}", 26 | "Finished at", 27 | t, 28 | ), 29 | None => println!( 30 | "{:14} {}", 31 | "Finished at", 32 | b.stoptime, 33 | ), 34 | } 35 | 36 | println!("{:14} {}", "Derviation", b.drvpath); 37 | println!("{:14}", "Build outputs"); 38 | for (k, v) in &b.buildoutputs { 39 | println!(" {:12} {}", k, v.path); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nlewo/hydra-cli/dbb6eaa45c362969382bae7142085be769fa14e6/logo.png -------------------------------------------------------------------------------- /nix/nixpkgs.nix: -------------------------------------------------------------------------------- 1 | {}: 2 | let 3 | sources = import ./sources.nix; 4 | nixpkgs = sources.nixpkgs; 5 | in 6 | import nixpkgs { } 7 | -------------------------------------------------------------------------------- /nix/sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "nixpkgs": { 3 | "branch": "22.11", 4 | "description": "Nix Packages collection & NixOS", 5 | "homepage": "", 6 | "owner": "nixos", 7 | "ref": "nixos-22.11", 8 | "repo": "nixpkgs", 9 | "rev": "bbc56fd1c7311b69abf0e206fc00378ca170abe9", 10 | "sha256": "08g6azdr5lq2jmkal596b3w7j0z5v18fmh7ff25y5fixv8wsh34g", 11 | "type": "tarball", 12 | "url": "https://github.com/nixos/nixpkgs/archive/bbc56fd1c7311b69abf0e206fc00378ca170abe9.tar.gz", 13 | "url_template": "https://github.com///archive/.tar.gz" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /nix/sources.nix: -------------------------------------------------------------------------------- 1 | # This file has been generated by Niv. 2 | 3 | let 4 | 5 | # 6 | # The fetchers. fetch_ fetches specs of type . 7 | # 8 | 9 | fetch_file = pkgs: name: spec: 10 | let 11 | name' = sanitizeName name + "-src"; 12 | in 13 | if spec.builtin or true then 14 | builtins_fetchurl { inherit (spec) url sha256; name = name'; } 15 | else 16 | pkgs.fetchurl { inherit (spec) url sha256; name = name'; }; 17 | 18 | fetch_tarball = pkgs: name: spec: 19 | let 20 | name' = sanitizeName name + "-src"; 21 | in 22 | if spec.builtin or true then 23 | builtins_fetchTarball { name = name'; inherit (spec) url sha256; } 24 | else 25 | pkgs.fetchzip { name = name'; inherit (spec) url sha256; }; 26 | 27 | fetch_git = name: spec: 28 | let 29 | ref = 30 | if spec ? ref then spec.ref else 31 | if spec ? branch then "refs/heads/${spec.branch}" else 32 | if spec ? tag then "refs/tags/${spec.tag}" else 33 | abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!"; 34 | submodules = if spec ? submodules then spec.submodules else false; 35 | submoduleArg = 36 | let 37 | nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0; 38 | emptyArgWithWarning = 39 | if submodules == true 40 | then 41 | builtins.trace 42 | ( 43 | "The niv input \"${name}\" uses submodules " 44 | + "but your nix's (${builtins.nixVersion}) builtins.fetchGit " 45 | + "does not support them" 46 | ) 47 | {} 48 | else {}; 49 | in 50 | if nixSupportsSubmodules 51 | then { inherit submodules; } 52 | else emptyArgWithWarning; 53 | in 54 | builtins.fetchGit 55 | ({ url = spec.repo; inherit (spec) rev; inherit ref; } // submoduleArg); 56 | 57 | fetch_local = spec: spec.path; 58 | 59 | fetch_builtin-tarball = name: throw 60 | ''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`. 61 | $ niv modify ${name} -a type=tarball -a builtin=true''; 62 | 63 | fetch_builtin-url = name: throw 64 | ''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`. 65 | $ niv modify ${name} -a type=file -a builtin=true''; 66 | 67 | # 68 | # Various helpers 69 | # 70 | 71 | # https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695 72 | sanitizeName = name: 73 | ( 74 | concatMapStrings (s: if builtins.isList s then "-" else s) 75 | ( 76 | builtins.split "[^[:alnum:]+._?=-]+" 77 | ((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name) 78 | ) 79 | ); 80 | 81 | # The set of packages used when specs are fetched using non-builtins. 82 | mkPkgs = sources: system: 83 | let 84 | sourcesNixpkgs = 85 | import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; }; 86 | hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath; 87 | hasThisAsNixpkgsPath = == ./.; 88 | in 89 | if builtins.hasAttr "nixpkgs" sources 90 | then sourcesNixpkgs 91 | else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then 92 | import {} 93 | else 94 | abort 95 | '' 96 | Please specify either (through -I or NIX_PATH=nixpkgs=...) or 97 | add a package called "nixpkgs" to your sources.json. 98 | ''; 99 | 100 | # The actual fetching function. 101 | fetch = pkgs: name: spec: 102 | 103 | if ! builtins.hasAttr "type" spec then 104 | abort "ERROR: niv spec ${name} does not have a 'type' attribute" 105 | else if spec.type == "file" then fetch_file pkgs name spec 106 | else if spec.type == "tarball" then fetch_tarball pkgs name spec 107 | else if spec.type == "git" then fetch_git name spec 108 | else if spec.type == "local" then fetch_local spec 109 | else if spec.type == "builtin-tarball" then fetch_builtin-tarball name 110 | else if spec.type == "builtin-url" then fetch_builtin-url name 111 | else 112 | abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}"; 113 | 114 | # If the environment variable NIV_OVERRIDE_${name} is set, then use 115 | # the path directly as opposed to the fetched source. 116 | replace = name: drv: 117 | let 118 | saneName = stringAsChars (c: if isNull (builtins.match "[a-zA-Z0-9]" c) then "_" else c) name; 119 | ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}"; 120 | in 121 | if ersatz == "" then drv else 122 | # this turns the string into an actual Nix path (for both absolute and 123 | # relative paths) 124 | if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}"; 125 | 126 | # Ports of functions for older nix versions 127 | 128 | # a Nix version of mapAttrs if the built-in doesn't exist 129 | mapAttrs = builtins.mapAttrs or ( 130 | f: set: with builtins; 131 | listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set)) 132 | ); 133 | 134 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 135 | range = first: last: if first > last then [] else builtins.genList (n: first + n) (last - first + 1); 136 | 137 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 138 | stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); 139 | 140 | # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 141 | stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); 142 | concatMapStrings = f: list: concatStrings (map f list); 143 | concatStrings = builtins.concatStringsSep ""; 144 | 145 | # https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331 146 | optionalAttrs = cond: as: if cond then as else {}; 147 | 148 | # fetchTarball version that is compatible between all the versions of Nix 149 | builtins_fetchTarball = { url, name ? null, sha256 }@attrs: 150 | let 151 | inherit (builtins) lessThan nixVersion fetchTarball; 152 | in 153 | if lessThan nixVersion "1.12" then 154 | fetchTarball ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; })) 155 | else 156 | fetchTarball attrs; 157 | 158 | # fetchurl version that is compatible between all the versions of Nix 159 | builtins_fetchurl = { url, name ? null, sha256 }@attrs: 160 | let 161 | inherit (builtins) lessThan nixVersion fetchurl; 162 | in 163 | if lessThan nixVersion "1.12" then 164 | fetchurl ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; })) 165 | else 166 | fetchurl attrs; 167 | 168 | # Create the final "sources" from the config 169 | mkSources = config: 170 | mapAttrs ( 171 | name: spec: 172 | if builtins.hasAttr "outPath" spec 173 | then abort 174 | "The values in sources.json should not have an 'outPath' attribute" 175 | else 176 | spec // { outPath = replace name (fetch config.pkgs name spec); } 177 | ) config.sources; 178 | 179 | # The "config" used by the fetchers 180 | mkConfig = 181 | { sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null 182 | , sources ? if isNull sourcesFile then {} else builtins.fromJSON (builtins.readFile sourcesFile) 183 | , system ? builtins.currentSystem 184 | , pkgs ? mkPkgs sources system 185 | }: rec { 186 | # The sources, i.e. the attribute set of spec name to spec 187 | inherit sources; 188 | 189 | # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers 190 | inherit pkgs; 191 | }; 192 | 193 | in 194 | mkSources (mkConfig {}) // { __functor = _: settings: mkSources (mkConfig settings); } 195 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , rustPlatform 3 | , fetchFromGitHub 4 | , pkg-config 5 | , openssl 6 | , stdenv 7 | , darwin 8 | }: 9 | 10 | rustPlatform.buildRustPackage rec { 11 | pname = "hydra-cli"; 12 | version = "0.3.0"; 13 | 14 | src = ./hydra-cli; 15 | 16 | cargoLock = { 17 | lockFile = ./hydra-cli/Cargo.lock; 18 | }; 19 | 20 | nativeBuildInputs = [ 21 | pkg-config 22 | ]; 23 | 24 | buildInputs = [ 25 | openssl 26 | ] ++ lib.optionals stdenv.isDarwin [ 27 | darwin.apple_sdk.frameworks.Security 28 | ]; 29 | 30 | __darwinAllowLocalNetworking = true; 31 | 32 | meta = with lib; { 33 | description = "A client for the Hydra CI"; 34 | homepage = "https://github.com/nlewo/hydra-cli"; 35 | license = with licenses; [ mit ]; 36 | maintainers = with maintainers; [ gilligan lewo ]; 37 | }; 38 | } -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import ./nix/nixpkgs.nix {} 2 | , devBuild ? true 3 | }: 4 | 5 | let 6 | updateCrateDeps = pkgs.writeShellScriptBin "update-crate-deps" '' 7 | ${pkgs.crate2nix}/bin/crate2nix generate 8 | ''; 9 | in 10 | pkgs.mkShell { 11 | buildInputs = [ 12 | pkgs.pkg-config 13 | pkgs.openssl 14 | pkgs.rustc 15 | pkgs.cargo 16 | ] ++ pkgs.lib.optionals devBuild [ 17 | pkgs.rustfmt 18 | pkgs.direnv 19 | pkgs.crate2nix 20 | updateCrateDeps 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /tests/vm.nix: -------------------------------------------------------------------------------- 1 | { pkgs, system, hydra-cli }: 2 | 3 | let 4 | jobSuccess = pkgs.writeTextDir "job.nix" '' 5 | { success = builtins.derivation { 6 | name = "success"; 7 | system = "x86_64-linux"; 8 | builder = "/bin/sh"; 9 | args = ["-c" "echo success > $out; exit 0"]; 10 | }; 11 | } 12 | ''; 13 | 14 | jobFail = pkgs.writeTextDir "job.nix" '' 15 | { fail = builtins.derivation { 16 | name = "fail"; 17 | system = "x86_64-linux"; 18 | builder = "/bin/sh"; 19 | args = ["-c" "sleep 5; echo fail > $out; exit 1"]; 20 | }; 21 | } 22 | ''; 23 | 24 | mkJobset = description: path: pkgs.writeTextFile { 25 | name = "jobset.json"; 26 | text = builtins.toJSON { 27 | inherit description; 28 | checkinterval = 60; 29 | enabled = 1; 30 | visible = true; 31 | keepnr = 1; 32 | nixexprinput = "expr"; 33 | nixexprpath = "job.nix"; 34 | inputs = { 35 | expr = { 36 | value = path; 37 | type = "path"; 38 | }; 39 | }; 40 | }; 41 | }; 42 | 43 | jobsetSuccess = mkJobset "Success" jobSuccess; 44 | jobsetFail = mkJobset "Fail" jobFail; 45 | 46 | in 47 | pkgs.nixosTest { 48 | name = "hydra"; 49 | nodes.machine = { pkgs, ... }: 50 | { 51 | virtualisation.memorySize = 1024; 52 | time.timeZone = "UTC"; 53 | networking.firewall.allowedTCPPorts = [ 3000 ]; 54 | environment.systemPackages = [ hydra-cli ]; 55 | services.hydra = { 56 | enable = true; 57 | package = pkgs.hydra-unstable; 58 | #Hydra needs those settings to start up, so we add something not harmfull. 59 | hydraURL = "example.com"; 60 | notificationSender = "example@example.com"; 61 | }; 62 | nix = { 63 | buildMachines = [{ 64 | hostName = "localhost"; 65 | systems = [ "x86_64-linux" ]; 66 | }]; 67 | 68 | settings.substituters = pkgs.lib.mkForce []; 69 | }; 70 | }; 71 | testScript = '' 72 | # let the system boot up 73 | machine.wait_for_unit("multi-user.target"); 74 | # test whether the database is running 75 | machine.succeed("systemctl status postgresql.service"); 76 | # test whether the actual hydra daemons are running 77 | machine.succeed("systemctl status hydra-queue-runner.service"); 78 | machine.succeed("systemctl status hydra-init.service"); 79 | machine.succeed("systemctl status hydra-evaluator.service"); 80 | machine.succeed("systemctl status hydra-send-stats.service"); 81 | 82 | machine.succeed("hydra-create-user admin --role admin --password admin"); 83 | 84 | # create a project with a trivial job 85 | machine.wait_for_open_port(3000); 86 | 87 | machine.succeed("hydra-cli -H http://localhost:3000 project-create test --password admin --user admin"); 88 | machine.succeed("hydra-cli -H http://localhost:3000 project-list | grep -q test"); 89 | 90 | machine.succeed("hydra-cli -H http://localhost:3000 jobset-create test success ${jobsetSuccess} --password admin --user admin "); 91 | machine.succeed("hydra-cli -H http://localhost:3000 jobset-wait test success"); 92 | machine.succeed("hydra-cli -H http://localhost:3000 jobset-eval test success"); 93 | 94 | machine.succeed("hydra-cli -H http://localhost:3000 jobset-create test fail ${jobsetFail} --password admin --user admin"); 95 | machine.fail("hydra-cli -H http://localhost:3000 jobset-wait test fail"); 96 | machine.succeed("hydra-cli -H http://localhost:3000 jobset-eval test fail"); 97 | ''; 98 | } 99 | --------------------------------------------------------------------------------