├── 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 |
2 |
3 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/web/src/components/ServerInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Version:
5 | {{ version }}
6 |
7 |
8 | Server:
9 | {{ server }}
10 |
11 |
12 | Started:
13 | {{ started | moment("from", "now") }}
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/web/src/Error.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Could not connect to Duck server
9 |
10 |
11 |
{{ this.getMessage() }}
12 |
Trying to reconnect...
13 |
14 |
15 |
16 |
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 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
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 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------
/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 |
2 |
3 |
4 |
5 | -
6 |
16 |
17 |
18 | -
22 |
31 |
32 |
33 |
34 |
35 |
36 | No views available
37 |
38 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
20 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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