├── web ├── .browserslistrc ├── output.js ├── vue.config.js ├── public │ ├── favicon.ico │ └── index.html ├── babel.config.js ├── src │ ├── js │ │ ├── config.js │ │ └── store.js │ ├── assets │ │ ├── styles │ │ │ └── main.css │ │ ├── octopusdeploy.svg │ │ ├── github.svg │ │ ├── azuredevops.svg │ │ ├── appveyor.svg │ │ └── teamcity.svg │ ├── components │ │ ├── BuildIcon.vue │ │ ├── StatusIcon.vue │ │ ├── ServerInfo.vue │ │ ├── SkeletonBuild.vue │ │ ├── BuildList.vue │ │ ├── ViewList.vue │ │ ├── Settings.vue │ │ └── Build.vue │ ├── main.js │ ├── Error.vue │ └── App.vue ├── jest.config.js ├── tailwind.config.js ├── .gitignore ├── README.md ├── .eslintrc.js ├── postcss.config.js ├── tests │ └── unit │ │ ├── BuildIcon.spec.js │ │ ├── Error.spec.js │ │ ├── StatusIcon.spec.js │ │ ├── Settings.spec.js │ │ ├── ServerInfo.spec.js │ │ └── ViewList.spec.js ├── wallaby.js └── package.json ├── .dockerignore ├── .gitignore ├── media ├── images │ └── frontend.png └── logo │ ├── duck_64x64.png │ ├── duck_128x128.png │ └── duck_256x256.png ├── src ├── config │ ├── test_data │ │ └── config.json │ └── loader.rs ├── commands.rs ├── providers │ ├── collectors │ │ ├── duck │ │ │ ├── test_data │ │ │ │ ├── view.json │ │ │ │ └── builds.json │ │ │ ├── validation.rs │ │ │ └── client.rs │ │ ├── debugger │ │ │ ├── test_data │ │ │ │ └── builds.json │ │ │ ├── validation.rs │ │ │ ├── client.rs │ │ │ └── mod.rs │ │ ├── octopus │ │ │ └── client.rs │ │ ├── appveyor │ │ │ ├── client.rs │ │ │ └── validation.rs │ │ ├── azure │ │ │ └── client.rs │ │ ├── teamcity │ │ │ ├── mod.rs │ │ │ ├── validation.rs │ │ │ └── client.rs │ │ └── github │ │ │ └── client.rs │ ├── collectors.rs │ ├── observers.rs │ └── observers │ │ ├── slack │ │ ├── client.rs │ │ └── validation.rs │ │ ├── mattermost │ │ ├── client.rs │ │ └── validation.rs │ │ └── hue │ │ ├── client.rs │ │ ├── validation.rs │ │ └── mod.rs ├── commands │ ├── schema.rs │ ├── validate.rs │ ├── start.rs │ └── service.rs ├── utils │ ├── switch.rs │ ├── colors.rs │ ├── date.rs │ └── text.rs ├── engine │ ├── state.rs │ └── state │ │ ├── ui.rs │ │ └── views.rs ├── utils.rs ├── api │ ├── endpoints.rs │ └── models.rs ├── lib.rs ├── providers.rs ├── query.rs ├── api.rs ├── main.rs ├── query │ └── parser.rs └── builds.rs ├── ui.dev.dockerfile ├── .gitattributes ├── .vscode └── settings.json ├── app.dev.dockerfile ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── Cargo.toml ├── deny.toml └── CODE_OF_CONDUCT.md /web/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | docs 3 | **/node_modules 4 | **/.git -------------------------------------------------------------------------------- /web/output.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/web/output.js -------------------------------------------------------------------------------- /web/vue.config.js: -------------------------------------------------------------------------------- 1 | process.env.VUE_APP_VERSION = require('./package.json').version; -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target 3 | **/*.rs.bk 4 | 5 | # Configuration 6 | /config.json 7 | data/ -------------------------------------------------------------------------------- /media/images/frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/media/images/frontend.png -------------------------------------------------------------------------------- /media/logo/duck_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/media/logo/duck_64x64.png -------------------------------------------------------------------------------- /media/logo/duck_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/media/logo/duck_128x128.png -------------------------------------------------------------------------------- /media/logo/duck_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duckhq/duck/HEAD/media/logo/duck_256x256.png -------------------------------------------------------------------------------- /web/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /web/src/js/config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | imageLoader: require.context("../assets/", false, /\.svg$/) 4 | } -------------------------------------------------------------------------------- /src/config/test_data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "interval": 99, 3 | "title": "Duck test server", 4 | "collectors": [ ] 5 | } -------------------------------------------------------------------------------- /web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | verbose: true 4 | }; 5 | -------------------------------------------------------------------------------- /ui.dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /ui 4 | RUN apk --no-cache add ca-certificates 5 | EXPOSE 8080 6 | CMD npm install && npm run serve 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default 2 | * text=auto 3 | 4 | # Shell scripts 5 | * .sh text eol=lf 6 | 7 | # Images 8 | *.png binary 9 | *.jpg binary 10 | *.dat binary -------------------------------------------------------------------------------- /web/src/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | 7 | * { 8 | font-family: "Roboto", "Helvetica Neue", Arial, "Noto Sans", sans-serif; 9 | } -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: { 4 | screens: { 5 | 'dark-mode': { raw: '(prefers-color-scheme: dark)' } 6 | } 7 | } 8 | }, 9 | variants: {}, 10 | plugins: [] 11 | }; 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | ".vscode": true, 4 | "Cargo.lock": true, 5 | "CODE_OF_CONDUCT.md": true, 6 | "LICENSE": true, 7 | "res": true, 8 | "target": true 9 | } 10 | } -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | pub mod schema; 2 | pub mod start; 3 | pub mod validate; 4 | 5 | #[cfg(windows)] 6 | pub mod service; 7 | 8 | pub const DEFAULT_CONFIG: &str = "config.json"; 9 | pub const ENV_CONFIG: &str = "DUCK_CONFIG"; 10 | pub const ENV_BINDING: &str = "DUCK_BIND"; 11 | -------------------------------------------------------------------------------- /app.dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ekidd/rust-musl-builder:stable 2 | 3 | WORKDIR /home/rust/src/duck 4 | RUN mkdir ../out 5 | RUN cargo install cargo-watch 6 | EXPOSE 15825 7 | ENTRYPOINT cargo watch -w Cargo.toml -w deny.toml -w src/ -x 'run --verbose --target-dir ../out --features docker -- start --config ./data/duck.json' 8 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /web/src/components/BuildIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Duck frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Run unit tests 24 | ``` 25 | npm run test:unit 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /src/providers/collectors/duck/test_data/view.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1619989489999387859, 4 | "provider": "GitHub", 5 | "collector": "duck_other", 6 | "project": "spectresystems/duck", 7 | "build": "ci.yaml", 8 | "branch": "setup-docker-for-local-development", 9 | "buildId": "58880314", 10 | "buildNumber": "24", 11 | "started": 1584617069, 12 | "finished": 1584617318, 13 | "url": "https://github.com/spectresystems/duck/actions/runs/58880314", 14 | "status": "Success" 15 | } 16 | ] -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Duck 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/providers/collectors/debugger/test_data/builds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "project": "Cauldron", 5 | "definition": "Debug", 6 | "branch": "master", 7 | "status": 0, 8 | "started": "2020-04-13T22:59:22.7241896+02:00", 9 | "finished": "2020-04-13T23:03:47.7243751+02:00" 10 | }, 11 | { 12 | "id": 2, 13 | "project": "Mercury", 14 | "definition": "Debug", 15 | "branch": "master", 16 | "status": 1, 17 | "started": "2020-04-13T22:59:25.2815355+02:00", 18 | "finished": "2020-04-13T23:01:12.2815786+02:00" 19 | } 20 | ] -------------------------------------------------------------------------------- /src/commands/schema.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::prelude::*; 3 | use std::path::PathBuf; 4 | use structopt::StructOpt; 5 | 6 | use duck::DuckResult; 7 | 8 | /////////////////////////////////////////////////////////// 9 | // Arguments 10 | 11 | #[derive(StructOpt, Debug)] 12 | pub struct Arguments { 13 | #[structopt(short, long, parse(from_os_str))] 14 | pub output: PathBuf, 15 | } 16 | 17 | /////////////////////////////////////////////////////////// 18 | // Command 19 | 20 | pub fn execute(args: Arguments) -> DuckResult<()> { 21 | let mut file = File::create(args.output)?; 22 | file.write_all(duck::get_schema().as_bytes())?; 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true 6 | }, 7 | 8 | 'extends': [ 9 | 'plugin:vue/essential', 10 | 'eslint:recommended' 11 | ], 12 | 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 16 | }, 17 | 18 | parserOptions: { 19 | parser: 'babel-eslint' 20 | }, 21 | 22 | overrides: [ 23 | { 24 | files: [ 25 | '**/__tests__/*.{j,t}s?(x)', 26 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 27 | ], 28 | env: { 29 | jest: true 30 | } 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | const purgecss = require('@fullhuman/postcss-purgecss')({ 3 | 4 | // Specify the paths to all of the template files in your project 5 | content: [ 6 | './src/**/*.html', 7 | './src/**/*.vue', 8 | './src/**/*.js(x)', 9 | // etc. 10 | ], 11 | 12 | // Include any special characters you're using in this regular expression 13 | defaultExtractor: content => content.match(/[\w-/:]+(? DuckResult>; 16 | } 17 | 18 | pub trait Collector: Send { 19 | fn info(&self) -> &CollectorInfo; 20 | fn collect( 21 | &self, 22 | handle: WaitHandleListener, 23 | callback: &mut dyn FnMut(Build), 24 | ) -> DuckResult<()>; 25 | } 26 | 27 | pub struct CollectorInfo { 28 | pub id: String, 29 | pub enabled: bool, 30 | pub provider: String, 31 | } 32 | -------------------------------------------------------------------------------- /web/src/assets/octopusdeploy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | image: duckhq/app:dev 5 | build: 6 | context: . 7 | dockerfile: app.dev.dockerfile 8 | ports: 9 | - "15825:15825" 10 | volumes: 11 | - './:/home/rust/src/duck' 12 | container_name: duck-app 13 | ui: 14 | image: duckhq/ui:dev 15 | environment: 16 | - VUE_APP_MY_DUCK_SERVER=http://localhost:15825 17 | build: 18 | context: . 19 | dockerfile: ui.dev.dockerfile 20 | ports: 21 | - "8080:8080" 22 | volumes: 23 | - './web:/ui' 24 | container_name: duck-ui 25 | depends_on: 26 | - app 27 | ducktor: 28 | image: duckhq/ducktor:latest 29 | ports: 30 | - "5000:80" 31 | container_name: ducktor -------------------------------------------------------------------------------- /src/utils/switch.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | pub struct Switch { 4 | value: RefCell, 5 | } 6 | 7 | impl Switch { 8 | pub fn new(value: bool) -> Self { 9 | Self { 10 | value: RefCell::new(value), 11 | } 12 | } 13 | 14 | pub fn is_on(&self) -> bool { 15 | self.is(true) 16 | } 17 | 18 | pub fn is_off(&self) -> bool { 19 | self.is(false) 20 | } 21 | 22 | pub fn turn_on(&self) { 23 | self.set(true); 24 | } 25 | 26 | pub fn turn_off(&self) { 27 | self.set(false); 28 | } 29 | 30 | fn is(&self, value: bool) -> bool { 31 | *self.value.borrow() == value 32 | } 33 | 34 | fn set(&self, value: bool) { 35 | *self.value.borrow_mut() = value; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/assets/github.svg: -------------------------------------------------------------------------------- 1 | GitHub icon -------------------------------------------------------------------------------- /web/tests/unit/BuildIcon.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock("@/js/config", () => ({ 2 | imageLoader: jest.fn().mockReturnValueOnce("./assets/azure.svg") 3 | })) 4 | 5 | import { imageLoader } from "@/js/config" 6 | import { shallowMount } from "@vue/test-utils" 7 | import BuildIcon from '@/components/BuildIcon.vue' 8 | 9 | describe("BuildIcon", () => { 10 | let wrapper = null 11 | let provider = null 12 | 13 | function mount() { 14 | wrapper = shallowMount(BuildIcon, { 15 | propsData: { 16 | build: { 17 | provider 18 | } 19 | } 20 | }) 21 | } 22 | 23 | it("displays svg based on build status", () => { 24 | provider = "azure" 25 | mount() 26 | expect(imageLoader).toHaveBeenCalledWith("./azure.svg") 27 | expect(wrapper.find("img").attributes().src).toBe("./assets/azure.svg") 28 | }) 29 | }) -------------------------------------------------------------------------------- /web/tests/unit/Error.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock("@/js/store", () => ({ 2 | data: {} 3 | })) 4 | 5 | import { shallowMount } from '@vue/test-utils' 6 | import { data } from "@/js/store" 7 | import Error from '@/Error.vue' 8 | 9 | const mount = function() { 10 | return shallowMount(Error, { 11 | stubs: [ 12 | 'fa-icon' 13 | ] 14 | }) 15 | } 16 | 17 | describe('Error.vue', () => { 18 | beforeEach(() => { 19 | data.server = "" 20 | }) 21 | 22 | it('shows server host in error message when present', () => { 23 | data.server = "localhost:12345" 24 | const wrapper = mount() 25 | 26 | expect(wrapper.text()).toContain(`The Duck server could not be reached at "${data.server}".`) 27 | }) 28 | 29 | it('shows a different error message when server host not supplied', () => { 30 | const wrapper = mount() 31 | 32 | expect(wrapper.text()).toContain("The local Duck server could not be reached.") 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /web/src/assets/azuredevops.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /web/wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | process.env.VUE_CLI_BABEL_TRANSPILE_MODULES = true; 3 | 4 | return { 5 | files: ["src/**/*", "jest.config.js", "package.json", "tsconfig.json"], 6 | 7 | tests: ["tests/**/*.spec.ts", "tests/**/*.spec.js"], 8 | 9 | env: { 10 | type: "node" 11 | }, 12 | 13 | preprocessors: { 14 | "**/*.js?(x)": file => 15 | require("babel-core").transform(file.content, { 16 | babelrc: true, 17 | sourceMap: true, 18 | compact: false, 19 | filename: file.path, 20 | plugins: [ 21 | "babel-plugin-jest-hoist", 22 | "@babel/plugin-syntax-dynamic-import" 23 | ] 24 | }) 25 | }, 26 | 27 | setup(wallaby) { 28 | const jestConfig = require("./package").jest || require("./jest.config"); 29 | jestConfig.transform = {}; 30 | wallaby.testFramework.configure(jestConfig); 31 | }, 32 | 33 | testFramework: "jest", 34 | 35 | debug: true 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Moment from 'vue-moment' 3 | import VueRouter from 'vue-router' 4 | import vueHeadful from 'vue-headful'; 5 | import VueProgressBar from 'vue-progressbar' 6 | 7 | import App from './App.vue' 8 | import { data } from "@/js/store.js"; 9 | 10 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 11 | import "@/assets/styles/main.css"; 12 | 13 | Vue.component('vue-headful', vueHeadful); 14 | Vue.component('fa-icon', FontAwesomeIcon) 15 | 16 | Vue.use(Moment); 17 | Vue.use(VueRouter); 18 | 19 | Vue.use(VueProgressBar, { 20 | color: '#9CCC6588', 21 | failedColor: '#874b4b88', 22 | thickness: '5px', 23 | transition: { 24 | speed: '0.2s', 25 | opacity: '0.6s', 26 | termination: 300 27 | }, 28 | autoRevert: false, 29 | location: 'top', 30 | inverse: false 31 | }) 32 | 33 | new Vue({ 34 | data, 35 | router: new VueRouter({ 36 | mode: 'history', 37 | routes: [] 38 | }), 39 | render: h => h(App) 40 | }).$mount('#app') -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build server 2 | FROM ekidd/rust-musl-builder:stable AS server-builder 3 | ARG VERSION=0.1.0 4 | ADD . ./ 5 | RUN sudo chown -R rust:rust . 6 | RUN sed -i -e "/^version/ s/[[:digit:]][[:digit:]]*.[[:digit:]][[:digit:]]*.[[:digit:]][[:digit:]]*/$VERSION/" Cargo.toml 7 | RUN cargo build --release --target x86_64-unknown-linux-musl --features docker 8 | 9 | # Build frontend 10 | FROM node:lts-alpine as frontend-builder 11 | ARG VERSION=0.1.0 12 | WORKDIR /app 13 | ENV VUE_APP_MY_DUCK_SERVER= 14 | COPY ./web/package*.json ./ 15 | RUN npm install 16 | COPY ./web . 17 | RUN sed -i -e "/version/ s/[[:digit:]][[:digit:]]*.[[:digit:]][[:digit:]]*.[[:digit:]][[:digit:]]*/$VERSION/" package.json 18 | RUN npm run build 19 | 20 | # Copy to Alpine container 21 | FROM alpine:latest 22 | EXPOSE 15825 23 | RUN apk --no-cache add ca-certificates 24 | COPY --from=server-builder /home/rust/src/target/x86_64-unknown-linux-musl/release/duck /usr/local/bin/ 25 | COPY --from=frontend-builder /app/dist /usr/local/bin/web 26 | WORKDIR /usr/local/bin 27 | ENTRYPOINT ["duck"] 28 | CMD ["--help"] -------------------------------------------------------------------------------- /src/engine/state.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use log::debug; 4 | 5 | use crate::config::Configuration; 6 | use crate::engine::state::builds::BuildRepository; 7 | use crate::engine::state::ui::UiRepository; 8 | use crate::engine::state::views::ViewRepository; 9 | 10 | pub mod builds; 11 | pub mod ui; 12 | pub mod views; 13 | 14 | pub struct EngineState { 15 | pub started: SystemTime, 16 | pub builds: BuildRepository, 17 | pub ui: UiRepository, 18 | pub views: ViewRepository, 19 | } 20 | 21 | impl EngineState { 22 | pub fn new() -> Self { 23 | return EngineState { 24 | started: SystemTime::now(), 25 | builds: BuildRepository::new(), 26 | ui: UiRepository::new(), 27 | views: ViewRepository::new(), 28 | }; 29 | } 30 | 31 | pub fn refresh(&self, config: &Configuration) { 32 | debug!("Refreshing configuration"); 33 | self.ui.set_title(&config.title[..]); 34 | if let Some(views) = &config.views { 35 | self.views.add_views(views); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © Patrik Svensson and Gary McLean Hall 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR 15 | A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 18 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /web/src/components/StatusIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /web/src/components/ServerInfo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /web/src/Error.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /web/tests/unit/StatusIcon.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils" 2 | import StatusIcon from "@/components/StatusIcon" 3 | 4 | describe("StatusIcon", () => { 5 | let status = null; 6 | let wrapper = null; 7 | 8 | const expectations = { 9 | Success: "check-circle", 10 | Failed: "exclamation-triangle", 11 | Running: "running", 12 | Canceled: "stop-circle", 13 | Queued: "clock", 14 | Skipped: "stop-circle" 15 | } 16 | 17 | function mount() { 18 | wrapper = shallowMount(StatusIcon, { 19 | stubs: [ 20 | "fa-icon" 21 | ], 22 | propsData: { 23 | build: { 24 | status 25 | } 26 | } 27 | }) 28 | } 29 | 30 | Object.keys(expectations).forEach(buildStatus => { 31 | 32 | it(`should show '${expectations[buildStatus]}' for '${buildStatus}' build status`, () => { 33 | status = buildStatus 34 | mount() 35 | const faIcon = wrapper.find('fa-icon-stub') 36 | expect(faIcon.attributes().icon).toBe(expectations[buildStatus]) 37 | }) 38 | 39 | }) 40 | 41 | it("should gracefully handle null build status", () => { 42 | status = null 43 | mount() 44 | const faIcon = wrapper.find('fa-icon-stub') 45 | expect(faIcon.attributes().icon).toBe("question-circle") 46 | }) 47 | }) -------------------------------------------------------------------------------- /src/engine/state/ui.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Mutex; 2 | 3 | use crate::utils::UI_TITLE; 4 | 5 | pub struct UiRepository { 6 | title: Mutex, 7 | } 8 | 9 | impl UiRepository { 10 | pub fn new() -> Self { 11 | Self { 12 | title: Mutex::new(UI_TITLE.to_owned()), 13 | } 14 | } 15 | 16 | pub fn title(&self) -> String { 17 | let guard = self.title.lock().unwrap(); 18 | guard.clone() 19 | } 20 | 21 | pub fn set_title(&self, title: &str) { 22 | let mut guard = self.title.lock().unwrap(); 23 | *guard = title.to_string(); 24 | } 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | use super::*; 30 | 31 | #[test] 32 | fn should_return_default_title_if_not_explicitly_set() { 33 | // Given 34 | let repository = UiRepository::new(); 35 | 36 | // When 37 | let title = repository.title(); 38 | 39 | // Then 40 | assert_eq!(UI_TITLE, title); 41 | } 42 | 43 | #[test] 44 | fn should_return_correct_title_after_update() { 45 | // Given 46 | let repository = UiRepository::new(); 47 | repository.set_title("Foo"); 48 | 49 | // When 50 | let title = repository.title(); 51 | 52 | // Then 53 | assert_eq!("Foo", title); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web/src/assets/appveyor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "duck" 3 | version = "0.14.0" 4 | authors = ["Patrik Svensson", "Gary McLean Hall"] 5 | edition = "2018" 6 | license = "MIT OR Apache-2.0" 7 | 8 | [features] 9 | docker = [] 10 | embedded-web = [] 11 | 12 | [[bin]] 13 | name = "duck" 14 | path = "src/main.rs" 15 | 16 | [dependencies] 17 | actix-rt = "1" 18 | async-std = { version = "1", features = ["attributes"] } 19 | failure = "0.1.6" 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_json = "1.0" 22 | url = "2.1.0" 23 | reqwest = { version = "0.9.22", default-features = false, features = [ "rustls-tls" ] } 24 | waithandle = "0.4.0" 25 | actix-web = "2" 26 | actix-files = "0.2.1" 27 | actix-cors = "0.2.0" 28 | actix-web-static-files = "2.0.0" 29 | structopt = "0.3.9" 30 | log = "0.4" 31 | chrono = "0.4.10" 32 | regex = "1.3.3" 33 | schemars = "0.8.3" 34 | derive_builder = "0.10.0" 35 | base64 = "0.13.0" 36 | ctrlc = { version = "3.1.4", features = ["termination"] } 37 | futures = "0.3.4" 38 | simplelog = "0.10.0" 39 | 40 | [target.'cfg(windows)'.dependencies] 41 | windows-service = { git = "https://github.com/mullvad/windows-service-rs", rev="202d88bf438fdb870f4fc26e2031d36ab083fc42" } 42 | 43 | [dev-dependencies] 44 | test-case = "1.1.0" 45 | 46 | [build-dependencies] 47 | actix-web-static-files = "2.0.0" 48 | 49 | [profile.release] 50 | panic = 'abort' 51 | lto = true 52 | codegen-units = 1 53 | incremental = false 54 | opt-level = "z" -------------------------------------------------------------------------------- /src/commands/validate.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use duck::DuckResult; 4 | use structopt::StructOpt; 5 | 6 | use crate::commands::{DEFAULT_CONFIG, ENV_CONFIG}; 7 | 8 | /////////////////////////////////////////////////////////// 9 | // Arguments 10 | 11 | #[derive(StructOpt, Debug)] 12 | pub struct Arguments { 13 | /// The configuration file 14 | #[structopt( 15 | short, 16 | long, 17 | parse(from_os_str), 18 | default_value = DEFAULT_CONFIG, 19 | env = ENV_CONFIG 20 | )] 21 | pub config: PathBuf, 22 | } 23 | 24 | impl Default for Arguments { 25 | fn default() -> Self { 26 | Arguments { 27 | config: PathBuf::from(DEFAULT_CONFIG), 28 | } 29 | } 30 | } 31 | 32 | /////////////////////////////////////////////////////////// 33 | // Command 34 | 35 | pub fn execute(args: Arguments) -> DuckResult<()> { 36 | duck::validate_config(args.config)?; 37 | Ok(()) 38 | } 39 | 40 | /////////////////////////////////////////////////////////// 41 | // Tests 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | 47 | #[test] 48 | pub fn default_arguments_should_have_correct_configuration_file() { 49 | // Given, When 50 | let args = Arguments::default(); 51 | // When 52 | let config = args.config.to_str().unwrap(); 53 | // Then 54 | assert_eq!(DEFAULT_CONFIG, config); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/providers/observers.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::builds::{Build, BuildStatus}; 4 | use crate::filters::BuildFilter; 5 | use crate::DuckResult; 6 | 7 | mod hue; 8 | mod mattermost; 9 | mod slack; 10 | 11 | pub trait ObserverLoader { 12 | fn load(&self) -> DuckResult>; 13 | } 14 | 15 | pub trait Observer: Send { 16 | fn info(&self) -> &ObserverInfo; 17 | fn observe(&self, observation: Observation) -> DuckResult<()>; 18 | } 19 | 20 | pub struct ObserverInfo { 21 | pub id: String, 22 | pub enabled: bool, 23 | pub filter: BuildFilter, 24 | pub collectors: Option>, 25 | } 26 | 27 | pub enum Observation<'a> { 28 | DuckStatusChanged(BuildStatus), 29 | BuildUpdated(&'a Build), 30 | BuildStatusChanged(&'a Build), 31 | ShuttingDown, 32 | } 33 | 34 | pub enum ObservationOrigin<'a> { 35 | System, 36 | Collector(&'a str), 37 | } 38 | 39 | impl<'a> Observation<'a> { 40 | /// Gets the collector for an observation. 41 | pub fn get_origin(&self) -> ObservationOrigin { 42 | match self { 43 | Observation::DuckStatusChanged(_) => ObservationOrigin::System, 44 | Observation::BuildUpdated(build) => ObservationOrigin::Collector(&build.collector), 45 | Observation::BuildStatusChanged(build) => { 46 | ObservationOrigin::Collector(&build.collector) 47 | } 48 | Observation::ShuttingDown => ObservationOrigin::System, 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/providers/observers/slack/client.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{SlackConfiguration, SlackCredentials}; 2 | use crate::utils::http::{HttpClient, HttpRequestBuilder, HttpResponse}; 3 | use crate::DuckResult; 4 | 5 | pub struct SlackClient { 6 | credentials: SlackCredentials, 7 | } 8 | 9 | impl SlackCredentials { 10 | pub fn get_url(&self) -> &str { 11 | match self { 12 | SlackCredentials::Webhook { url } => &url[..], 13 | } 14 | } 15 | } 16 | 17 | impl SlackClient { 18 | pub fn new(config: &SlackConfiguration) -> Self { 19 | SlackClient { 20 | credentials: config.credentials.clone(), 21 | } 22 | } 23 | 24 | pub fn send(&self, client: &impl HttpClient, message: &str, icon: &str) -> DuckResult<()> { 25 | let mut builder = HttpRequestBuilder::put(self.credentials.get_url().to_string()); 26 | builder.add_header("Content-Type", "application/json"); 27 | builder.add_header("Accept", "application/json"); 28 | builder.set_body( 29 | json!({ 30 | "username": "Duck", 31 | "icon_emoji": icon, 32 | "text": message 33 | }) 34 | .to_string(), 35 | ); 36 | 37 | let response = client.send(&builder)?; 38 | if !response.status().is_success() { 39 | return Err(format_err!( 40 | "Could not send Slack message ({})", 41 | response.status() 42 | )); 43 | } 44 | 45 | Ok(()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.14.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "npx vue-cli-service serve", 7 | "build": "npx vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "npx vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^1.2.35", 13 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 14 | "@fortawesome/vue-fontawesome": "^0.1.10", 15 | "@fullhuman/postcss-purgecss": "^2.3.0", 16 | "axios": "^0.21.1", 17 | "core-js": "^3.10.1", 18 | "tailwindcss": "^1.9.6", 19 | "vue": "^2.6.12", 20 | "vue-headful": "^2.1.0", 21 | "vue-moment": "^4.1.0", 22 | "vue-progressbar": "^0.7.5", 23 | "vue-router": "^3.5.1" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^25.2.3", 27 | "@vue/cli-plugin-babel": "^4.5.12", 28 | "@vue/cli-plugin-eslint": "^4.5.12", 29 | "@vue/cli-plugin-unit-jest": "^4.5.12", 30 | "@vue/cli-service": "^4.5.12", 31 | "@vue/test-utils": "1.0.0-beta.31", 32 | "babel-eslint": "^10.1.0", 33 | "eslint": "^5.16.0", 34 | "eslint-plugin-vue": "^5.0.0", 35 | "jest": "^25.5.4", 36 | "node-sass": "^4.14.1", 37 | "sass-loader": "^8.0.0", 38 | "vue-cli-plugin-tailwindcss": "~0.1.1", 39 | "vue-template-compiler": "^2.6.12", 40 | "webpack": "^4.46.0", 41 | "webpack-cli": "^3.3.12" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/src/components/SkeletonBuild.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 37 | 38 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc::{channel, Receiver, Sender}; 2 | use std::sync::Mutex; 3 | 4 | use crate::DuckResult; 5 | 6 | pub mod colors; 7 | pub mod date; 8 | pub mod http; 9 | pub mod switch; 10 | pub mod text; 11 | 12 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 13 | pub const UI_TITLE: &str = "Duck"; 14 | 15 | /// A super naive implementation of a message bus 16 | /// where all subscribers get all messages. I need something 17 | /// to synchronize the threads in the engine and the option was 18 | /// to take a dependency on something like crosstream - which 19 | /// is a great thing - but a bit overkill for what I need atm. 20 | pub struct NaiveMessageBus { 21 | pub channels: Mutex>>, 22 | } 23 | 24 | impl NaiveMessageBus { 25 | /// Creates a new message bus 26 | pub fn new() -> Self { 27 | NaiveMessageBus:: { 28 | channels: Mutex::new(Vec::new()), 29 | } 30 | } 31 | 32 | /// Subsribes to messages from the bus 33 | pub fn subscribe(&self) -> Receiver { 34 | let (sender, reciever) = channel::(); 35 | 36 | let mut channels = self.channels.lock().unwrap(); 37 | channels.push(sender); 38 | 39 | reciever 40 | } 41 | 42 | /// Send a message to subscribers 43 | pub fn send(&self, message: T) -> DuckResult<()> { 44 | let channels = self.channels.lock().unwrap(); 45 | for sender in channels.iter() { 46 | sender.send(message.clone())?; 47 | } 48 | Ok(()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/colors.rs: -------------------------------------------------------------------------------- 1 | pub struct Rgb { 2 | red: u8, 3 | green: u8, 4 | blue: u8, 5 | } 6 | 7 | impl Rgb { 8 | pub fn new(red: u8, green: u8, blue: u8) -> Self { 9 | Rgb { red, green, blue } 10 | } 11 | 12 | // Converts a RGB color to coordinates in the CIE color space. 13 | // https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/ 14 | pub fn to_cie_coordinates(&self) -> (f32, f32) { 15 | let red = f32::from(self.red) / 255_f32; 16 | let green = f32::from(self.green) / 255_f32; 17 | let blue = f32::from(self.blue) / 255_f32; 18 | 19 | let red = if red > 0.04045_f32 { 20 | (red + 0.055_f32) / (1.0_f32 + 0.055_f32).powf(2.4_f32) 21 | } else { 22 | red / 12.92_f32 23 | }; 24 | let green = if green > 0.04045_f32 { 25 | (green + 0.055_f32) / (1.0_f32 + 0.055_f32).powf(2.4_f32) 26 | } else { 27 | green / 12.92_f32 28 | }; 29 | let blue = if blue > 0.04045_f32 { 30 | (blue + 0.055_f32) / (1.0_f32 + 0.055_f32).powf(2.4_f32) 31 | } else { 32 | blue / 12.92_f32 33 | }; 34 | 35 | let x = red * 0.436_074_7_f32 + green * 0.385_064_9_f32 + blue * 0.093_080_4_f32; 36 | let y = red * 0.222_504_5_f32 + green * 0.716_878_6_f32 + blue * 0.040_616_9_f32; 37 | let z = red * 0.013_932_2_f32 + green * 0.097_104_5_f32 + blue * 0.714_173_3_f32; 38 | 39 | let cx = x / (x + y + z); 40 | let cy = y / (x + y + z); 41 | 42 | return (cx, cy); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/providers/observers/mattermost/client.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{MattermostConfiguration, MattermostCredentials}; 2 | use crate::utils::http::{HttpClient, HttpRequestBuilder, HttpResponse}; 3 | use crate::DuckResult; 4 | 5 | pub struct MattermostClient { 6 | channel: Option, 7 | credentials: MattermostCredentials, 8 | } 9 | 10 | impl MattermostCredentials { 11 | fn get_url(&self) -> &str { 12 | match self { 13 | MattermostCredentials::Webhook { url } => url, 14 | } 15 | } 16 | } 17 | 18 | impl MattermostClient { 19 | pub fn new(config: &MattermostConfiguration) -> Self { 20 | MattermostClient { 21 | channel: config.channel.clone(), 22 | credentials: config.credentials.clone(), 23 | } 24 | } 25 | 26 | pub fn send(&self, client: &impl HttpClient, message: &str) -> DuckResult<()> { 27 | let mut builder = HttpRequestBuilder::post(self.credentials.get_url().to_string()); 28 | builder.add_header("Content-Type", "application/json"); 29 | builder.add_header("Accept", "application/json"); 30 | builder.set_body(self.get_payload(message).to_string()); 31 | 32 | let response = client.send(&builder)?; 33 | if !response.status().is_success() { 34 | return Err(format_err!( 35 | "Could not send Mattermost message. ({})", 36 | response.status() 37 | )); 38 | } 39 | 40 | Ok(()) 41 | } 42 | 43 | fn get_payload(&self, message: &str) -> serde_json::Value { 44 | match self.channel { 45 | Option::None => json!({ "text": message }), 46 | Option::Some(_) => json!({ 47 | "channel_id": self.channel, 48 | "text": message 49 | }), 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/providers/collectors/duck/validation.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{DuckConfiguration, Validate}; 2 | use crate::DuckResult; 3 | 4 | impl Validate for DuckConfiguration { 5 | fn validate(&self) -> DuckResult<()> { 6 | if self.server_url.is_empty() { 7 | return Err(format_err!("[{}] Duck server URL is empty", self.id)); 8 | } 9 | Ok(()) 10 | } 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use crate::config::*; 16 | use crate::providers; 17 | use crate::utils::text::TestVariableProvider; 18 | 19 | #[test] 20 | #[should_panic(expected = "The id \\'\\' is invalid")] 21 | fn should_return_error_if_duck_id_is_empty() { 22 | let config = Configuration::from_json( 23 | &TestVariableProvider::new(), 24 | r#" 25 | { 26 | "collectors": [ 27 | { 28 | "duck": { 29 | "id": "", 30 | "serverUrl": "http://127.0.0.1:8081" 31 | } 32 | } 33 | ] 34 | } 35 | "#, 36 | ) 37 | .unwrap(); 38 | 39 | providers::create_collectors(&config).unwrap(); 40 | } 41 | 42 | #[test] 43 | #[should_panic(expected = "[duck_other] Duck server URL is empty")] 44 | fn should_return_error_if_duck_server_url_is_empty() { 45 | let config = Configuration::from_json( 46 | &TestVariableProvider::new(), 47 | r#" 48 | { 49 | "collectors": [ 50 | { 51 | "duck": { 52 | "id": "duck_other", 53 | "serverUrl": "" 54 | } 55 | } 56 | ] 57 | } 58 | "#, 59 | ) 60 | .unwrap(); 61 | 62 | providers::create_collectors(&config).unwrap(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /web/src/js/store.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import axios from "axios"; 3 | 4 | //This represent the application state 5 | export const data = Vue.observable({ 6 | server: null, 7 | version: process.env.VUE_APP_VERSION, 8 | builds: null, 9 | view: null, 10 | info: null, 11 | error: false 12 | }); 13 | 14 | export const store = { 15 | update(progress, server, view) { 16 | progress.start(); 17 | 18 | if(server == undefined) { 19 | server = ""; 20 | } 21 | 22 | data.server = server; 23 | 24 | let address = `${server}/api/builds`; 25 | if (view != undefined && view != null) { 26 | data.view = view; 27 | address = address + "/view/" + view; 28 | } else { 29 | data.view = null; 30 | } 31 | 32 | // Get all builds from the Duck server. 33 | axios 34 | .get(address) 35 | .then(response => { 36 | data.builds = response.data; 37 | data.error = false; 38 | 39 | progress.finish(); 40 | 41 | if (data.info == null) { 42 | // Get server information. 43 | // We only need to do this once. 44 | axios 45 | .get(`${data.server}/api/server`) 46 | .then(response => { 47 | data.info = response.data; 48 | }) 49 | .catch(() => { 50 | data.info = null; 51 | }); 52 | } 53 | }) 54 | .catch(() => { 55 | // Reset everything 56 | data.builds = null; 57 | data.error = true; 58 | data.info = null; 59 | progress.fail(); 60 | }) 61 | .finally(() => { 62 | data.loading = false; 63 | }); 64 | }, 65 | }; -------------------------------------------------------------------------------- /src/utils/date.rs: -------------------------------------------------------------------------------- 1 | use chrono::DateTime; 2 | 3 | use crate::DuckResult; 4 | 5 | pub static TEAMCITY_FORMAT: &str = "%Y%m%dT%H%M%S%z"; 6 | pub static AZURE_DEVOPS_FORMAT: &str = "%+"; 7 | pub static GITHUB_FORMAT: &str = "%+"; 8 | pub static OCTOPUS_DEPLOY_FORMAT: &str = "%+"; 9 | pub static APPVEYOR_FORMAT: &str = "%+"; 10 | pub static DEBUGGER_FORMAT: &str = "%+"; 11 | 12 | pub fn to_timestamp(input: &str, pattern: &str) -> DuckResult { 13 | match DateTime::parse_from_str(input, pattern) { 14 | Ok(res) => Ok(res.timestamp()), 15 | Err(e) => Err(format_err!("Could not parse date. {}", e)), 16 | } 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::*; 22 | 23 | #[test] 24 | fn should_parse_teamcity_format() { 25 | let result = to_timestamp("20191230T091041+0100", TEAMCITY_FORMAT).unwrap(); 26 | assert_eq!(1577693441, result); 27 | } 28 | 29 | #[test] 30 | fn should_parse_azure_devops_format() { 31 | let result = to_timestamp("2020-01-12T09:05:21.0733795Z", AZURE_DEVOPS_FORMAT).unwrap(); 32 | assert_eq!(1578819921, result); 33 | } 34 | 35 | #[test] 36 | fn should_parse_octopus_deploy_format() { 37 | let result = to_timestamp("2018-09-04T14:48:22.534+02:00", OCTOPUS_DEPLOY_FORMAT).unwrap(); 38 | assert_eq!(1536065302, result); 39 | } 40 | 41 | #[test] 42 | fn should_parse_github_format() { 43 | let result = to_timestamp("2020-02-01T20:43:16Z", GITHUB_FORMAT).unwrap(); 44 | assert_eq!(1580589796, result); 45 | } 46 | 47 | #[test] 48 | fn should_parse_appveyor_format() { 49 | let result = to_timestamp("2020-03-11T12:09:48.1638791+00:00", APPVEYOR_FORMAT).unwrap(); 50 | assert_eq!(1583928588, result); 51 | } 52 | 53 | #[test] 54 | fn should_parse_debugger_format() { 55 | let result = to_timestamp("2020-04-13T17:04:23.6101884+02:00", DEBUGGER_FORMAT).unwrap(); 56 | assert_eq!(1586790263, result); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/providers/collectors/debugger/validation.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{DebuggerConfiguration, Validate}; 2 | use crate::DuckResult; 3 | 4 | impl Validate for DebuggerConfiguration { 5 | fn validate(&self) -> DuckResult<()> { 6 | if self.server_url.is_empty() { 7 | return Err(format_err!("[{}] Debugger server URL is empty", self.id)); 8 | } 9 | Ok(()) 10 | } 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use crate::config::*; 16 | use crate::providers; 17 | use crate::utils::text::TestVariableProvider; 18 | 19 | #[test] 20 | #[should_panic(expected = "The id \\'\\' is invalid")] 21 | fn should_return_error_if_duck_id_is_empty() { 22 | let config = Configuration::from_json( 23 | &TestVariableProvider::new(), 24 | r#" 25 | { 26 | "collectors": [ 27 | { 28 | "debugger": { 29 | "id": "", 30 | "serverUrl": "http://127.0.0.1:8081" 31 | } 32 | } 33 | ] 34 | } 35 | "#, 36 | ) 37 | .unwrap(); 38 | 39 | providers::create_collectors(&config).unwrap(); 40 | } 41 | 42 | #[test] 43 | #[should_panic(expected = "[duck_debugger] Debugger server URL is empty")] 44 | fn should_return_error_if_duck_server_url_is_empty() { 45 | let config = Configuration::from_json( 46 | &TestVariableProvider::new(), 47 | r#" 48 | { 49 | "collectors": [ 50 | { 51 | "debugger": { 52 | "id": "duck_debugger", 53 | "serverUrl": "" 54 | } 55 | } 56 | ] 57 | } 58 | "#, 59 | ) 60 | .unwrap(); 61 | 62 | providers::create_collectors(&config).unwrap(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /web/src/components/BuildList.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 52 | 53 | -------------------------------------------------------------------------------- /web/tests/unit/Settings.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock("@/js/store", () => ({ 2 | data: {} 3 | })) 4 | 5 | import { shallowMount } from "@vue/test-utils" 6 | import ViewList from "@/components/ViewList.vue" 7 | import Settings from "@/components/Settings.vue" 8 | 9 | describe("Settings", () => { 10 | let wrapper = null 11 | 12 | function mount() { 13 | wrapper = shallowMount(Settings, { 14 | stubs: [ 15 | "fa-icon" 16 | ], 17 | components: { 18 | ViewList 19 | } 20 | }) 21 | } 22 | 23 | it("should initialize to the 'views' page", () => { 24 | mount() 25 | expect(wrapper.vm.$data.current).toBe('views') 26 | }) 27 | 28 | it("should show views page when views button clicked", async () => { 29 | mount() 30 | wrapper.find("button#info").trigger("click") 31 | wrapper.find("button#views").trigger("click") 32 | 33 | await wrapper.vm.$nextTick() 34 | 35 | expect(wrapper.vm.show_views).toBe(true) 36 | expect(wrapper.vm.show_info).toBe(false) 37 | expect(wrapper.find("ViewList-stub").exists()).toBeTruthy() 38 | expect(wrapper.find("ServerInfo-stub").exists()).toBe(false) 39 | }) 40 | 41 | it("should show info page when info button clicked", async () => { 42 | mount() 43 | wrapper.find("button#info").trigger("click") 44 | 45 | await wrapper.vm.$nextTick() 46 | 47 | expect(wrapper.vm.show_info).toBe(true) 48 | expect(wrapper.vm.show_views).toBe(false) 49 | expect(wrapper.find("ServerInfo-stub").exists()).toBeTruthy() 50 | expect(wrapper.find("ViewList-stub").exists()).toBe(false) 51 | }) 52 | 53 | it("should emit 'close' event when escape key pressed", async () => { 54 | mount() 55 | const modal = wrapper.find({ ref: "modal" }) 56 | modal.trigger('keyup.esc') 57 | await wrapper.vm.$nextTick() 58 | expect(wrapper.emitted('close')).toBeTruthy() 59 | }) 60 | 61 | it("should emit 'close' event when view list data changes", async () => { 62 | mount() 63 | const viewList = wrapper.find("ViewList-stub") 64 | viewList.vm.$emit("view_changed") 65 | await wrapper.vm.$nextTick() 66 | expect(wrapper.emitted('close')).toBeTruthy() 67 | }) 68 | 69 | }) 70 | -------------------------------------------------------------------------------- /src/providers/collectors/debugger/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | 3 | use crate::builds::BuildStatus; 4 | use crate::config::DebuggerConfiguration; 5 | use crate::utils::http::*; 6 | use crate::DuckResult; 7 | 8 | pub struct DebuggerClient { 9 | pub server_url: String, 10 | } 11 | 12 | impl DebuggerClient { 13 | pub fn new(config: &DebuggerConfiguration) -> Self { 14 | Self { 15 | server_url: config.server_url.clone(), 16 | } 17 | } 18 | 19 | pub fn get_builds(&self, client: &impl HttpClient) -> DuckResult> { 20 | let url = format!("{server}/api/builds", server = self.server_url); 21 | let body = self.send_get_request(client, url)?; 22 | Ok(serde_json::from_str(&body[..])?) 23 | } 24 | 25 | fn send_get_request(&self, client: &impl HttpClient, url: String) -> DuckResult { 26 | trace!("Sending request to: {}", url); 27 | let mut builder = HttpRequestBuilder::get(&url); 28 | builder.add_header("Content-Type", "application/json"); 29 | builder.add_header("Accept", "application/json"); 30 | 31 | let mut response = client.send(&builder)?; 32 | 33 | trace!("Received response: {}", response.status()); 34 | if !response.status().is_success() { 35 | return Err(format_err!( 36 | "Received non 200 HTTP status code. ({})", 37 | response.status() 38 | )); 39 | } 40 | 41 | response.body() 42 | } 43 | } 44 | 45 | #[derive(Deserialize, Debug)] 46 | pub struct DebuggerBuild { 47 | pub id: u64, 48 | pub status: i8, 49 | pub started: String, 50 | pub finished: Option, 51 | pub project: String, 52 | pub definition: String, 53 | pub branch: String, 54 | } 55 | 56 | impl DebuggerBuild { 57 | pub fn get_status(&self) -> BuildStatus { 58 | match self.status { 59 | 0 => BuildStatus::Success, 60 | 1 => BuildStatus::Failed, 61 | 2 => BuildStatus::Running, 62 | 3 => BuildStatus::Canceled, 63 | 4 => BuildStatus::Queued, 64 | 5 => BuildStatus::Skipped, 65 | _ => BuildStatus::Unknown, 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/api/endpoints.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use actix_web::web; 4 | use actix_web::HttpResponse; 5 | 6 | use crate::engine::state::EngineState; 7 | use crate::utils::VERSION; 8 | 9 | use super::models::{BuildViewModel, ServerInfoModel, ViewInfoModel}; 10 | 11 | /////////////////////////////////////////////////////////// 12 | // Server information 13 | 14 | pub async fn server_info(state: web::Data>) -> HttpResponse { 15 | let info = ServerInfoModel { 16 | title: &state.ui.title()[..], 17 | started: state 18 | .started 19 | .duration_since(std::time::SystemTime::UNIX_EPOCH) 20 | .unwrap() 21 | .as_secs(), 22 | version: VERSION, 23 | views: state 24 | .views 25 | .get_views() 26 | .iter() 27 | .map(ViewInfoModel::from) 28 | .collect(), 29 | }; 30 | let json = serde_json::to_string(&info).unwrap(); 31 | HttpResponse::Ok() 32 | .content_type("application/json") 33 | .body(json) 34 | } 35 | 36 | /////////////////////////////////////////////////////////// 37 | // All builds 38 | 39 | pub async fn get_builds(state: web::Data>) -> HttpResponse { 40 | // Convert to view models 41 | let builds: Vec = state 42 | .builds 43 | .all() 44 | .iter() 45 | .map(BuildViewModel::from) 46 | .collect(); 47 | 48 | // Serialize to JSON and return. 49 | let json = serde_json::to_string(&builds).unwrap(); 50 | HttpResponse::Ok() 51 | .content_type("application/json") 52 | .body(json) 53 | } 54 | 55 | /////////////////////////////////////////////////////////// 56 | // Builds for view 57 | 58 | pub async fn get_builds_for_view( 59 | id: web::Path, 60 | state: web::Data>, 61 | ) -> HttpResponse { 62 | // Convert to view models 63 | let builds: Vec = state 64 | .builds 65 | .for_view(&state.views, &id[..]) 66 | .iter() 67 | .map(BuildViewModel::from) 68 | .collect(); 69 | 70 | // Serialize to JSON and return. 71 | let json = serde_json::to_string(&builds).unwrap(); 72 | HttpResponse::Ok() 73 | .content_type("application/json") 74 | .body(json) 75 | } 76 | -------------------------------------------------------------------------------- /web/src/assets/teamcity.svg: -------------------------------------------------------------------------------- 1 | icon_TeamCity -------------------------------------------------------------------------------- /src/commands/start.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::time::Duration; 3 | 4 | use log::info; 5 | 6 | use duck::DuckResult; 7 | use structopt::StructOpt; 8 | 9 | use crate::commands::{DEFAULT_CONFIG, ENV_BINDING, ENV_CONFIG}; 10 | 11 | /////////////////////////////////////////////////////////// 12 | // Arguments 13 | 14 | #[derive(StructOpt, Debug)] 15 | pub struct Arguments { 16 | /// The configuration file 17 | #[structopt( 18 | short, 19 | long, 20 | parse(from_os_str), 21 | default_value = DEFAULT_CONFIG, 22 | env = ENV_CONFIG 23 | )] 24 | pub config: PathBuf, 25 | /// The server address to bind to 26 | #[structopt(name = "bind", short, long, env = ENV_BINDING)] 27 | server_address: Option, 28 | } 29 | 30 | impl Default for Arguments { 31 | fn default() -> Self { 32 | Arguments { 33 | config: PathBuf::from(DEFAULT_CONFIG), 34 | server_address: None, 35 | } 36 | } 37 | } 38 | 39 | /////////////////////////////////////////////////////////// 40 | // Command 41 | 42 | pub async fn execute(args: Arguments) -> DuckResult<()> { 43 | let handle = duck::run(args.config, args.server_address)?; 44 | 45 | wait_for_ctrl_c(); 46 | 47 | info!("Stopping..."); 48 | handle.stop().await?; 49 | info!("Duck has been stopped"); 50 | 51 | Ok(()) 52 | } 53 | 54 | fn wait_for_ctrl_c() { 55 | let (signaler, listener) = waithandle::new(); 56 | ctrlc::set_handler(move || { 57 | signaler.signal(); 58 | }) 59 | .expect("Error setting Ctrl-C handler"); 60 | info!("Press Ctrl-C to exit"); 61 | while !listener.wait(Duration::from_millis(50)) { 62 | // Idle 63 | } 64 | } 65 | 66 | /////////////////////////////////////////////////////////// 67 | // Tests 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | pub fn default_arguments_should_have_correct_configuration_file() { 75 | // Given, When 76 | let args = Arguments::default(); 77 | // When 78 | let config = args.config.to_str().unwrap(); 79 | // Then 80 | assert_eq!(DEFAULT_CONFIG, config); 81 | } 82 | 83 | #[test] 84 | pub fn default_arguments_should_have_correct_server_address() { 85 | // Given, When 86 | let args = Arguments::default(); 87 | // When 88 | let server_address = args.server_address; 89 | // Then 90 | assert!(server_address.is_none()); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /web/tests/unit/ServerInfo.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { shallowMount } from '@vue/test-utils' 3 | import ServerInfo from '@/components/ServerInfo.vue' 4 | 5 | 6 | describe('Server Info', () => { 7 | let moment = null 8 | let wrapper = null 9 | let data = {} 10 | 11 | function mount() { 12 | wrapper = shallowMount(ServerInfo, { 13 | stubs: [ 14 | 'fa-icon' 15 | ], 16 | propsData: { 17 | data 18 | } 19 | }) 20 | } 21 | 22 | beforeEach(() => { 23 | moment = jest.fn().mockReturnValueOnce("a minute ago") 24 | Vue.filter('moment', moment) 25 | }) 26 | 27 | describe("happy path", () => { 28 | beforeEach(() => { 29 | data.server = "localhost:12345" 30 | data.version = "1.2.3.4" 31 | data.info = { 32 | started: new Date(2020, 5, 1) 33 | } 34 | mount() 35 | }) 36 | 37 | it('shows the server version info', () => { 38 | expect(wrapper.find("#version").text()).toBe(data.version) 39 | }) 40 | 41 | it('shows the server host', () => { 42 | expect(wrapper.find("#server").text()).toBe(data.server) 43 | }) 44 | 45 | it('shows the server started time', () => { 46 | expect(wrapper.find("#started").text()).toBe("a minute ago") 47 | expect(moment.mock.calls.length).toBe(1) 48 | expect(moment.mock.calls[0]).toEqual([data.info.started, "from", "now"]) 49 | }) 50 | }) 51 | 52 | describe("when values are missing", () => { 53 | beforeEach(() => { 54 | data.version = null 55 | data.server = null 56 | data.info.started = null 57 | mount() 58 | }) 59 | 60 | it('does not show version info when null', () => { 61 | expect(wrapper.find("#version").exists()).toBe(false) 62 | }) 63 | 64 | it("does not show server host when null", () => { 65 | expect(wrapper.find("#server").exists()).toBe(false) 66 | }) 67 | 68 | it("does not show server started time when null", () => { 69 | expect(wrapper.find("#started").exists()).toBe(false) 70 | }) 71 | }) 72 | 73 | describe("when data is missing", () => { 74 | beforeEach(() => { 75 | data = null 76 | mount() 77 | }) 78 | 79 | it('does not show version info when null', () => { 80 | expect(wrapper.find("#version").exists()).toBe(false) 81 | }) 82 | 83 | it("does not show server host when null", () => { 84 | expect(wrapper.find("#server").exists()).toBe(false) 85 | }) 86 | 87 | it("does not show server started time when null", () => { 88 | expect(wrapper.find("#started").exists()).toBe(false) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /web/tests/unit/ViewList.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock("@/js/store", () => ({ 2 | data: {} 3 | })) 4 | 5 | import { createLocalVue, shallowMount } from "@vue/test-utils" 6 | import VueList from "@/components/ViewList.vue" 7 | import { data } from "@/js/store" 8 | import VueRouter from "vue-router" 9 | 10 | const localVue = createLocalVue() 11 | localVue.use(VueRouter) 12 | const router = new VueRouter() 13 | 14 | describe("Vue List", () => { 15 | let wrapper = null 16 | 17 | const title = "Hello!" 18 | const view1Name = "View 1" 19 | const view2Name = "View 2" 20 | 21 | function mockView() { 22 | data.server = "localhost" 23 | data.info = { 24 | title, 25 | views: [ 26 | { 27 | name: view1Name, 28 | slug: "/view1" 29 | }, 30 | { 31 | name: view2Name 32 | } 33 | ] 34 | } 35 | } 36 | 37 | function mount() { 38 | router.push = jest.fn() 39 | wrapper = shallowMount(VueList, { 40 | stubs: [ 41 | "fa-icon" 42 | ], 43 | localVue, 44 | router 45 | }) 46 | } 47 | 48 | it("display message if no views available", () => { 49 | mount() 50 | expect(wrapper.text()).toBe("No views available") 51 | }) 52 | 53 | it("displays active view button when at least one view available", () => { 54 | mockView() 55 | mount() 56 | const viewButtons = wrapper.findAll("button") 57 | expect(viewButtons.at(0).text()).toBe(title) 58 | expect(viewButtons.at(1).text()).toBe(view1Name) 59 | expect(viewButtons.at(2).text()).toBe(view2Name) 60 | }) 61 | 62 | it("emits 'view_changed' event when a view is clicked", async () => { 63 | mockView() 64 | mount() 65 | const viewButtons = wrapper.findAll("button") 66 | const view1Button = viewButtons.at(1) 67 | view1Button.trigger("click") 68 | await wrapper.vm.$nextTick() 69 | wrapper.emitted("view_changed") 70 | }) 71 | 72 | it("routes when a view is clicked", async () => { 73 | mockView() 74 | mount() 75 | const viewButtons = wrapper.findAll("button") 76 | const view1Button = viewButtons.at(1) 77 | view1Button.trigger("click") 78 | await wrapper.vm.$nextTick() 79 | expect(wrapper.vm.$router.push).toHaveBeenCalledWith("/?view=/view1&server=localhost") 80 | }) 81 | 82 | it("routes when first view is clicked", async () => { 83 | mockView() 84 | mount() 85 | const viewButtons = wrapper.findAll("button") 86 | const view1Button = viewButtons.at(0) 87 | view1Button.trigger("click") 88 | await wrapper.vm.$nextTick() 89 | expect(wrapper.vm.$router.push).toHaveBeenCalledWith("?server=localhost") 90 | }) 91 | }) -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::needless_return)] 2 | #![allow(clippy::mutex_atomic)] 3 | 4 | #[macro_use] 5 | extern crate failure; 6 | #[macro_use] 7 | extern crate serde; 8 | #[macro_use] 9 | extern crate serde_json; 10 | #[macro_use] 11 | extern crate derive_builder; 12 | 13 | use std::path::PathBuf; 14 | 15 | use failure::Error; 16 | use log::info; 17 | 18 | use crate::config::loader::JsonConfigurationLoader; 19 | use crate::config::ConfigurationLoader; 20 | use crate::utils::text::EnvironmentVariableProvider; 21 | 22 | pub type DuckResult = Result; 23 | 24 | mod api; 25 | mod builds; 26 | mod config; 27 | mod engine; 28 | mod filters; 29 | mod providers; 30 | mod utils; 31 | 32 | #[allow(dead_code)] 33 | mod query; 34 | 35 | /////////////////////////////////////////////////////////// 36 | // Handle 37 | 38 | pub struct DuckHandle { 39 | engine: engine::EngineHandle, 40 | http: api::HttpServerHandle, 41 | } 42 | 43 | impl DuckHandle { 44 | pub async fn stop(self) -> DuckResult<()> { 45 | self.engine.stop()?; 46 | self.http.stop().await; 47 | Ok(()) 48 | } 49 | } 50 | 51 | /////////////////////////////////////////////////////////// 52 | // Run 53 | 54 | pub fn run>( 55 | config_path: T, 56 | server_address: Option, 57 | ) -> DuckResult { 58 | // Write some info to the console. 59 | info!("Version: {}", utils::VERSION); 60 | 61 | // Start the engine. 62 | let loader = JsonConfigurationLoader::new(config_path.into()); 63 | let engine = engine::Engine::new()?; 64 | let engine_handle = engine.run(loader)?; 65 | 66 | // Start the HTTP server. 67 | let server = api::start(engine.get_state(), server_address)?; 68 | 69 | Ok(DuckHandle { 70 | engine: engine_handle, 71 | http: server, 72 | }) 73 | } 74 | 75 | /////////////////////////////////////////////////////////// 76 | // Schema 77 | 78 | pub fn get_schema() -> String { 79 | let settings = schemars::gen::SchemaSettings::draft07().with(|s| { 80 | s.option_nullable = false; 81 | s.option_add_null_type = false; 82 | }); 83 | let gen = settings.into_generator(); 84 | let schema = gen.into_root_schema_for::(); 85 | serde_json::to_string_pretty(&schema).unwrap() 86 | } 87 | 88 | /////////////////////////////////////////////////////////// 89 | // Validate 90 | 91 | pub fn validate_config>(config_path: T) -> DuckResult<()> { 92 | // Load and validate the configuration file. 93 | let loader = JsonConfigurationLoader::new(config_path.into()); 94 | loader.load(&EnvironmentVariableProvider::new())?; 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /src/providers/observers/slack/validation.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::config::{SlackConfiguration, SlackCredentials, Validate}; 4 | use crate::DuckResult; 5 | 6 | impl Validate for SlackConfiguration { 7 | fn validate(&self) -> DuckResult<()> { 8 | match &self.credentials { 9 | SlackCredentials::Webhook { url } => { 10 | if let Err(e) = Url::parse(url) { 11 | return Err(format_err!( 12 | "[{}] Slack webhook URL is invalid: {}", 13 | self.id, 14 | e 15 | )); 16 | } 17 | } 18 | }; 19 | Ok(()) 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use crate::config::Configuration; 26 | use crate::providers; 27 | use crate::utils::text::TestVariableProvider; 28 | 29 | #[test] 30 | #[should_panic(expected = "The id \\'\\' is invalid")] 31 | fn should_return_error_if_slack_id_is_empty() { 32 | let config = Configuration::from_json( 33 | &TestVariableProvider::new(), 34 | r#" 35 | { 36 | "collectors": [ ], 37 | "observers": [ 38 | { 39 | "slack": { 40 | "id": "", 41 | "credentials": { 42 | "webhook": { 43 | "url": "https://slack.com/MY-WEBHOOK-URL" 44 | } 45 | } 46 | } 47 | } 48 | ] 49 | } 50 | "#, 51 | ) 52 | .unwrap(); 53 | 54 | providers::create_observers(&config).unwrap(); 55 | } 56 | 57 | #[test] 58 | #[should_panic(expected = "[foo] Slack webhook URL is invalid: relative URL without a base")] 59 | fn should_return_error_if_slack_webhook_url_is_invalid() { 60 | let config = Configuration::from_json( 61 | &TestVariableProvider::new(), 62 | r#" 63 | { 64 | "collectors": [ ], 65 | "observers": [ 66 | { 67 | "slack": { 68 | "id": "foo", 69 | "credentials": { 70 | "webhook": { 71 | "url": "" 72 | } 73 | } 74 | } 75 | } 76 | ] 77 | } 78 | "#, 79 | ) 80 | .unwrap(); 81 | 82 | providers::create_observers(&config).unwrap(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/providers.rs: -------------------------------------------------------------------------------- 1 | pub mod collectors; 2 | pub mod observers; 3 | 4 | use log::debug; 5 | 6 | use crate::config::{CollectorConfiguration, Configuration, ObserverConfiguration}; 7 | use crate::DuckResult; 8 | 9 | use self::collectors::*; 10 | use self::observers::*; 11 | 12 | /////////////////////////////////////////////////////////// 13 | // Collectors 14 | 15 | pub fn create_collectors(config: &Configuration) -> DuckResult>> { 16 | let mut collectors = Vec::>::new(); 17 | for config in config.collectors.iter() { 18 | if config.is_enabled() { 19 | let loader = get_collector_loader(&config); 20 | collectors.push(loader.load()?); 21 | } else { 22 | debug!("Collector '{}' has been disabled", config.get_id()); 23 | } 24 | } 25 | Ok(collectors) 26 | } 27 | 28 | fn get_collector_loader(config: &CollectorConfiguration) -> &dyn CollectorLoader { 29 | match config { 30 | CollectorConfiguration::TeamCity(config) => config, 31 | CollectorConfiguration::Azure(config) => config, 32 | CollectorConfiguration::GitHub(config) => config, 33 | CollectorConfiguration::OctopusDeploy(config) => config, 34 | CollectorConfiguration::AppVeyor(config) => config, 35 | CollectorConfiguration::Duck(config) => config, 36 | CollectorConfiguration::Debugger(config) => config, 37 | } 38 | } 39 | 40 | /////////////////////////////////////////////////////////// 41 | // Observers 42 | 43 | pub fn create_observers(config: &Configuration) -> DuckResult>> { 44 | let mut result = Vec::>::new(); 45 | if let Some(observers) = &config.observers { 46 | for config in observers.iter() { 47 | if config.is_enabled() { 48 | let loader = get_observer_loader(&config); 49 | match loader.load() { 50 | Ok(observer) => { 51 | result.push(observer); 52 | } 53 | Err(e) => { 54 | return Err(format_err!( 55 | "An error occured when loading observer '{}'. {}", 56 | config.get_id(), 57 | e 58 | )) 59 | } 60 | } 61 | } else { 62 | debug!("Observer '{}' has been disabled", config.get_id()); 63 | } 64 | } 65 | } 66 | Ok(result) 67 | } 68 | 69 | fn get_observer_loader(config: &ObserverConfiguration) -> &dyn ObserverLoader { 70 | match config { 71 | ObserverConfiguration::Hue(config) => config, 72 | ObserverConfiguration::Mattermost(config) => config, 73 | ObserverConfiguration::Slack(config) => config, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/text.rs: -------------------------------------------------------------------------------- 1 | use regex::*; 2 | 3 | use crate::DuckResult; 4 | 5 | static VARIABLE_REGEX: &str = r"\$\{(?P[A-Z_a-z0-1]+)\}"; 6 | 7 | pub trait VariableProvider { 8 | fn get_variable(&self, name: &str) -> DuckResult; 9 | } 10 | 11 | ////////////////////////////////////////////////////////////////////// 12 | // The expander 13 | ////////////////////////////////////////////////////////////////////// 14 | 15 | pub struct Expander<'a> { 16 | provider: &'a dyn VariableProvider, 17 | regex: Regex, 18 | } 19 | 20 | impl<'a> Expander<'a> { 21 | pub fn new(provider: &'a dyn VariableProvider) -> Self { 22 | Self { 23 | provider, 24 | regex: Regex::new(VARIABLE_REGEX).unwrap(), 25 | } 26 | } 27 | 28 | #[allow(clippy::redundant_clone)] 29 | pub fn expand>(&self, field: T) -> DuckResult { 30 | let mut text = field.into(); 31 | for capture in self.regex.captures_iter(&text.clone()[..]) { 32 | let variable = capture.name("VARIABLE").unwrap().as_str(); 33 | let value = self.provider.get_variable(variable)?; 34 | text = text.replace(&format!("${{{}}}", variable)[..], &value[..]); 35 | } 36 | return Ok(text); 37 | } 38 | } 39 | 40 | ////////////////////////////////////////////////////////////////////// 41 | // Variable providers 42 | ////////////////////////////////////////////////////////////////////// 43 | 44 | pub struct EnvironmentVariableProvider {} 45 | impl EnvironmentVariableProvider { 46 | pub fn new() -> Self { 47 | Self {} 48 | } 49 | } 50 | impl VariableProvider for EnvironmentVariableProvider { 51 | fn get_variable(&self, name: &str) -> DuckResult { 52 | let value = std::env::var(name); 53 | match value { 54 | Result::Ok(v) => Ok(v), 55 | Result::Err(_) => Err(format_err!( 56 | "Environment variable '{}' has not been set", 57 | name 58 | )), 59 | } 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | pub struct TestVariableProvider { 65 | lookup: std::collections::HashMap, 66 | } 67 | 68 | #[cfg(test)] 69 | impl VariableProvider for TestVariableProvider { 70 | fn get_variable(&self, name: &str) -> DuckResult { 71 | let foo = self.lookup.get(name); 72 | match foo { 73 | Option::Some(t) => Ok(t.clone()), 74 | Option::None => Err(format_err!( 75 | "Environment variable '{}' has not been set", 76 | name 77 | )), 78 | } 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | impl TestVariableProvider { 84 | pub fn new() -> Self { 85 | Self { 86 | lookup: std::collections::HashMap::new(), 87 | } 88 | } 89 | 90 | pub fn add>(&mut self, key: T, value: T) { 91 | self.lookup.insert(key.into(), value.into()); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/api/models.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | use crate::builds::{Build, BuildStatus}; 4 | use crate::config::ViewConfiguration; 5 | 6 | /////////////////////////////////////////////////////////// 7 | // Server information 8 | 9 | #[derive(Serialize, Clone)] 10 | pub struct ServerInfoModel<'a> { 11 | pub title: &'a str, 12 | pub version: &'static str, 13 | pub started: u64, 14 | pub views: Vec, 15 | } 16 | 17 | /////////////////////////////////////////////////////////// 18 | // View information 19 | 20 | #[derive(Serialize, Clone)] 21 | pub struct ViewInfoModel { 22 | pub slug: String, 23 | pub name: String, 24 | } 25 | 26 | impl<'a> From<&ViewConfiguration> for ViewInfoModel { 27 | fn from(view: &ViewConfiguration) -> Self { 28 | ViewInfoModel { 29 | slug: view.id.clone(), 30 | name: view.name.clone(), 31 | } 32 | } 33 | } 34 | 35 | /////////////////////////////////////////////////////////// 36 | // Builds 37 | 38 | #[derive(Serialize, Clone)] 39 | pub struct BuildViewModel { 40 | pub id: u64, 41 | pub provider: String, 42 | pub collector: String, 43 | pub project: String, 44 | pub build: String, 45 | pub branch: String, 46 | #[serde(rename(serialize = "buildId"))] 47 | pub build_id: String, 48 | #[serde(rename(serialize = "buildNumber"))] 49 | pub build_number: String, 50 | pub started: i64, 51 | pub finished: Option, 52 | pub url: String, 53 | pub status: BuildStatusViewModel, 54 | } 55 | 56 | #[derive(Serialize, Clone)] 57 | pub enum BuildStatusViewModel { 58 | Unknown, 59 | Success, 60 | Failed, 61 | Running, 62 | Canceled, 63 | Queued, 64 | Skipped, 65 | } 66 | 67 | impl From<&Build> for BuildViewModel { 68 | fn from(item: &Build) -> Self { 69 | BuildViewModel { 70 | id: item.id, 71 | provider: item.provider.clone(), 72 | collector: item.collector.clone(), 73 | project: item.project_name.clone(), 74 | build: item.definition_name.clone(), 75 | branch: item.branch.clone(), 76 | build_id: item.build_id.clone(), 77 | build_number: item.build_number.clone(), 78 | url: item.url.clone(), 79 | started: item.started_at, 80 | finished: item.finished_at, 81 | status: BuildStatusViewModel::from(&item.status), 82 | } 83 | } 84 | } 85 | 86 | impl From<&BuildStatus> for BuildStatusViewModel { 87 | fn from(item: &BuildStatus) -> Self { 88 | match item { 89 | BuildStatus::Unknown => BuildStatusViewModel::Unknown, 90 | BuildStatus::Success => BuildStatusViewModel::Success, 91 | BuildStatus::Failed => BuildStatusViewModel::Failed, 92 | BuildStatus::Running => BuildStatusViewModel::Running, 93 | BuildStatus::Canceled => BuildStatusViewModel::Canceled, 94 | BuildStatus::Queued => BuildStatusViewModel::Queued, 95 | BuildStatus::Skipped => BuildStatusViewModel::Skipped, 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/providers/observers/hue/client.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::builds::BuildStatus; 4 | use crate::config::HueConfiguration; 5 | use crate::utils::colors::Rgb; 6 | use crate::utils::http::{HttpClient, HttpRequestBuilder, HttpResponse}; 7 | use crate::DuckResult; 8 | 9 | pub struct HueClient { 10 | brightness: u8, 11 | url: Url, 12 | username: String, 13 | lights: Vec, 14 | } 15 | 16 | impl HueClient { 17 | pub fn new(config: &HueConfiguration) -> Self { 18 | HueClient { 19 | brightness: config.brightness.unwrap_or(255), 20 | url: Url::parse(&config.hub_url[..]).unwrap(), 21 | username: config.username.clone(), 22 | lights: config.lights.clone(), 23 | } 24 | } 25 | 26 | pub fn turn_off(&self, client: &impl HttpClient) -> DuckResult<()> { 27 | self.set_light_state(client, format!("{{\"on\": {on} }}", on = false))?; 28 | Ok(()) 29 | } 30 | 31 | pub fn set_state(&self, client: &impl HttpClient, status: BuildStatus) -> DuckResult<()> { 32 | if let Some((x, y)) = Self::get_cie_coordinates(&status) { 33 | self.set_light_state( 34 | client, 35 | format!( 36 | "{{\"alert\":\"{alert}\",\"xy\":[{x},{y}],\"on\":{on},\"bri\":{brightness}}}", 37 | alert = match status { 38 | BuildStatus::Failed => "select", 39 | _ => "none", 40 | }, 41 | x = x, 42 | y = y, 43 | brightness = self.brightness, 44 | on = true 45 | ), 46 | )?; 47 | } 48 | Ok(()) 49 | } 50 | 51 | fn get_cie_coordinates(status: &BuildStatus) -> Option<(f32, f32)> { 52 | return match status { 53 | BuildStatus::Success => Some(Rgb::new(0, 255, 0).to_cie_coordinates()), 54 | BuildStatus::Failed => Some(Rgb::new(255, 0, 0).to_cie_coordinates()), 55 | BuildStatus::Running => Some(Rgb::new(127, 200, 255).to_cie_coordinates()), 56 | _ => None, 57 | }; 58 | } 59 | 60 | fn set_light_state(&self, client: &impl HttpClient, body: String) -> DuckResult<()> { 61 | for light in &self.lights { 62 | let url = format!( 63 | "{url}api/{username}/lights/{id}/state", 64 | url = self.url, 65 | username = self.username, 66 | id = light 67 | ); 68 | 69 | let mut builder = HttpRequestBuilder::put(url); 70 | builder.add_header("Content-Type", "application/json"); 71 | builder.add_header("Accept", "application/json"); 72 | builder.set_body(body.clone()); 73 | 74 | let response = client.send(&builder)?; 75 | if !response.status().is_success() { 76 | return Err(format_err!( 77 | "Could not update state for light '{id}' ({status})", 78 | id = light, 79 | status = response.status() 80 | )); 81 | } 82 | } 83 | 84 | Ok(()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /web/src/components/ViewList.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 91 | 92 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | ############################################################################################# 2 | # This file is based on https://github.com/EmbarkStudios/cargo-fetcher/blob/master/deny.toml 3 | ############################################################################################# 4 | 5 | targets = [ 6 | { triple = "x86_64-unknown-linux-gnu" }, 7 | { triple = "x86_64-unknown-linux-musl" }, 8 | { triple = "x86_64-apple-darwin" }, 9 | { triple = "x86_64-pc-windows-msvc" }, 10 | ] 11 | 12 | [advisories] 13 | vulnerability = "deny" 14 | unmaintained = "deny" 15 | notice = "deny" 16 | ignore = [ 17 | # Reqwest is dependent on Spin which is unmaintained. 18 | "RUSTSEC-2019-0031", 19 | ] 20 | 21 | [bans] 22 | multiple-versions = "deny" 23 | deny = [ 24 | # we never want a dependency on openssl due to all of the cross platform 25 | # issues it has, particularly on windows 26 | { name = "openssl" }, 27 | { name = "openssl-sys" }, 28 | ] 29 | skip = [ 30 | # reqwest (<=2.0) 31 | { name = "autocfg", version = "=0.1.7" }, 32 | { name = "idna", version = "=0.1.5" }, 33 | { name = "percent-encoding", version = "=1.0.1" }, 34 | { name = "serde_urlencoded", version = "=0.5.5" }, 35 | { name = "url", version = "=1.7.2" }, 36 | { name = "tokio", version = "=0.1.22" }, 37 | { name = "parking_lot", version = "=0.9.0" }, 38 | { name = "parking_lot_core", version = "=0.6.2" }, 39 | { name = "http", version = "=0.1.21" }, 40 | { name = "h2", version = "=0.1.26" }, 41 | { name = "futures", version = "=0.1.29" }, 42 | { name = "bytes", version = "=0.4.12" }, 43 | { name = "winapi", version = "=0.2.8" }, 44 | { name = "version_check", version = "=0.1.5" }, 45 | { name = "base64", version = "=0.10.1" }, 46 | { name = "smallvec", version = "=0.6.13" }, 47 | { name = "rand_core", version = "=0.3.1" }, 48 | { name = "rand_core", version = "=0.4.2" }, 49 | { name = "rand_chacha", version = "=0.1.1" }, 50 | { name = "rand", version = "=0.6.5" }, 51 | 52 | # structop (clap is the culprit) 53 | { name = "strsim", version = "=0.8.0" }, 54 | ] 55 | 56 | [sources] 57 | unknown-registry = "deny" 58 | unknown-git = "deny" 59 | allow-git = [] 60 | 61 | [licenses] 62 | unlicensed = "deny" 63 | allow-osi-fsf-free = "neither" 64 | copyleft = "deny" 65 | confidence-threshold = 0.92 66 | allow = [ 67 | "Apache-2.0", 68 | "BSD-3-Clause", 69 | "ISC", 70 | "MIT", 71 | "MPL-2.0", 72 | "OpenSSL", 73 | "Zlib", 74 | ] 75 | 76 | [[licenses.clarify]] 77 | name = "ring" 78 | # SPDX considers OpenSSL to encompass both the OpenSSL and SSLeay licenses 79 | # https://spdx.org/licenses/OpenSSL.html 80 | # ISC - Both BoringSSL and ring use this for their new files 81 | # MIT - "Files in third_party/ have their own licenses, as described therein. The MIT 82 | # license, for third_party/fiat, which, unlike other third_party directories, is 83 | # compiled into non-test libraries, is included below." 84 | # OpenSSL - Obviously 85 | expression = "ISC AND MIT AND OpenSSL" 86 | license-files = [ 87 | { path = "LICENSE", hash = 0xbd0eed23 }, 88 | ] 89 | 90 | [[licenses.clarify]] 91 | name = "webpki" 92 | expression = "ISC" 93 | license-files = [ 94 | { path = "LICENSE", hash = 0x001c7e6c }, 95 | ] -------------------------------------------------------------------------------- /src/providers/collectors/duck/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | 3 | use crate::builds::BuildStatus; 4 | use crate::config::DuckConfiguration; 5 | use crate::utils::http::*; 6 | use crate::DuckResult; 7 | 8 | pub struct DuckClient { 9 | pub server_url: String, 10 | pub view: Option, 11 | } 12 | 13 | impl DuckClient { 14 | pub fn new(config: &DuckConfiguration) -> Self { 15 | Self { 16 | server_url: config.server_url.clone(), 17 | view: config.view.clone(), 18 | } 19 | } 20 | 21 | pub fn get_server_version(&self, client: &impl HttpClient) -> DuckResult { 22 | let url = format!("{}/api/server", owner = self.server_url,); 23 | 24 | let body = self.send_get_request(client, url)?; 25 | let info: DuckServerInfo = serde_json::from_str(&body[..])?; 26 | 27 | Ok(info.version) 28 | } 29 | 30 | pub fn get_builds(&self, client: &impl HttpClient) -> DuckResult> { 31 | let url = match &self.view { 32 | Some(view) => format!( 33 | "{owner}/api/builds/view/{view}", 34 | owner = self.server_url, 35 | view = view 36 | ), 37 | None => format!("{owner}/api/builds", owner = self.server_url), 38 | }; 39 | 40 | let body = self.send_get_request(client, url)?; 41 | Ok(serde_json::from_str(&body[..])?) 42 | } 43 | 44 | fn send_get_request(&self, client: &impl HttpClient, url: String) -> DuckResult { 45 | trace!("Sending request to: {}", url); 46 | let mut builder = HttpRequestBuilder::get(&url); 47 | builder.add_header("Content-Type", "application/json"); 48 | builder.add_header("Accept", "application/json"); 49 | 50 | let mut response = client.send(&builder)?; 51 | 52 | trace!("Received response: {}", response.status()); 53 | if !response.status().is_success() { 54 | return Err(format_err!( 55 | "Received non 200 HTTP status code. ({})", 56 | response.status() 57 | )); 58 | } 59 | 60 | response.body() 61 | } 62 | } 63 | 64 | #[derive(Deserialize, Debug)] 65 | pub struct DuckServerInfo { 66 | pub version: String, 67 | } 68 | 69 | #[derive(Deserialize, Debug)] 70 | pub struct DuckBuild { 71 | pub id: u64, 72 | pub provider: String, 73 | pub collector: String, 74 | pub project: String, 75 | pub build: String, 76 | pub branch: String, 77 | #[serde(alias = "buildId")] 78 | pub build_id: String, 79 | #[serde(alias = "buildNumber")] 80 | pub build_number: String, 81 | pub started: i64, 82 | pub finished: Option, 83 | pub url: String, 84 | pub status: String, 85 | } 86 | 87 | impl DuckBuild { 88 | pub fn get_status(&self) -> BuildStatus { 89 | match &self.status[..] { 90 | "Success" => BuildStatus::Success, 91 | "Failed" => BuildStatus::Failed, 92 | "Running" => BuildStatus::Running, 93 | "Canceled" => BuildStatus::Canceled, 94 | "Queued" => BuildStatus::Queued, 95 | "Skipped" => BuildStatus::Skipped, 96 | _ => BuildStatus::Unknown, 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/providers/observers/hue/validation.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::config::{HueConfiguration, Validate}; 4 | use crate::DuckResult; 5 | 6 | impl Validate for HueConfiguration { 7 | fn validate(&self) -> DuckResult<()> { 8 | if let Err(e) = Url::parse(&self.hub_url[..]) { 9 | return Err(format_err!("[{}] Hue hub URL is invalid: {}", self.id, e)); 10 | } 11 | if self.username.is_empty() { 12 | return Err(format_err!("[{}] Hue username is empty", self.id)); 13 | } 14 | Ok(()) 15 | } 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use crate::config::Configuration; 21 | use crate::providers; 22 | use crate::utils::text::TestVariableProvider; 23 | 24 | #[test] 25 | #[should_panic(expected = "The id \\'\\' is invalid")] 26 | fn should_return_error_if_hue_id_is_empty() { 27 | let config = Configuration::from_json( 28 | &TestVariableProvider::new(), 29 | r#" 30 | { 31 | "collectors": [ ], 32 | "observers": [ 33 | { 34 | "hue": { 35 | "id": "", 36 | "hubUrl": "https://localhost:5000", 37 | "username": "vpBIFkq-2iWFvSLf62u1HvcmLbqbDf76N-CTom8b", 38 | "lights": [ "3" ] 39 | } 40 | } 41 | ] 42 | } 43 | "#, 44 | ) 45 | .unwrap(); 46 | 47 | providers::create_observers(&config).unwrap(); 48 | } 49 | 50 | #[test] 51 | #[should_panic(expected = "[bar] Hue hub URL is invalid: relative URL without a base")] 52 | fn should_return_error_if_hue_hub_url_is_empty() { 53 | let config = Configuration::from_json( 54 | &TestVariableProvider::new(), 55 | r#" 56 | { 57 | "collectors": [ ], 58 | "observers": [ 59 | { 60 | "hue": { 61 | "id": "bar", 62 | "hubUrl": "", 63 | "username": "vpBIFkq-2iWFvSLf62u1HvcmLbqbDf76N-CTom8b", 64 | "lights": [ "3" ] 65 | } 66 | } 67 | ] 68 | } 69 | "#, 70 | ) 71 | .unwrap(); 72 | 73 | providers::create_observers(&config).unwrap(); 74 | } 75 | 76 | #[test] 77 | #[should_panic(expected = "[bar] Hue username is empty")] 78 | fn should_return_error_if_hue_username_is_empty() { 79 | let config = Configuration::from_json( 80 | &TestVariableProvider::new(), 81 | r#" 82 | { 83 | "collectors": [ ], 84 | "observers": [ 85 | { 86 | "hue": { 87 | "id": "bar", 88 | "hubUrl": "https://localhost:6000", 89 | "username": "", 90 | "lights": [ "3" ] 91 | } 92 | } 93 | ] 94 | } 95 | "#, 96 | ) 97 | .unwrap(); 98 | 99 | providers::create_observers(&config).unwrap(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@spectresystems.se. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 121 | 122 | 140 | -------------------------------------------------------------------------------- /src/query.rs: -------------------------------------------------------------------------------- 1 | use crate::builds::BuildStatus; 2 | use crate::DuckResult; 3 | use std::fmt::Display; 4 | 5 | mod lexer; 6 | mod parser; 7 | 8 | pub fn parse>(expression: T) -> DuckResult { 9 | match parse_expression(expression) { 10 | Ok(expression) => Ok(expression), 11 | Err(e) => Err(format_err!("Error parsing expression: {}", e)), 12 | } 13 | } 14 | 15 | fn parse_expression>(expression: T) -> DuckResult { 16 | let expression = expression.into(); 17 | parser::parse(&mut lexer::tokenize(&expression[..])?) 18 | } 19 | 20 | /////////////////////////////////////////////////////////// 21 | // Visitor 22 | 23 | pub trait Visitor { 24 | fn or(&self, ctx: &TContext, left: &Expression, right: &Expression) -> DuckResult; 25 | fn and(&self, ctx: &TContext, left: &Expression, right: &Expression) -> DuckResult; 26 | fn not(&self, ctx: &TContext, exp: &Expression) -> DuckResult; 27 | fn constant(&self, ctx: &TContext, constant: &Constant) -> DuckResult; 28 | fn property(&self, ctx: &TContext, property: &Property) -> DuckResult; 29 | fn scope(&self, ctx: &TContext, exp: &Expression) -> DuckResult; 30 | fn relational( 31 | &self, 32 | ctx: &TContext, 33 | left: &Expression, 34 | right: &Expression, 35 | op: &Operator, 36 | ) -> DuckResult; 37 | } 38 | 39 | /////////////////////////////////////////////////////////// 40 | // AST 41 | 42 | #[derive(Debug, PartialEq, Clone)] 43 | pub enum Expression { 44 | And(Box, Box), 45 | Or(Box, Box), 46 | Not(Box), 47 | Constant(Constant), 48 | Property(Property), 49 | Relational(Box, Box, Operator), 50 | Scope(Box), 51 | } 52 | 53 | impl Expression { 54 | pub fn accept( 55 | &self, 56 | ctx: &TContext, 57 | visitor: &dyn Visitor, 58 | ) -> DuckResult { 59 | match self { 60 | Expression::And(lhs, rhs) => visitor.and(ctx, lhs, rhs), 61 | Expression::Or(lhs, rhs) => visitor.or(ctx, lhs, rhs), 62 | Expression::Not(expression) => visitor.not(ctx, expression), 63 | Expression::Constant(constant) => visitor.constant(ctx, constant), 64 | Expression::Property(property) => visitor.property(ctx, property), 65 | Expression::Relational(lhs, rhs, op) => visitor.relational(ctx, lhs, rhs, op), 66 | Expression::Scope(expression) => visitor.scope(ctx, expression), 67 | } 68 | } 69 | } 70 | 71 | #[derive(Debug, PartialEq, Clone)] 72 | pub enum Constant { 73 | Boolean(bool), 74 | Integer(i64), 75 | String(String), 76 | Status(BuildStatus), 77 | } 78 | 79 | #[derive(Debug, PartialEq, Clone)] 80 | pub enum Operator { 81 | EqualTo, 82 | NotEqualTo, 83 | GreaterThan, 84 | GreaterThanOrEqualTo, 85 | LessThan, 86 | LessThanOrEqualTo, 87 | } 88 | 89 | impl Display for Operator { 90 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 | match self { 92 | Operator::EqualTo => write!(f, "=="), 93 | Operator::NotEqualTo => write!(f, "!="), 94 | Operator::GreaterThan => write!(f, ">"), 95 | Operator::GreaterThanOrEqualTo => write!(f, ">="), 96 | Operator::LessThan => write!(f, "<"), 97 | Operator::LessThanOrEqualTo => write!(f, "<="), 98 | } 99 | } 100 | } 101 | 102 | #[derive(Debug, PartialEq, Clone)] 103 | pub enum Property { 104 | Branch, 105 | Status, 106 | Project, 107 | Definition, 108 | Build, 109 | Collector, 110 | Provider, 111 | } 112 | -------------------------------------------------------------------------------- /src/engine/state/views.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::sync::Mutex; 3 | 4 | use crate::config::ViewConfiguration; 5 | 6 | pub struct ViewRepository { 7 | views: Mutex>, 8 | } 9 | 10 | impl ViewRepository { 11 | pub fn new() -> Self { 12 | Self { 13 | views: Mutex::new(Vec::new()), 14 | } 15 | } 16 | 17 | pub fn add_views(&self, views: &[ViewConfiguration]) { 18 | let mut guard = self.views.lock().unwrap(); 19 | guard.clear(); 20 | for view in views.iter() { 21 | guard.push(view.clone()); 22 | } 23 | } 24 | 25 | pub fn get_collectors(&self, view_id: &str) -> Option> { 26 | let guard = self.views.lock().unwrap(); 27 | let view = guard.iter().find(|&x| x.id == view_id); 28 | if let Some(view) = view { 29 | let mut result = HashSet::::new(); 30 | for collector in view.collectors.iter() { 31 | result.insert(collector.clone()); 32 | } 33 | return Some(result); 34 | } 35 | None 36 | } 37 | 38 | pub fn get_views(&self) -> Vec { 39 | let guard = self.views.lock().unwrap(); 40 | guard.clone() 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | use crate::config::Configuration; 48 | use crate::utils::text::TestVariableProvider; 49 | 50 | #[test] 51 | fn should_return_collectors_for_view() { 52 | // Given 53 | let repository = ViewRepository::new(); 54 | repository.add_views( 55 | &Configuration::from_json( 56 | &TestVariableProvider::new(), 57 | r#" 58 | { 59 | "collectors": [ 60 | { "duck": { "id": "a1", "serverUrl": "http://localhost/a1" } }, 61 | { "duck": { "id": "a2", "serverUrl": "http://localhost/a2" } }, 62 | { "duck": { "id": "b1", "serverUrl": "http://localhost/b1" } }, 63 | { "duck": { "id": "b2", "serverUrl": "http://localhost/b2" } }, 64 | { "duck": { "id": "b3", "serverUrl": "http://localhost/b3" } }, 65 | { "duck": { "id": "c1", "serverUrl": "http://localhost/c1" } }, 66 | { "duck": { "id": "c2", "serverUrl": "http://localhost/c2" } }, 67 | { "duck": { "id": "c3", "serverUrl": "http://localhost/c3" } }, 68 | { "duck": { "id": "c4", "serverUrl": "http://localhost/c4" } } 69 | ], 70 | "views": [ 71 | { 72 | "id": "foo", 73 | "name": "Foo", 74 | "collectors": [ "a1", "a2" ] 75 | }, 76 | { 77 | "id": "bar", 78 | "name": "Bar", 79 | "collectors": [ "b1", "b2", "b3" ] 80 | }, 81 | { 82 | "id": "baz", 83 | "name": "Bar", 84 | "collectors": [ "c1", "c2", "c3", "c4" ] 85 | } 86 | ] 87 | }"#, 88 | ) 89 | .unwrap() 90 | .views 91 | .unwrap(), 92 | ); 93 | 94 | // When 95 | let collectors = repository.get_collectors("bar").unwrap(); 96 | 97 | // Then 98 | assert_eq!(3, collectors.len()); 99 | assert!(collectors.contains("b1")); 100 | assert!(collectors.contains("b2")); 101 | assert!(collectors.contains("b3")); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/providers/collectors/octopus/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | use reqwest::header::ACCEPT; 3 | use reqwest::{Client, ClientBuilder, RequestBuilder}; 4 | use url::Url; 5 | 6 | use crate::config::OctopusDeployCredentials; 7 | use crate::DuckResult; 8 | 9 | pub struct OctopusDeployClient { 10 | url: Url, 11 | credentials: OctopusDeployCredentials, 12 | client: Client, 13 | } 14 | 15 | impl OctopusDeployClient { 16 | pub fn new(server_url: Url, credentials: OctopusDeployCredentials) -> Self { 17 | OctopusDeployClient { 18 | url: server_url, 19 | credentials, 20 | client: ClientBuilder::new().build().unwrap(), 21 | } 22 | } 23 | 24 | pub fn get_dashboard(&self) -> DuckResult { 25 | let mut response = 26 | self.send_get_request(&format!("{url}api/dashboard", url = self.url,)[..])?; 27 | let result: OctopusDashboard = response.json()?; 28 | Ok(result) 29 | } 30 | 31 | fn send_get_request(&self, url: &str) -> DuckResult { 32 | trace!("Sending request to: {}", url); 33 | let response = self.client.get(url).header(ACCEPT, "application/json"); 34 | let response = self.credentials.authenticate(response).send()?; 35 | 36 | trace!("Received response: {}", response.status()); 37 | if !response.status().is_success() { 38 | return Err(format_err!( 39 | "Received non 200 HTTP status code. {}", 40 | response.status() 41 | )); 42 | } 43 | 44 | Ok(response) 45 | } 46 | } 47 | 48 | impl OctopusDeployCredentials { 49 | fn authenticate(&self, builder: RequestBuilder) -> RequestBuilder { 50 | return match self { 51 | OctopusDeployCredentials::ApiKey(api_key) => { 52 | builder.header("X-Octopus-ApiKey", api_key) 53 | } 54 | }; 55 | } 56 | } 57 | 58 | #[derive(Deserialize, Debug)] 59 | pub struct OctopusDashboard { 60 | #[serde(rename = "Projects")] 61 | pub projects: Vec, 62 | #[serde(rename = "Environments")] 63 | pub environments: Vec, 64 | #[serde(rename = "Items")] 65 | pub deployments: Vec, 66 | } 67 | 68 | #[derive(Deserialize, Debug)] 69 | pub struct OctopusProject { 70 | #[serde(rename = "Id")] 71 | pub id: String, 72 | #[serde(rename = "Name")] 73 | pub name: String, 74 | #[serde(rename = "Slug")] 75 | pub slug: String, 76 | #[serde(rename = "EnvironmentIds")] 77 | pub environments: Vec, 78 | } 79 | 80 | #[derive(Deserialize, Debug)] 81 | pub struct OctopusEnvironment { 82 | #[serde(rename = "Id")] 83 | pub id: String, 84 | #[serde(rename = "Name")] 85 | pub name: String, 86 | } 87 | 88 | #[derive(Deserialize, Debug)] 89 | pub struct OctopusDeployment { 90 | #[serde(rename = "Id")] 91 | pub id: String, 92 | #[serde(rename = "ProjectId")] 93 | pub project: String, 94 | #[serde(rename = "EnvironmentId")] 95 | pub environment: String, 96 | #[serde(rename = "ReleaseId")] 97 | pub release_id: String, 98 | #[serde(rename = "ReleaseVersion")] 99 | pub release_version: String, 100 | #[serde(rename = "State")] 101 | pub status: String, 102 | #[serde(rename = "Links")] 103 | pub links: OctopusDeploymentLinks, 104 | #[serde(rename = "Created")] 105 | pub created_time: String, 106 | #[serde(rename = "QueueTime")] 107 | pub queue_time: Option, 108 | #[serde(rename = "StartTime")] 109 | pub start_time: Option, 110 | #[serde(rename = "CompletedTime")] 111 | pub finish_time: Option, 112 | } 113 | 114 | #[derive(Deserialize, Debug)] 115 | pub struct OctopusDeploymentLinks { 116 | #[serde(rename = "Self")] 117 | pub deployment: String, 118 | } 119 | -------------------------------------------------------------------------------- /web/src/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 77 | 78 | -------------------------------------------------------------------------------- /src/providers/observers/mattermost/validation.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::config::{MattermostConfiguration, MattermostCredentials, Validate}; 4 | use crate::DuckResult; 5 | 6 | impl Validate for MattermostConfiguration { 7 | fn validate(&self) -> DuckResult<()> { 8 | if let Some(channel) = &self.channel { 9 | if channel.is_empty() { 10 | return Err(format_err!("[{}] Mattermost channel is empty", self.id)); 11 | } 12 | } 13 | 14 | match &self.credentials { 15 | MattermostCredentials::Webhook { url } => { 16 | if let Err(e) = Url::parse(url) { 17 | return Err(format_err!( 18 | "[{}] Mattermost webhook URL is invalid: {}", 19 | self.id, 20 | e 21 | )); 22 | } 23 | } 24 | }; 25 | 26 | Ok(()) 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use crate::config::Configuration; 33 | use crate::providers; 34 | use crate::utils::text::TestVariableProvider; 35 | 36 | #[test] 37 | #[should_panic(expected = "The id \\'\\' is invalid")] 38 | fn should_return_error_if_mattermost_id_is_empty() { 39 | let config = Configuration::from_json( 40 | &TestVariableProvider::new(), 41 | r#" 42 | { 43 | "collectors": [ ], 44 | "observers": [ 45 | { 46 | "mattermost": { 47 | "id": "", 48 | "credentials": { 49 | "webhook": { 50 | "url": "https://mattermost.example.com" 51 | } 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | "#, 58 | ) 59 | .unwrap(); 60 | 61 | providers::create_observers(&config).unwrap(); 62 | } 63 | 64 | #[test] 65 | #[should_panic(expected = "[foo] Mattermost channel is empty")] 66 | fn should_return_error_if_mattermost_channel_is_empty() { 67 | let config = Configuration::from_json( 68 | &TestVariableProvider::new(), 69 | r#" 70 | { 71 | "collectors": [ ], 72 | "observers": [ 73 | { 74 | "mattermost": { 75 | "id": "foo", 76 | "channel": "", 77 | "credentials": { 78 | "webhook": { 79 | "url": "https://mattermost.example.com" 80 | } 81 | } 82 | } 83 | } 84 | ] 85 | } 86 | "#, 87 | ) 88 | .unwrap(); 89 | 90 | providers::create_observers(&config).unwrap(); 91 | } 92 | 93 | #[test] 94 | #[should_panic( 95 | expected = "[foo] Mattermost webhook URL is invalid: relative URL without a base" 96 | )] 97 | fn should_return_error_if_mattermost_webhook_url_is_invalid() { 98 | let config = Configuration::from_json( 99 | &TestVariableProvider::new(), 100 | r#" 101 | { 102 | "collectors": [ ], 103 | "observers": [ 104 | { 105 | "mattermost": { 106 | "id": "foo", 107 | "credentials": { 108 | "webhook": { 109 | "url": "" 110 | } 111 | } 112 | } 113 | } 114 | ] 115 | } 116 | "#, 117 | ) 118 | .unwrap(); 119 | 120 | providers::create_observers(&config).unwrap(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::mpsc; 3 | use std::sync::Arc; 4 | use std::thread; 5 | 6 | use actix_cors::Cors; 7 | use actix_files as fs; 8 | use actix_rt::System; 9 | use actix_web::dev::Server; 10 | use actix_web::{web, App, HttpServer}; 11 | use log::{debug, info}; 12 | 13 | mod endpoints; 14 | mod models; 15 | 16 | static DEFAULT_SERVER_ADDRESS: &str = "127.0.0.1:15825"; 17 | static EMBEDDED_SERVER_ADDRESS: &str = "127.0.0.1:8080"; 18 | static DOCKER_SERVER_ADDRESS: &str = "0.0.0.0:15825"; 19 | 20 | use crate::engine::state::EngineState; 21 | use crate::DuckResult; 22 | 23 | #[cfg(feature = "embedded-web")] 24 | include!(concat!(env!("OUT_DIR"), "/generated.rs")); 25 | 26 | // Polyfill for building non embedded web. 27 | #[cfg(not(feature = "embedded-web"))] 28 | use actix_web_static_files::Resource; 29 | #[cfg(not(feature = "embedded-web"))] 30 | fn generate() -> HashMap<&'static str, Resource> { 31 | HashMap::new() 32 | } 33 | 34 | /////////////////////////////////////////////////////////// 35 | // Handle 36 | 37 | pub struct HttpServerHandle { 38 | server: actix_web::dev::Server, 39 | } 40 | 41 | impl HttpServerHandle { 42 | pub fn new(server: Server) -> Self { 43 | Self { server } 44 | } 45 | pub async fn stop(&self) { 46 | info!("Stopping HTTP server..."); 47 | self.server.stop(true).await; 48 | } 49 | } 50 | 51 | /////////////////////////////////////////////////////////// 52 | // Start HTTP server 53 | 54 | pub fn start( 55 | context: Arc, 56 | server_address: Option, 57 | ) -> DuckResult { 58 | let bind = get_binding(&server_address); 59 | 60 | // Are we running embedded web? 61 | if cfg!(feature = "embedded-web") { 62 | debug!("Serving embedded UI"); 63 | } 64 | 65 | let (tx, rx) = mpsc::channel(); 66 | thread::spawn(move || { 67 | let system = System::new("duck-http-server"); 68 | let server = HttpServer::new(move || { 69 | let app = App::new() 70 | .wrap(Cors::new().finish()) 71 | .data(context.clone()) 72 | .service(web::resource("/api/server").to(endpoints::server_info)) 73 | .service(web::resource("/api/builds").to(endpoints::get_builds)) 74 | .service(web::resource("/api/builds/view/{id}").to(endpoints::get_builds_for_view)); 75 | 76 | // Serve static files from the web directory? 77 | if cfg!(feature = "docker") { 78 | return app.service(fs::Files::new("/", "./web").index_file("index.html")); 79 | } 80 | 81 | // Serve embedded web? 82 | if cfg!(feature = "embedded-web") { 83 | let generated = generate(); 84 | return app.service(actix_web_static_files::ResourceFiles::new("/", generated)); 85 | } 86 | 87 | return app; 88 | }) 89 | .bind(bind.clone()) 90 | .unwrap() 91 | .disable_signals() 92 | .run(); 93 | 94 | info!("HTTP server started: {}", bind); 95 | 96 | tx.send(server).unwrap(); 97 | system.run() 98 | }); 99 | 100 | Ok(HttpServerHandle::new(rx.recv()?)) 101 | } 102 | 103 | fn get_binding(server_address: &Option) -> String { 104 | // Get the address to bind to. 105 | match server_address { 106 | Some(ref address) => address.to_owned(), 107 | None => { 108 | if cfg!(feature = "docker") { 109 | // Bind to host container 110 | info!("Duck is compiled for docker, so binding to host container"); 111 | DOCKER_SERVER_ADDRESS.to_owned() 112 | } else if cfg!(feature = "embedded-web") { 113 | // Bind to port 8080 114 | info!("Duck is compiled with embedded UI, so binding to port 8080"); 115 | EMBEDDED_SERVER_ADDRESS.to_owned() 116 | } else { 117 | // Bind to localhost 118 | DEFAULT_SERVER_ADDRESS.to_owned() 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/providers/collectors/duck/test_data/builds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 15843239315283625983, 4 | "provider": "AzureDevOps", 5 | "collector": "duck_other", 6 | "project": "Cake", 7 | "build": "Azure Pipelines - Build Cake Centos 7", 8 | "branch": "refs/heads/develop", 9 | "buildId": "9767", 10 | "buildNumber": "9767", 11 | "started": 1584846026, 12 | "finished": 1584846262, 13 | "url": "https://dev.azure.com/cake-build/af63183c-ac1f-4dbb-93bc-4fa862ea5809/_build/results?buildId=9767", 14 | "status": "Success" 15 | }, 16 | { 17 | "id": 12452450510413470902, 18 | "provider": "AzureDevOps", 19 | "collector": "duck_other", 20 | "project": "Cake", 21 | "build": "Azure Pipelines - Build Cake Fedora 28", 22 | "branch": "refs/heads/develop", 23 | "buildId": "9768", 24 | "buildNumber": "9768", 25 | "started": 1584846026, 26 | "finished": 1584846268, 27 | "url": "https://dev.azure.com/cake-build/af63183c-ac1f-4dbb-93bc-4fa862ea5809/_build/results?buildId=9768", 28 | "status": "Success" 29 | }, 30 | { 31 | "id": 1061725911475334018, 32 | "provider": "GitHub", 33 | "collector": "duck_other", 34 | "project": "spectresystems/duck", 35 | "build": "ci.yaml", 36 | "branch": "master", 37 | "buildId": "60516618", 38 | "buildNumber": "44", 39 | "started": 1584834636, 40 | "finished": 1584836047, 41 | "url": "https://github.com/spectresystems/duck/actions/runs/60516618", 42 | "status": "Success" 43 | }, 44 | { 45 | "id": 12843506156844495118, 46 | "provider": "GitHub", 47 | "collector": "duck_other", 48 | "project": "spectresystems/duck", 49 | "build": "ci.yaml", 50 | "branch": "integrate-tailwindcss", 51 | "buildId": "60402460", 52 | "buildNumber": "43", 53 | "started": 1584815202, 54 | "finished": 1584815705, 55 | "url": "https://github.com/spectresystems/duck/actions/runs/60402460", 56 | "status": "Success" 57 | }, 58 | { 59 | "id": 4209841394734603281, 60 | "provider": "GitHub", 61 | "collector": "duck_other", 62 | "project": "spectresystems/duck", 63 | "build": "ci.yaml", 64 | "branch": "feature/GH-50", 65 | "buildId": "59962971", 66 | "buildNumber": "38", 67 | "started": 1584741176, 68 | "finished": 1584741702, 69 | "url": "https://github.com/spectresystems/duck/actions/runs/59962971", 70 | "status": "Success" 71 | }, 72 | { 73 | "id": 8692424097266382097, 74 | "provider": "GitHub", 75 | "collector": "duck_other", 76 | "project": "spectresystems/duck", 77 | "build": "ci.yaml", 78 | "branch": "feature/hot-reload", 79 | "buildId": "59915627", 80 | "buildNumber": "35", 81 | "started": 1584734841, 82 | "finished": 1584735097, 83 | "url": "https://github.com/spectresystems/duck/actions/runs/59915627", 84 | "status": "Success" 85 | }, 86 | { 87 | "id": 6230016329709965671, 88 | "provider": "GitHub", 89 | "collector": "duck_other", 90 | "project": "spectresystems/duck", 91 | "build": "ci.yaml", 92 | "branch": "integrate-purgecss", 93 | "buildId": "58880905", 94 | "buildNumber": "26", 95 | "started": 1584617164, 96 | "finished": 1584617437, 97 | "url": "https://github.com/spectresystems/duck/actions/runs/58880905", 98 | "status": "Success" 99 | }, 100 | { 101 | "id": 1619989489999387859, 102 | "provider": "GitHub", 103 | "collector": "duck_other", 104 | "project": "spectresystems/duck", 105 | "build": "ci.yaml", 106 | "branch": "setup-docker-for-local-development", 107 | "buildId": "58880314", 108 | "buildNumber": "24", 109 | "started": 1584617069, 110 | "finished": 1584617318, 111 | "url": "https://github.com/spectresystems/duck/actions/runs/58880314", 112 | "status": "Success" 113 | } 114 | ] -------------------------------------------------------------------------------- /src/providers/collectors/appveyor/client.rs: -------------------------------------------------------------------------------- 1 | use log::{trace, warn}; 2 | 3 | use crate::builds::BuildStatus; 4 | use crate::config::{AppVeyorConfiguration, AppVeyorCredentials}; 5 | use crate::utils::date; 6 | use crate::utils::http::*; 7 | use crate::DuckResult; 8 | 9 | pub struct AppVeyorClient { 10 | credentials: AppVeyorCredentials, 11 | } 12 | 13 | impl AppVeyorClient { 14 | pub fn new(config: &AppVeyorConfiguration) -> Self { 15 | Self { 16 | credentials: config.credentials.clone(), 17 | } 18 | } 19 | 20 | pub fn get_builds( 21 | &self, 22 | client: &impl HttpClient, 23 | account: &str, 24 | project: &str, 25 | count: u16, 26 | ) -> DuckResult { 27 | let url = format!( 28 | "https://ci.appveyor.com/api/projects/{account}/{project}/history?recordsNumber={count}", 29 | account = account, 30 | project = project, 31 | count = count 32 | ); 33 | 34 | trace!("Sending request to: {}", url); 35 | let mut builder = HttpRequestBuilder::get(&url); 36 | builder.add_header("Content-Type", "application/json"); 37 | builder.add_header("Accept", "application/json"); 38 | 39 | self.credentials.authenticate(&mut builder); 40 | let mut response = client.send(&builder)?; 41 | 42 | trace!("Received response: {}", response.status()); 43 | if !response.status().is_success() { 44 | return Err(format_err!( 45 | "Received non 200 HTTP status code. ({})", 46 | response.status() 47 | )); 48 | } 49 | 50 | // Get the response body. 51 | let body = response.body()?; 52 | // Deserialize and return the value. 53 | Ok(serde_json::from_str(&body[..])?) 54 | } 55 | } 56 | 57 | impl AppVeyorCredentials { 58 | fn authenticate(&self, builder: &mut HttpRequestBuilder) { 59 | match self { 60 | AppVeyorCredentials::Bearer(token) => { 61 | builder.bearer(token); 62 | } 63 | } 64 | } 65 | } 66 | 67 | #[derive(Deserialize, Debug)] 68 | pub struct AppVeyorResponse { 69 | pub project: AppVeyorProject, 70 | pub builds: Vec, 71 | } 72 | 73 | #[derive(Deserialize, Debug)] 74 | pub struct AppVeyorProject { 75 | #[serde(alias = "accountId")] 76 | pub account_id: u64, 77 | #[serde(alias = "accountName")] 78 | pub account_name: String, 79 | #[serde(alias = "projectId")] 80 | pub project_id: u64, 81 | #[serde(alias = "name")] 82 | pub project_name: String, 83 | #[serde(alias = "repositoryName")] 84 | pub repository_name: String, 85 | } 86 | 87 | #[derive(Deserialize, Debug)] 88 | pub struct AppVeyorBuild { 89 | #[serde(alias = "buildId")] 90 | pub build_id: u64, 91 | #[serde(alias = "buildNumber")] 92 | pub build_number: u64, 93 | pub branch: String, 94 | pub status: String, 95 | pub created: String, 96 | pub started: Option, 97 | pub finished: Option, 98 | } 99 | 100 | impl AppVeyorBuild { 101 | pub fn get_status(&self) -> BuildStatus { 102 | match &self.status[..] { 103 | "success" => BuildStatus::Success, 104 | "queued" => BuildStatus::Queued, 105 | "starting" => BuildStatus::Queued, 106 | "running" => BuildStatus::Running, 107 | "failed" => BuildStatus::Failed, 108 | "cancelled" => BuildStatus::Canceled, 109 | status => { 110 | warn!("Unknown build status: {}", status); 111 | BuildStatus::Unknown 112 | } 113 | } 114 | } 115 | 116 | pub fn get_started_timestamp(&self) -> DuckResult { 117 | let started = match &self.started { 118 | Some(started) => started, 119 | None => &self.created, 120 | }; 121 | date::to_timestamp(&started[..], date::APPVEYOR_FORMAT) 122 | } 123 | 124 | pub fn get_finished_timestamp(&self) -> DuckResult> { 125 | if let Some(finished) = &self.finished { 126 | let ts = date::to_timestamp(&finished[..], date::APPVEYOR_FORMAT)?; 127 | return Ok(Some(ts)); 128 | } 129 | Ok(None) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/providers/collectors/azure/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | use url::Url; 3 | 4 | use crate::config::{AzureDevOpsConfiguration, AzureDevOpsCredentials}; 5 | use crate::utils::http::*; 6 | use crate::DuckResult; 7 | 8 | pub struct AzureDevOpsClient { 9 | server_url: Url, 10 | organization: String, 11 | project: String, 12 | credentials: AzureDevOpsCredentials, 13 | } 14 | 15 | impl AzureDevOpsClient { 16 | pub fn new(config: &AzureDevOpsConfiguration) -> Self { 17 | AzureDevOpsClient { 18 | server_url: match &config.server_url { 19 | Some(url) => Url::parse(&url[..]).unwrap(), 20 | None => Url::parse("https://dev.azure.com").unwrap(), 21 | }, 22 | organization: config.organization.clone(), 23 | project: config.project.clone(), 24 | credentials: config.credentials.clone(), 25 | } 26 | } 27 | 28 | pub fn get_origin(&self) -> String { 29 | format!( 30 | "{}{}/{}", 31 | self.server_url.as_str(), 32 | self.organization, 33 | self.project 34 | ) 35 | } 36 | 37 | pub fn get_builds( 38 | &self, 39 | client: &impl HttpClient, 40 | branch: &str, 41 | definitions: &[String], 42 | ) -> DuckResult { 43 | let url = format!( 44 | "{server}{organization}/{project}/_apis/build/builds?api-version=5.0\ 45 | &branchName={branch}&definitions={definitions}&maxBuildsPerDefinition=1\ 46 | &queryOrder=startTimeDescending&deletedFilter=excludeDeleted\ 47 | &statusFilter=cancelling,completed,inProgress", 48 | server = self.server_url, 49 | organization = self.organization, 50 | project = self.project, 51 | branch = branch, 52 | definitions = definitions.join(","), 53 | ); 54 | 55 | trace!("Sending request to: {}", url); 56 | let mut builder = HttpRequestBuilder::get(&url); 57 | builder.add_header("Content-Type", "application/json"); 58 | builder.add_header("Accept", "application/json"); 59 | 60 | self.credentials.authenticate(&mut builder); 61 | let mut response = client.send(&builder)?; 62 | 63 | trace!("Received response: {}", response.status()); 64 | if !response.status().is_success() { 65 | return Err(format_err!( 66 | "Received non 200 HTTP status code. ({})", 67 | response.status() 68 | )); 69 | } 70 | 71 | // Get the response body. 72 | let body = response.body()?; 73 | // Deserialize and return the value. 74 | Ok(serde_json::from_str(&body[..])?) 75 | } 76 | } 77 | 78 | impl AzureDevOpsCredentials { 79 | fn authenticate(&self, builder: &mut HttpRequestBuilder) { 80 | match self { 81 | AzureDevOpsCredentials::Anonymous => {} 82 | AzureDevOpsCredentials::PersonalAccessToken(token) => { 83 | builder.basic_auth("", Some(token)) 84 | } 85 | } 86 | } 87 | } 88 | 89 | #[derive(Deserialize, Debug)] 90 | pub struct AzureResponse { 91 | pub value: Vec, 92 | } 93 | 94 | #[derive(Deserialize, Debug)] 95 | pub struct AzureBuild { 96 | pub id: u64, 97 | #[serde(alias = "buildNumber")] 98 | pub build_number: String, 99 | pub project: AzureProject, 100 | pub definition: AzureBuildDefinition, 101 | pub status: String, 102 | pub result: Option, 103 | #[serde(alias = "startTime")] 104 | pub start_time: String, 105 | #[serde(alias = "finishTime")] 106 | pub finish_time: Option, 107 | #[serde(alias = "sourceBranch")] 108 | pub branch: String, 109 | #[serde(alias = "_links")] 110 | pub links: AzureLinks, 111 | } 112 | 113 | #[derive(Deserialize, Debug)] 114 | pub struct AzureLinks { 115 | pub web: AzureWebLink, 116 | } 117 | 118 | #[derive(Deserialize, Debug)] 119 | pub struct AzureWebLink { 120 | pub href: String, 121 | } 122 | 123 | #[derive(Deserialize, Debug)] 124 | pub struct AzureProject { 125 | pub id: String, 126 | pub name: String, 127 | } 128 | 129 | #[derive(Deserialize, Debug)] 130 | pub struct AzureBuildDefinition { 131 | pub id: u64, 132 | pub name: String, 133 | } 134 | -------------------------------------------------------------------------------- /src/providers/collectors/appveyor/validation.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{AppVeyorConfiguration, Validate}; 2 | use crate::DuckResult; 3 | 4 | impl Validate for AppVeyorConfiguration { 5 | fn validate(&self) -> DuckResult<()> { 6 | if self.account.is_empty() { 7 | return Err(format_err!("[{}] AppVeyor account is empty", self.id)); 8 | } 9 | if self.project.is_empty() { 10 | return Err(format_err!("[{}] AppVeyor project is empty", self.id)); 11 | } 12 | match &self.credentials { 13 | crate::config::AppVeyorCredentials::Bearer(token) => { 14 | if token.is_empty() { 15 | return Err(format_err!("[{}] AppVeyor bearer token is empty", self.id)); 16 | } 17 | } 18 | }; 19 | 20 | Ok(()) 21 | } 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use crate::config::*; 27 | use crate::providers; 28 | use crate::providers::collectors::Collector; 29 | use crate::utils::text::TestVariableProvider; 30 | 31 | fn create_collectors_from_config(json: &str) -> Vec> { 32 | providers::create_collectors( 33 | &Configuration::from_json(&TestVariableProvider::new(), json).unwrap(), 34 | ) 35 | .unwrap() 36 | } 37 | 38 | #[test] 39 | #[should_panic(expected = "The id \\'\\' is invalid")] 40 | fn should_return_error_if_id_is_empty() { 41 | create_collectors_from_config( 42 | r#" 43 | { 44 | "collectors": [ 45 | { 46 | "appveyor": { 47 | "id": "", 48 | "credentials": { 49 | "bearer": "SECRET" 50 | }, 51 | "account": "patriksvensson", 52 | "project": "spectre-commandline", 53 | "count": 5 54 | } 55 | } 56 | ] 57 | }"#, 58 | ); 59 | } 60 | 61 | #[test] 62 | #[should_panic(expected = "[appveyor_spectrecli] AppVeyor bearer token is empty")] 63 | fn should_return_error_if_credentials_is_empty() { 64 | create_collectors_from_config( 65 | r#" 66 | { 67 | "collectors": [ 68 | { 69 | "appveyor": { 70 | "id": "appveyor_spectrecli", 71 | "credentials": { 72 | "bearer": "" 73 | }, 74 | "account": "patriksvensson", 75 | "project": "spectre-commandline", 76 | "count": 5 77 | } 78 | } 79 | ] 80 | }"#, 81 | ); 82 | } 83 | 84 | #[test] 85 | #[should_panic(expected = "[appveyor_spectrecli] AppVeyor account is empty")] 86 | fn should_return_error_if_account_is_empty() { 87 | create_collectors_from_config( 88 | r#" 89 | { 90 | "collectors": [ 91 | { 92 | "appveyor": { 93 | "id": "appveyor_spectrecli", 94 | "credentials": { 95 | "bearer": "" 96 | }, 97 | "account": "", 98 | "project": "spectre-commandline", 99 | "count": 5 100 | } 101 | } 102 | ] 103 | }"#, 104 | ); 105 | } 106 | 107 | #[test] 108 | #[should_panic(expected = "[appveyor_spectrecli] AppVeyor project is empty")] 109 | fn should_return_error_if_project_is_empty() { 110 | create_collectors_from_config( 111 | r#" 112 | { 113 | "collectors": [ 114 | { 115 | "appveyor": { 116 | "id": "appveyor_spectrecli", 117 | "credentials": { 118 | "bearer": "" 119 | }, 120 | "account": "patriksvensson", 121 | "project": "", 122 | "count": 5 123 | } 124 | } 125 | ] 126 | }"#, 127 | ); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /web/src/components/Build.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 92 | 93 | -------------------------------------------------------------------------------- /src/providers/collectors/teamcity/mod.rs: -------------------------------------------------------------------------------- 1 | use log::{error, trace, warn}; 2 | use waithandle::WaitHandleListener; 3 | 4 | use crate::builds::{Build, BuildBuilder, BuildStatus}; 5 | use crate::config::TeamCityConfiguration; 6 | use crate::providers::collectors::{Collector, CollectorInfo}; 7 | use crate::utils::date; 8 | use crate::DuckResult; 9 | 10 | use self::client::*; 11 | use super::CollectorLoader; 12 | 13 | mod client; 14 | mod validation; 15 | 16 | impl CollectorLoader for TeamCityConfiguration { 17 | fn load(&self) -> DuckResult> { 18 | Ok(Box::new(TeamCityCollector { 19 | client: TeamCityClient::new(self), 20 | build_types: self.builds.clone(), 21 | info: CollectorInfo { 22 | id: self.id.clone(), 23 | enabled: self.enabled.unwrap_or(true), 24 | provider: "TeamCity".to_string(), 25 | }, 26 | })) 27 | } 28 | } 29 | 30 | pub struct TeamCityCollector { 31 | client: TeamCityClient, 32 | build_types: Vec, 33 | info: CollectorInfo, 34 | } 35 | 36 | impl Collector for TeamCityCollector { 37 | fn info(&self) -> &CollectorInfo { 38 | &self.info 39 | } 40 | 41 | fn collect( 42 | &self, 43 | listener: WaitHandleListener, 44 | callback: &mut dyn FnMut(Build), 45 | ) -> DuckResult<()> { 46 | // Make sure TeamCity is online. 47 | if !self.client.is_online() { 48 | error!("There was a problem contacting TeamCity."); 49 | return Err(format_err!("There was a problem contacting TeamCity.")); 50 | } 51 | 52 | // Get all known build types from TeamCity. 53 | let known_build_types = self.client.get_build_types()?; 54 | 55 | // Get builds for all build types. 56 | for build_type in self.build_types.iter() { 57 | if listener.check() { 58 | return Ok(()); 59 | } 60 | 61 | // Make sure the build type is known. 62 | let found = match known_build_types.iter().find(|t| t.id.eq(build_type)) { 63 | Option::None => { 64 | warn!( 65 | "The build type '{}' does not exist in TeamCity.", 66 | build_type 67 | ); 68 | continue; 69 | } 70 | Option::Some(r) => r, 71 | }; 72 | 73 | trace!("Getting builds for {}...", build_type); 74 | let result = self.client.get_builds(found)?; 75 | for branch in result.branches { 76 | if listener.check() { 77 | return Ok(()); 78 | } 79 | 80 | let branch_name = if branch.name == "" { 81 | "default" 82 | } else { 83 | &branch.name 84 | }; 85 | 86 | match branch.builds.builds.first() { 87 | None => trace!("No builds found for branch '{}'", branch_name), 88 | Some(build) => { 89 | callback( 90 | BuildBuilder::new() 91 | .build_id(build.id.to_string()) 92 | .provider("TeamCity") 93 | .origin(self.client.url.as_str()) 94 | .collector(&self.info.id) 95 | .project_id(&found.project_id) 96 | .project_name(&found.project_name) 97 | .definition_id(&found.id) 98 | .definition_name(&found.name) 99 | .build_number(&build.number) 100 | .status(build.get_build_status()) 101 | .url(&build.url) 102 | .started_at(date::to_timestamp( 103 | &build.started_at, 104 | date::TEAMCITY_FORMAT, 105 | )?) 106 | .finished_at(build.get_finished_at()?) 107 | .branch(branch_name) 108 | .build() 109 | .unwrap(), 110 | ); 111 | } 112 | }; 113 | } 114 | 115 | // Wait for a little time between calls. 116 | if listener.wait(std::time::Duration::from_millis(300)) { 117 | return Ok(()); 118 | } 119 | } 120 | 121 | Ok(()) 122 | } 123 | } 124 | 125 | impl TeamCityBuildModel { 126 | pub fn get_build_status(&self) -> BuildStatus { 127 | return if self.running { 128 | BuildStatus::Running 129 | } else { 130 | match self.status.as_ref() { 131 | "SUCCESS" => BuildStatus::Success, 132 | "UNKNOWN" => BuildStatus::Canceled, 133 | _ => BuildStatus::Failed, 134 | } 135 | }; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate log; 2 | 3 | use log::error; 4 | use simplelog::*; 5 | use structopt::StructOpt; 6 | 7 | use duck::DuckResult; 8 | 9 | mod commands; 10 | 11 | #[derive(StructOpt)] 12 | #[structopt(name = "Duck")] 13 | struct Opt { 14 | /// The log level to use (info, debug, trace) 15 | #[structopt(short, long, parse(from_str = parse_level), env = "DUCK_LEVEL")] 16 | level: Option, 17 | /// Whether or not to log to file. 18 | #[structopt(long = "file", short = "f")] 19 | log_to_file: bool, 20 | /// Disables the startup banner 21 | #[structopt(short, long)] 22 | no_logo: bool, 23 | /// Available subcommands 24 | #[structopt(subcommand)] 25 | command: Command, 26 | } 27 | 28 | #[derive(Debug)] 29 | enum LogLevel { 30 | Information, 31 | Debug, 32 | Trace, 33 | } 34 | 35 | fn parse_level(src: &str) -> LogLevel { 36 | match src { 37 | "debug" => LogLevel::Debug, 38 | "trace" => LogLevel::Trace, 39 | _ => LogLevel::Information, 40 | } 41 | } 42 | 43 | #[derive(StructOpt)] 44 | enum Command { 45 | /// Starts the Duck server 46 | Start(commands::start::Arguments), 47 | /// Generates the JSON schema 48 | Schema(commands::schema::Arguments), 49 | /// Validates the Duck configuration 50 | Validate(commands::validate::Arguments), 51 | /// Starts Duck as a Windows service 52 | #[cfg(windows)] 53 | #[structopt(setting = structopt::clap::AppSettings::Hidden)] 54 | Service, 55 | /// Installs Duck as a Windows service. 56 | /// Requires administrator user. 57 | #[cfg(windows)] 58 | Install, 59 | /// Uninstalls the Duck Windows service. 60 | /// Requires administrator user. 61 | #[cfg(windows)] 62 | Uninstall, 63 | } 64 | 65 | impl Command { 66 | pub fn show_logo(&self) -> bool { 67 | match self { 68 | Command::Start(_) => true, 69 | Command::Schema(_) => false, 70 | Command::Validate(_) => false, 71 | #[cfg(windows)] 72 | Command::Service => false, 73 | #[cfg(windows)] 74 | Command::Install => false, 75 | #[cfg(windows)] 76 | Command::Uninstall => false, 77 | } 78 | } 79 | } 80 | 81 | #[actix_rt::main] 82 | async fn main() { 83 | let args = Opt::from_args(); 84 | 85 | initialize_logging(&args.level, args.log_to_file) 86 | .expect("An error occured while setting up logging"); 87 | 88 | if args.command.show_logo() && !args.no_logo { 89 | println!(r#" ____ __ "#); 90 | println!(r#" / __ \__ _______/ /__"#); 91 | println!(r#" / / / / / / / ___/ //_/"#); 92 | println!(r#" / /_/ / /_/ / /__/ < "#); 93 | println!(r#" /_____/\____/\___/_/|_| "#); 94 | println!(); 95 | } 96 | 97 | // Execute the command 98 | let result = match args.command { 99 | Command::Start(args) => commands::start::execute(args).await, 100 | Command::Schema(args) => commands::schema::execute(args), 101 | Command::Validate(args) => commands::validate::execute(args), 102 | #[cfg(windows)] 103 | Command::Service => commands::service::start(), 104 | #[cfg(windows)] 105 | Command::Install => commands::service::install(), 106 | #[cfg(windows)] 107 | Command::Uninstall => commands::service::uninstall(), 108 | }; 109 | 110 | // Return the correct exit code 111 | match result { 112 | Ok(_) => std::process::exit(0), 113 | Err(e) => { 114 | error!("An error occured: {}", e); 115 | std::process::exit(-1); 116 | } 117 | } 118 | } 119 | 120 | fn initialize_logging(level: &Option, log_to_file: bool) -> DuckResult<()> { 121 | let level = match level { 122 | None => LevelFilter::Info, 123 | Some(level) => match level { 124 | LogLevel::Information => LevelFilter::Info, 125 | LogLevel::Debug => LevelFilter::Debug, 126 | LogLevel::Trace => LevelFilter::Trace, 127 | }, 128 | }; 129 | 130 | let padding = match level { 131 | LevelFilter::Info => LevelPadding::Off, 132 | _ => LevelPadding::Left, 133 | }; 134 | 135 | let mut config = ConfigBuilder::new(); 136 | config.set_level_padding(padding); 137 | config.add_filter_ignore_str("actix"); 138 | config.add_filter_ignore_str("mio"); 139 | config.add_filter_ignore_str("tokio"); 140 | config.add_filter_ignore_str("want"); 141 | config.add_filter_ignore_str("hyper"); 142 | config.add_filter_ignore_str("reqwest"); 143 | config.add_filter_ignore_str("rustls"); 144 | config.add_filter_ignore_str("h2"); 145 | let config = config.build(); 146 | 147 | if log_to_file { 148 | // Log both to file 149 | let file = std::fs::File::create(std::env::current_exe()?.with_file_name("duck.log"))?; 150 | CombinedLogger::init(vec![WriteLogger::new(level, config, file)])?; 151 | } else { 152 | // Log to stdout 153 | let logger = TermLogger::new(level, config, TerminalMode::Mixed, ColorChoice::Auto); 154 | CombinedLogger::init(vec![logger])?; 155 | } 156 | 157 | Ok(()) 158 | } 159 | -------------------------------------------------------------------------------- /src/providers/collectors/teamcity/validation.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | use crate::config::{TeamCityAuth, TeamCityConfiguration, Validate}; 4 | use crate::DuckResult; 5 | 6 | impl Validate for TeamCityConfiguration { 7 | fn validate(&self) -> DuckResult<()> { 8 | if let Err(e) = Url::parse(&self.server_url[..]) { 9 | return Err(format_err!( 10 | "[{}] TeamCity server URL is invalid: {}", 11 | self.id, 12 | e 13 | )); 14 | } 15 | 16 | match &self.credentials { 17 | TeamCityAuth::Guest => (), 18 | TeamCityAuth::BasicAuth { username, password } => { 19 | if username.is_empty() { 20 | return Err(format_err!( 21 | "[{}] TeamCity username cannot be empty", 22 | self.id 23 | )); 24 | } 25 | if password.is_empty() { 26 | return Err(format_err!( 27 | "[{}] TeamCity password cannot be empty", 28 | self.id 29 | )); 30 | } 31 | } 32 | }; 33 | 34 | Ok(()) 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use crate::config::Configuration; 41 | use crate::providers; 42 | use crate::utils::text::TestVariableProvider; 43 | 44 | #[test] 45 | #[should_panic(expected = "The id \\'\\' is invalid")] 46 | fn should_return_error_if_id_is_empty() { 47 | let config = Configuration::from_json( 48 | &TestVariableProvider::new(), 49 | r#" 50 | { 51 | "collectors": [ 52 | { 53 | "teamcity": { 54 | "id": "", 55 | "serverUrl": "https://localhost:5000", 56 | "credentials": "guest", 57 | "builds": [ "Foo" ] 58 | } 59 | } 60 | ] 61 | } 62 | "#, 63 | ) 64 | .unwrap(); 65 | 66 | providers::create_collectors(&config).unwrap(); 67 | } 68 | 69 | #[test] 70 | #[should_panic(expected = "[foo] TeamCity server URL is invalid: relative URL without a base")] 71 | fn should_return_error_if_teamcity_server_is_empty() { 72 | let config = Configuration::from_json( 73 | &TestVariableProvider::new(), 74 | r#" 75 | { 76 | "collectors": [ 77 | { 78 | "teamcity": { 79 | "id": "foo", 80 | "serverUrl": "", 81 | "credentials": "guest", 82 | "builds": [ "Foo" ] 83 | } 84 | } 85 | ] 86 | } 87 | "#, 88 | ) 89 | .unwrap(); 90 | 91 | providers::create_collectors(&config).unwrap(); 92 | } 93 | 94 | #[test] 95 | #[should_panic(expected = "[foo] TeamCity username cannot be empty")] 96 | fn should_return_error_if_teamcity_username_is_empty() { 97 | let config = Configuration::from_json( 98 | &TestVariableProvider::new(), 99 | r#" 100 | { 101 | "collectors": [ 102 | { 103 | "teamcity": { 104 | "id": "foo", 105 | "serverUrl": "https://localhost:5000", 106 | "credentials": { 107 | "basic": { 108 | "username": "", 109 | "password": "bar" 110 | } 111 | }, 112 | "builds": [ "Foo" ] 113 | } 114 | } 115 | ] 116 | } 117 | "#, 118 | ) 119 | .unwrap(); 120 | 121 | providers::create_collectors(&config).unwrap(); 122 | } 123 | 124 | #[test] 125 | #[should_panic(expected = "[foo] TeamCity password cannot be empty")] 126 | fn should_return_error_if_teamcity_password_is_empty() { 127 | let config = Configuration::from_json( 128 | &TestVariableProvider::new(), 129 | r#" 130 | { 131 | "collectors": [ 132 | { 133 | "teamcity": { 134 | "id": "foo", 135 | "serverUrl": "https://localhost:5000", 136 | "credentials": { 137 | "basic": { 138 | "username": "john.doe", 139 | "password": "" 140 | } 141 | }, 142 | "builds": [ "Foo" ] 143 | } 144 | } 145 | ] 146 | } 147 | "#, 148 | ) 149 | .unwrap(); 150 | 151 | providers::create_collectors(&config).unwrap(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/providers/collectors/teamcity/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | use reqwest::header::ACCEPT; 3 | use reqwest::{Client, ClientBuilder, RequestBuilder}; 4 | use url::Url; 5 | 6 | use crate::config::{TeamCityAuth, TeamCityConfiguration}; 7 | use crate::utils::date; 8 | use crate::DuckResult; 9 | 10 | pub struct TeamCityClient { 11 | pub url: Url, 12 | credentials: TeamCityAuth, 13 | client: Client, 14 | } 15 | 16 | impl TeamCityClient { 17 | pub fn new(settings: &TeamCityConfiguration) -> Self { 18 | Self { 19 | url: Url::parse(&settings.server_url[..]).unwrap(), 20 | credentials: settings.credentials.clone(), 21 | client: ClientBuilder::new().build().unwrap(), 22 | } 23 | } 24 | 25 | pub fn is_online(&self) -> bool { 26 | self.send_get_request(format!( 27 | "{url}{authtype}/app/rest/server", 28 | url = self.url, 29 | authtype = self.credentials.get_auth_type() 30 | )) 31 | .is_ok() 32 | } 33 | 34 | pub fn get_build_types(&self) -> DuckResult> { 35 | // Get all branches for this build configuration. 36 | let mut response = self.send_get_request(format!( 37 | "{url}{authtype}/app/rest/buildTypes", 38 | url = self.url, 39 | authtype = self.credentials.get_auth_type() 40 | ))?; 41 | 42 | let result: TeamCityBuildTypeCollectionModel = response.json()?; 43 | 44 | Ok(result.build_types) 45 | } 46 | 47 | pub fn get_builds( 48 | &self, 49 | build_type: &TeamCityBuildTypeModel, 50 | ) -> DuckResult { 51 | // Get all branches for this build configuration. 52 | let mut response = self.send_get_request(format!( 53 | "{url}{authtype}/app/rest/buildTypes/id:{id}/branches?locator=default:any\ 54 | &fields=count,branch(name,default,active,builds(build(id,number,running,status,\ 55 | branchName,webUrl,startDate,finishDate),count,$locator(running:any,canceled:any,count:1)))", 56 | url = self.url, 57 | authtype = self.credentials.get_auth_type(), 58 | id = build_type.id 59 | ))?; 60 | 61 | let result: TeamCityBranchCollectionModel = response.json()?; 62 | 63 | Ok(result) 64 | } 65 | 66 | fn send_get_request(&self, url: String) -> DuckResult { 67 | trace!("Sending request to: {}", url); 68 | let response = self.client.get(&url).header(ACCEPT, "application/json"); 69 | let response = self.credentials.authenticate(response).send()?; 70 | 71 | trace!("Received response: {}", response.status()); 72 | if !response.status().is_success() { 73 | return Err(format_err!("Received non 200 HTTP status code.")); 74 | } 75 | 76 | Ok(response) 77 | } 78 | } 79 | 80 | impl TeamCityAuth { 81 | pub fn get_auth_type(&self) -> String { 82 | return match self { 83 | TeamCityAuth::Guest => "guestAuth".to_string(), 84 | TeamCityAuth::BasicAuth { .. } => "httpAuth".to_string(), 85 | }; 86 | } 87 | pub fn authenticate(&self, builder: RequestBuilder) -> RequestBuilder { 88 | return match self { 89 | TeamCityAuth::Guest => builder, 90 | TeamCityAuth::BasicAuth { username, password } => { 91 | builder.basic_auth(username, Some(password)) 92 | } 93 | }; 94 | } 95 | } 96 | 97 | #[derive(Deserialize, Debug)] 98 | pub struct TeamCityBuildTypeCollectionModel { 99 | pub count: u32, 100 | #[serde(alias = "buildType")] 101 | pub build_types: Vec, 102 | } 103 | 104 | #[derive(Deserialize, Debug)] 105 | pub struct TeamCityBuildTypeModel { 106 | pub id: String, 107 | pub name: String, 108 | #[serde(alias = "projectId")] 109 | pub project_id: String, 110 | #[serde(alias = "projectName")] 111 | pub project_name: String, 112 | } 113 | 114 | #[derive(Deserialize, Debug)] 115 | pub struct TeamCityBranchCollectionModel { 116 | pub count: u32, 117 | #[serde(alias = "branch")] 118 | pub branches: Vec, 119 | } 120 | 121 | #[derive(Deserialize, Debug)] 122 | pub struct TeamCityBranchModel { 123 | pub name: String, 124 | #[serde(default)] 125 | pub default: bool, 126 | pub active: bool, 127 | pub builds: TeamCityBuildCollectionModel, 128 | } 129 | 130 | #[derive(Deserialize, Debug)] 131 | pub struct TeamCityBuildCollectionModel { 132 | count: u32, 133 | #[serde(alias = "build")] 134 | pub builds: Vec, 135 | } 136 | 137 | #[derive(Deserialize, Debug)] 138 | pub struct TeamCityBuildModel { 139 | pub id: u32, 140 | pub number: String, 141 | pub running: bool, 142 | pub status: String, 143 | #[serde(alias = "webUrl")] 144 | pub url: String, 145 | #[serde(alias = "startDate")] 146 | pub started_at: String, 147 | #[serde(alias = "finishDate")] 148 | pub finished_at: Option, 149 | } 150 | 151 | impl TeamCityBuildModel { 152 | pub fn get_finished_at(&self) -> DuckResult> { 153 | let finished_at = match &self.finished_at { 154 | Option::None => None, 155 | Option::Some(value) => { 156 | Option::Some(date::to_timestamp(&value[..], date::TEAMCITY_FORMAT)?) 157 | } 158 | }; 159 | Ok(finished_at) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/config/loader.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::{Arc, Mutex}; 3 | use std::time::SystemTime; 4 | use std::{fs, path::Path}; 5 | 6 | use crate::config::{Configuration, ConfigurationLoader}; 7 | use crate::utils::text::VariableProvider; 8 | use crate::DuckResult; 9 | 10 | /////////////////////////////////////////////////////////// 11 | // Configuration loader 12 | 13 | #[derive(Clone)] 14 | pub struct JsonConfigurationLoader<'a> { 15 | path: PathBuf, 16 | reader: &'a dyn FileReader, 17 | modified: Arc>, 18 | } 19 | 20 | impl<'a> JsonConfigurationLoader<'a> { 21 | pub fn new(path: PathBuf) -> Self { 22 | JsonConfigurationLoader::create(path, &DefaultFileReader {}) 23 | } 24 | 25 | fn create(path: PathBuf, reader: &'a dyn FileReader) -> Self { 26 | JsonConfigurationLoader { 27 | path, 28 | reader, 29 | modified: Arc::new(Mutex::new(0)), 30 | } 31 | } 32 | } 33 | 34 | impl<'a> ConfigurationLoader for JsonConfigurationLoader<'a> { 35 | fn exist(&self) -> bool { 36 | self.path.exists() 37 | } 38 | 39 | fn has_changed(&self) -> DuckResult { 40 | let modified = self.reader.modified(&self.path)?; 41 | if *self.modified.lock().unwrap() != modified { 42 | return Ok(true); 43 | } 44 | Ok(false) 45 | } 46 | 47 | fn load(&self, variables: &dyn VariableProvider) -> DuckResult { 48 | // Read the configuration and deserialize it 49 | let json = self.reader.read_to_string(&self.path)?; 50 | let config: Configuration = Configuration::from_json(variables, json)?; 51 | // Update the modified time to the current one. 52 | let modified = self.reader.modified(&self.path)?; 53 | *self.modified.lock().unwrap() = modified; 54 | Ok(config) 55 | } 56 | } 57 | 58 | /////////////////////////////////////////////////////////// 59 | // File reader 60 | 61 | trait FileReader: Send + Sync { 62 | /// Returns the content of the file as a string 63 | fn read_to_string(&self, path: &Path) -> DuckResult; 64 | /// Gets the modified time as Epoch time 65 | fn modified(&self, path: &Path) -> DuckResult; 66 | } 67 | 68 | struct DefaultFileReader {} 69 | impl FileReader for DefaultFileReader { 70 | fn read_to_string(&self, path: &Path) -> DuckResult { 71 | Ok(fs::read_to_string(path)?) 72 | } 73 | 74 | fn modified(&self, path: &Path) -> DuckResult { 75 | Ok(fs::metadata(path)? 76 | .modified()? 77 | .duration_since(SystemTime::UNIX_EPOCH)? 78 | .as_secs()) 79 | } 80 | } 81 | 82 | /////////////////////////////////////////////////////////// 83 | // Tests 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | use super::*; 88 | use crate::utils::text::TestVariableProvider; 89 | 90 | struct FakeFileReader { 91 | json: String, 92 | modified: Arc>, 93 | } 94 | 95 | impl FakeFileReader { 96 | fn new>(json: T, modified: u64) -> Self { 97 | Self { 98 | json: json.into(), 99 | modified: Arc::new(Mutex::new(modified)), 100 | } 101 | } 102 | 103 | pub fn inc_modified(&self) { 104 | let mut modified = self.modified.lock().unwrap(); 105 | *modified = *modified + 1; 106 | } 107 | } 108 | 109 | impl FileReader for FakeFileReader { 110 | fn read_to_string(&self, _path: &Path) -> DuckResult { 111 | Ok(self.json.clone()) 112 | } 113 | 114 | fn modified(&self, _path: &Path) -> DuckResult { 115 | let modified = self.modified.lock().unwrap(); 116 | Ok(*modified) 117 | } 118 | } 119 | 120 | #[test] 121 | fn should_load_expected_configuration() { 122 | // Given 123 | let path = PathBuf::from("config.json"); 124 | let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970); 125 | let config = JsonConfigurationLoader::create(path, &reader); 126 | let variables = TestVariableProvider::new(); 127 | 128 | // When 129 | let config = config.load(&variables).unwrap(); 130 | 131 | // Then 132 | assert_eq!(99, config.interval); 133 | assert_eq!("Duck test server", config.title); 134 | } 135 | 136 | #[test] 137 | fn should_indicate_if_configuration_has_not_changed_since_read() { 138 | // Given 139 | let path = PathBuf::from("config.json"); 140 | let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970); 141 | let handle = JsonConfigurationLoader::create(path, &reader); 142 | let variables = TestVariableProvider::new(); 143 | 144 | // When 145 | handle.load(&variables).unwrap(); 146 | let has_changed = handle.has_changed().unwrap(); 147 | 148 | // Then 149 | assert!(!has_changed); 150 | } 151 | 152 | #[test] 153 | fn should_indicate_if_configuration_changed_since_read() { 154 | // Given 155 | let path = PathBuf::from("config.json"); 156 | let reader = FakeFileReader::new(include_str!("test_data/config.json"), 1583092970); 157 | let handle = JsonConfigurationLoader::create(path, &reader); 158 | let variables = TestVariableProvider::new(); 159 | 160 | // When 161 | handle.load(&variables).unwrap(); 162 | reader.inc_modified(); 163 | let has_changed = handle.has_changed().unwrap(); 164 | 165 | // Then 166 | assert!(has_changed); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/providers/collectors/debugger/mod.rs: -------------------------------------------------------------------------------- 1 | use waithandle::WaitHandleListener; 2 | 3 | use crate::builds::{Build, BuildBuilder}; 4 | use crate::config::DebuggerConfiguration; 5 | use crate::providers::collectors::{Collector, CollectorInfo, CollectorLoader}; 6 | use crate::utils::date; 7 | use crate::utils::http::{HttpClient, ReqwestClient}; 8 | use crate::DuckResult; 9 | 10 | mod client; 11 | mod validation; 12 | 13 | use self::client::DebuggerClient; 14 | 15 | impl CollectorLoader for DebuggerConfiguration { 16 | fn load(&self) -> DuckResult> { 17 | Ok(Box::new(DebuggerCollector::::new(self))) 18 | } 19 | } 20 | 21 | pub struct DebuggerCollector { 22 | http: T, 23 | client: DebuggerClient, 24 | server_url: String, 25 | info: CollectorInfo, 26 | } 27 | 28 | impl DebuggerCollector { 29 | pub fn new(config: &DebuggerConfiguration) -> Self { 30 | return DebuggerCollector { 31 | http: Default::default(), 32 | client: DebuggerClient::new(config), 33 | server_url: config.server_url.clone(), 34 | info: CollectorInfo { 35 | id: config.id.clone(), 36 | enabled: config.enabled.unwrap_or(true), 37 | provider: "Debugger".to_owned(), 38 | }, 39 | }; 40 | } 41 | 42 | #[cfg(test)] 43 | pub fn get_client(&self) -> &T { 44 | &self.http 45 | } 46 | } 47 | 48 | impl Collector for DebuggerCollector { 49 | fn info(&self) -> &CollectorInfo { 50 | &self.info 51 | } 52 | 53 | fn collect( 54 | &self, 55 | _handle: WaitHandleListener, 56 | callback: &mut dyn FnMut(Build), 57 | ) -> DuckResult<()> { 58 | let builds = self.client.get_builds(&self.http)?; 59 | for build in builds { 60 | callback( 61 | BuildBuilder::new() 62 | .build_id(&build.id.to_string()) 63 | .provider("Debugger") 64 | .origin(&self.server_url) 65 | .collector(&self.info.id) 66 | .project_id(&build.project) 67 | .project_name(&build.project) 68 | .definition_id(&build.definition) 69 | .definition_name(&build.definition) 70 | .build_number(&build.id.to_string()) 71 | .branch(&build.branch) 72 | .status(build.get_status()) 73 | .url(format!("{}/Edit?id={}", &self.server_url, build.id)) 74 | .started_at(date::to_timestamp(&build.started, date::DEBUGGER_FORMAT)?) 75 | .finished_at(match &build.finished { 76 | Option::None => None, 77 | Option::Some(value) => { 78 | Option::Some(date::to_timestamp(&value[..], date::DEBUGGER_FORMAT)?) 79 | } 80 | }) 81 | .build() 82 | .unwrap(), 83 | ); 84 | } 85 | 86 | Ok(()) 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | use super::*; 93 | use crate::builds::BuildStatus; 94 | use crate::utils::http::{HttpMethod, MockHttpClient, MockHttpResponseBuilder}; 95 | use reqwest::StatusCode; 96 | 97 | fn create_collector() -> DebuggerCollector { 98 | DebuggerCollector::::new(&DebuggerConfiguration { 99 | id: "debug".to_owned(), 100 | enabled: Some(true), 101 | server_url: "http://localhost:5000".to_owned(), 102 | }) 103 | } 104 | 105 | #[test] 106 | fn should_return_correct_provider_name() { 107 | // Given 108 | let debugger = create_collector(); 109 | // When 110 | let provider = &debugger.info().provider; 111 | // Then 112 | assert_eq!("Debugger", provider); 113 | } 114 | 115 | #[test] 116 | fn should_get_correct_data() { 117 | // Given 118 | let debugger = create_collector(); 119 | let (_, listener) = waithandle::new(); 120 | let client = debugger.get_client(); 121 | 122 | client.add_response( 123 | MockHttpResponseBuilder::new(HttpMethod::Get, "http://localhost:5000/api/builds") 124 | .returns_status(StatusCode::OK) 125 | .returns_body(include_str!("test_data/builds.json")), 126 | ); 127 | 128 | // When 129 | let mut result = Vec::::new(); 130 | debugger 131 | .collect(listener, &mut |build: Build| { 132 | // Store the results 133 | result.push(build); 134 | }) 135 | .unwrap(); 136 | 137 | // Then 138 | assert_eq!(2, result.len()); 139 | assert_eq!("1", result[0].build_id); 140 | assert_eq!("Debugger", result[0].provider); 141 | assert_eq!("debug", result[0].collector); 142 | assert_eq!("Cauldron", result[0].project_id); 143 | assert_eq!("Cauldron", result[0].project_name); 144 | assert_eq!("Debug", result[0].definition_id); 145 | assert_eq!("Debug", result[0].definition_name); 146 | assert_eq!("1", result[0].build_number); 147 | assert_eq!(BuildStatus::Success, result[0].status); 148 | assert_eq!("master", result[0].branch); 149 | assert_eq!("http://localhost:5000/Edit?id=1", result[0].url); 150 | assert_eq!(1586811562, result[0].started_at); 151 | assert_eq!(1586811827, result[0].finished_at.unwrap()); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/providers/collectors/github/client.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | 3 | use crate::builds::BuildStatus; 4 | use crate::config::{GitHubConfiguration, GitHubCredentials}; 5 | use crate::utils::date; 6 | use crate::utils::http::*; 7 | use crate::DuckResult; 8 | 9 | pub struct GitHubClient { 10 | pub owner: String, 11 | pub repository: String, 12 | pub workflow: String, 13 | credentials: GitHubCredentials, 14 | etag: std::sync::Mutex>, 15 | cached: std::sync::Mutex>, 16 | } 17 | 18 | impl GitHubClient { 19 | pub fn new(config: &GitHubConfiguration) -> Self { 20 | Self { 21 | owner: config.owner.clone(), 22 | repository: config.repository.clone(), 23 | workflow: config.workflow.clone(), 24 | credentials: config.credentials.clone(), 25 | etag: std::sync::Mutex::new(None), 26 | cached: std::sync::Mutex::new(None), 27 | } 28 | } 29 | 30 | pub fn get_builds(&self, client: &impl HttpClient) -> DuckResult { 31 | let url = format!( 32 | "https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/runs?page=0&per_page=25", 33 | owner = self.owner, 34 | repo = self.repository, 35 | workflow = self.workflow 36 | ); 37 | 38 | trace!("Sending request to: {}", url); 39 | let mut builder = HttpRequestBuilder::get(&url); 40 | builder.add_header("Content-Type", "application/json"); 41 | builder.add_header("Accept", "application/json"); 42 | 43 | // Do we have an etag? 44 | let mut etag = self.etag.lock().unwrap(); 45 | if etag.is_some() { 46 | let etag_value = etag.as_ref().unwrap(); 47 | trace!("Using etag {}", etag_value); 48 | builder.add_header("If-None-Match", etag_value); 49 | } 50 | 51 | self.credentials.authenticate(&mut builder); 52 | let mut response = client.send(&builder)?; 53 | 54 | // Got an etag? 55 | let new_etag = response.headers().get("ETag"); 56 | if let Some(new_etag) = new_etag { 57 | let new_etag_value = new_etag.to_str()?; 58 | trace!("Got a new etag: {}", new_etag_value); 59 | *etag = Some(new_etag_value.to_owned()); 60 | } 61 | 62 | trace!("Received response: {}", response.status()); 63 | if !response.status().is_success() { 64 | // Not modified? 65 | if response.status() == reqwest::StatusCode::NOT_MODIFIED { 66 | let cached = self.cached.lock().unwrap(); 67 | if cached.is_none() { 68 | return Err(format_err!( 69 | "Got a 304 not modified, but we didn't have a response cached" 70 | )); 71 | } 72 | return Ok(serde_json::from_str(&cached.as_ref().unwrap()[..])?); 73 | } 74 | return Err(format_err!( 75 | "Received non 200 HTTP status code. ({})", 76 | response.status() 77 | )); 78 | } 79 | 80 | // Get the response body. 81 | let body = response.body()?; 82 | 83 | // Cache the response. We need this if we return a 304 Not Modified. 84 | trace!("Cached the response from GitHub."); 85 | let mut cached = self.cached.lock().unwrap(); 86 | *cached = Some(body.clone()); 87 | 88 | // Deserialize and return the value. 89 | Ok(serde_json::from_str(&body[..])?) 90 | } 91 | } 92 | 93 | impl GitHubCredentials { 94 | fn authenticate(&self, builder: &mut HttpRequestBuilder) { 95 | match self { 96 | GitHubCredentials::Basic { username, password } => { 97 | builder.basic_auth(username, Some(password)); 98 | } 99 | } 100 | } 101 | } 102 | 103 | #[derive(Deserialize, Debug)] 104 | pub struct GitHubResponse { 105 | pub total_count: u16, 106 | pub workflow_runs: Vec, 107 | } 108 | 109 | #[derive(Deserialize, Debug)] 110 | pub struct GitHubWorkflowRun { 111 | pub id: u64, 112 | #[serde(alias = "head_branch")] 113 | pub branch: String, 114 | #[serde(alias = "run_number")] 115 | pub number: u64, 116 | pub status: String, 117 | pub conclusion: Option, 118 | pub html_url: String, 119 | pub created_at: String, 120 | pub updated_at: String, 121 | } 122 | 123 | impl GitHubWorkflowRun { 124 | pub fn get_status(&self) -> DuckResult { 125 | match &self.status[..] { 126 | "completed" => match &self.conclusion { 127 | None => Err(format_err!("Build is completed without a conclusion")), 128 | Some(conclusion) => match &conclusion[..] { 129 | "success" => Ok(BuildStatus::Success), 130 | "cancelled" => Ok(BuildStatus::Canceled), 131 | "failure" => Ok(BuildStatus::Failed), 132 | "skipped" => Ok(BuildStatus::Skipped), 133 | _ => Ok(BuildStatus::Failed), 134 | }, 135 | }, 136 | "queued" => Ok(BuildStatus::Queued), 137 | "in_progress" => Ok(BuildStatus::Running), 138 | status => Err(format_err!("Unknown build status '{}'", status)), 139 | } 140 | } 141 | 142 | pub fn get_started_timestamp(&self) -> DuckResult { 143 | let result = date::to_timestamp(&self.created_at, date::GITHUB_FORMAT)?; 144 | Ok(result) 145 | } 146 | 147 | pub fn get_finished_timestamp(&self) -> DuckResult> { 148 | if self.status == "completed" { 149 | let result = date::to_timestamp(&self.updated_at, date::GITHUB_FORMAT)?; 150 | Ok(Some(result)) 151 | } else { 152 | Ok(None) 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/query/parser.rs: -------------------------------------------------------------------------------- 1 | use crate::query::lexer::{Token, TokenStream}; 2 | use crate::query::{Constant, Expression, Property}; 3 | use crate::DuckResult; 4 | 5 | pub fn parse(stream: &mut TokenStream) -> DuckResult { 6 | parse_or(stream) 7 | } 8 | 9 | fn parse_or(stream: &mut TokenStream) -> DuckResult { 10 | if stream.current().is_none() { 11 | return Err(format_err!("Unexpected end of token stream")); 12 | } 13 | 14 | let mut expression = parse_and(stream)?; 15 | while let Some(token) = stream.current() { 16 | if token == &Token::Or { 17 | stream.move_next(); 18 | expression = Expression::Or(Box::new(expression), Box::new(parse_and(stream)?)); 19 | } else { 20 | break; 21 | } 22 | } 23 | 24 | Ok(expression) 25 | } 26 | 27 | fn parse_and(stream: &mut TokenStream) -> DuckResult { 28 | if stream.current().is_none() { 29 | return Err(format_err!("Unexpected end of token stream")); 30 | } 31 | 32 | let mut expression = parse_predicate(stream)?; 33 | while let Some(token) = stream.current() { 34 | if token == &Token::And { 35 | stream.move_next(); 36 | expression = Expression::And(Box::new(expression), Box::new(parse_predicate(stream)?)); 37 | } else { 38 | break; 39 | } 40 | } 41 | 42 | Ok(expression) 43 | } 44 | 45 | fn parse_predicate(stream: &mut TokenStream) -> DuckResult { 46 | if stream.current().is_none() { 47 | return Err(format_err!("Unexpected end of token stream")); 48 | } 49 | 50 | if let Some(token) = stream.current() { 51 | if token == &Token::Not { 52 | stream.move_next(); 53 | return Ok(Expression::Not(Box::new(parse_predicate(stream)?))); 54 | } 55 | } 56 | 57 | parse_relation(stream) 58 | } 59 | 60 | fn parse_relation(stream: &mut TokenStream) -> DuckResult { 61 | if stream.current().is_none() { 62 | return Err(format_err!("Unexpected end of token stream")); 63 | } 64 | 65 | let expression = parse_literal(stream)?; 66 | stream.move_next(); 67 | 68 | if let Some(token) = stream.current() { 69 | if let Some(op) = token.get_operator() { 70 | stream.move_next(); 71 | 72 | let left = expression; 73 | let right = parse_literal(stream)?; 74 | stream.move_next(); 75 | 76 | return Ok(Expression::Relational(Box::new(left), Box::new(right), op)); 77 | } 78 | } 79 | 80 | Ok(expression) 81 | } 82 | 83 | fn parse_literal(stream: &mut TokenStream) -> DuckResult { 84 | match stream.current() { 85 | None => Err(format_err!("Unexpected end of token stream")), 86 | Some(token) => match token { 87 | Token::Word(word) => match &word[..] { 88 | "branch" => Ok(Expression::Property(Property::Branch)), 89 | "status" => Ok(Expression::Property(Property::Status)), 90 | "project" => Ok(Expression::Property(Property::Project)), 91 | "definition" => Ok(Expression::Property(Property::Definition)), 92 | "build" => Ok(Expression::Property(Property::Build)), 93 | "collector" => Ok(Expression::Property(Property::Collector)), 94 | "provider" => Ok(Expression::Property(Property::Provider)), 95 | _ => Err(format_err!("Unknown property '{}'", word)), 96 | }, 97 | Token::Literal(literal) => Ok(Expression::Constant(Constant::String(literal.clone()))), 98 | Token::Integer(number) => Ok(Expression::Constant(Constant::Integer(*number))), 99 | Token::Status(status) => Ok(Expression::Constant(Constant::Status(status.clone()))), 100 | Token::True => Ok(Expression::Constant(Constant::Boolean(true))), 101 | Token::False => Ok(Expression::Constant(Constant::Boolean(false))), 102 | Token::LParen => parse_scope(stream), 103 | _ => Err(format_err!("Could not parse literal expression")), 104 | }, 105 | } 106 | } 107 | 108 | fn parse_scope(stream: &mut TokenStream) -> DuckResult { 109 | stream.consume(Token::LParen)?; 110 | let expression = parse(stream)?; 111 | Ok(Expression::Scope(Box::new(expression))) 112 | } 113 | 114 | /////////////////////////////////////////////////////////// 115 | // Tests 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::*; 120 | use crate::builds::BuildStatus; 121 | use crate::query::lexer; 122 | use crate::query::Operator; 123 | 124 | #[test] 125 | fn should_parse_expression() { 126 | // Given 127 | let query = "branch == 'master' || (branch != 'master' and status != 'skipped'"; 128 | let tokens = &mut lexer::tokenize(&query[..]).unwrap(); 129 | 130 | // When 131 | let expression = parse(tokens).unwrap(); 132 | 133 | // Then 134 | assert_eq!( 135 | expression, 136 | Expression::Or( 137 | Box::new(Expression::Relational( 138 | Box::new(Expression::Property(Property::Branch)), 139 | Box::new(Expression::Constant(Constant::String("master".to_owned()))), 140 | Operator::EqualTo 141 | )), 142 | Box::new(Expression::Scope(Box::new(Expression::And( 143 | Box::new(Expression::Relational( 144 | Box::new(Expression::Property(Property::Branch)), 145 | Box::new(Expression::Constant(Constant::String("master".to_owned()))), 146 | Operator::NotEqualTo 147 | )), 148 | Box::new(Expression::Relational( 149 | Box::new(Expression::Property(Property::Status)), 150 | Box::new(Expression::Constant(Constant::Status(BuildStatus::Skipped))), 151 | Operator::NotEqualTo 152 | )) 153 | )))) 154 | ) 155 | ) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/builds.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::DefaultHasher; 2 | use std::fmt::Display; 3 | use std::hash::{Hash, Hasher}; 4 | 5 | #[derive(Clone, Builder, Debug, PartialEq, Eq)] 6 | #[builder(field(private), build_fn(skip), setter(into), pattern = "immutable")] // TODO: Should not be immutable 7 | pub struct Build { 8 | #[builder(setter(skip))] 9 | pub id: u64, 10 | #[builder(setter(skip))] 11 | pub partition: u64, 12 | pub origin: String, 13 | pub build_id: String, 14 | pub provider: String, 15 | pub collector: String, 16 | pub project_id: String, 17 | pub project_name: String, 18 | pub definition_id: String, 19 | pub definition_name: String, 20 | pub build_number: String, 21 | pub status: BuildStatus, 22 | pub branch: String, 23 | pub url: String, 24 | pub started_at: i64, 25 | pub finished_at: Option, 26 | } 27 | 28 | impl BuildBuilder { 29 | pub fn new() -> Self { 30 | Default::default() 31 | } 32 | 33 | #[cfg(test)] 34 | pub fn dummy() -> Self { 35 | BuildBuilder::new() 36 | .build_id("foo") 37 | .provider("TeamCity") 38 | .origin("origin") 39 | .collector("collector") 40 | .project_id("project_id") 41 | .project_name("project_name") 42 | .definition_id("definition_id") 43 | .definition_name("definition_name") 44 | .build_number("build_number") 45 | .status(BuildStatus::Success) 46 | .branch("branch") 47 | .url("https://dummy") 48 | .started_at(1578819921) 49 | .finished_at(Some(1578820921)) 50 | } 51 | 52 | pub fn build(&self) -> Result { 53 | let build_id = Clone::clone(self.build_id.as_ref().ok_or("Build ID is missing")?); 54 | let provider = Clone::clone(self.provider.as_ref().ok_or("Build provider is missing")?); 55 | let origin = Clone::clone(self.origin.as_ref().ok_or("Origin is missing")?); 56 | let collector = Clone::clone(self.collector.as_ref().ok_or("Collector is missing")?); 57 | let project_id = Clone::clone(self.project_id.as_ref().ok_or("Project ID is missing")?); 58 | let project_name = Clone::clone( 59 | self.project_name 60 | .as_ref() 61 | .ok_or("Project Name is missing")?, 62 | ); 63 | let definition_id = Clone::clone( 64 | self.definition_id 65 | .as_ref() 66 | .ok_or("Definition ID is missing")?, 67 | ); 68 | let definition_name = Clone::clone( 69 | self.definition_name 70 | .as_ref() 71 | .ok_or("Definition name is missing")?, 72 | ); 73 | let build_number = Clone::clone( 74 | self.build_number 75 | .as_ref() 76 | .ok_or("Build number is missing")?, 77 | ); 78 | let status = Clone::clone(self.status.as_ref().ok_or("Build status is missing")?); 79 | let branch = Clone::clone(self.branch.as_ref().ok_or("Branch is missing")?); 80 | let url = Clone::clone(self.url.as_ref().ok_or("Url is missing")?); 81 | let started_at = Clone::clone(self.started_at.as_ref().ok_or("Start time is missing")?); 82 | let finished_at = Clone::clone(self.finished_at.as_ref().ok_or("Finish time is missing")?); 83 | 84 | // Generate a hash that represents the build. 85 | let mut hasher = DefaultHasher::new(); 86 | provider.hash(&mut hasher); 87 | origin.hash(&mut hasher); 88 | project_id.hash(&mut hasher); 89 | definition_id.hash(&mut hasher); 90 | branch.hash(&mut hasher); 91 | build_id.hash(&mut hasher); 92 | let id = hasher.finish(); 93 | 94 | // Generate a hash that represents the build 95 | // definition (partition) of the build, not the build itself. 96 | let mut hasher = DefaultHasher::new(); 97 | provider.hash(&mut hasher); 98 | origin.hash(&mut hasher); 99 | project_id.hash(&mut hasher); 100 | definition_id.hash(&mut hasher); 101 | branch.hash(&mut hasher); 102 | let partition = hasher.finish(); 103 | 104 | Ok(Build { 105 | id, 106 | partition, 107 | build_id, 108 | provider, 109 | origin, 110 | collector, 111 | project_id, 112 | project_name, 113 | definition_id, 114 | definition_name, 115 | build_number, 116 | status, 117 | branch, 118 | url, 119 | started_at, 120 | finished_at, 121 | }) 122 | } 123 | 124 | #[cfg(test)] 125 | pub fn unwrap(&self) -> Build { 126 | self.build().unwrap() 127 | } 128 | } 129 | 130 | #[derive(Clone, Debug, PartialEq, Eq)] 131 | pub enum BuildStatus { 132 | Unknown, 133 | Success, 134 | Failed, 135 | Running, 136 | Canceled, 137 | Queued, 138 | Skipped, 139 | } 140 | 141 | impl Display for BuildStatus { 142 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 143 | match self { 144 | BuildStatus::Unknown => write!(f, "Unknown"), 145 | BuildStatus::Success => write!(f, "Success"), 146 | BuildStatus::Failed => write!(f, "Failed"), 147 | BuildStatus::Running => write!(f, "Running"), 148 | BuildStatus::Canceled => write!(f, "Canceled"), 149 | BuildStatus::Queued => write!(f, "Queued"), 150 | BuildStatus::Skipped => write!(f, "Skipped"), 151 | } 152 | } 153 | } 154 | 155 | impl BuildStatus { 156 | pub fn is_absolute(&self) -> bool { 157 | match self { 158 | BuildStatus::Unknown => false, 159 | BuildStatus::Success => true, 160 | BuildStatus::Failed => true, 161 | BuildStatus::Running => false, 162 | BuildStatus::Canceled => false, 163 | BuildStatus::Queued => false, 164 | BuildStatus::Skipped => false, 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/commands/service.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | use std::sync::mpsc; 3 | use std::thread; 4 | use std::time::Duration; 5 | 6 | use futures::executor::block_on; 7 | use log::error; 8 | use windows_service::service::{ 9 | ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, 10 | ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, 11 | }; 12 | use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; 13 | use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; 14 | use windows_service::{define_windows_service, service_dispatcher}; 15 | 16 | use duck::DuckResult; 17 | 18 | /////////////////////////////////////////////////////////// 19 | // Constants 20 | 21 | const SERVICE_EXECUTABLE: &str = "duck.exe"; 22 | const SERVICE_NAME: &str = "Duck Service"; 23 | const SERVICE_DISPLAY_NAME: &str = "Duck Service"; 24 | const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; 25 | 26 | /////////////////////////////////////////////////////////// 27 | // Installation 28 | 29 | pub fn install() -> DuckResult<()> { 30 | let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE; 31 | let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; 32 | 33 | let service_info = ServiceInfo { 34 | name: OsString::from(SERVICE_NAME), 35 | display_name: OsString::from(SERVICE_DISPLAY_NAME), 36 | service_type: ServiceType::OWN_PROCESS, 37 | start_type: ServiceStartType::OnDemand, 38 | error_control: ServiceErrorControl::Normal, 39 | executable_path: std::env::current_exe()?.with_file_name(SERVICE_EXECUTABLE), 40 | launch_arguments: vec![OsString::from("-f"), OsString::from("service")], 41 | dependencies: vec![], 42 | account_name: None, // run as System 43 | account_password: None, 44 | }; 45 | 46 | service_manager.create_service(&service_info, ServiceAccess::empty())?; 47 | 48 | Ok(()) 49 | } 50 | 51 | /////////////////////////////////////////////////////////// 52 | // Uninstallation 53 | 54 | pub fn uninstall() -> DuckResult<()> { 55 | let manager_access = ServiceManagerAccess::CONNECT; 56 | let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; 57 | 58 | let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE; 59 | let service = service_manager.open_service(SERVICE_NAME, service_access)?; 60 | 61 | let service_status = service.query_status()?; 62 | if service_status.current_state != ServiceState::Stopped { 63 | service.stop()?; 64 | // Wait for service to stop 65 | thread::sleep(Duration::from_secs(1)); 66 | } 67 | 68 | service.delete()?; 69 | 70 | Ok(()) 71 | } 72 | 73 | /////////////////////////////////////////////////////////// 74 | // Running 75 | 76 | pub fn start() -> DuckResult<()> { 77 | service_dispatcher::start(SERVICE_NAME, ffi_service_main)?; 78 | Ok(()) 79 | } 80 | 81 | // Generate the windows service boilerplate. 82 | // The boilerplate contains the low-level service entry function (ffi_service_main) that parses 83 | // incoming service arguments into Vec and passes them to user defined service 84 | // entry (duck_service_main). 85 | define_windows_service!(ffi_service_main, duck_service_main); 86 | 87 | pub fn duck_service_main(_arguments: Vec) { 88 | if let Err(e) = run_service() { 89 | error!( 90 | "An error occured while running Duck as a Windows service: {}", 91 | e 92 | ) 93 | } 94 | } 95 | 96 | pub fn run_service() -> DuckResult<()> { 97 | // Create a channel to be able to poll a stop event from the service worker loop. 98 | let (shutdown_tx, shutdown_rx) = mpsc::channel(); 99 | 100 | // Define system service event handler that will be receiving service events. 101 | let event_handler = move |control_event| -> ServiceControlHandlerResult { 102 | match control_event { 103 | // Notifies a service to report its current status information to the service 104 | // control manager. Always return NoError even if not implemented. 105 | ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, 106 | 107 | // Handle stop 108 | ServiceControl::Stop => { 109 | shutdown_tx.send(()).unwrap(); 110 | ServiceControlHandlerResult::NoError 111 | } 112 | 113 | _ => ServiceControlHandlerResult::NotImplemented, 114 | } 115 | }; 116 | 117 | // Register system service event handler. 118 | // The returned status handle should be used to report service status changes to the system. 119 | let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; 120 | 121 | // Tell the system that service is running 122 | status_handle.set_service_status(ServiceStatus { 123 | service_type: SERVICE_TYPE, 124 | current_state: ServiceState::Running, 125 | controls_accepted: ServiceControlAccept::STOP, 126 | exit_code: ServiceExitCode::Win32(0), 127 | checkpoint: 0, 128 | wait_hint: Duration::from_secs(10), 129 | })?; 130 | 131 | // Start Duck server. 132 | let configuration = std::env::current_exe()?.with_file_name("config.json"); 133 | let handle = duck::run(configuration, None)?; 134 | 135 | // Wait for exit 136 | loop { 137 | // Poll shutdown event. 138 | match shutdown_rx.recv_timeout(Duration::from_secs(1)) { 139 | Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => { 140 | block_on(handle.stop())?; 141 | break; 142 | } 143 | Err(mpsc::RecvTimeoutError::Timeout) => (), 144 | }; 145 | } 146 | 147 | // Tell the system that service has stopped. 148 | status_handle.set_service_status(ServiceStatus { 149 | service_type: SERVICE_TYPE, 150 | current_state: ServiceState::Stopped, 151 | controls_accepted: ServiceControlAccept::empty(), 152 | exit_code: ServiceExitCode::Win32(0), 153 | checkpoint: 0, 154 | wait_hint: Duration::from_secs(10), 155 | })?; 156 | 157 | Ok(()) 158 | } 159 | -------------------------------------------------------------------------------- /src/providers/observers/hue/mod.rs: -------------------------------------------------------------------------------- 1 | use log::debug; 2 | 3 | use crate::config::HueConfiguration; 4 | use crate::filters::BuildFilter; 5 | use crate::providers::observers::{Observation, Observer, ObserverInfo, ObserverLoader}; 6 | use crate::utils::http::{HttpClient, ReqwestClient}; 7 | use crate::DuckResult; 8 | 9 | use self::client::HueClient; 10 | 11 | mod client; 12 | mod validation; 13 | 14 | impl ObserverLoader for HueConfiguration { 15 | fn load(&self) -> DuckResult> { 16 | Ok(Box::new(HueObserver::::new(self)?)) 17 | } 18 | } 19 | 20 | pub struct HueObserver { 21 | client: HueClient, 22 | http: T, 23 | info: ObserverInfo, 24 | } 25 | 26 | impl HueObserver { 27 | pub fn new(config: &HueConfiguration) -> DuckResult { 28 | Ok(HueObserver { 29 | client: HueClient::new(config), 30 | http: Default::default(), 31 | info: ObserverInfo { 32 | id: config.id.clone(), 33 | enabled: config.enabled.unwrap_or(true), 34 | filter: BuildFilter::new(config.filter.clone())?, 35 | collectors: match &config.collectors { 36 | Option::None => Option::None, 37 | Option::Some(collectors) => Some(collectors.iter().cloned().collect()), 38 | }, 39 | }, 40 | }) 41 | } 42 | 43 | #[cfg(test)] 44 | pub fn get_client(&self) -> &T { 45 | &self.http 46 | } 47 | } 48 | 49 | impl Observer for HueObserver { 50 | fn info(&self) -> &ObserverInfo { 51 | &self.info 52 | } 53 | 54 | fn observe(&self, observation: Observation) -> DuckResult<()> { 55 | match observation { 56 | Observation::DuckStatusChanged(status) => { 57 | debug!("[{}] Setting light state to '{}'...", self.info.id, status); 58 | self.client.set_state(&self.http, status)?; 59 | } 60 | Observation::ShuttingDown => { 61 | debug!("[{}] Turning off all lights...", self.info.id); 62 | self.client.turn_off(&self.http)?; 63 | } 64 | _ => {} 65 | } 66 | Ok(()) 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use super::*; 73 | use crate::builds::BuildStatus; 74 | use crate::utils::http::{HttpMethod, MockHttpClient, MockHttpResponseBuilder}; 75 | use reqwest::StatusCode; 76 | use test_case::test_case; 77 | 78 | #[test] 79 | fn should_post_to_correct_url() { 80 | // Given 81 | let hue = HueObserver::::new(&HueConfiguration { 82 | id: "hue".to_string(), 83 | enabled: Some(true), 84 | brightness: Some(255), 85 | collectors: None, 86 | filter: None, 87 | hub_url: "https://example.com".to_string(), 88 | username: "patrik".to_string(), 89 | lights: vec!["foo".to_string()], 90 | }) 91 | .unwrap(); 92 | 93 | let client = hue.get_client(); 94 | client.add_response( 95 | MockHttpResponseBuilder::new( 96 | HttpMethod::Put, 97 | "https://example.com/api/patrik/lights/foo/state", 98 | ) 99 | .returns_status(StatusCode::OK), 100 | ); 101 | 102 | // When 103 | hue.observe(Observation::DuckStatusChanged(BuildStatus::Success)) 104 | .unwrap(); 105 | 106 | // Then 107 | let requests = client.get_sent_requests(); 108 | assert_eq!(1, requests.len()); 109 | assert_eq!(HttpMethod::Put, requests[0].method); 110 | assert_eq!( 111 | "https://example.com/api/patrik/lights/foo/state", 112 | &requests[0].url 113 | ); 114 | } 115 | 116 | #[test_case(BuildStatus::Success, "{\"alert\":\"none\",\"xy\":[0.32114217,0.59787315],\"on\":true,\"bri\":255}" ; "Success")] 117 | #[test_case(BuildStatus::Failed, "{\"alert\":\"select\",\"xy\":[0.64842725,0.3308561],\"on\":true,\"bri\":255}" ; "Failed")] 118 | #[test_case(BuildStatus::Running, "{\"alert\":\"none\",\"xy\":[0.29151475,0.33772817],\"on\":true,\"bri\":255}" ; "Running")] 119 | fn should_send_correct_payload(status: BuildStatus, expected: &str) { 120 | // Given 121 | let hue = HueObserver::::new(&HueConfiguration { 122 | id: "hue".to_string(), 123 | enabled: Some(true), 124 | brightness: Some(255), 125 | collectors: None, 126 | filter: None, 127 | hub_url: "https://example.com".to_string(), 128 | username: "patrik".to_string(), 129 | lights: vec!["foo".to_string()], 130 | }) 131 | .unwrap(); 132 | 133 | let client = hue.get_client(); 134 | client.add_response( 135 | MockHttpResponseBuilder::new( 136 | HttpMethod::Put, 137 | "https://example.com/api/patrik/lights/foo/state", 138 | ) 139 | .returns_status(StatusCode::OK), 140 | ); 141 | 142 | // When 143 | hue.observe(Observation::DuckStatusChanged(status)).unwrap(); 144 | 145 | // Then 146 | let requests = client.get_sent_requests(); 147 | assert_eq!(1, requests.len()); 148 | assert!(&requests[0].body.is_some()); 149 | assert_eq!(expected, &requests[0].body.clone().unwrap()); 150 | } 151 | 152 | #[test] 153 | #[should_panic(expected = "Could not update state for light \\'foo\\' (502 Bad Gateway)")] 154 | fn should_return_error_if_server_return_non_successful_http_status_code() { 155 | // Given 156 | let hue = HueObserver::::new(&HueConfiguration { 157 | id: "hue".to_string(), 158 | enabled: Some(true), 159 | brightness: Some(255), 160 | collectors: None, 161 | filter: None, 162 | hub_url: "https://example.com".to_string(), 163 | username: "patrik".to_string(), 164 | lights: vec!["foo".to_string()], 165 | }) 166 | .unwrap(); 167 | 168 | let client = hue.get_client(); 169 | client.add_response( 170 | MockHttpResponseBuilder::new( 171 | HttpMethod::Put, 172 | "https://example.com/api/patrik/lights/foo/state", 173 | ) 174 | .returns_status(StatusCode::BAD_GATEWAY), 175 | ); 176 | 177 | // When, Then 178 | hue.observe(Observation::DuckStatusChanged(BuildStatus::Success)) 179 | .unwrap(); 180 | } 181 | } 182 | --------------------------------------------------------------------------------