├── CODEOWNERS
├── frontend
├── src
│ ├── actions
│ │ ├── phase.js
│ │ ├── index.js
│ │ ├── self.js
│ │ ├── search.js
│ │ ├── token.js
│ │ └── admin.js
│ ├── types
│ │ ├── train.js
│ │ ├── config.js
│ │ ├── actions.js
│ │ └── proptypes.js
│ ├── reducers
│ │ ├── phase.js
│ │ ├── self.js
│ │ ├── search.js
│ │ ├── token.js
│ │ ├── admin.js
│ │ └── index.js
│ ├── components
│ │ ├── Root.jsx
│ │ ├── Error.jsx
│ │ ├── DevTools.jsx
│ │ ├── Routes.jsx
│ │ ├── Loading.jsx
│ │ ├── Root.prod.jsx
│ │ ├── TrainComponent.jsx
│ │ ├── Card.jsx
│ │ ├── Root.dev.jsx
│ │ ├── Details.spec.jsx
│ │ ├── TitledList.jsx
│ │ ├── Auth.jsx
│ │ ├── App.jsx
│ │ ├── Commits.spec.jsx
│ │ ├── Train.jsx
│ │ ├── TrainHeader.jsx
│ │ ├── Commits.jsx
│ │ ├── Header.jsx
│ │ ├── Details.jsx
│ │ ├── Search.jsx
│ │ └── ApiButton.jsx
│ ├── index.jsx
│ ├── containers
│ │ ├── Commits.js
│ │ ├── App.js
│ │ ├── Train.js
│ │ ├── Search.js
│ │ ├── TrainHeader.js
│ │ ├── Header.js
│ │ ├── Phases.js
│ │ ├── Admin.js
│ │ ├── Details.js
│ │ └── Summary.js
│ └── test
│ │ └── TestData.js
├── README.md
├── Makefile
├── package.json
├── webpack.config.js
└── .eslintrc
├── ci:cd.png
├── .dockerignore
├── resources
├── frontend
│ ├── images
│ │ ├── fail.png
│ │ ├── link.png
│ │ ├── pass.png
│ │ ├── favicon.ico
│ │ ├── nextdoor.png
│ │ ├── arrow-left.png
│ │ ├── arrow-right.png
│ │ ├── arrow-left-disabled.png
│ │ └── arrow-right-disabled.png
│ └── index.html
├── decrypt_secrets.sh
├── entrypoint.sh
└── nginx-mac.conf
├── .gitignore
├── swagger
├── README.md
└── config.json
├── services
├── build
│ ├── build.go
│ └── jenkins.go
├── phase
│ ├── phase_mock.go
│ ├── jenkins.go
│ ├── job.go
│ └── job_test.go
├── messaging
│ ├── slack_test.go
│ ├── engine_mock.go
│ ├── messaging_mock.go
│ └── slack.go
├── data
│ ├── postgres.go
│ └── data.go
├── ticket
│ ├── ticket_test.go
│ ├── ticket_mock.go
│ └── ticket.go
├── code
│ ├── code_mock.go
│ ├── code.go
│ └── github.go
└── auth
│ ├── github.go
│ └── auth.go
├── core
├── user.go
├── server.go
├── panic_recovery_test.go
├── code.go
├── phase_integration_test.go
├── search.go
├── panic_recovery.go
├── testutils_test.go
├── background.go
├── ticket_test.go
├── metadata.go
├── ticket.go
├── core.go
├── auth.go
└── endpoints_test.go
├── checkOAuthEnv.sh
├── etc
├── configure-repository.sh
└── githooks
│ └── pre-commit
├── cmd
└── conductor
│ └── conductor.go
├── setup.sh
├── shared
├── github
│ ├── github.go
│ └── auth.go
├── datadog
│ ├── datadog_test.go
│ └── datadog.go
├── logger
│ ├── logger_test.go
│ └── logger.go
├── settings
│ ├── settings_test.go
│ └── settings.go
├── types
│ ├── fields.go
│ ├── enums.go
│ └── models_test.go
└── flags
│ ├── flags.go
│ └── flags_test.go
├── Dockerfile
├── .circleci
└── config.yml
├── go.mod
├── dockerSetup.sh
├── test.sh
└── Makefile
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @eshaverma @RKTMN @masterginger
2 |
--------------------------------------------------------------------------------
/frontend/src/actions/phase.js:
--------------------------------------------------------------------------------
1 | // TODO: Phase restart from frontend?
2 |
--------------------------------------------------------------------------------
/ci:cd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/ci:cd.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore everything except what the Docker container needs.
2 | *
3 | !.build
4 | !resources
5 | !swagger
6 |
--------------------------------------------------------------------------------
/frontend/src/types/train.js:
--------------------------------------------------------------------------------
1 | export const Phases = {
2 | Delivery: 0,
3 | Verification: 1,
4 | Deploy: 2,
5 | };
6 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | Frontend Content for Conductor
2 |
3 | Uses Webpack and React.js
4 |
5 | Built into resources/frontend.
6 |
--------------------------------------------------------------------------------
/frontend/src/reducers/phase.js:
--------------------------------------------------------------------------------
1 | const phase = (state = null) => {
2 | return state;
3 | };
4 |
5 | export default phase;
6 |
--------------------------------------------------------------------------------
/resources/frontend/images/fail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/fail.png
--------------------------------------------------------------------------------
/resources/frontend/images/link.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/link.png
--------------------------------------------------------------------------------
/resources/frontend/images/pass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/pass.png
--------------------------------------------------------------------------------
/resources/frontend/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 | /vendor
4 | resources/frontend/gen
5 | frontend/node_modules
6 | envfile
7 | testenv
8 | .build
9 |
--------------------------------------------------------------------------------
/resources/frontend/images/nextdoor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/nextdoor.png
--------------------------------------------------------------------------------
/resources/frontend/images/arrow-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/arrow-left.png
--------------------------------------------------------------------------------
/resources/frontend/images/arrow-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/arrow-right.png
--------------------------------------------------------------------------------
/resources/frontend/images/arrow-left-disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/arrow-left-disabled.png
--------------------------------------------------------------------------------
/resources/frontend/images/arrow-right-disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nextdoor/conductor-open/HEAD/resources/frontend/images/arrow-right-disabled.png
--------------------------------------------------------------------------------
/frontend/src/components/Root.jsx:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV === 'production') {
2 | module.exports = require('./Root.prod');
3 | } else {
4 | module.exports = require('./Root.dev');
5 | }
6 |
--------------------------------------------------------------------------------
/swagger/README.md:
--------------------------------------------------------------------------------
1 | # Swagger API Docs
2 |
3 | This specification defines the HTTP REST API contract.
4 |
5 | To view this in an interactive format, run `conductor` and go to /api/help.
6 |
7 | Alternately, visit http://editor.swagger.io/ and import the `swagger.yml` file.
8 |
--------------------------------------------------------------------------------
/services/build/build.go:
--------------------------------------------------------------------------------
1 | /* Handles building jobs remotely (like Jenkins). */
2 | package build
3 |
4 | type Service interface {
5 | CancelJob(jobName string, jobURL string, params map[string]string) error
6 | TriggerJob(jobName string, params map[string]string) error
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/index.jsx:
--------------------------------------------------------------------------------
1 | import '../style.scss';
2 | import 'typeface-source-sans-pro';
3 |
4 | import React from 'react';
5 | import ReactDOM from 'react-dom';
6 |
7 | import Root from 'components/Root';
8 |
9 | ReactDOM.render(, document.getElementById('app'));
10 |
--------------------------------------------------------------------------------
/core/user.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import "net/http"
4 |
5 | func userEndpoints() []endpoint {
6 | return []endpoint{
7 | newEp("/api/user", get, currentUser),
8 | }
9 | }
10 |
11 | func currentUser(r *http.Request) response {
12 | return dataResponse(r.Context().Value("user"))
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/components/Error.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Error = ({message}) => {
5 | return (
6 |
Error: {message}
7 | );
8 | };
9 |
10 | Error.propTypes = {
11 | message: PropTypes.string.isRequired
12 | };
13 |
14 | export default Error;
15 |
--------------------------------------------------------------------------------
/frontend/src/containers/Commits.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Component from 'components/Commits';
4 |
5 | const mapStateToProps = (state) => {
6 | return {
7 | train: state.train.details,
8 | request: state.train.request
9 | };
10 | };
11 |
12 | export default connect(
13 | mapStateToProps
14 | )(Component);
15 |
--------------------------------------------------------------------------------
/checkOAuthEnv.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | if [[ -z "${CONDUCTOR_OAUTH_CLIENT_ID}" ]]; then
4 | echo "Please go to https://github.com/settings/developers, and create a new OAuth app for conductor then set CONDUCTOR_OAUTH_CLIENT_ID env variable."
5 | echo "For example, you can add export CONDUCTOR_OAUTH_CLIENT_ID=your_client_id to ~/.profile"
6 | exit 1
7 | fi
8 |
--------------------------------------------------------------------------------
/frontend/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import Admin from 'actions/admin';
2 | import Phase from 'actions/phase';
3 | import Search from 'actions/search';
4 | import Self from 'actions/self';
5 | import Token from 'actions/token';
6 | import Train from 'actions/train';
7 |
8 | export default {
9 | Admin,
10 | Phase,
11 | Search,
12 | Self,
13 | Token,
14 | Train,
15 | };
16 |
--------------------------------------------------------------------------------
/resources/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Conductor
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/components/DevTools.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {createDevTools} from 'redux-devtools';
4 |
5 | import LogMonitor from 'redux-devtools-log-monitor';
6 | import DockMonitor from 'redux-devtools-dock-monitor';
7 |
8 | const DevTools = createDevTools(
9 |
13 |
14 |
15 | );
16 |
17 | export default DevTools;
18 |
--------------------------------------------------------------------------------
/swagger/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "input": "swagger/swagger.yml",
3 | "output": "swagger/index.html",
4 | "format": "singlefile",
5 | "markdown": true,
6 | "theme": {
7 | "default": "light-blue",
8 | "GET": "light-blue",
9 | "POST": "orange"
10 | },
11 | "fixedNav": true,
12 | "autoTags": false,
13 | "collapse": {
14 | "path": true,
15 | "method": true,
16 | "tool": true
17 | },
18 | "home": {
19 | "URL": "https://github.com/Nextdoor/conductor",
20 | "location": "RR",
21 | "text": "GitHub"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/components/Routes.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Router, Route, browserHistory} from 'react-router';
3 |
4 | import App from 'containers/App';
5 |
6 | export default class Routes extends Component {
7 | render() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/containers/App.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/App';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | needToken: state.token.token === null,
9 | promptLogin: state.token.promptLogin
10 | };
11 | };
12 |
13 | const mapDispatchToProps = (dispatch) => {
14 | return {
15 | getToken: () => {
16 | dispatch(Actions.Token.get());
17 | }
18 | };
19 | };
20 |
21 | export default connect(
22 | mapStateToProps,
23 | mapDispatchToProps
24 | )(Component);
25 |
--------------------------------------------------------------------------------
/frontend/src/containers/Train.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Train';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | train: state.train.details,
9 | request: state.train.request
10 | };
11 | };
12 |
13 | const mapDispatchToProps = (dispatch) => {
14 | return {
15 | load: (trainId) => {
16 | dispatch(Actions.Train.fetch(trainId));
17 | }
18 | };
19 | };
20 |
21 | export default connect(
22 | mapStateToProps,
23 | mapDispatchToProps
24 | )(Component);
25 |
--------------------------------------------------------------------------------
/frontend/src/types/config.js:
--------------------------------------------------------------------------------
1 | export const Modes = {
2 | Schedule: 0,
3 | Manual: 1,
4 | };
5 |
6 | export function modeToString(mode) {
7 | switch (mode) {
8 | case Modes.Schedule:
9 | return 'schedule';
10 | case Modes.Manual:
11 | return 'manual';
12 | default:
13 | return null;
14 | }
15 | }
16 |
17 | export function stringToMode(modeString) {
18 | switch (modeString) {
19 | case 'schedule':
20 | return Modes.Schedule;
21 | case 'manual':
22 | return Modes.Manual;
23 | default:
24 | return null;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/containers/Search.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Search';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | details: state.search.details,
9 | request: state.search.request
10 | };
11 | };
12 |
13 | const mapDispatchToProps = (dispatch) => {
14 | return {
15 | search: (params) => {
16 | dispatch(Actions.Search.fetch(params));
17 | }
18 | };
19 | };
20 |
21 | export default connect(
22 | mapStateToProps,
23 | mapDispatchToProps
24 | )(Component);
25 |
--------------------------------------------------------------------------------
/frontend/src/containers/TrainHeader.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/TrainHeader';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | train: state.train.details,
9 | request: state.train.request
10 | };
11 | };
12 |
13 | const mapDispatchToProps = (dispatch) => {
14 | return {
15 | goToTrain: (trainId) => {
16 | dispatch(Actions.Train.goToTrain(trainId));
17 | }
18 | };
19 | };
20 |
21 | export default connect(
22 | mapStateToProps,
23 | mapDispatchToProps
24 | )(Component);
25 |
--------------------------------------------------------------------------------
/frontend/Makefile:
--------------------------------------------------------------------------------
1 | all: watch
2 |
3 | SHELL := /bin/bash
4 | ENVFILE = if [ -e envfile ]; then set -a; source envfile; fi;
5 | install:
6 | yarn install
7 |
8 | compile: install
9 | $(ENVFILE) ./node_modules/webpack/bin/webpack.js \
10 | --progress --colors --bail
11 |
12 | prod-compile: install
13 | $(ENVFILE) IS_PRODUCTION=true ./node_modules/webpack/bin/webpack.js \
14 | --progress --colors --bail
15 |
16 | watch: install
17 | $(ENVFILE) ./node_modules/webpack/bin/webpack.js \
18 | --progress --colors --watch --content-base ../resources/frontend/
19 |
20 | .PHONY: install compile prod-compile watch
21 |
--------------------------------------------------------------------------------
/etc/configure-repository.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | REPO_NAME='conductor'
4 |
5 | # Create symlinks under .git/hooks to hook scripts in the githooks
6 | # directory.
7 | hooks="pre-commit"
8 | git_hooks_dir=$(git rev-parse --git-dir)/hooks
9 | target_dir=../../etc/githooks
10 | for hook in $hooks; do
11 | ln -sf $target_dir/$hook $git_hooks_dir/$hook
12 | done
13 |
14 | # Configure remote tracking branches for rebase.
15 | git config branch.master.rebase true
16 | git config branch.autosetuprebase remote
17 |
18 | # Configure aliases.
19 | git config alias.pre-commit '!$(git rev-parse --git-dir)/hooks/pre-commit'
20 |
--------------------------------------------------------------------------------
/core/server.go:
--------------------------------------------------------------------------------
1 | /* Conductor server definition.
2 |
3 | */
4 | package core
5 |
6 | import (
7 | "net/http"
8 |
9 | "github.com/gorilla/mux"
10 | )
11 |
12 | func NewServer(endpoints []endpoint) *mux.Router {
13 | router := mux.NewRouter().StrictSlash(true)
14 |
15 | middlewares := []middleware{
16 | newPanicRecoveryMiddleware(),
17 | newAuthMiddleware(),
18 | }
19 |
20 | for _, ep := range endpoints {
21 | var handler http.Handler = ep
22 | for i := len(middlewares) - 1; i >= 0; i-- {
23 | handler = middlewares[i].Wrap(handler)
24 | }
25 | ep.Route(router, handler)
26 | }
27 |
28 | return router
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {SyncIcon} from 'react-octicons';
3 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
4 |
5 | const Loading = () => {
6 | return (
7 |
8 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default Loading;
21 |
--------------------------------------------------------------------------------
/frontend/src/components/Root.prod.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Provider} from 'react-redux';
3 | import {createStore, applyMiddleware} from 'redux';
4 | import thunkMiddleware from 'redux-thunk';
5 |
6 | import Routes from 'components/Routes';
7 | import reducer, {initialState} from 'reducers';
8 |
9 | const store = createStore(
10 | reducer,
11 | initialState,
12 | applyMiddleware(
13 | thunkMiddleware
14 | )
15 | );
16 |
17 | export default class Root extends Component {
18 | render() {
19 | return (
20 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/cmd/conductor/conductor.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "net/http"
7 | "os"
8 |
9 | "github.com/Nextdoor/conductor/core"
10 | "github.com/Nextdoor/conductor/shared/datadog"
11 | )
12 |
13 | func main() {
14 | flag.Parse()
15 |
16 | core.Preload()
17 |
18 | endpoints := core.Endpoints()
19 | server := core.NewServer(endpoints)
20 |
21 | address := ":8400"
22 | datadog.Info("Starting the Conductor server on %s ...", address)
23 | if e := http.ListenAndServe(address, server); e != nil {
24 | err := fmt.Errorf("Failed to start server on %s: %v", address, e)
25 | datadog.Error("Shutting down: %v", err)
26 | os.Exit(1)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/containers/Header.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Header';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | self: state.self.details,
9 | request: state.self.request,
10 | train: state.train.details
11 | };
12 | };
13 |
14 | const mapDispatchToProps = (dispatch) => {
15 | return {
16 | load: () => {
17 | dispatch(Actions.Self.fetch());
18 | },
19 | logout: () => {
20 | dispatch(Actions.Token.logout());
21 | }
22 | };
23 | };
24 |
25 | export default connect(
26 | mapStateToProps,
27 | mapDispatchToProps
28 | )(Component);
29 |
--------------------------------------------------------------------------------
/frontend/src/actions/self.js:
--------------------------------------------------------------------------------
1 | import Actions from 'types/actions';
2 | import API from 'api';
3 |
4 | const request = () => {
5 | return {
6 | type: Actions.RequestSelf
7 | };
8 | };
9 |
10 | const receive = (self) => {
11 | return {
12 | type: Actions.ReceiveSelf,
13 | self: self,
14 | receivedAt: Date.now()
15 | };
16 | };
17 |
18 | const receiveError = (error) => {
19 | return {
20 | type: Actions.ReceiveSelfError,
21 | error: error,
22 | receivedAt: Date.now()
23 | };
24 | };
25 |
26 | const fetch = () => (dispatch) => {
27 | API.getSelf(dispatch);
28 | };
29 |
30 | export default {
31 | fetch,
32 | request,
33 | receive,
34 | receiveError
35 | };
36 |
--------------------------------------------------------------------------------
/frontend/src/containers/Phases.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Phases';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | self: state.self,
9 | train: state.train.details,
10 | request: state.train.request,
11 | requestRestart: state.train.requestRestart,
12 | };
13 | };
14 |
15 | const mapDispatchToProps = (dispatch) => {
16 | return {
17 | restartJob: (trainId, phaseName) => {
18 | dispatch(Actions.Train.restart(trainId, phaseName));
19 | }
20 | };
21 | };
22 |
23 |
24 | export default connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | )(Component);
28 |
29 |
--------------------------------------------------------------------------------
/services/phase/phase_mock.go:
--------------------------------------------------------------------------------
1 | package phase
2 |
3 | import (
4 | "github.com/Nextdoor/conductor/shared/types"
5 | )
6 |
7 | type PhaseServiceMock struct {
8 | StartMock func(
9 | phaseType types.PhaseType, trainID,
10 | deliveryPhaseID, verificationPhaseID, deployPhaseID uint64, branch, sha string,
11 | buildUser *types.User) error
12 | }
13 |
14 | func (m *PhaseServiceMock) Start(
15 | phaseType types.PhaseType,
16 | trainID, deliveryPhaseID, verificationPhaseID, deployPhaseID uint64,
17 | branch, sha string,
18 | buildUser *types.User) error {
19 |
20 | if m.StartMock == nil {
21 | return nil
22 | }
23 | return m.StartMock(
24 | phaseType, trainID, deliveryPhaseID, verificationPhaseID, deployPhaseID,
25 | branch, sha, buildUser)
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/actions/search.js:
--------------------------------------------------------------------------------
1 | import Actions from 'types/actions';
2 | import API from 'api';
3 |
4 | const request = () => {
5 | return {
6 | type: Actions.RequestSearch
7 | };
8 | };
9 |
10 | const receive = (search) => {
11 | return {
12 | type: Actions.ReceiveSearch,
13 | search: search,
14 | receivedAt: Date.now(),
15 | searchQuery: search.params.commit
16 | };
17 | };
18 |
19 | const receiveError = (error) => {
20 | return {
21 | type: Actions.ReceiveSearchError,
22 | error: error,
23 | receivedAt: Date.now()
24 | };
25 | };
26 |
27 | const fetch = (params) => (dispatch) => {
28 | API.getSearch(dispatch, params);
29 | };
30 |
31 | export default {
32 | fetch,
33 | request,
34 | receive,
35 | receiveError
36 | };
37 |
--------------------------------------------------------------------------------
/frontend/src/components/TrainComponent.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {trainProps, requestProps} from 'types/proptypes';
4 | import Error from 'components/Error';
5 | import Loading from 'components/Loading';
6 |
7 | class TrainComponent extends React.Component {
8 | getRequestComponent() {
9 | const {request, train} = this.props;
10 | if (train === null && (request.fetching === true || request.receivedAt === null)) {
11 | return ;
12 | }
13 |
14 | if (request.error !== null) {
15 | return ;
16 | }
17 |
18 | return null;
19 | }
20 | }
21 |
22 | TrainComponent.propTypes = {
23 | train: trainProps,
24 | request: requestProps
25 | };
26 |
27 | export default TrainComponent;
28 |
--------------------------------------------------------------------------------
/frontend/src/components/Card.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class Card extends React.Component {
5 | render() {
6 | const {header, className} = this.props;
7 | let fullClassName = 'card';
8 | if (className) {
9 | fullClassName = 'card ' + className;
10 | }
11 | return (
12 |
13 |
14 | {header}
15 |
16 |
17 |
18 | {this.props.children}
19 |
20 |
21 | );
22 | }
23 | }
24 |
25 | Card.propTypes = {
26 | header: PropTypes.node.isRequired,
27 | className: PropTypes.string,
28 | children: PropTypes.node.isRequired
29 | };
30 |
31 | export default Card;
32 |
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | setup_data() {
4 | if [[ -e envfile ]]; then
5 | set -a; eval $(cat envfile | sed 's/"/\\"/g' | sed 's/=\(.*\)/="\1"/g'); set +a
6 | fi
7 |
8 | export POSTGRES_HOST=localhost;
9 |
10 | local type=$1;
11 |
12 | if [[ $type == "" || $type == "f" || $type == "full" ]]; then
13 | make postgres-wipe
14 | type=full
15 | elif [[ $type == "e" || $type == "extend" ]]; then
16 | type=extend
17 | elif [[ $type == "c" || $type == "create" ]]; then
18 | type=create
19 | else
20 | echo "Unknown setup type $type"
21 | exit 1
22 | fi
23 |
24 | touch envfile
25 | set -a
26 | source envfile
27 | set +a
28 | source resources/decrypt_secrets.sh
29 | go run cmd/test_data.go -test_data_type $type
30 | }
31 |
32 | setup_data $@
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Root.dev.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import {Provider} from 'react-redux';
3 | import {createStore, applyMiddleware, compose} from 'redux';
4 | import thunkMiddleware from 'redux-thunk';
5 |
6 | import DevTools from 'components/DevTools';
7 | import Routes from 'components/Routes';
8 | import reducer, {initialState} from 'reducers';
9 |
10 | const enhancer = compose(
11 | applyMiddleware(
12 | thunkMiddleware
13 | ),
14 | DevTools.instrument()
15 | );
16 |
17 | const store = createStore(
18 | reducer,
19 | initialState,
20 | enhancer
21 | );
22 |
23 | export default class Root extends Component {
24 | render() {
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/shared/github/github.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/url"
7 |
8 | "github.com/google/go-github/github"
9 | "golang.org/x/oauth2"
10 |
11 | "github.com/Nextdoor/conductor/shared/flags"
12 | )
13 |
14 | var (
15 | // No trailing slash.
16 | githubHost = flags.EnvString("GITHUB_HOST", "")
17 | )
18 |
19 | func newClient(accessToken string) (*github.Client, error) {
20 | if githubHost == "" {
21 | return nil, errors.New("github_host flag must be set")
22 | }
23 | githubURL, err := url.Parse(fmt.Sprintf("%s/api/v3/", githubHost))
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | tokenSource := oauth2.StaticTokenSource(
29 | &oauth2.Token{AccessToken: accessToken},
30 | )
31 | tokenClient := oauth2.NewClient(oauth2.NoContext, tokenSource)
32 |
33 | client := github.NewClient(tokenClient)
34 | client.BaseURL = githubURL
35 | return client, nil
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/containers/Admin.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Admin';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | self: state.self.details,
9 | train: state.train.details,
10 | config: state.admin.config,
11 | fetchConfigRequest: state.admin.requestConfig,
12 | toggleModeRequest: state.admin.requestToggleMode,
13 | toggleCloseRequest: state.train.requestToggleClose
14 | };
15 | };
16 |
17 | const mapDispatchToProps = (dispatch) => {
18 | return {
19 | toggleMode: () => {
20 | dispatch(Actions.Admin.toggleMode());
21 | },
22 | toggleClose: () => {
23 | dispatch(Actions.Train.toggleClose());
24 | },
25 | fetchConfig: () => {
26 | dispatch(Actions.Admin.fetchConfig());
27 | }
28 | };
29 | };
30 |
31 | export default connect(
32 | mapStateToProps,
33 | mapDispatchToProps
34 | )(Component);
35 |
--------------------------------------------------------------------------------
/frontend/src/components/Details.spec.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-invalid-this */
2 | import React from 'react';
3 | import {shallow} from 'enzyme';
4 | import {newTrain, noRequest, completeRequest} from 'test/TestData';
5 | import Details from './Details';
6 |
7 | describe('Phases', function() {
8 |
9 | it('Waits for train gracefully', function() {
10 | this.train = JSON.parse(JSON.stringify(newTrain));
11 | const wrapper = shallow(
12 | );
15 | expect(wrapper.debug()).toEqual(expect.stringContaining('Loading'));
16 | });
17 |
18 | it('Renders correctly', function() {
19 | this.train = JSON.parse(JSON.stringify(newTrain));
20 | const wrapper = shallow(
21 | );
24 | expect(wrapper.debug()).toEqual(expect.stringContaining('Details'));
25 | expect(wrapper.debug()).not.toEqual(expect.stringContaining('Loading'));
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/frontend/src/reducers/self.js:
--------------------------------------------------------------------------------
1 | import Actions from 'types/actions';
2 |
3 | const self = (state = null, action) => {
4 | switch (action.type) {
5 | case Actions.RequestSelf:
6 | return Object.assign({}, state, {
7 | details: null,
8 | request: {
9 | fetching: true,
10 | error: null,
11 | receivedAt: null
12 | }
13 | });
14 | case Actions.ReceiveSelf:
15 | return Object.assign({}, state, {
16 | details: action.self,
17 | request: {
18 | fetching: false,
19 | error: null,
20 | receivedAt: action.receivedAt
21 | }
22 | });
23 | case Actions.ReceiveSelfError:
24 | return Object.assign({}, state, {
25 | details: null,
26 | request: {
27 | fetching: false,
28 | error: action.error,
29 | receivedAt: action.receivedAt
30 | }
31 | });
32 | default:
33 | return state;
34 | }
35 | };
36 |
37 | export default self;
38 |
--------------------------------------------------------------------------------
/shared/datadog/datadog_test.go:
--------------------------------------------------------------------------------
1 | package datadog
2 |
3 | import (
4 | "net"
5 | "os"
6 | "testing"
7 |
8 | "github.com/DataDog/datadog-go/statsd"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | var addr, _ = net.ResolveUDPAddr("udp", "localhost:8125")
13 | var sock, _ = net.ListenUDP("udp", addr)
14 |
15 | func TestLogInfo(t *testing.T) {
16 | os.Setenv("STATSD_HOST", "localhost")
17 | enableDatadog = true
18 | c = newStatsdClient()
19 | assert.NotNil(t, c)
20 | log(statsd.Info, "%s testing", "conductor")
21 | buf := make([]byte, 1024)
22 | rlen, _, _ := sock.ReadFromUDP(buf)
23 | assert.Contains(t, string(buf[0:rlen]), "conductor testing")
24 | }
25 |
26 | func TestLogError(t *testing.T) {
27 | os.Setenv("STATSD_HOST", "localhost")
28 | enableDatadog = true
29 | c = newStatsdClient()
30 | assert.NotNil(t, c)
31 | log(statsd.Error, "%s testing", "conductor")
32 | buf := make([]byte, 1024)
33 | rlen, _, _ := sock.ReadFromUDP(buf)
34 | assert.Contains(t, string(buf[0:rlen]), "conductor testing")
35 | }
36 |
--------------------------------------------------------------------------------
/services/messaging/slack_test.go:
--------------------------------------------------------------------------------
1 | // +build messaging
2 |
3 | package messaging
4 |
5 | import (
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestEmailToSlackUser(t *testing.T) {
13 | slackService := newSlackEngine()
14 | slackEngine := slackService.Engine.(*slackEngine)
15 |
16 | now := time.Now()
17 | // Test the cache TTL. We should get back the currently cached version, which is an empty map.
18 | slackEmailUserCacheUnixTime = now.Unix()
19 | users, err := slackEngine.cacheSlackUsers()
20 | assert.NoError(t, err)
21 | assert.Empty(t, users)
22 |
23 | // Reset the cache timestamp to force a lookup.
24 | slackEmailUserCacheUnixTime = 0
25 |
26 | users, err = slackEngine.cacheSlackUsers()
27 | assert.NoError(t, err)
28 | assert.NotEmpty(t, users)
29 |
30 | // Pick first email in the cache
31 | for email, _ := range users {
32 | user, err := slackEngine.emailToSlackUser(email)
33 | assert.NoError(t, err)
34 | assert.NotNil(t, user)
35 | return
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/containers/Details.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Details';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | train: state.train.details,
9 | request: state.train.request,
10 | requestExtend: state.train.requestExtend,
11 | requestBlock: state.train.requestBlock,
12 | requestUnblock: state.train.requestUnblock
13 | };
14 | };
15 |
16 | const mapDispatchToProps = (dispatch) => {
17 | return {
18 | extendTrain: (trainId) => {
19 | dispatch(Actions.Train.extend(trainId));
20 | },
21 | blockTrain: (trainId) => {
22 | dispatch(Actions.Train.block(trainId));
23 | },
24 | unblockTrain: (trainId) => {
25 | dispatch(Actions.Train.unblock(trainId));
26 | },
27 | changeEngineer: (trainId) => {
28 | dispatch(Actions.Train.changeEngineer(trainId));
29 | }
30 | };
31 | };
32 |
33 | export default connect(
34 | mapStateToProps,
35 | mapDispatchToProps
36 | )(Component);
37 |
38 |
--------------------------------------------------------------------------------
/resources/decrypt_secrets.sh:
--------------------------------------------------------------------------------
1 | # Decrypts secrets. Should be sourced.
2 |
3 | # Helper to decrypt KMS blobs
4 | kms_decrypt () {
5 | set -e
6 | local ENCRYPTED=$1
7 | local BLOB_PATH=$(mktemp)
8 | echo $ENCRYPTED | base64 --decode > $BLOB_PATH
9 | aws kms decrypt --ciphertext-blob fileb://$BLOB_PATH --output text --query Plaintext | base64 --decode
10 | rm -f $BLOB_PATH
11 | }
12 |
13 | SECRETS="GITHUB_ADMIN_TOKEN GITHUB_AUTH_CLIENT_SECRET GITHUB_WEBHOOK_SECRET JENKINS_PASSWORD SLACK_TOKEN JIRA_API_TOKEN JIRA_CONDUCTOR_ACCOUNT_ID"
14 | # Try to decrypt the various KMS blobs, if they're set.
15 | touch /tmp/secrets
16 | for SECRET in $SECRETS; do
17 | BLOB_NAME="${SECRET}_BLOB"
18 | if [[ -n "${!BLOB_NAME}" ]]; then
19 | (
20 | echo "Decoding ${BLOB_NAME}."
21 | DECRYPTED=$(kms_decrypt "${!BLOB_NAME}")
22 | echo "${SECRET}='${DECRYPTED}'" >> /tmp/secrets
23 | ) &
24 | fi
25 | done
26 |
27 | wait
28 |
29 | set -a
30 | source /tmp/secrets
31 | set +a
32 | rm -rf /tmp/secrets
33 |
--------------------------------------------------------------------------------
/core/panic_recovery_test.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "io/ioutil"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | // Test that the panic is recovered.
13 | func TestPanickingEndpoint(t *testing.T) {
14 | // Create a request to hit the handler.
15 | req, err := http.NewRequest("GET", "/test-panic", nil)
16 | assert.NoError(t, err)
17 | res := httptest.NewRecorder()
18 |
19 | handler := func(r *http.Request) response {
20 | panic("test-panic")
21 | return dataResponse("didn't panic")
22 | }
23 |
24 | // Create a server with test handler.
25 | endpoints := []endpoint{newOpenEp("/test-panic", get, handler)}
26 | server := NewServer(endpoints)
27 | assert.NoError(t, err)
28 |
29 | server.ServeHTTP(res, req)
30 |
31 | resp := res.Result()
32 | body, err := ioutil.ReadAll(resp.Body)
33 | assert.NoError(t, err)
34 |
35 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
36 | assert.Contains(t, string(body), `"error":"Panic: test-panic. Stack trace: `)
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/components/TitledList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class TitledList extends React.Component {
5 | render() {
6 | const {className, items} = this.props;
7 | let fullClassName = 'titled-list';
8 | if (className) {
9 | fullClassName = 'titled-list ' + className;
10 | }
11 | return (
12 |
13 |
14 |
15 | {items.map((item, i) =>
16 | - {item[0]}
17 | )}
18 |
19 |
20 |
21 |
22 | {items.map((item, i) =>
23 | - {item[1]}
24 | )}
25 |
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | TitledList.propTypes = {
33 | className: PropTypes.string,
34 | items: PropTypes.arrayOf(
35 | PropTypes.arrayOf(PropTypes.node)
36 | ).isRequired
37 | };
38 |
39 | export default TitledList;
40 |
--------------------------------------------------------------------------------
/frontend/src/components/Auth.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {MarkGithubIcon} from 'react-octicons';
3 |
4 | import API from 'api';
5 | import Card from './Card';
6 |
7 | const oauthProvider = process.env.OAUTH_PROVIDER;
8 | const oauthEndpoint = process.env.OAUTH_ENDPOINT;
9 | const oauthPayload = JSON.parse(process.env.OAUTH_PAYLOAD);
10 |
11 | const oauthURL = oauthEndpoint + '?' + API.encodeQueryParams(oauthPayload);
12 |
13 | const Auth = () => {
14 | let icon;
15 | if (oauthProvider.toLowerCase() === "github") {
16 | icon = ;
17 | }
18 | const header = (
19 |
20 |
Welcome to Conductor
21 |
All aboard!
22 |
23 | );
24 | return (
25 |
26 |
27 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default Auth;
39 |
--------------------------------------------------------------------------------
/frontend/src/reducers/search.js:
--------------------------------------------------------------------------------
1 | import Actions from 'types/actions';
2 |
3 | const search = (state = null, action) => {
4 | let newState = state;
5 | switch (action.type) {
6 | case Actions.RequestSearch:
7 | newState = Object.assign({}, state, {
8 | request: {
9 | fetching: true,
10 | error: null,
11 | receivedAt: null
12 | }
13 | });
14 | break;
15 | case Actions.ReceiveSearch:
16 | newState = Object.assign({}, state, {
17 | details: action.search,
18 | request: {
19 | fetching: false,
20 | error: null,
21 | receivedAt: action.receivedAt,
22 | searchQuery: action.searchQuery
23 | }
24 | });
25 | break;
26 | case Actions.ReceiveSearchError:
27 | newState = Object.assign({}, state, {
28 | details: null,
29 | request: {
30 | fetching: false,
31 | error: action.error,
32 | receivedAt: action.receivedAt
33 | }
34 | });
35 | break;
36 | default:
37 | break;
38 | }
39 | return newState;
40 | };
41 |
42 | export default search;
43 |
--------------------------------------------------------------------------------
/core/code.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/Nextdoor/conductor/services/code"
7 | "github.com/Nextdoor/conductor/services/data"
8 | "github.com/Nextdoor/conductor/services/messaging"
9 | "github.com/Nextdoor/conductor/services/phase"
10 | "github.com/Nextdoor/conductor/services/ticket"
11 | "github.com/Nextdoor/conductor/shared/logger"
12 | )
13 |
14 | func codeEndpoints() []endpoint {
15 | return []endpoint{
16 | newOpenEp("/api/code/webhook", post, codeWebhook),
17 | }
18 | }
19 |
20 | func codeWebhook(r *http.Request) response {
21 | codeService := code.GetService()
22 | messagingService := messaging.GetService()
23 | phaseService := phase.GetService()
24 | ticketService := ticket.GetService()
25 | branch, err := codeService.ParseWebhookForBranch(r)
26 | if err != nil {
27 | return errorResponse(err.Error(), http.StatusInternalServerError)
28 | }
29 |
30 | if branch != "" {
31 | logger.Info("There was a push event to branch %s", branch)
32 | go checkBranch(
33 | data.NewClient(), codeService, messagingService, phaseService, ticketService,
34 | branch, nil)
35 | }
36 |
37 | return emptyResponse()
38 | }
39 |
--------------------------------------------------------------------------------
/services/data/postgres.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/astaxie/beego/orm"
7 | _ "github.com/lib/pq"
8 |
9 | "github.com/Nextdoor/conductor/shared/flags"
10 | )
11 |
12 | var (
13 | postgresHost = flags.EnvString("POSTGRES_HOST", "conductor-postgres")
14 | postgresPort = flags.EnvString("POSTGRES_PORT", "5432")
15 | postgresUsername = flags.EnvString("POSTGRES_USERNAME", "conductor")
16 | postgresPassword = flags.EnvString("POSTGRES_PASSWORD", "conductor")
17 | postgresDatabaseName = flags.EnvString("POSTGRES_DATABASE_NAME", "conductor")
18 | postgresSSLMode = flags.EnvString("POSTGRES_SSL_MODE", "disable")
19 | )
20 |
21 | type Postgres struct{ data }
22 |
23 | func newPostgres() *Postgres {
24 | postgres := Postgres{}
25 |
26 | postgres.RegisterDB = func() error {
27 | return orm.RegisterDataBase("default", "postgres",
28 | fmt.Sprintf(
29 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
30 | postgresHost, postgresPort, postgresUsername, postgresPassword,
31 | postgresDatabaseName, postgresSSLMode))
32 | }
33 |
34 | postgres.initialize()
35 |
36 | return &Postgres{}
37 | }
38 |
--------------------------------------------------------------------------------
/core/phase_integration_test.go:
--------------------------------------------------------------------------------
1 | // +build data,phase
2 |
3 | // To run Jenkins integration tests:
4 | // Make sure you set the necessary environment variables, and then set the
5 | // "jenkins" build flag.
6 | //
7 | // i.e.:
8 | // PHASE_IMPL=jenkins \
9 | // JENKINS_USERNAME=jenkins JENKINS_PASSWORD='supersecret' JENKINS_URL='https://jenkins-server' \
10 | // JENKINS_BUILD_JOB='conductor-integration-build' JENKINS_DEPLOY_JOB='conductor-integration-deploy' \
11 | // go test -tags=jenkins ./...
12 | package core
13 |
14 | import (
15 | "testing"
16 |
17 | "github.com/stretchr/testify/assert"
18 |
19 | "github.com/Nextdoor/conductor/services/phase"
20 | "github.com/Nextdoor/conductor/shared/types"
21 | )
22 |
23 | func TestJenkinsPhase(t *testing.T) {
24 | server, testData := setup(t)
25 | go listen(t, server)
26 |
27 | phaseService := phase.GetService()
28 |
29 | err := phaseService.Start(types.Delivery,
30 | testData.Train.ID,
31 | testData.Train.ActivePhases.Delivery.ID,
32 | testData.Train.ActivePhases.Verification.ID,
33 | testData.Train.ActivePhases.Deploy.ID,
34 | "branch", "sha", nil)
35 | assert.NoError(t, err)
36 |
37 | // TODO: Test the job API calls.
38 | }
39 |
--------------------------------------------------------------------------------
/core/search.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/Nextdoor/conductor/services/data"
8 | "github.com/Nextdoor/conductor/shared/types"
9 | )
10 |
11 | func searchEndpoints() []endpoint {
12 | return []endpoint{
13 | newEp("/api/search", get, search),
14 | }
15 | }
16 |
17 | func search(r *http.Request) response {
18 | dataClient := data.NewClient()
19 |
20 | query := r.URL.Query()
21 | params := make(map[string]string)
22 | for key, values := range query {
23 | params[key] = values[0]
24 | }
25 | sha, ok := params["commit"]
26 | if !ok {
27 | return errorResponse(
28 | "Search only supports commit",
29 | http.StatusBadRequest)
30 | }
31 | commit := &types.Commit{SHA: sha}
32 |
33 | trains, err := dataClient.TrainsByCommit(commit)
34 | if err != nil {
35 | return errorResponse(
36 | fmt.Sprintf("Error getting trains for commit: %v", err),
37 | http.StatusInternalServerError)
38 | }
39 | if len(trains) == 0 {
40 | return errorResponse(
41 | fmt.Sprintf("Could not find any trains for commit %s", sha),
42 | http.StatusNotFound)
43 | }
44 |
45 | return dataResponse(&types.Search{
46 | Params: params,
47 | Results: trains,
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/src/containers/Summary.js:
--------------------------------------------------------------------------------
1 | import {connect} from 'react-redux';
2 |
3 | import Actions from 'actions';
4 | import Component from 'components/Summary';
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | train: state.train.details,
9 | request: state.train.request,
10 | requestExtend: state.train.requestExtend,
11 | requestBlock: state.train.requestBlock,
12 | requestUnblock: state.train.requestUnblock,
13 | requestCancel: state.train.requestCancel,
14 | requestRollback: state.train.requestRollback
15 | };
16 | };
17 |
18 | const mapDispatchToProps = (dispatch) => {
19 | return {
20 | extendTrain: (trainId) => {
21 | dispatch(Actions.Train.extend(trainId));
22 | },
23 | blockTrain: (trainId) => {
24 | dispatch(Actions.Train.block(trainId));
25 | },
26 | unblockTrain: (trainId) => {
27 | dispatch(Actions.Train.unblock(trainId));
28 | },
29 | cancelTrain: (trainId) => {
30 | dispatch(Actions.Train.cancel(trainId));
31 | },
32 | rollbackToTrain: (trainId) => {
33 | dispatch(Actions.Train.rollbackTo(trainId));
34 | }
35 | };
36 | };
37 |
38 | export default connect(
39 | mapStateToProps,
40 | mapDispatchToProps
41 | )(Component);
42 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.11.13
2 | ENTRYPOINT [ "/app/entrypoint.sh" ]
3 | EXPOSE 80 443
4 |
5 | # Install packages.
6 | RUN apt-get update && apt-get install -y jq nginx nodejs patch unzip && \
7 | apt-get clean
8 |
9 | # Generate SSL certs.
10 | RUN mkdir -p /app/ssl && cd /app/ssl && \
11 | openssl req -x509 -nodes -newkey rsa:4096 -sha256 \
12 | -keyout privkey.pem -out fullchain.pem \
13 | -days 36500 -subj '/CN=localhost' && \
14 | openssl dhparam -dsaparam -out dhparam.pem 4096
15 |
16 | # Generate swagger docs.
17 | RUN apt-get install -y npm && npm install -g pretty-swag@0.1.144
18 | ADD swagger/swagger.yml swagger/config.json /app/swagger/
19 | RUN ls /app/swagger/
20 | RUN cd /app && pretty-swag -c /app/swagger/config.json
21 |
22 | # Add awscli
23 | RUN apt-get install -y \
24 | python3-pip \
25 | && pip3 --no-cache-dir install --upgrade awscli \
26 | && apt-get clean
27 |
28 | # Set up Go app.
29 | ADD .build /src/github.com/Nextdoor/conductor/
30 | ADD .build /go/src/github.com/Nextdoor/conductor/
31 | RUN cd /src/github.com/Nextdoor/conductor/ && go build -o /app/conductor /src/github.com/Nextdoor/conductor/cmd/conductor/conductor.go
32 |
33 | # Add static resources.
34 | ADD resources/ /app
35 |
--------------------------------------------------------------------------------
/etc/githooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 | #
3 | # Runs the following pre-commit checks and aborts the commit if there
4 | # are any failures. Runs tests and style tests.
5 |
6 | fail=0
7 |
8 | function usage {
9 | cat < {
4 | switch (action.type) {
5 | case Actions.SetToken:
6 | return Object.assign({}, state, {
7 | token: action.token,
8 | promptLogin: false
9 | });
10 | case Actions.PromptLogin:
11 | return Object.assign({}, state, {
12 | promptLogin: true
13 | });
14 | case Actions.DeleteToken:
15 | return Object.assign({}, state, {
16 | token: null
17 | });
18 | case Actions.RequestLogout:
19 | return Object.assign({}, state, {
20 | logoutRequest: {
21 | fetching: true,
22 | error: null,
23 | receivedAt: null
24 | }
25 | });
26 | case Actions.ReceiveLogout:
27 | return Object.assign({}, state, {
28 | logoutRequest: {
29 | fetching: false,
30 | error: null,
31 | receivedAt: action.receivedAt
32 | }
33 | });
34 | case Actions.ReceiveLogoutError:
35 | return Object.assign({}, state, {
36 | logoutRequest: {
37 | fetching: false,
38 | error: action.error,
39 | receivedAt: action.receivedAt
40 | }
41 | });
42 | default:
43 | return state;
44 | }
45 | };
46 |
47 | export default token;
48 |
--------------------------------------------------------------------------------
/resources/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 | set -o pipefail
4 |
5 | # Set timezone.
6 | export TZ=${TIMEZONE:-"America/Los_Angeles"}
7 |
8 | CURRENT_DIR=$(dirname ${BASH_SOURCE[0]})
9 | source ${CURRENT_DIR}/decrypt_secrets.sh
10 |
11 | if [[ "$#" != "0" ]]; then
12 | cd /go/src/github.com/Nextdoor/conductor
13 | go test "$@"
14 | exit 0
15 | fi
16 |
17 | # Check if the CLIENT_USER_SECRET variables was passed in - if so, this
18 | # variable will contain our database username and password through a call to
19 | # the Secrets Manager.
20 | if [[ -n "${CLIENT_USER_SECRET}" ]]; then
21 | # Go get the value from the Secrets Manager.
22 | SECRET=$(aws secretsmanager get-secret-value \
23 | --secret-id ${CLIENT_USER_SECRET} \
24 | --query SecretString \
25 | --output text)
26 |
27 | # Get the DB Username/Password from the JSON-based Secret.
28 | export POSTGRES_USERNAME=$(echo ${SECRET} | jq -r .username)
29 | export POSTGRES_PASSWORD=$(echo ${SECRET} | jq -r .password)
30 | fi
31 |
32 | # Set CONTAINER_HOST_IP to the container's host ip or the
33 | # value of the STATSD_HOST environment variable
34 | CONTAINER_HOST_IP=$(ip route show | awk '/default/ {print $3}')
35 | export STATSD_HOST=${STATSD_HOST:-$CONTAINER_HOST_IP}
36 |
37 | /usr/sbin/nginx -c /app/nginx.conf -p /app/ &
38 | exec /app/conductor
39 |
--------------------------------------------------------------------------------
/shared/settings/settings_test.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestParseFlags(t *testing.T) {
10 | adminUserFlag = "admin-1, admin-2,admin-3"
11 | noStagingVerificationUsersFlag = "no-staging-1, no-staging-2"
12 | robotUserFlag = "robot-1,robot-2"
13 | deliveryJobsFlag = "delivery-1"
14 | verificationJobsFlag = "verification-1, verification-2"
15 | deployJobsFlag = "deploy-1"
16 |
17 | defer clearFlags()
18 |
19 | parseFlags()
20 |
21 | assert.Equal(t, "admin-1", AdminUsers[0])
22 | assert.Equal(t, "admin-2", AdminUsers[1])
23 | assert.Equal(t, "admin-3", AdminUsers[2])
24 |
25 | assert.Equal(t, "no-staging-1", NoStagingVerificationUsers[0])
26 | assert.Equal(t, "no-staging-2", NoStagingVerificationUsers[1])
27 |
28 | assert.Equal(t, "robot-1", RobotUsers[0])
29 | assert.Equal(t, "robot-2", RobotUsers[1])
30 |
31 | assert.Equal(t, "delivery-1", DeliveryJobs[0])
32 |
33 | assert.Equal(t, "verification-1", VerificationJobs[0])
34 | assert.Equal(t, "verification-2", VerificationJobs[1])
35 |
36 | assert.Equal(t, "deploy-1", DeployJobs[0])
37 | }
38 |
39 | func clearFlags() {
40 | adminUserFlag = ""
41 | noStagingVerificationUsersFlag = ""
42 | robotUserFlag = ""
43 | deliveryJobsFlag = ""
44 | verificationJobsFlag = ""
45 | deployJobsFlag = ""
46 | }
47 |
--------------------------------------------------------------------------------
/shared/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "os"
8 |
9 | "github.com/Nextdoor/conductor/shared/flags"
10 | )
11 |
12 | var (
13 | debugLoggingEnabled = flags.EnvBool("DEBUG_LOGGING_ENABLED", false)
14 | useStructuredLogging = flags.EnvBool("USE_STRUCTURED_LOGGING", false)
15 | )
16 |
17 | type logMessage struct {
18 | Service string `json:"service"`
19 | Level string `json:"level"`
20 | Message string `json:"message"`
21 | }
22 |
23 | var output io.Writer = os.Stdout
24 |
25 | func log(level string, format string, args ...interface{}) {
26 | message := fmt.Sprintf(format, args...)
27 | if useStructuredLogging {
28 | structuredMessage, err := json.Marshal(logMessage{
29 | Service: "conductor",
30 | Level: level,
31 | Message: message,
32 | })
33 | if err != nil {
34 | fmt.Fprintf(output, "Error encoding structured log message: %v\n", err)
35 | } else {
36 | fmt.Fprintln(output, string(structuredMessage))
37 | }
38 | } else {
39 | fmt.Fprintf(output, "%s: %s\n", level, message)
40 | }
41 | }
42 |
43 | func Debug(format string, args ...interface{}) {
44 | if debugLoggingEnabled {
45 | log("DEBUG", format, args...)
46 | }
47 | }
48 |
49 | func Info(format string, args ...interface{}) {
50 | log("INFO", format, args...)
51 | }
52 |
53 | func Error(format string, args ...interface{}) {
54 | log("ERROR", format, args...)
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/actions/token.js:
--------------------------------------------------------------------------------
1 | import cookie from 'react-cookie';
2 |
3 | import Actions from 'types/actions';
4 | import API from 'api';
5 |
6 | const authCookieName = process.env.AUTH_COOKIE_NAME;
7 |
8 | const set = (token) => {
9 | return {
10 | type: Actions.SetToken,
11 | token: token
12 | };
13 | };
14 |
15 | const promptLogin = () => {
16 | return {
17 | type: Actions.PromptLogin
18 | };
19 | };
20 |
21 | const get = () => (dispatch) => {
22 | const token = cookie.load(authCookieName);
23 | if (token === undefined) {
24 | return dispatch(promptLogin());
25 | } else {
26 | return dispatch(set(token));
27 | }
28 | };
29 |
30 | const del = () => {
31 | return {
32 | type: Actions.DeleteToken
33 | };
34 | };
35 |
36 | const requestLogout = () => {
37 | return {
38 | type: Actions.RequestLogout
39 | };
40 | };
41 |
42 | const receiveLogout = () => (dispatch) => {
43 | dispatch({
44 | type: Actions.ReceiveLogout,
45 | receivedAt: Date.now()
46 | });
47 | dispatch(promptLogin());
48 | };
49 |
50 | const receiveLogoutError = (error) => {
51 | return {
52 | type: Actions.ReceiveLogoutError,
53 | error: error,
54 | receivedAt: Date.now()
55 | };
56 | };
57 |
58 | const logout = () => (dispatch) => {
59 | API.logout(dispatch);
60 | };
61 |
62 | export default {
63 | set,
64 | promptLogin,
65 | get,
66 | del,
67 | requestLogout,
68 | receiveLogout,
69 | receiveLogoutError,
70 | logout
71 | };
72 |
--------------------------------------------------------------------------------
/core/panic_recovery.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 | "runtime/debug"
8 |
9 | "github.com/Nextdoor/conductor/shared/logger"
10 | )
11 |
12 | // parsePanic should be called with recover() as the parameter.
13 | // Needed because recover() doesn't work if not called directly by the deferred function.
14 | // See https://golang.org/ref/spec#Handling_panics
15 | func parsePanic(panicValue interface{}) (error, string) {
16 | var err error
17 | var stack string
18 | if panicValue != nil {
19 | switch v := panicValue.(type) {
20 | case string:
21 | err = errors.New(v)
22 | case error:
23 | err = v
24 | default:
25 | err = errors.New("Unknown error")
26 | }
27 | stack = string(debug.Stack())
28 | }
29 | return err, stack
30 | }
31 |
32 | func newPanicRecoveryMiddleware() panicRecoveryMiddleware {
33 | return panicRecoveryMiddleware{}
34 | }
35 |
36 | type panicRecoveryMiddleware struct{}
37 |
38 | func (_ panicRecoveryMiddleware) Wrap(handler http.Handler) http.Handler {
39 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 | defer func() {
41 | err, stack := parsePanic(recover())
42 | if err != nil {
43 | logger.Error("Panic in request: %v. Stack trace: %v", err, stack)
44 | errorResponse(
45 | fmt.Sprintf("Panic: %s. Stack trace: %v", err.Error(), stack),
46 | http.StatusInternalServerError).Write(w, r)
47 | return
48 | }
49 | }()
50 | handler.ServeHTTP(w, r)
51 | })
52 | }
53 |
--------------------------------------------------------------------------------
/services/ticket/ticket_mock.go:
--------------------------------------------------------------------------------
1 | package ticket
2 |
3 | import "github.com/Nextdoor/conductor/shared/types"
4 |
5 | type TicketServiceMock struct {
6 | CreateTicketsMock func(*types.Train, []*types.Commit) ([]*types.Ticket, error)
7 | CloseTicketsMock func([]*types.Ticket) error
8 | DeleteTicketsMock func(*types.Train) error
9 | SyncTicketsMock func(*types.Train) ([]*types.Ticket, []*types.Ticket, error)
10 | CloseTrainTicketsMock func(*types.Train) error
11 | }
12 |
13 | func (m *TicketServiceMock) CreateTickets(train *types.Train, commits []*types.Commit) ([]*types.Ticket, error) {
14 | if m.CreateTicketsMock == nil {
15 | return nil, nil
16 | }
17 | return m.CreateTicketsMock(train, commits)
18 | }
19 |
20 | func (m *TicketServiceMock) CloseTickets(tickets []*types.Ticket) error {
21 | if m.CloseTicketsMock == nil {
22 | return nil
23 | }
24 | return m.CloseTicketsMock(tickets)
25 | }
26 |
27 | func (m *TicketServiceMock) DeleteTickets(train *types.Train) error {
28 | if m.DeleteTicketsMock == nil {
29 | return nil
30 | }
31 | return m.DeleteTicketsMock(train)
32 | }
33 |
34 | func (m *TicketServiceMock) SyncTickets(train *types.Train) ([]*types.Ticket, []*types.Ticket, error) {
35 | if m.SyncTicketsMock == nil {
36 | return nil, nil, nil
37 | }
38 | return m.SyncTicketsMock(train)
39 | }
40 |
41 | func (m *TicketServiceMock) CloseTrainTickets(train *types.Train) error {
42 | if m.CloseTrainTicketsMock == nil {
43 | return nil
44 | }
45 | return m.CloseTrainTicketsMock(train)
46 | }
47 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | defaults: &defaults
4 | docker:
5 | - image: circleci/golang:1.12.2-stretch-node
6 | environment:
7 | DOCKER_IMAGE: conductor
8 | DOCKER_REGISTRY: hub.docker.com
9 | DOCKER_NAMESPACE: nextdoor
10 |
11 | jobs:
12 | build:
13 | docker:
14 | - image: circleci/golang:1.12.2-stretch-node
15 | environment:
16 | GO111MODULE: "on"
17 | working_directory: /go/src/github.com/Nextdoor/conductor
18 | steps:
19 | - checkout
20 | - setup_remote_docker:
21 | docker_layer_caching: true
22 | - run:
23 | name: Set environment variables
24 | command: |
25 | # Set vars.
26 | SHA1=$(echo $CIRCLE_SHA1 | cut -c -16)
27 | DOCKER_TAG=$(echo ${CIRCLE_TAG:-$CIRCLE_BRANCH-$SHA1} | sed 's|/|_|g')
28 | CACHE_FROM="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${DOCKER_IMAGE}"
29 | # Export them into bash env.
30 | echo "export SHA1=$SHA1" >> $BASH_ENV
31 | echo "export DOCKER_TAG=$DOCKER_TAG" >> $BASH_ENV
32 | echo "export CACHE_FROM=$CACHE_FROM" >> $BASH_ENV
33 | # Install yarn
34 | - run: curl -o- -L https://yarnpkg.com/install.sh | bash
35 | - run: docker pull $CACHE_FROM || true
36 | - run: make postgres
37 | # Compile the frontend
38 | - run: make -C frontend prod-compile
39 | - run: make docker-build
40 | # Check for module files
41 | - run: make test
42 |
43 | workflows:
44 | version: 2
45 | workflow:
46 | jobs:
47 | - build
48 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/Nextdoor/conductor
2 |
3 | go 1.11.13
4 |
5 | require (
6 | github.com/DataDog/datadog-go v2.2.0+incompatible
7 | github.com/Nextdoor/go-jira v0.0.0-20190424190010-1c43ea14716d
8 | github.com/astaxie/beego v0.0.0-20171218111859-f16688817aa4
9 | github.com/fatih/structs v1.0.0 // indirect
10 | github.com/go-sql-driver/mysql v1.4.1 // indirect
11 | github.com/golang/protobuf v0.0.0-20161117033126-8ee79997227b // indirect
12 | github.com/google/go-github v0.0.0-20170213222733-30a21ee1a383
13 | github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect
14 | github.com/gorilla/context v1.1.1 // indirect
15 | github.com/gorilla/mux v0.0.0-20160920230813-757bef944d0f
16 | github.com/lib/pq v0.0.0-20170213221049-ba5d4f7a3556
17 | github.com/mattn/go-sqlite3 v1.11.0 // indirect
18 | github.com/nlopes/slack v0.0.0-20190421170715-65ea2b979a7f
19 | github.com/satori/go.uuid v0.0.0-20160927100844-b061729afc07
20 | github.com/stretchr/testify v1.2.2
21 | github.com/trivago/tgo v0.0.0-20170209110642-4c3f9107de30 // indirect
22 | github.com/xeipuuv/gojsonpointer v0.0.0-20151027082146-e0fe6f683076 // indirect
23 | github.com/xeipuuv/gojsonreference v0.0.0-20150808065054-e02fc20de94c // indirect
24 | github.com/xeipuuv/gojsonschema v0.0.0-20170210233622-6b67b3fab74d
25 | golang.org/x/net v0.0.0-20170211013127-61557ac0112b // indirect
26 | golang.org/x/oauth2 v0.0.0-20170214231824-b9780ec78894
27 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect
28 | google.golang.org/appengine v0.0.0-20170206203024-2e4a801b39fc // indirect
29 | )
30 |
--------------------------------------------------------------------------------
/shared/types/fields.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/astaxie/beego/orm"
9 | )
10 |
11 | // Custom type for JSON formatting.
12 | // Implements beego 'fielder' interface.
13 | type Time struct {
14 | Value time.Time
15 | }
16 |
17 | func (t Time) MarshalJSON() ([]byte, error) {
18 | return []byte(t.String()), nil
19 | }
20 |
21 | func (t *Time) UnmarshalJSON(data []byte) error {
22 | var timeStr string
23 | err := json.Unmarshal(data, &timeStr)
24 | if err != nil {
25 | return err
26 | }
27 | if timeStr == "" {
28 | t.Value = time.Time{}
29 | } else {
30 | value, err := time.Parse(time.RFC3339, timeStr)
31 | if err != nil {
32 | return err
33 | }
34 | t.Value = value
35 | }
36 | return nil
37 | }
38 |
39 | func (t Time) HasValue() bool {
40 | zeroTime := time.Time{}
41 | return !t.Value.Equal(zeroTime)
42 | }
43 |
44 | func (t Time) Get() *time.Time {
45 | if t.HasValue() {
46 | return &t.Value
47 | }
48 | return nil
49 | }
50 |
51 | func (t Time) String() string {
52 | if t.HasValue() {
53 | return fmt.Sprintf(`"%s"`, t.Value.Format(time.RFC3339))
54 | }
55 | return "null"
56 | }
57 |
58 | func (t Time) FieldType() int {
59 | return orm.TypeDateTimeField
60 | }
61 | func (t *Time) SetRaw(value interface{}) error {
62 | if value == nil {
63 | t.Value = time.Time{}
64 | } else {
65 | t.Value = value.(time.Time)
66 | }
67 | return nil
68 | }
69 |
70 | func (t Time) RawValue() interface{} {
71 | if t.HasValue() {
72 | return t.Value
73 | } else {
74 | return nil
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/services/code/code_mock.go:
--------------------------------------------------------------------------------
1 | package code
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/Nextdoor/conductor/shared/types"
7 | )
8 |
9 | type CodeServiceMock struct {
10 | CommitsOnBranchMock func(string, int) ([]*types.Commit, error)
11 | CommitsOnBranchAfterMock func(string, string) ([]*types.Commit, error)
12 | CompareRefsMock func(string, string) ([]*types.Commit, error)
13 | RevertMock func(sha1, branch string) error
14 | ParseWebhookForBranchMock func(r *http.Request) (string, error)
15 | }
16 |
17 | func (m *CodeServiceMock) CommitsOnBranch(branch string, max int) ([]*types.Commit, error) {
18 | if m.CommitsOnBranchMock == nil {
19 | return nil, nil
20 | }
21 | return m.CommitsOnBranchMock(branch, max)
22 | }
23 |
24 | func (m *CodeServiceMock) CommitsOnBranchAfter(branch string, sha string) ([]*types.Commit, error) {
25 | if m.CommitsOnBranchAfterMock == nil {
26 | return nil, nil
27 | }
28 | return m.CommitsOnBranchAfterMock(branch, sha)
29 | }
30 |
31 | func (m *CodeServiceMock) CompareRefs(oldRef, newRef string) ([]*types.Commit, error) {
32 | if m.CompareRefsMock == nil {
33 | return nil, nil
34 | }
35 | return m.CompareRefsMock(oldRef, newRef)
36 | }
37 |
38 | func (m *CodeServiceMock) Revert(sha1, branch string) error {
39 | if m.RevertMock == nil {
40 | return nil
41 | }
42 | return m.RevertMock(sha1, branch)
43 | }
44 |
45 | func (m *CodeServiceMock) ParseWebhookForBranch(r *http.Request) (string, error) {
46 | if m.ParseWebhookForBranchMock == nil {
47 | return "", nil
48 | }
49 | return m.ParseWebhookForBranchMock(r)
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/actions/admin.js:
--------------------------------------------------------------------------------
1 | import API from 'api';
2 | import Actions from 'types/actions';
3 | import {stringToMode} from 'types/config';
4 |
5 | const requestConfig = () => {
6 | return {
7 | type: Actions.RequestConfig
8 | };
9 | };
10 |
11 | const receiveConfig = (response) => {
12 | return {
13 | type: Actions.ReceiveConfig,
14 | config: response,
15 | receivedAt: Date.now()
16 | };
17 | };
18 |
19 | const receiveConfigError = (error) => {
20 | return {
21 | type: Actions.ReceiveConfigError,
22 | error: error,
23 | receivedAt: Date.now()
24 | };
25 | };
26 |
27 | const requestToggleMode = () => {
28 | return {
29 | type: Actions.RequestToggleMode
30 | };
31 | };
32 |
33 | const receiveToggleMode = (mode) => {
34 | return {
35 | type: Actions.ReceiveToggleMode,
36 | mode: stringToMode(mode),
37 | receivedAt: Date.now()
38 | };
39 | };
40 |
41 | const receiveToggleModeError = (error) => {
42 | return {
43 | type: Actions.ReceiveToggleModeError,
44 | error: error,
45 | receivedAt: Date.now()
46 | };
47 | };
48 |
49 | const fetchConfig = () => (dispatch) => {
50 | API.getConfig(dispatch);
51 | };
52 |
53 | const toggleMode = () => (dispatch, getState) => {
54 | const state = getState();
55 | const train = state.train.details;
56 | API.toggleMode(train.id, state.admin.config.mode, dispatch);
57 | };
58 |
59 | export default {
60 | requestConfig,
61 | receiveConfig,
62 | receiveConfigError,
63 | requestToggleMode,
64 | receiveToggleMode,
65 | receiveToggleModeError,
66 | fetchConfig,
67 | toggleMode
68 | };
69 |
--------------------------------------------------------------------------------
/services/auth/github.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | "github.com/Nextdoor/conductor/shared/flags"
8 | "github.com/Nextdoor/conductor/shared/github"
9 | )
10 |
11 | var (
12 | githubAuthClientID = flags.EnvString("GITHUB_AUTH_CLIENT_ID", "")
13 | githubAuthClientSecret = flags.EnvString("GITHUB_AUTH_CLIENT_SECRET", "")
14 | )
15 |
16 | type githubAuth struct {
17 | authClient github.Auth
18 | }
19 |
20 | func newGithubAuth() *githubAuth {
21 | if githubAuthClientID == "" {
22 | panic(errors.New("github_auth_client_id flag must be set."))
23 | }
24 | if githubAuthClientSecret == "" {
25 | panic(errors.New("github_auth_client_secret flag must be set."))
26 | }
27 | return &githubAuth{
28 | authClient: github.NewAuth(githubAuthClientID, githubAuthClientSecret),
29 | }
30 | }
31 |
32 | func (a *githubAuth) AuthProvider() string {
33 | return "Github"
34 | }
35 |
36 | func (a *githubAuth) AuthURL(hostname string) string {
37 | req, _ := http.NewRequest("GET", a.authClient.AuthorizeURL(), nil)
38 |
39 | q := req.URL.Query()
40 | q.Add("client_id", githubAuthClientID)
41 | q.Add("redirect_uri", redirectEndpoint(hostname))
42 | q.Add("scope", "user repo")
43 | req.URL.RawQuery = q.Encode()
44 |
45 | return req.URL.String()
46 | }
47 |
48 | func (a *githubAuth) Login(code string) (string, string, string, string, error) {
49 | accessToken, err := a.authClient.AccessToken(code)
50 | if err != nil {
51 | return "", "", "", "", err
52 | }
53 |
54 | name, email, avatar, err := a.authClient.UserInfo(accessToken)
55 | if err != nil {
56 | return "", "", "", "", err
57 | }
58 | return name, email, avatar, accessToken, err
59 | }
60 |
--------------------------------------------------------------------------------
/frontend/src/components/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import Auth from 'components/Auth';
5 | import Header from 'containers/Header';
6 | import Train from 'containers/Train';
7 | import Search from 'containers/Search';
8 |
9 | class App extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | }
13 |
14 | componentWillMount() {
15 | const {needToken, promptLogin, getToken} = this.props;
16 | if (needToken === true && promptLogin !== true) {
17 | getToken();
18 | }
19 | }
20 |
21 | render() {
22 | const {needToken, promptLogin} = this.props;
23 | if (needToken === true && promptLogin !== true) {
24 | return null;
25 | }
26 |
27 | if (promptLogin) {
28 | return ;
29 | }
30 |
31 | if (this.props.location.pathname.includes('/search')) {
32 | return this.getSearch(this.props.params);
33 | }
34 |
35 | return this.getTrain();
36 | }
37 |
38 | getTrain() {
39 | return (
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | getSearch(params) {
48 | return (
49 |
50 |
51 |
52 |
53 | );
54 | }
55 | }
56 |
57 | App.propTypes = {
58 | needToken: PropTypes.bool.isRequired,
59 | promptLogin: PropTypes.bool.isRequired,
60 | getToken: PropTypes.func.isRequired,
61 | params: PropTypes.shape({
62 | trainId: PropTypes.string,
63 |
64 | search: PropTypes.bool,
65 | commit: PropTypes.string
66 | }),
67 | location: PropTypes.shape({
68 | pathname: PropTypes.string.isRequired
69 | }).isRequired
70 | };
71 |
72 | export default App;
73 |
--------------------------------------------------------------------------------
/services/phase/jenkins.go:
--------------------------------------------------------------------------------
1 | package phase
2 |
3 | import (
4 | "strconv"
5 |
6 | "github.com/Nextdoor/conductor/services/build"
7 | "github.com/Nextdoor/conductor/shared/flags"
8 | "github.com/Nextdoor/conductor/shared/settings"
9 | "github.com/Nextdoor/conductor/shared/types"
10 | )
11 |
12 | var (
13 | // These are the names of the Jenkins jobs which will kick off each phase.
14 | jenkinsDeliveryJob = flags.EnvString("JENKINS_DELIVERY_JOB", "")
15 | jenkinsVerificationJob = flags.EnvString("JENKINS_VERIFICATION_JOB", "")
16 | jenkinsDeployJob = flags.EnvString("JENKINS_DEPLOY_JOB", "")
17 | )
18 |
19 | type jenkinsPhase struct{}
20 |
21 | func newJenkins() *jenkinsPhase {
22 | return &jenkinsPhase{}
23 | }
24 |
25 | func (p *jenkinsPhase) Start(phaseType types.PhaseType, trainID,
26 | deliveryPhaseID, verificationPhaseID, deployPhaseID uint64, branch, sha string,
27 | buildUser *types.User) error {
28 |
29 | params := make(map[string]string)
30 | params["TRAIN_ID"] = strconv.FormatUint(trainID, 10)
31 | params["DELIVERY_PHASE_ID"] = strconv.FormatUint(deliveryPhaseID, 10)
32 | params["VERIFICATION_PHASE_ID"] = strconv.FormatUint(verificationPhaseID, 10)
33 | params["DEPLOY_PHASE_ID"] = strconv.FormatUint(deployPhaseID, 10)
34 | params["BRANCH"] = branch
35 | params["SHA"] = sha
36 | params["CONDUCTOR_HOSTNAME"] = settings.GetHostname()
37 | if buildUser != nil {
38 | params["BUILD_USER"] = buildUser.Name
39 | } else {
40 | params["BUILD_USER"] = "Conductor"
41 | }
42 |
43 | var job string
44 | switch phaseType {
45 | case types.Delivery:
46 | job = jenkinsDeliveryJob
47 | case types.Verification:
48 | job = jenkinsVerificationJob
49 | case types.Deploy:
50 | job = jenkinsDeployJob
51 | }
52 |
53 | if job == "" {
54 | return nil
55 | }
56 |
57 | return build.Jenkins().TriggerJob(job, params)
58 | }
59 |
--------------------------------------------------------------------------------
/frontend/src/reducers/admin.js:
--------------------------------------------------------------------------------
1 | import Actions from 'types/actions';
2 |
3 | const admin = (state = null, action) => {
4 | switch (action.type) {
5 | case Actions.RequestConfig:
6 | return Object.assign({}, state, {
7 | requestConfig: {
8 | fetching: true,
9 | error: null,
10 | receivedAt: null
11 | }
12 | });
13 | case Actions.ReceiveConfig:
14 | return Object.assign({}, state, {
15 | config: action.config,
16 | requestConfig: {
17 | fetching: false,
18 | error: null,
19 | receivedAt: action.receivedAt
20 | }
21 | });
22 | case Actions.ReceiveConfigError:
23 | return Object.assign({}, state, {
24 | config: null,
25 | requestConfig: {
26 | fetching: false,
27 | error: action.error,
28 | receivedAt: action.receivedAt
29 | }
30 | });
31 | case Actions.RequestToggleMode:
32 | return Object.assign({}, state, {
33 | requestToggleMode: {
34 | fetching: true,
35 | error: null,
36 | receivedAt: null
37 | }
38 | });
39 | case Actions.ReceiveToggleMode:
40 | return Object.assign({}, state, {
41 | config: Object.assign({}, state.config, {
42 | mode: action.mode
43 | }),
44 | requestToggleMode: {
45 | fetching: false,
46 | error: null,
47 | receivedAt: action.receivedAt
48 | }
49 | });
50 | case Actions.ReceiveToggleModeError:
51 | return Object.assign({}, state, {
52 | requestToggleMode: {
53 | fetching: false,
54 | error: action.error,
55 | receivedAt: action.receivedAt
56 | }
57 | });
58 | default:
59 | return state;
60 | }
61 | };
62 |
63 | export default admin;
64 |
--------------------------------------------------------------------------------
/frontend/src/components/Commits.spec.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-invalid-this */
2 |
3 | import React from 'react';
4 | import {mount} from 'enzyme';
5 | import Commits from 'components/Commits';
6 | import {newTrain, completeRequest} from 'test/TestData';
7 |
8 | describe('Commits', function() {
9 |
10 | it('gets groups', function() {
11 |
12 | this.train = newTrain;
13 | this.wrapper = mount(
14 | );
15 |
16 | const groups = this.wrapper.instance().getGroups();
17 | expect(groups).toEqual(expect.arrayContaining([
18 | [this.train.commits[0].author_name,
19 | [{
20 | message: this.train.commits[0].message,
21 | url: this.train.commits[0].url,
22 | }],
23 | [{
24 | done: false,
25 | key: this.train.tickets[0].key,
26 | url: this.train.tickets[0].url,
27 | }]
28 | ],
29 | [this.train.commits[1].author_name,
30 | [{
31 | message: this.train.commits[1].message,
32 | url: this.train.commits[1].url,
33 | }, {
34 | message: this.train.commits[2].message,
35 | url: this.train.commits[2].url,
36 | }],
37 | [{
38 | done: false,
39 | key: this.train.tickets[1].key,
40 | url: this.train.tickets[1].url,
41 | }]
42 | ]
43 | ]));
44 | expect(groups.length).toEqual(2);
45 | });
46 |
47 | it('renders correctly', function() {
48 |
49 | this.train = newTrain;
50 | this.wrapper = mount(
51 | );
52 |
53 | expect(this.wrapper.text()).toEqual(expect.stringContaining(this.train.commits[0].author_name));
54 | expect(this.wrapper.text()).toEqual(expect.stringContaining(this.train.commits[0].message));
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/shared/types/enums.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | type Mode int
8 |
9 | const (
10 | Schedule Mode = iota
11 | Manual
12 | )
13 |
14 | func (m Mode) String() string {
15 | switch m {
16 | case Schedule:
17 | return "schedule"
18 | case Manual:
19 | return "manual"
20 | default:
21 | panic(fmt.Errorf("Unknown mode: %d", m))
22 | }
23 | }
24 |
25 | func (m Mode) IsScheduleMode() bool {
26 | return m == Schedule
27 | }
28 |
29 | func (m Mode) IsManualMode() bool {
30 | return m == Manual
31 | }
32 |
33 | func ModeFromString(mode string) (Mode, error) {
34 | switch mode {
35 | case Schedule.String():
36 | return Schedule, nil
37 | case Manual.String():
38 | return Manual, nil
39 | default:
40 | return Schedule, fmt.Errorf("Unknown mode %s", mode)
41 | }
42 | }
43 |
44 | type PhaseType int
45 |
46 | const (
47 | Delivery PhaseType = iota
48 | Verification
49 | Deploy
50 | )
51 |
52 | func (e PhaseType) String() string {
53 | switch e {
54 | case Delivery:
55 | return "delivery"
56 | case Verification:
57 | return "verification"
58 | case Deploy:
59 | return "deploy"
60 | default:
61 | panic(fmt.Errorf("Unknown mode: %d", e))
62 | }
63 | }
64 |
65 | func PhaseTypeFromString(phaseType string) (PhaseType, error) {
66 | switch phaseType {
67 | case "delivery":
68 | return Delivery, nil
69 | case "verification":
70 | return Verification, nil
71 | case "deploy":
72 | return Deploy, nil
73 | default:
74 | return -1, fmt.Errorf("Unknown phase type: %s", phaseType)
75 | }
76 | }
77 |
78 | type JobResult int
79 |
80 | const (
81 | Ok JobResult = iota
82 | Error
83 | )
84 |
85 | func (j JobResult) String() string {
86 | switch j {
87 | case Ok:
88 | return "ok"
89 | case Error:
90 | return "error"
91 | default:
92 | panic(fmt.Errorf("Unknown mode: %d", j))
93 | }
94 | }
95 |
96 | func (j JobResult) IsValid() bool {
97 | return j >= Ok && j <= Error
98 | }
99 |
--------------------------------------------------------------------------------
/shared/flags/flags.go:
--------------------------------------------------------------------------------
1 | package flags
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strconv"
7 | )
8 |
9 | func EnvString(name string, defaultValue string) string {
10 | value, present := os.LookupEnv(name)
11 | if !present {
12 | return defaultValue
13 | }
14 | return value
15 | }
16 |
17 | func RequiredEnvString(name string) string {
18 | value, present := os.LookupEnv(name)
19 | if !present {
20 | panic(fmt.Sprintf("Env string %s must be set", name))
21 | }
22 | return value
23 | }
24 |
25 | func EnvBool(name string, defaultValue bool) bool {
26 | value, present := os.LookupEnv(name)
27 | if !present {
28 | return defaultValue
29 | }
30 |
31 | result, err := strconv.ParseBool(value)
32 | if err != nil {
33 | panic(fmt.Sprintf(
34 | "Env bool %s must be /[0-1]|t(rue)?|f(alse)?/i, not %s", name, value))
35 | }
36 | return result
37 | }
38 |
39 | func RequiredEnvBool(name string) bool {
40 | value, present := os.LookupEnv(name)
41 | if !present {
42 | panic(fmt.Sprintf("Env bool %s must be set", name))
43 | }
44 |
45 | result, err := strconv.ParseBool(value)
46 | if err != nil {
47 | panic(fmt.Sprintf(
48 | "Env bool %s must be /[0-1]|t(rue)?|f(alse)?/i, not %s", name, value))
49 | }
50 | return result
51 | }
52 |
53 | func EnvInt(name string, defaultValue int) int {
54 | value, present := os.LookupEnv(name)
55 | if !present {
56 | return defaultValue
57 | }
58 |
59 | result, err := strconv.ParseInt(value, 10, 32)
60 | if err != nil || result < 0 {
61 | panic(fmt.Sprintf("Env int %s must be a valid positive integer, not %s", name, value))
62 | }
63 | return int(result)
64 | }
65 |
66 | func RequiredEnvInt(name string) int {
67 | value, present := os.LookupEnv(name)
68 | if !present {
69 | panic(fmt.Sprintf("Env int %s must be set", name))
70 | }
71 |
72 | result, err := strconv.ParseInt(value, 10, 32)
73 | if err != nil || result < 0 {
74 | panic(fmt.Sprintf("Env int %s must be a valid positive integer, not %s", name, value))
75 | }
76 | return int(result)
77 | }
78 |
--------------------------------------------------------------------------------
/dockerSetup.sh:
--------------------------------------------------------------------------------
1 | # This script setups docker containers for the postgres database and conductor service, on your local machine.
2 | # Hence you should have docker app downloaded and running on your machine before running this script.
3 | # It is also adviced to increase available memory for the docker app more than the default setting, to speed up performance.
4 |
5 | #!/bin/bash
6 |
7 | set -e
8 |
9 | PINK='\033[0;35m'
10 | NC='\033[0m' # No Color
11 |
12 | ./checkOAuthEnv.sh
13 | OAUTH_CLIENT_ID="${CONDUCTOR_OAUTH_CLIENT_ID}"
14 |
15 | if [ ! -f "frontend/envfile" ]; then
16 | echo -e "${PINK}creating frontend/envfile ...${NC}"
17 | echo -e "OAUTH_PROVIDER=Github \nOAUTH_ENDPOINT=https://github.com/login/oauth/authorize \nOAUTH_PAYLOAD='{\"client_id\": \"${OAUTH_CLIENT_ID}\", \"redirect_uri\": \"http://localhost/api/auth/login\", \"scope\": \"user repo\"}'" > frontend/envfile
18 | fi
19 |
20 | echo -e "${PINK}checking install of package management tools..${NC}"
21 | if which brew && ! brew ls --versions yarn; then brew install yarn; fi;
22 |
23 | echo -e "${PINK}stop all existing containers to avoid attached port conflicts..${NC}"
24 | docker container stop $(docker container ls -aq)
25 |
26 | echo -e "${PINK}bringing up new postgres docker container for conductor...${NC}"
27 | make postgres
28 |
29 | echo -e "${PINK}sleeping for 5 seconds before connecting with new postgres instance...${NC}"
30 | sleep 5
31 |
32 | echo -e "${PINK}filling postgres instance with test data...${NC}"
33 | make test-data
34 |
35 | echo -e "${PINK}building react.js and frontend static files webpack into resources/frontend...${NC}"
36 | make prod-compile -C frontend
37 |
38 | if pgrep nginx; then
39 | echo -e "${PINK} Stop potential running ngnix (from nativeMacSetup) to avoid port conflict...${NC}"
40 | sudo nginx -s stop
41 | fi
42 |
43 | echo -e "${PINK}building backend conductor service...${NC}"
44 | export POSTGRES_HOST=conductor-postgres
45 | make docker-build
46 |
47 | echo -e "${PINK}starting conductor service${NC}"
48 | make docker-run
49 |
--------------------------------------------------------------------------------
/core/testutils_test.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 | "testing"
7 |
8 | "github.com/gorilla/mux"
9 | "github.com/stretchr/testify/assert"
10 |
11 | "github.com/Nextdoor/conductor/services/auth"
12 | "github.com/Nextdoor/conductor/services/data"
13 | "github.com/Nextdoor/conductor/shared/types"
14 | )
15 |
16 | type TestData struct {
17 | Train *types.Train
18 | User *types.User
19 | TokenCookie *http.Cookie
20 | }
21 |
22 | var token uint64
23 | var robotCreated bool
24 |
25 | func setup(t *testing.T) (*mux.Router, *TestData) {
26 | endpoints := Endpoints()
27 | conductorServer := NewServer(endpoints)
28 |
29 | dataClient := data.NewClient()
30 |
31 | commit := types.Commit{SHA: "sha1"}
32 | commits := []*types.Commit{&commit}
33 |
34 | if !robotCreated {
35 | user, err := dataClient.ReadOrCreateUser("robot", "robot@example.com")
36 | assert.NoError(t, err)
37 |
38 | err = dataClient.WriteToken("robot", user.Name, user.Email, "", "")
39 | assert.NoError(t, err)
40 |
41 | robotCreated = true
42 | }
43 |
44 | user, err := dataClient.ReadOrCreateUser("test_user", "test_email")
45 | assert.NoError(t, err)
46 |
47 | tokenVal := strconv.FormatUint(token, 10)
48 | token += 1
49 |
50 | types.CustomizeJobs(types.Delivery, []string{})
51 | types.CustomizeJobs(types.Verification, []string{})
52 | types.CustomizeJobs(types.Deploy, []string{})
53 |
54 | err = dataClient.SetMode(types.Manual)
55 | assert.NoError(t, err)
56 | err = dataClient.SetOptions(&types.DefaultOptions)
57 | assert.NoError(t, err)
58 |
59 | err = dataClient.WriteToken(tokenVal, user.Name, user.Email, "", "")
60 | assert.NoError(t, err)
61 | train, err := dataClient.CreateTrain("test_train", user, commits)
62 | assert.NoError(t, err)
63 |
64 | return conductorServer, &TestData{
65 | Train: train,
66 | User: user,
67 | TokenCookie: auth.NewCookie(tokenVal),
68 | }
69 | }
70 |
71 | func listen(t *testing.T, server *mux.Router) {
72 | if err := http.ListenAndServe(":8400", server); err != nil {
73 | t.Error(err)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/src/types/actions.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | // Admin
4 | RequestConfig: 'RequestConfig',
5 | ReceiveConfig: 'ReceiveConfig',
6 | ReceiveConfigError: 'ReceiveConfigError',
7 | RequestToggleMode: 'RequestToggleMode',
8 | ReceiveToggleMode: 'ReceiveToggleMode',
9 | ReceiveToggleModeError: 'ReceiveToggleModeError',
10 |
11 | // Phase
12 |
13 | // Search
14 | RequestSearch: 'RequestSearch',
15 | ReceiveSearch: 'ReceiveSearch',
16 | ReceiveSearchError: 'ReceiveSearchError',
17 |
18 | // Self
19 | RequestSelf: 'RequestSelf',
20 | ReceiveSelf: 'ReceiveSelf',
21 | ReceiveSelfError: 'ReceiveSelfError',
22 |
23 | // Token
24 | SetToken: 'SetToken',
25 | PromptLogin: 'PromptLogin',
26 | DeleteToken: 'DeleteToken',
27 | RequestLogout: 'RequestLogout',
28 | ReceiveLogout: 'ReceiveLogout',
29 | ReceiveLogoutError: 'ReceiveLogoutError',
30 |
31 | // Train
32 | RequestTrain: 'RequestTrain',
33 | ReceiveTrain: 'ReceiveTrain',
34 | ReceiveTrainError: 'ReceiveTrainError',
35 | RequestToggleClose: 'RequestToggleClose',
36 | ReceiveToggleClose: 'ReceiveToggleClose',
37 | ReceiveToggleCloseError: 'ReceiveToggleCloseError',
38 | RequestExtend: 'RequestExtend',
39 | ReceiveExtend: 'ReceiveExtend',
40 | ReceiveExtendError: 'ReceiveExtendError',
41 | RequestBlock: 'RequestBlock',
42 | ReceiveBlock: 'ReceiveBlock',
43 | ReceiveBlockError: 'ReceiveBlockError',
44 | RequestUnblock: 'RequestUnblock',
45 | ReceiveUnblock: 'ReceiveUnblock',
46 | ReceiveUnblockError: 'ReceiveUnblockError',
47 | RequestCancel: 'RequestCancel',
48 | ReceiveCancel: 'ReceiveCancel',
49 | ReceiveCancelError: 'ReceiveCancelError',
50 | RequestRollback: 'RequestRollback',
51 | ReceiveRollback: 'ReceiveRollback',
52 | ReceiveRollbackError: 'ReceiveRollbackError',
53 | RequestRestart: 'RequestRestart',
54 | ReceiveRestart: 'ReceiveRestart',
55 | ReceiveRestartError: 'ReceiveRestartError',
56 | RequestChangeEngineer: 'RequestChangeEngineer',
57 | ReceiveChangeEngineer: 'ReceiveChangeEngineer',
58 | ReceiveChangeEngineerError: 'ReceiveChangeEngineerError',
59 | };
60 |
--------------------------------------------------------------------------------
/services/code/code.go:
--------------------------------------------------------------------------------
1 | /* Handles retrieving descriptions of the code and reverting. */
2 | package code
3 |
4 | import (
5 | "fmt"
6 | "net/http"
7 | "regexp"
8 | "sync"
9 |
10 | "github.com/Nextdoor/conductor/shared/flags"
11 | "github.com/Nextdoor/conductor/shared/logger"
12 | "github.com/Nextdoor/conductor/shared/types"
13 | )
14 |
15 | var (
16 | implementationFlag = flags.EnvString("CODE_IMPL", "fake")
17 | // Target branch pattern (regex).
18 | branchPattern = flags.EnvString("BRANCH_PATTERN", "refs/heads/(master)")
19 | )
20 |
21 | var branchRegex *regexp.Regexp
22 |
23 | type Service interface {
24 | CommitsOnBranch(string, int) ([]*types.Commit, error)
25 | CommitsOnBranchAfter(string, string) ([]*types.Commit, error)
26 | CompareRefs(string, string) ([]*types.Commit, error)
27 | Revert(sha1, branch string) error
28 | ParseWebhookForBranch(r *http.Request) (string, error)
29 | }
30 |
31 | var (
32 | service Service
33 | getOnce sync.Once
34 | )
35 |
36 | func GetService() Service {
37 | getOnce.Do(func() {
38 | service = newService()
39 | })
40 | return service
41 | }
42 |
43 | type fake struct{}
44 |
45 | func newService() Service {
46 | branchRegex = regexp.MustCompile(branchPattern)
47 |
48 | logger.Info("Using %s implementation for Code service", implementationFlag)
49 | var service Service
50 | switch implementationFlag {
51 | case "fake":
52 | service = newFake()
53 | case "github":
54 | service = newGithub()
55 | default:
56 | panic(fmt.Errorf("Unknown Code Implementation: %s", implementationFlag))
57 | }
58 | return service
59 | }
60 |
61 | func newFake() *fake {
62 | return &fake{}
63 | }
64 |
65 | func (c *fake) CommitsOnBranch(branch string, max int) ([]*types.Commit, error) {
66 | return nil, nil
67 | }
68 |
69 | func (c *fake) CommitsOnBranchAfter(branch string, sha string) ([]*types.Commit, error) {
70 | return nil, nil
71 | }
72 |
73 | func (c *fake) CompareRefs(oldRef, newRef string) ([]*types.Commit, error) {
74 | return nil, nil
75 | }
76 |
77 | func (c *fake) Revert(sha1, branch string) error {
78 | return nil
79 | }
80 |
81 | func (c *fake) ParseWebhookForBranch(r *http.Request) (string, error) {
82 | return "", nil
83 | }
84 |
--------------------------------------------------------------------------------
/shared/datadog/datadog.go:
--------------------------------------------------------------------------------
1 | package datadog
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/DataDog/datadog-go/statsd"
8 |
9 | "github.com/Nextdoor/conductor/shared/flags"
10 | "github.com/Nextdoor/conductor/shared/logger"
11 | )
12 |
13 | var enableDatadog = flags.EnvBool("ENABLE_DATADOG", true)
14 |
15 | func newStatsdClient() *statsd.Client {
16 | if !enableDatadog {
17 | return nil
18 | }
19 | // All metrics with be prefixed with "conductor."
20 | c, err := statsd.New(fmt.Sprintf("%v:%v", os.Getenv("STATSD_HOST"), 8125),
21 | statsd.WithNamespace("conductor."))
22 | if err != nil {
23 | panic(fmt.Sprintf("Could not create statsd client: %v", err))
24 | }
25 | return c
26 | }
27 |
28 | var c = newStatsdClient()
29 |
30 | func Client() *statsd.Client {
31 | return c
32 | }
33 |
34 | func Incr(name string, tags []string) {
35 | if !enableDatadog {
36 | return
37 | }
38 | err := c.Incr(name, tags, 1)
39 | if err != nil {
40 | logger.Error("Error sending %s metric: %v", name, err)
41 | }
42 | }
43 |
44 | func Count(name string, count int, tags []string) {
45 | if !enableDatadog {
46 | return
47 | }
48 | err := c.Count(name, int64(count), tags, 1)
49 | if err != nil {
50 | logger.Error("Error sending %s metric: %v", name, err)
51 | }
52 | }
53 |
54 | func Gauge(name string, value float64, tags []string) {
55 | if !enableDatadog {
56 | return
57 | }
58 | err := c.Gauge(name, value, tags, 1)
59 | if err != nil {
60 | logger.Error("Error sending %s metric: %v", name, err)
61 | }
62 | }
63 |
64 | // log logs an event to stdout, and also sends it to datadog.
65 | func log(alertType statsd.EventAlertType, format string, args ...interface{}) {
66 | if enableDatadog {
67 | e := statsd.NewEvent("conductor", fmt.Sprintf(format, args...))
68 | e.AlertType = alertType
69 | err := c.Event(e)
70 | if err != nil {
71 | logger.Error("Could not create datadog event: %v", err)
72 | }
73 | }
74 | switch alertType {
75 | case statsd.Info:
76 | logger.Info(format, args...)
77 | default:
78 | logger.Error(format, args...)
79 | }
80 | }
81 |
82 | func Info(format string, args ...interface{}) {
83 | log(statsd.Info, format, args...)
84 | }
85 |
86 | func Error(format string, args ...interface{}) {
87 | log(statsd.Error, format, args...)
88 | }
89 |
--------------------------------------------------------------------------------
/core/background.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/Nextdoor/conductor/services/code"
7 | "github.com/Nextdoor/conductor/services/data"
8 | "github.com/Nextdoor/conductor/services/messaging"
9 | "github.com/Nextdoor/conductor/services/phase"
10 | "github.com/Nextdoor/conductor/services/ticket"
11 | "github.com/Nextdoor/conductor/shared/datadog"
12 | "github.com/Nextdoor/conductor/shared/flags"
13 | )
14 |
15 | const SyncTicketsInterval = time.Second * 10
16 | const CheckJobsInterval = time.Second * 5
17 | const CheckTrainLockInterval = time.Second * 5
18 |
19 | // How long to wait until starting background tasks after boot, in seconds.
20 | // This is useful when upgrading Conductor, to avoid race conditions when two instances are polling at once.
21 | var backgroundTaskStartDelay = flags.EnvInt("BACKGROUND_TASK_START_DELAY", 0)
22 |
23 | func backgroundTaskLoop() {
24 | datadog.Info("Waiting %d seconds to start background jobs.", backgroundTaskStartDelay)
25 | time.Sleep(time.Second * time.Duration(backgroundTaskStartDelay))
26 | datadog.Info("Starting background jobs.")
27 |
28 | // This loop handles restarting the background task loop if it ever panics.
29 | killed := make(chan bool)
30 | for {
31 | go func() {
32 | dataClient := data.NewClient()
33 | codeService := code.GetService()
34 | messagingService := messaging.GetService()
35 | phaseService := phase.GetService()
36 | ticketService := ticket.GetService()
37 |
38 | syncTicketsTicker := time.NewTicker(SyncTicketsInterval)
39 | checkJobsTicker := time.NewTicker(CheckJobsInterval)
40 | checkTrainLockTicker := time.NewTicker(CheckTrainLockInterval)
41 | defer func() {
42 | err, stack := parsePanic(recover())
43 | if err != nil {
44 | datadog.Error("Panic in background task: %v. Stack trace: %v", err, stack)
45 | }
46 | killed <- true
47 | }()
48 |
49 | for {
50 | select {
51 | case <-syncTicketsTicker.C:
52 | syncTickets(dataClient, codeService, messagingService, phaseService, ticketService)
53 | case <-checkJobsTicker.C:
54 | checkJobs(dataClient)
55 | case <-checkTrainLockTicker.C:
56 | checkTrainLock(dataClient, codeService, messagingService, phaseService, ticketService)
57 | }
58 | }
59 | }()
60 | <-killed
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/frontend/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from 'redux';
2 |
3 | import admin from 'reducers/admin';
4 | import phase from 'reducers/phase';
5 | import search from 'reducers/search';
6 | import self from 'reducers/self';
7 | import token from 'reducers/token';
8 | import train from 'reducers/train';
9 |
10 | export const initialState = {
11 | admin: {
12 | config: null,
13 | requestConfig: {
14 | fetching: false,
15 | error: null,
16 | receivedAt: null
17 | },
18 | requestToggleMode: {
19 | fetching: false,
20 | error: null,
21 | receivedAt: null
22 | }
23 | },
24 | phase: {
25 | deployRequest: {
26 | fetching: false,
27 | error: null,
28 | receivedAt: null
29 | }
30 | },
31 | search: {
32 | details: null,
33 | request: {
34 | fetching: false,
35 | error: null,
36 | receivedAt: null
37 | }
38 | },
39 | self: {
40 | details: null,
41 | request: {
42 | fetching: false,
43 | error: null,
44 | receivedAt: null
45 | }
46 | },
47 | token: {
48 | promptLogin: false,
49 | token: null,
50 | logoutRequest: {
51 | fetching: false,
52 | error: null,
53 | receivedAt: null
54 | }
55 | },
56 | train: {
57 | details: null,
58 | request: {
59 | fetching: false,
60 | error: null,
61 | receivedAt: null
62 | },
63 | requestToggleClose: {
64 | fetching: false,
65 | error: null,
66 | receivedAt: null
67 | },
68 | requestExtend: {
69 | fetching: false,
70 | error: null,
71 | receivedAt: null
72 | },
73 | requestBlock: {
74 | fetching: false,
75 | error: null,
76 | receivedAt: null
77 | },
78 | requestUnblock: {
79 | fetching: false,
80 | error: null,
81 | receivedAt: null
82 | },
83 | requestCancel: {
84 | fetching: false,
85 | error: null,
86 | receivedAt: null
87 | },
88 | requestRollback: {
89 | fetching: false,
90 | error: null,
91 | receivedAt: null
92 | }
93 | }
94 | };
95 |
96 | export default combineReducers({
97 | admin,
98 | phase,
99 | search,
100 | self,
101 | token,
102 | train,
103 | });
104 |
--------------------------------------------------------------------------------
/services/auth/auth.go:
--------------------------------------------------------------------------------
1 | /* Handles authenticating requests and performing oauth. */
2 | package auth
3 |
4 | import (
5 | "fmt"
6 | "net/http"
7 | "sync"
8 | "time"
9 |
10 | "github.com/Nextdoor/conductor/shared/flags"
11 | "github.com/Nextdoor/conductor/shared/logger"
12 | )
13 |
14 | var (
15 | implementationFlag = flags.EnvString("AUTH_IMPL", "fake")
16 |
17 | // This cookie name has to match the cookie name clients expect.
18 | authCookieName = flags.EnvString("AUTH_COOKIE_NAME", "conductor-auth")
19 | )
20 |
21 | type Service interface {
22 | AuthProvider() string
23 | AuthURL(hostname string) string
24 | Login(code string) (name, email, avatar, codeToken string, err error)
25 | }
26 |
27 | type auth struct{}
28 |
29 | func GetCookieName() string {
30 | return authCookieName
31 | }
32 |
33 | func NewCookie(token string) *http.Cookie {
34 | return &http.Cookie{Name: GetCookieName(), Value: token, Path: "/"}
35 | }
36 |
37 | func EmptyCookie() *http.Cookie {
38 | return &http.Cookie{Name: GetCookieName(), Value: "", Path: "/", Expires: time.Time{}}
39 | }
40 |
41 | var (
42 | service Service
43 | getOnce sync.Once
44 | )
45 |
46 | func GetService() Service {
47 | getOnce.Do(func() {
48 | service = newService()
49 | })
50 | return service
51 | }
52 |
53 | func newService() Service {
54 | logger.Info("Using %s implementation for Auth service", implementationFlag)
55 | var service Service
56 | switch implementationFlag {
57 | case "fake":
58 | service = newFake()
59 | case "github":
60 | service = newGithubAuth()
61 | default:
62 | panic(fmt.Errorf("Unknown Auth Implementation: %s", implementationFlag))
63 | }
64 | return service
65 | }
66 |
67 | type fake struct{}
68 |
69 | func newFake() *fake {
70 | return &fake{}
71 | }
72 |
73 | func redirectEndpoint(hostname string) string {
74 | return fmt.Sprintf("%s/api/auth/done", hostname)
75 | }
76 |
77 | func (a *fake) AuthProvider() string {
78 | return ""
79 | }
80 |
81 | func (a *fake) AuthURL(hostname string) string {
82 | return ""
83 | }
84 |
85 | func (a *fake) Login(code string) (name, email, avatar, codeToken string, err error) {
86 | // If a developer doesn't choose to do github setup in envfile,
87 | // this should still allow going past the login page, without fetching
88 | // github profile details and avatar
89 | return "dev", "dev@conductor.com", "", "", nil
90 | }
91 |
--------------------------------------------------------------------------------
/core/ticket_test.go:
--------------------------------------------------------------------------------
1 | // +build data,ticket
2 |
3 | package core
4 |
5 | import (
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 |
10 | "github.com/Nextdoor/conductor/services/code"
11 | "github.com/Nextdoor/conductor/services/data"
12 | "github.com/Nextdoor/conductor/services/messaging"
13 | "github.com/Nextdoor/conductor/services/phase"
14 | "github.com/Nextdoor/conductor/services/ticket"
15 | "github.com/Nextdoor/conductor/shared/types"
16 | )
17 |
18 | const (
19 | branch = "foobar"
20 | email1 = "test@example.com"
21 | email2 = "test2@example.com"
22 | message1 = "commit number 1"
23 | message2 = "a second commit"
24 | message3 = "the third commit message"
25 | sha1 = "0"
26 | sha2 = "1"
27 | sha3 = "2"
28 | )
29 |
30 | func TestSyncTickets(t *testing.T) {
31 | dataClient := data.NewClient()
32 | codeService := &code.CodeServiceMock{}
33 | messagingService := messaging.MessagingServiceMock{}
34 | phaseService := &phase.PhaseServiceMock{}
35 | // This is a real ticketing system (JIRA) test.
36 | ticketService := ticket.GetService()
37 | authorName := ticket.DefaultAccountID
38 | testCommits := []*types.Commit{
39 | {AuthorEmail: email1, AuthorName: authorName, SHA: sha1, Message: message1},
40 | {AuthorEmail: email1, AuthorName: authorName, SHA: sha2, Message: message2},
41 | {AuthorEmail: email2, AuthorName: authorName, SHA: sha3, Message: message3}}
42 |
43 | train, err := dataClient.CreateTrain(branch, testData.User, testCommits)
44 | assert.NoError(t, err)
45 |
46 | err = dataClient.StartPhase(train.ActivePhases.Verification)
47 | assert.NoError(t, err)
48 |
49 | // Create some tickets in DB and ticket service.
50 | newTickets, err := ticketService.CreateTickets(train, testCommits)
51 | assert.NoError(t, err)
52 | assert.Len(t, newTickets, 2)
53 | dataClient.WriteTickets(newTickets)
54 |
55 | // Close them only in the remote ticket service.
56 | err = ticketService.CloseTickets(newTickets)
57 | assert.NoError(t, err)
58 |
59 | // Rely on syncTickets to update the database state from ticket service.
60 | syncTickets(dataClient, codeService, messagingService, phaseService, ticketService)
61 |
62 | latestTrain, err := dataClient.LatestTrain()
63 | assert.NoError(t, err)
64 | // Expect that it has been marked as closed in the DB
65 | assert.True(t, latestTrain.Tickets[0].ClosedAt.HasValue())
66 |
67 | // Clean up
68 | err = ticketService.DeleteTickets(train)
69 | assert.NoError(t, err)
70 | }
71 |
--------------------------------------------------------------------------------
/services/phase/job.go:
--------------------------------------------------------------------------------
1 | package phase
2 |
3 | import (
4 | "sort"
5 | "time"
6 |
7 | "github.com/Nextdoor/conductor/shared/types"
8 | )
9 |
10 | const (
11 | // Jobs are allowed 5 minutes grace time to report they have started
12 | MaxJobStartDelay = time.Minute * 5
13 |
14 | // Jobs must complete within 30 minutes of when they have started
15 | MaxJobRuntime = time.Minute * 30
16 | )
17 |
18 | func AllJobsComplete(phaseType types.PhaseType, completedJobs []string) bool {
19 | expectedJobs := types.JobsForPhase(phaseType)
20 |
21 | if completedJobs == nil && expectedJobs == nil {
22 | return true
23 | }
24 |
25 | if completedJobs == nil || expectedJobs == nil {
26 | return false
27 | }
28 |
29 | if len(completedJobs) != len(expectedJobs) {
30 | return false
31 | }
32 |
33 | sort.Strings(completedJobs)
34 | sort.Strings(expectedJobs)
35 |
36 | for i := range completedJobs {
37 | if completedJobs[i] != expectedJobs[i] {
38 | return false
39 | }
40 | }
41 |
42 | return true
43 | }
44 |
45 | /*
46 | TODO Need a Job type in phase?
47 | // Check that the given expected jobs have started within the grace period.
48 | //
49 | // Returns string array of any missing jobs.
50 | func MissingJobs(timeStarted, currentTime time.Time, expectedJobs []string, startedJobs []*data.Job) []string {
51 | expectedMap := make(map[string]bool)
52 | var missing []string
53 | for _, expectedJob := range expectedJobs {
54 | expectedMap[expectedJob] = false
55 | }
56 | // Track the expected jobs which have been reported as started to us
57 | for _, startedJob := range startedJobs {
58 | expectedMap[startedJob.Name] = true
59 | }
60 | // Given the map, make sure any missing expected jobs which haven't
61 | // been reported are within their reporting deadlines.
62 | for _, expectedJob := range expectedJobs {
63 | // An expected job hasn't started yet.
64 | if !expectedMap[expectedJob] {
65 | // Are we past the MaxExpectedJobStartDelay deadline?
66 | if currentTime.Sub(timeStarted) > MaxExpectedJobStartDelay {
67 | missing = append(missing, expectedJob)
68 | }
69 | }
70 | }
71 |
72 | return missing
73 | }
74 |
75 | func CheckJobRuntime(phaseStart time.Time, runningJobs []*data.Job) []*data.Job {
76 | var exceeded []*data.Job
77 | for _, job := range runningJobs {
78 | if job.StartedAt != nil && job.StartedAt.Sub(phaseStart) > MaxExpectedJobRuntime {
79 | exceeded = append(exceeded, job)
80 | }
81 | }
82 | return exceeded
83 | }
84 | */
85 |
--------------------------------------------------------------------------------
/frontend/src/components/Train.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import {trainProps, requestProps} from 'types/proptypes';
5 | import Admin from 'containers/Admin';
6 | import Commits from 'containers/Commits';
7 | import Details from 'containers/Details';
8 | import Error from 'components/Error';
9 | import Phases from 'containers/Phases';
10 | import Summary from 'containers/Summary';
11 | import TrainComponent from 'components/TrainComponent';
12 | import TrainHeader from 'containers/TrainHeader';
13 |
14 | class Train extends TrainComponent {
15 | componentWillMount() {
16 | const {trainId, request, load} = this.props;
17 |
18 | if (request.fetching !== true && request.receivedAt === null) {
19 | load(trainId);
20 | }
21 |
22 | this.beginAutoRefresh();
23 | }
24 |
25 | componentWillReceiveProps(nextProps) {
26 | this.beginAutoRefresh(nextProps.trainId);
27 | }
28 |
29 | beginAutoRefresh(trainId) {
30 | if (this.state && this.state.interval) {
31 | if (trainId === this.state.intervalTrainId) {
32 | return;
33 | }
34 | clearInterval(this.state.interval);
35 | }
36 |
37 | const interval = setInterval(function() {
38 | this.props.load(trainId);
39 | }.bind(this), 5000);
40 |
41 | this.setState({
42 | interval: interval,
43 | intervalTrainId: trainId
44 | });
45 | }
46 |
47 | render() {
48 | const {request} = this.props;
49 |
50 | if (request.fetching !== true && request.receivedAt === null) {
51 | return null;
52 | }
53 |
54 | if (request.error !== null) {
55 | return ;
56 | }
57 |
58 | return (
59 |
60 |
65 |
66 |
67 |
68 |
69 | Queue
70 |
71 |
72 |
73 |
74 |
78 |
79 | );
80 | }
81 | }
82 |
83 | Train.propTypes = {
84 | trainId: PropTypes.string,
85 | train: trainProps,
86 | request: requestProps.isRequired,
87 | load: PropTypes.func.isRequired
88 | };
89 |
90 | export default Train;
91 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.0.1",
4 | "description": "Frontend Content",
5 | "main": "app.js",
6 | "dependencies": {
7 | "@babel/core": "^7.6.2",
8 | "@babel/plugin-proposal-class-properties": "^7.5.5",
9 | "@babel/plugin-transform-runtime": "^7.6.2",
10 | "@babel/preset-env": "^7.0.0",
11 | "@babel/preset-react": "^7.0.0",
12 | "babel-core": "^7.0.0-bridge.0",
13 | "babel-eslint": "^9.0.0",
14 | "babel-jest": "^23.4.2",
15 | "babel-loader": "7.1.1",
16 | "css-loader": "^3.5.1",
17 | "file-loader": "^6.0.0",
18 | "jest": "^24.9.0",
19 | "jest-cli": "^24.9.0",
20 | "less": "^3.10.3",
21 | "lodash": "^4.17.21",
22 | "minimatch": "3.0.3",
23 | "moment": "^2.29.4",
24 | "node-sass": "^4.1.0",
25 | "prop-types": "^15.5.8",
26 | "react": "^15.5.4",
27 | "react-addons-css-transition-group": "^15.4.1",
28 | "react-cookie": "0.4.7",
29 | "react-dom": "^15.5.4",
30 | "react-octicons": "^0.1.0",
31 | "react-overlays": "^0.8.0",
32 | "react-redux": "^5.0.4",
33 | "react-router": "^3.0.0",
34 | "redux": "^3.6.0",
35 | "redux-devtools": "^3.4.0",
36 | "redux-devtools-dock-monitor": "^1.1.2",
37 | "redux-devtools-log-monitor": "^1.3.0",
38 | "redux-thunk": "^2.2.0",
39 | "sass-loader": "^8.0.2",
40 | "style-loader": "^1.1.3",
41 | "symbol-observable": "^1.0.4",
42 | "typeface-source-sans-pro": "^0.0.31",
43 | "url-loader": "^4.1.0",
44 | "webpack": "^4.42.1",
45 | "whatwg-fetch": "^3.0.0"
46 | },
47 | "devDependencies": {
48 | "babel-jest": "^18.0.0",
49 | "chai": "^3.4.1",
50 | "enzyme": "^2.9.1",
51 | "eslint": "^4.18.2",
52 | "eslint-plugin-react": "^6.8.0",
53 | "identity-obj-proxy": "^3.0.0",
54 | "react-addons-test-utils": "^15.5.1",
55 | "react-test-renderer": "^15.6.1",
56 | "webpack-cli": "^3.3.11"
57 | },
58 | "babel": {
59 | "presets": [
60 | "@babel/react",
61 | "@babel/env"
62 | ],
63 | "plugins": [
64 | "@babel/plugin-proposal-class-properties"
65 | ]
66 | },
67 | "jest": {
68 | "modulePaths": [
69 | "src"
70 | ],
71 | "moduleNameMapper": {
72 | "\\.(css|less)$": "identity-obj-proxy"
73 | }
74 | },
75 | "scripts": {
76 | "test": "jest --colors",
77 | "lint": "./node_modules/eslint/bin/eslint.js --fix --ext .js --ext .jsx src/*"
78 | },
79 | "repository": {
80 | "type": "git",
81 | "url": "git://github.com/Nextdoor/conductor.git"
82 | },
83 | "author": "Steve Mostovoy",
84 | "license": "ISC"
85 | }
86 |
--------------------------------------------------------------------------------
/services/messaging/engine_mock.go:
--------------------------------------------------------------------------------
1 | package messaging
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/Nextdoor/conductor/shared/logger"
7 | "github.com/Nextdoor/conductor/shared/types"
8 | )
9 |
10 | type EngineMock struct {
11 | SendMock func(string)
12 | SendDirectMock func(string, string, string)
13 | FormatUserMock func(*types.User) string
14 | FormatNameEmailMock func(string, string) string
15 | FormatNameEmailNotificationMock func(string, string) string
16 | FormatLinkMock func(string, string) string
17 | FormatBoldMock func(string) string
18 | FormatMonospacedMock func(string) string
19 | Indent func(string) string
20 | Escape func(string) string
21 | }
22 |
23 | func (m *EngineMock) send(text string) {
24 | if m.SendMock != nil {
25 | m.SendMock(text)
26 | }
27 | logger.Info("%s", text)
28 | }
29 |
30 | func (m *EngineMock) sendDirect(name, email, text string) {
31 | if m.SendDirectMock != nil {
32 | m.SendDirectMock(name, email, text)
33 | }
34 | logger.Info("%s: %s", name, text)
35 | }
36 |
37 | func (m *EngineMock) formatUser(user *types.User) string {
38 | if m.FormatUserMock != nil {
39 | return m.FormatUserMock(user)
40 | }
41 | return user.Name
42 | }
43 |
44 | func (m *EngineMock) formatNameEmail(name, email string) string {
45 | if m.FormatNameEmailMock != nil {
46 | return m.FormatNameEmailMock(name, email)
47 | }
48 | return name
49 | }
50 |
51 | func (m *EngineMock) formatNameEmailNotification(name, email string) string {
52 | if m.FormatNameEmailNotificationMock != nil {
53 | return m.FormatNameEmailNotificationMock(name, email)
54 | }
55 | return name
56 | }
57 |
58 | func (m *EngineMock) formatLink(url, name string) string {
59 | if m.FormatLinkMock != nil {
60 | return m.FormatLinkMock(url, name)
61 | }
62 | return fmt.Sprintf("%s: %s", name, url)
63 | }
64 |
65 | func (m *EngineMock) formatBold(text string) string {
66 | if m.FormatBoldMock != nil {
67 | return m.FormatBoldMock(text)
68 | }
69 | return text
70 | }
71 |
72 | func (m *EngineMock) formatMonospaced(text string) string {
73 | if m.FormatMonospacedMock != nil {
74 | return m.FormatMonospacedMock(text)
75 | }
76 | return text
77 | }
78 |
79 | func (m *EngineMock) indent(text string) string {
80 | if m.Indent != nil {
81 | return m.Indent(text)
82 | }
83 | return fmt.Sprintf(" %s", text)
84 | }
85 |
86 | func (m *EngineMock) escape(text string) string {
87 | if m.Escape != nil {
88 | return m.Escape(text)
89 | }
90 | return text
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/src/components/TrainHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import {Link} from 'react-router';
4 |
5 | import TrainComponent from 'components/TrainComponent';
6 | import {trainProps, requestProps} from 'types/proptypes';
7 |
8 | class TrainHeader extends TrainComponent {
9 | render() {
10 | const {train, goToTrain} = this.props;
11 | if (train === null) {
12 | return (
13 |
14 | ...
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | let previousTrainId = null;
24 | let nextTrainId = null;
25 |
26 | if (train.previous_id !== null) {
27 | previousTrainId = parseInt(train.previous_id, 10);
28 | }
29 | if (train.next_id !== null) {
30 | nextTrainId = parseInt(train.next_id, 10);
31 | }
32 |
33 | return (
34 |
35 | Train {train.id}
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | TrainHeader.propTypes = {
46 | request: requestProps.isRequired,
47 | train: trainProps,
48 | goToTrain: PropTypes.func.isRequired
49 | };
50 |
51 | class TrainNav extends React.Component {
52 | render() {
53 | const {arrowType, toId, goToTrain} = this.props;
54 |
55 | let img = null;
56 | if (toId === null) {
57 | img = '/images/arrow-' + arrowType + '-disabled.png';
58 | } else {
59 | img = '/images/arrow-' + arrowType + '.png';
60 | }
61 | const imgTag =
;
62 |
63 | if (toId === null) {
64 | return (
65 |
68 | );
69 | }
70 |
71 | const uri = '/train/' + toId;
72 | return (
73 |
74 |
77 |
78 | );
79 | }
80 | }
81 |
82 | TrainNav.propTypes = {
83 | toId: PropTypes.number,
84 | arrowType: PropTypes.string.isRequired,
85 | goToTrain: PropTypes.func.isRequired
86 | };
87 |
88 |
89 | export default TrainHeader;
90 |
--------------------------------------------------------------------------------
/services/phase/job_test.go:
--------------------------------------------------------------------------------
1 | package phase
2 |
3 | /* TODO: Complete after implementing background worker loops.
4 | import (
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 |
10 | "github.com/Nextdoor/conductor/services/data"
11 | "github.com/Nextdoor/conductor/shared/types"
12 | )
13 |
14 | func TestParseJobsString(t *testing.T) {
15 | emptyString := ""
16 | assert.Len(t, parseJobsString(emptyString), 0)
17 |
18 | oneJob := "delivery"
19 | assert.Len(t, parseJobsString(oneJob), 1)
20 | assert.Equal(t, parseJobsString(oneJob)[0], oneJob)
21 |
22 | threeJobs := "delivery , test,build "
23 | assert.Len(t, parseJobsString(threeJobs), 3)
24 | assert.Equal(t, parseJobsString(threeJobs)[0], "delivery")
25 | assert.Equal(t, parseJobsString(threeJobs)[1], "test")
26 | assert.Equal(t, parseJobsString(threeJobs)[2], "build")
27 | }
28 |
29 | func TestGetExpectedJobsForPhase(t *testing.T) {
30 | mockPhase := data.Phase{Type: types.Delivery}
31 | *deliveryJobs = "test, build, delivery, static"
32 | *deployJobs = "delivery"
33 |
34 | res, _ := expectedJobsForPhase(&mockPhase)
35 | assert.Len(t, res, 4)
36 | assert.Equal(t, res[0], "test")
37 | assert.Equal(t, res[1], "build")
38 | assert.Equal(t, res[2], "delivery")
39 | assert.Equal(t, res[3], "static")
40 |
41 | mockPhase = data.Phase{Type: data.Deploy}
42 | res, _ = expectedJobsForPhase(&mockPhase)
43 | assert.Len(t, res, 1)
44 | assert.Equal(t, res[0], "delivery")
45 | }
46 |
47 | func TestGetExpectedJobsNotStarted(t *testing.T) {
48 | phaseStart := time.Time{}
49 | // Test that is time.Now() against MaxExpectedJobStartDelay, so we test this.
50 | beforeTimeout := phaseStart.Add(MaxJobStartDelay - 1)
51 | afterTimeout := phaseStart.Add(MaxJobStartDelay + 1)
52 |
53 | startedJobs := []*data.Job{
54 | {Name: "expected", StartedAt: &phaseStart},
55 | }
56 |
57 | expectedJobs := []string{"expected"}
58 | missingJobs := expectedJobsNotStarted(phaseStart, beforeTimeout, expectedJobs, startedJobs)
59 | assert.Empty(t, missingJobs)
60 |
61 | expectedJobs = []string{"expected", "expected_but_not_started"}
62 | missingJobs = expectedJobsNotStarted(phaseStart, afterTimeout, expectedJobs, startedJobs)
63 | assert.Equal(t, missingJobs[0], "expected_but_not_started")
64 | }
65 |
66 | func TestGetExpectedJobsExceededRuntime(t *testing.T) {
67 | phaseStart := time.Time{}
68 | almostExceeded := phaseStart.Add(MaxJobRuntime)
69 | reallyExceeded := phaseStart.Add(MaxJobRuntime + 1)
70 |
71 | // Test that an job exceeding the runtime is caught.
72 | jobs := []*data.Job{
73 | {Name: "one", StartedAt: &almostExceeded},
74 | {Name: "two", StartedAt: &reallyExceeded},
75 | }
76 | exceededJobs := expectedJobsExceededRuntime(phaseStart, jobs)
77 | assert.Equal(t, len(exceededJobs), 1)
78 | assert.Equal(t, exceededJobs[0].Name, jobs[1].Name)
79 | }
80 | */
81 |
--------------------------------------------------------------------------------
/frontend/src/components/Commits.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Card from 'components/Card';
4 | import TrainComponent from 'components/TrainComponent';
5 | import {trainProps, requestProps} from 'types/proptypes';
6 |
7 | class Commits extends TrainComponent {
8 | render() {
9 | return (
10 |
11 | {this.getComponent()}
12 |
13 | );
14 | }
15 |
16 | getComponent() {
17 | const requestComponent = this.getRequestComponent();
18 | if (requestComponent !== null) {
19 | return requestComponent;
20 | }
21 |
22 | return (
23 |
24 | {this.getGroups().map((group, i) =>
25 | -
26 | {group[0]}
27 | {group[1].map((commit, j) =>
28 | {commit.message}
29 | )}
30 | {group[2].map((ticket, j) =>
31 | {ticket.key}
32 | )}
33 |
34 | )}
35 |
36 | );
37 | }
38 |
39 | getGroups() {
40 | const {train} = this.props;
41 | const groupsByEmail = {};
42 |
43 | for (let i = 0; i < train.commits.length; i++) {
44 | const commit = train.commits[i];
45 | if (!(commit.author_email in groupsByEmail)) {
46 | groupsByEmail[commit.author_email] = {
47 | author_name: commit.author_name,
48 | commits: [],
49 | tickets: []
50 | };
51 | }
52 | groupsByEmail[commit.author_email].commits.push({
53 | message: commit.message.split('\n')[0],
54 | url: commit.url
55 | });
56 | }
57 |
58 | for (let i = 0; i < train.tickets.length; i++) {
59 | const ticket = train.tickets[i];
60 | if (!(ticket.assignee_email in groupsByEmail)) {
61 | groupsByEmail[ticket.assignee_email] = {
62 | author_name: ticket.assignee_name,
63 | commits: [],
64 | tickets: []
65 | };
66 | }
67 | groupsByEmail[ticket.assignee_email].tickets.push({
68 | key: ticket.key,
69 | url: ticket.url,
70 | done: ticket.closed_at !== null || ticket.deleted_at !== null
71 | });
72 | }
73 |
74 | const groups = [];
75 |
76 | const authors = Object.keys(groupsByEmail);
77 | for (let i = 0; i < authors.length; i++) {
78 | const group = groupsByEmail[authors[i]];
79 | groups.push([group.author_name, group.commits, group.tickets]);
80 | }
81 |
82 | return groups;
83 | }
84 | }
85 |
86 | Commits.PropTypes = {
87 | train: trainProps,
88 | request: requestProps.isRequired
89 | };
90 |
91 | export default Commits;
92 |
--------------------------------------------------------------------------------
/shared/github/auth.go:
--------------------------------------------------------------------------------
1 | package github
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "net/url"
10 | "strings"
11 | )
12 |
13 | type Auth interface {
14 | AuthorizeURL() string
15 | AccessTokenURL() string
16 | AccessToken(code string) (string, error)
17 | UserInfo(string) (string, string, string, error)
18 | }
19 |
20 | type auth struct {
21 | clientID string
22 | clientSecret string
23 | }
24 |
25 | func NewAuth(clientID, clientSecret string) Auth {
26 | return &auth{
27 | clientID: clientID,
28 | clientSecret: clientSecret,
29 | }
30 | }
31 |
32 | func (g *auth) AuthorizeURL() string {
33 | return fmt.Sprintf("%s/login/oauth/authorize", githubHost)
34 | }
35 |
36 | func (g *auth) AccessTokenURL() string {
37 | return fmt.Sprintf("%s/login/oauth/access_token", githubHost)
38 | }
39 |
40 | func (g *auth) AccessToken(code string) (string, error) {
41 | data := url.Values{
42 | "client_id": []string{g.clientID},
43 | "client_secret": []string{g.clientSecret},
44 | "code": []string{code},
45 | }
46 | req, err := http.NewRequest("POST", g.AccessTokenURL(), strings.NewReader(data.Encode()))
47 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
48 | req.Header.Set("Accept", "application/json")
49 |
50 | resp, err := http.DefaultClient.Do(req)
51 | if err != nil {
52 | return "", err
53 | }
54 | if resp.StatusCode != 200 {
55 | return "", errors.New(resp.Status)
56 | }
57 |
58 | defer resp.Body.Close()
59 | b, err := ioutil.ReadAll(resp.Body)
60 | if err != nil {
61 | return "", err
62 | }
63 |
64 | loginResponse := githubLoginResponse{}
65 | err = json.Unmarshal(b, &loginResponse)
66 | if err != nil {
67 | return "", err
68 | }
69 | if loginResponse.Error != "" {
70 | err = errors.New(loginResponse.Error)
71 | return "", err
72 | }
73 |
74 | return loginResponse.AccessToken, nil
75 | }
76 |
77 | type githubLoginResponse struct {
78 | AccessToken string `json:"access_token"`
79 | Error string `json:"error_description"`
80 | }
81 |
82 | func (g *auth) UserInfo(accessToken string) (string, string, string, error) {
83 | client, err := newClient(accessToken)
84 | if err != nil {
85 | return "", "", "", err
86 | }
87 |
88 | user, _, err := client.Users.Get("")
89 | if err != nil {
90 | return "", "", "", err
91 | }
92 |
93 | userEmails, _, err := client.Users.ListEmails(nil)
94 | if err != nil {
95 | return "", "", "", err
96 | }
97 |
98 | var email string
99 | for _, userEmail := range userEmails {
100 | if *userEmail.Primary {
101 | email = *userEmail.Email
102 | break
103 | }
104 | }
105 | if email == "" {
106 | return "", "", "", errors.New("No primary email.")
107 | }
108 | return *user.Name, email, *user.AvatarURL, nil
109 | }
110 |
--------------------------------------------------------------------------------
/frontend/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {withRouter} from 'react-router';
3 | import PropTypes from 'prop-types';
4 |
5 | import {trainProps, requestProps} from 'types/proptypes';
6 | import Error from 'components/Error';
7 |
8 | class Header extends React.Component {
9 | componentWillMount() {
10 | const {request, load} = this.props;
11 | if (request.fetching !== true && request.receivedAt === null) {
12 | load();
13 | }
14 | }
15 |
16 | render() {
17 | return (
18 |
19 | {this.getComponent()}
20 |
21 | );
22 | }
23 |
24 | search(event) {
25 | const commit = event.target.value.trim();
26 | if (commit.length > 0) {
27 | this.props.router.push('/search/commit/' + commit);
28 | } else {
29 | this.props.router.push('/');
30 | }
31 | }
32 |
33 | getComponent() {
34 | const {self, request, logout} = this.props;
35 |
36 | if (request.fetching !== true && request.receivedAt === null) {
37 | return null;
38 | }
39 |
40 | if (request.fetching === true || request.receivedAt === null) {
41 | return null;
42 | }
43 |
44 | let content = null;
45 | if (request.error !== null) {
46 | content = ;
47 | } else {
48 | content = (
49 |
50 |
53 |
54 | Logged in as {self.name}
55 |
56 |
57 | {self.name}
58 |
59 |
60 |
61 | this.search(event)}/>
66 |
67 |
68 | );
69 | }
70 |
71 | return (
72 |
73 |

74 |
75 |
Conductor
76 | {content}
77 |
78 | );
79 | }
80 | }
81 |
82 | Header.propTypes = {
83 | self: PropTypes.shape({
84 | name: PropTypes.string.isRequired,
85 | email: PropTypes.string.isRequired,
86 | avatar_url: PropTypes.string.isRequired,
87 | }),
88 | request: requestProps.isRequired,
89 | train: trainProps,
90 | load: PropTypes.func.isRequired,
91 | logout: PropTypes.func.isRequired,
92 | router: PropTypes.object.isRequired,
93 | params: PropTypes.object.isRequired,
94 | };
95 |
96 | export default withRouter(Header);
97 |
--------------------------------------------------------------------------------
/frontend/src/components/Details.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment';
3 |
4 | import ApiButton from 'components/ApiButton';
5 | import Card from 'components/Card';
6 | import TrainComponent from 'components/TrainComponent';
7 | import TitledList from 'components/TitledList';
8 | import {trainProps, requestProps} from 'types/proptypes';
9 |
10 | class Details extends TrainComponent {
11 |
12 | render() {
13 | return (
14 |
15 | {this.getComponent()}
16 |
17 | );
18 | }
19 |
20 | getComponent() {
21 | const requestComponent = this.getRequestComponent();
22 | if (requestComponent !== null) {
23 | return requestComponent;
24 | }
25 |
26 | const {train} = this.props;
27 | const trainEngineer = train.engineer !== null ? train.engineer.name : 'None';
28 | const headCommit = train.commits[train.commits.length - 1];
29 | const items = [
30 | ['Branch:', train.branch],
31 | ['Head:',
32 |
33 | {headCommit.message.split('\n')[0]}
34 |
35 | ],
36 | ['Commits:', train.commits.length],
37 | ['Engineer:', trainEngineer],
38 | ['Created:', moment(train.created_at).fromNow()],
39 | ['Deployed:', train.deployed_at !== null ? moment(train.deployed_at).fromNow() : 'Not deployed']
40 | ];
41 |
42 | return (
43 |
44 |
45 | {this.claimEngineerButton(trainEngineer)}
46 |
47 | );
48 | }
49 |
50 | claimEngineerButton(trainEngineer) {
51 |
52 | let message = {
53 | title: 'Become the engineer for this train',
54 | body: (
55 |
56 | By clicking confirm, you will replace {trainEngineer} as the engineer for this train.
57 |
58 | Thank you for keeping our trains on schedule!
59 |
60 | )
61 | };
62 | if (!trainEngineer) {
63 | message = {
64 | title: 'Become the engineer for this train',
65 | body: (
66 |
67 | By clicking confirm, you will become the engineer for this train.
68 |
69 | Thank you for keeping our trains on schedule!
70 |
71 | )
72 | };
73 | }
74 | if (this.props.train.active_phases.deploy && !this.props.train.active_phases.deploy.completed_at)
75 | {
76 | return (
77 | this.props.changeEngineer(this.props.train.id)}
80 | request={this.props}
81 | className="button-claim">
82 | Claim Engineer Role
83 |
84 | );
85 | }
86 | }
87 | }
88 |
89 | Details.propTypes = {
90 | train: trainProps,
91 | request: requestProps.isRequired,
92 | };
93 |
94 | export default Details;
95 |
--------------------------------------------------------------------------------
/frontend/src/components/Search.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import _ from 'lodash';
4 |
5 | import {searchProps, requestProps} from 'types/proptypes';
6 |
7 | import Card from 'components/Card';
8 | import Error from 'components/Error';
9 | import Loading from 'components/Loading';
10 |
11 | class Search extends React.Component {
12 | constructor(props) {
13 | super(props);
14 |
15 | // Debounce search to avoid throttling the api
16 | this.searchDebounced = _.debounce(this.props.search, 300);
17 | }
18 |
19 | componentWillMount() {
20 | const {request, search, params} = this.props;
21 | if (request.fetching !== true && request.receivedAt === null) {
22 | search(params);
23 | }
24 | }
25 |
26 | componentWillReceiveProps(nextProps) {
27 | if (nextProps.params.commit !== this.props.params.commit) {
28 | this.searchDebounced(nextProps.params);
29 | }
30 | }
31 |
32 | render() {
33 | const {request, details, params} = this.props;
34 |
35 | // Request is still being fetched; render nothing
36 | if (request.fetching === true) {
37 | return null;
38 | }
39 |
40 | // Request might be fetched, but for some reason receivedAt is null; render nothing
41 | if (request.receivedAt === null) {
42 | return null;
43 | }
44 |
45 | // Don't render for previous commit
46 | if (request.searchQuery && (request.searchQuery !== params.commit)) {
47 | return null;
48 | }
49 |
50 | if (request.error !== null) {
51 | return ;
52 | }
53 |
54 | if (details === null) {
55 | return ;
56 | }
57 |
58 | return (
59 |
60 | {this.getComponent()}
61 |
62 | );
63 | }
64 |
65 | getComponent() {
66 | const {details, params} = this.props;
67 | const trains = [];
68 | details.results.forEach(function(train) {
69 | trains.push();
70 | });
71 |
72 | return (
73 |
74 |
75 | Results for {params.commit}
76 |
77 | {trains}
78 |
79 | );
80 | }
81 | }
82 |
83 | Search.propTypes = {
84 | details: searchProps,
85 | params: PropTypes.shape().isRequired,
86 | request: requestProps.isRequired,
87 | search: PropTypes.func.isRequired,
88 | commit: PropTypes.string,
89 | };
90 |
91 | class TrainLink extends React.Component {
92 | render() {
93 | const {id} = this.props;
94 |
95 | return (
96 |
97 |
100 |
101 | );
102 | }
103 | }
104 |
105 | TrainLink.propTypes = {
106 | id: PropTypes.string.isRequired,
107 | };
108 |
109 | export default Search;
110 |
--------------------------------------------------------------------------------
/shared/flags/flags_test.go:
--------------------------------------------------------------------------------
1 | package flags
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestEnvString(t *testing.T) {
11 | os.Setenv("test_env", "value")
12 | assert.Equal(t, "value", EnvString("test_env", "default"))
13 |
14 | os.Unsetenv("test_env")
15 | assert.Equal(t, "default", EnvString("test_env", "default"))
16 | }
17 |
18 | func TestRequiredEnvStringSuccess(t *testing.T) {
19 | os.Setenv("test_env", "value")
20 | assert.Equal(t, "value", RequiredEnvString("test_env"))
21 | }
22 |
23 | func TestRequiredEnvStringPanics(t *testing.T) {
24 | defer func() {
25 | if r := recover(); r == nil {
26 | assert.Fail(t, "RequiredEnvString did not panic.")
27 | }
28 | }()
29 |
30 | os.Unsetenv("test_env")
31 | RequiredEnvString("test_env")
32 | }
33 |
34 | func TestEnvBool(t *testing.T) {
35 | os.Setenv("test_env", "0")
36 | assert.Equal(t, false, EnvBool("test_env", true))
37 |
38 | os.Setenv("test_env", "true")
39 | assert.Equal(t, true, EnvBool("test_env", true))
40 |
41 | os.Unsetenv("test_env")
42 | assert.Equal(t, true, EnvBool("test_env", true))
43 | }
44 |
45 | func TestRequiredEnvBoolPasses(t *testing.T) {
46 | os.Setenv("test_env", "false")
47 | assert.Equal(t, false, RequiredEnvBool("test_env"))
48 |
49 | os.Setenv("test_env", "True")
50 | assert.Equal(t, true, RequiredEnvBool("test_env"))
51 | }
52 |
53 | func TestRequiredEnvBoolPanicsUnset(t *testing.T) {
54 | defer func() {
55 | if r := recover(); r == nil {
56 | assert.Fail(t, "RequiredEnvBool did not panic.")
57 | }
58 | }()
59 |
60 | os.Unsetenv("test_env")
61 | RequiredEnvBool("test_env")
62 | }
63 |
64 | func TestRequiredEnvBoolPanicsInvalid(t *testing.T) {
65 | defer func() {
66 | if r := recover(); r == nil {
67 | assert.Fail(t, "RequiredEnvBool did not panic.")
68 | }
69 | }()
70 |
71 | os.Setenv("test_env", "value")
72 | RequiredEnvBool("test_env")
73 | }
74 |
75 | func TestEnvInt(t *testing.T) {
76 | os.Setenv("test_env", "10")
77 | assert.Equal(t, 10, EnvInt("test_env", 5))
78 |
79 | os.Setenv("test_env", "50")
80 | assert.Equal(t, 50, EnvInt("test_env", 5))
81 |
82 | os.Unsetenv("test_env")
83 | assert.Equal(t, 5, EnvInt("test_env", 5))
84 | }
85 |
86 | func TestRequiredEnvIntPasses(t *testing.T) {
87 | os.Setenv("test_env", "0")
88 | assert.Equal(t, 0, RequiredEnvInt("test_env"))
89 |
90 | os.Setenv("test_env", "100")
91 | assert.Equal(t, 100, RequiredEnvInt("test_env"))
92 | }
93 |
94 | func TestRequiredEnvIntPanicsUnset(t *testing.T) {
95 | defer func() {
96 | if r := recover(); r == nil {
97 | assert.Fail(t, "RequiredEnvInt did not panic.")
98 | }
99 | }()
100 |
101 | os.Unsetenv("test_env")
102 | RequiredEnvInt("test_env")
103 | }
104 |
105 | func TestRequiredEnvIntPanicsInvalid(t *testing.T) {
106 | defer func() {
107 | if r := recover(); r == nil {
108 | assert.Fail(t, "RequiredEnvInt did not panic.")
109 | }
110 | }()
111 |
112 | os.Setenv("test_env", "value")
113 | RequiredEnvInt("test_env")
114 | }
115 |
--------------------------------------------------------------------------------
/services/ticket/ticket.go:
--------------------------------------------------------------------------------
1 | /* Handles creating verification tickets and checking their status. */
2 | package ticket
3 |
4 | import (
5 | "bytes"
6 | "fmt"
7 | "strings"
8 | "sync"
9 | "text/template"
10 |
11 | "github.com/Nextdoor/conductor/shared/flags"
12 | "github.com/Nextdoor/conductor/shared/logger"
13 | "github.com/Nextdoor/conductor/shared/types"
14 | )
15 |
16 | var (
17 | implementationFlag = flags.EnvString("TICKET_IMPL", "fake")
18 | )
19 |
20 | const descriptionTemplate = `{{ .AuthorName }}
21 |
22 | {{ range .Commits }} - http://c/{{ .ShortSHA }} - {{ index (Split .Message "\n") 0 }}
23 | {{ end }}`
24 |
25 | type Service interface {
26 | CreateTickets(*types.Train, []*types.Commit) ([]*types.Ticket, error)
27 | CloseTickets([]*types.Ticket) error
28 | DeleteTickets(*types.Train) error
29 | SyncTickets(*types.Train) ([]*types.Ticket, []*types.Ticket, error)
30 | CloseTrainTickets(*types.Train) error
31 | }
32 |
33 | var (
34 | service Service
35 | getOnce sync.Once
36 | DefaultAccountID string
37 | )
38 |
39 | func GetService() Service {
40 | getOnce.Do(func() {
41 | service = newService()
42 | })
43 | return service
44 | }
45 |
46 | func newService() Service {
47 | logger.Info("Using %s implementation for Ticket service", implementationFlag)
48 | var service Service
49 | switch implementationFlag {
50 | case "fake":
51 | service = newFake()
52 | case "jira":
53 | service = newJIRA()
54 | default:
55 | panic(fmt.Errorf("Unknown Verification Implementation: %s", implementationFlag))
56 | }
57 | return service
58 | }
59 |
60 | type fake struct{}
61 |
62 | func newFake() *fake {
63 | return &fake{}
64 | }
65 |
66 | func (t *fake) CreateTickets(train *types.Train, commits []*types.Commit) ([]*types.Ticket, error) {
67 | return nil, nil
68 | }
69 |
70 | func (t *fake) CloseTickets(tickets []*types.Ticket) error {
71 | return nil
72 | }
73 |
74 | func (t *fake) DeleteTickets(train *types.Train) error {
75 | return nil
76 | }
77 |
78 | func (t *fake) SyncTickets(train *types.Train) ([]*types.Ticket, []*types.Ticket, error) {
79 | return nil, nil, nil
80 | }
81 |
82 | func (t *fake) CloseTrainTickets(train *types.Train) error {
83 | return nil
84 | }
85 |
86 | func descriptionFromCommits(commits []*types.Commit) (string, error) {
87 | var output bytes.Buffer
88 | tplFuncMap := make(template.FuncMap)
89 | tplFuncMap["Split"] = strings.Split
90 | descTemplate, err := template.New("description").Funcs(tplFuncMap).Parse(descriptionTemplate)
91 | if err != nil {
92 | return "", err
93 | }
94 |
95 | authorName := commits[0].AuthorName
96 |
97 | templateCtx := make(map[string]interface{}, 0)
98 | templateCtx["AuthorName"] = authorName
99 | templateCtx["Commits"] = commits
100 | err = descTemplate.Execute(&output, templateCtx)
101 | if err != nil {
102 | return "", err
103 | }
104 |
105 | return output.String(), nil
106 | }
107 |
108 | func summaryForCommit(commit *types.Commit) string {
109 | return fmt.Sprintf("Verify %s's changes", commit.AuthorName)
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | const dir = path.join(__dirname, '..', 'resources', 'frontend', 'gen');
5 |
6 | const IS_PRODUCTION = process.env.IS_PRODUCTION === 'true';
7 |
8 | const REQUIRED_ENV_VARS = [
9 | 'OAUTH_PROVIDER',
10 | 'OAUTH_ENDPOINT',
11 |
12 | // json payload for the oauth request, e.g.
13 | // {"client_id": "123",
14 | // "redirect_uri": "/api/auth/done",
15 | // "scope": "commit"}
16 | 'OAUTH_PAYLOAD'
17 | ];
18 | const DEFAULT_ENV_VARS = {
19 | 'AUTH_COOKIE_NAME': 'conductor-auth'
20 | };
21 |
22 | // Look for missing env vars.
23 | let missing_env = false;
24 | REQUIRED_ENV_VARS.forEach(function (env_var) {
25 | if (!(env_var in process.env)) {
26 | console.error(
27 | 'Environmental variable ' + env_var +
28 | ' must be set for Webpack build.');
29 | missing_env = true;
30 | } else {
31 | console.log(env_var + ':');
32 | console.log(process.env[env_var]);
33 | }
34 | });
35 | if (missing_env) {
36 | process.exit(1);
37 | }
38 |
39 | for (const env_var in DEFAULT_ENV_VARS) {
40 | if (!(env_var in process.env)) {
41 | // Set to default value.
42 | process.env[env_var] = DEFAULT_ENV_VARS[env_var];
43 | }
44 | console.log(env_var + ':');
45 | console.log(process.env[env_var]);
46 | }
47 |
48 | const env_vars = REQUIRED_ENV_VARS.concat(Object.keys(DEFAULT_ENV_VARS));
49 |
50 | const PLUGIN_CONFIG = [
51 | new webpack.EnvironmentPlugin(env_vars)
52 | ];
53 |
54 | let DEV_TOOL = 'source-map';
55 |
56 | if (IS_PRODUCTION) {
57 | DEV_TOOL = 'false';
58 | }
59 |
60 | /** Webpack Config */
61 | module.exports = {
62 | entry: ['whatwg-fetch', './src/index.jsx'],
63 | output: {
64 | path: dir,
65 | filename: 'bundle.js'
66 | },
67 | resolve: {
68 | extensions: ['.js', '.jsx'],
69 | modules: [
70 | path.join(__dirname, "src"),
71 | "node_modules"
72 | ]
73 | },
74 | module: {
75 | rules: [
76 | {
77 | test: /\.jsx$/,
78 | loader: "babel-loader", // Do not use "use" here
79 | exclude: /(node_modules)/,
80 | query: {
81 | presets: ['@babel/react', '@babel/env']
82 | }
83 | },
84 | {
85 | test: /\.css$/,
86 | use: [
87 | {
88 | loader: "style-loader"
89 | },
90 | {
91 | loader: "css-loader",
92 | },
93 | {
94 | loader: "sass-loader"
95 | }
96 | ]
97 | },
98 | {
99 | test: /\.scss$/,
100 | use: [
101 | {
102 | loader: "style-loader"
103 | },
104 | {
105 | loader: "css-loader"
106 | },
107 | {
108 | loader: "sass-loader"
109 | }
110 | ]
111 | },
112 | {
113 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
114 | use: [
115 | {
116 | loader: 'url-loader',
117 | options: {
118 | limit: '10000',
119 | name: '/[hash].[ext]',
120 | }
121 | }
122 | ]
123 | }
124 | ]
125 | },
126 | plugins: PLUGIN_CONFIG,
127 | devtool: DEV_TOOL,
128 | performance: {
129 | hints: false
130 | }
131 | };
132 |
--------------------------------------------------------------------------------
/services/code/github.go:
--------------------------------------------------------------------------------
1 | package code
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | githubRaw "github.com/google/go-github/github"
8 |
9 | "github.com/Nextdoor/conductor/shared/flags"
10 | "github.com/Nextdoor/conductor/shared/github"
11 | "github.com/Nextdoor/conductor/shared/types"
12 | )
13 |
14 | var (
15 | // Github OAuth2 token for the Conductor application, from a user who has admin rights to the target repo.
16 | githubAdminToken = flags.EnvString("GITHUB_ADMIN_TOKEN", "")
17 |
18 | githubRepo = flags.EnvString("GITHUB_REPO", "")
19 | githubRepoOwner = flags.EnvString("GITHUB_REPO_OWNER", "")
20 | githubWebhookURL = flags.EnvString("GITHUB_WEBHOOK_URL", "")
21 | githubWebhookSecret = flags.EnvString("GITHUB_WEBHOOK_SECRET", "")
22 | )
23 |
24 | type githubCode struct {
25 | codeClient github.Code
26 | }
27 |
28 | func newGithub() *githubCode {
29 | if githubAdminToken == "" {
30 | panic(errors.New("github_admin_token flag must be set."))
31 | }
32 | if githubRepoOwner == "" {
33 | panic(errors.New("github_repo_owner flag must be set."))
34 | }
35 | if githubRepo == "" {
36 | panic(errors.New("github_repo flag must be set."))
37 | }
38 | if githubWebhookURL == "" {
39 | panic(errors.New("github_webhook_url flag must be set."))
40 | }
41 | if githubWebhookSecret == "" {
42 | panic(errors.New("github_webhook_secret flag must be set."))
43 | }
44 |
45 | return &githubCode{
46 | codeClient: github.NewCode(
47 | githubAdminToken,
48 | githubRepoOwner,
49 | githubRepo,
50 | githubWebhookURL,
51 | githubWebhookSecret,
52 | )}
53 | }
54 |
55 | func (c *githubCode) CommitsOnBranch(branch string, max int) ([]*types.Commit, error) {
56 | apiCommits, err := c.codeClient.CommitsOnBranch(branch, max)
57 | if err != nil {
58 | return nil, err
59 | }
60 | return c.convertCommits(apiCommits, branch), nil
61 | }
62 |
63 | func (c *githubCode) CommitsOnBranchAfter(branch string, sha string) ([]*types.Commit, error) {
64 | apiCommits, err := c.codeClient.CommitsOnBranchAfter(branch, sha)
65 | if err != nil {
66 | return nil, err
67 | }
68 | return c.convertCommits(apiCommits, branch), nil
69 | }
70 |
71 | func (c *githubCode) CompareRefs(oldRef, newRef string) ([]*types.Commit, error) {
72 | apiCommits, err := c.codeClient.CompareRefs(oldRef, newRef)
73 | if err != nil {
74 | return nil, err
75 | }
76 | return c.convertCommits(apiCommits, newRef), nil
77 | }
78 |
79 | func (c *githubCode) Revert(sha1, branch string) error {
80 | return c.codeClient.Revert(sha1, branch)
81 | }
82 |
83 | func (c *githubCode) ParseWebhookForBranch(r *http.Request) (string, error) {
84 | return c.codeClient.ParseWebhookForBranch(r, branchRegex)
85 | }
86 |
87 | // Convert slice of github.RepositoryCommit into internal commit slice.
88 | func (c *githubCode) convertCommits(apiCommits []*githubRaw.RepositoryCommit, branch string) []*types.Commit {
89 | commits := make([]*types.Commit, len(apiCommits))
90 | for i, apiCommit := range apiCommits {
91 | commits[i] = &types.Commit{
92 | SHA: *apiCommit.SHA,
93 | Message: *apiCommit.Commit.Message,
94 | Branch: branch,
95 | AuthorName: *apiCommit.Commit.Author.Name,
96 | AuthorEmail: *apiCommit.Commit.Author.Email,
97 | URL: *apiCommit.HTMLURL,
98 | }
99 | }
100 | return commits
101 | }
102 |
--------------------------------------------------------------------------------
/resources/nginx-mac.conf:
--------------------------------------------------------------------------------
1 | worker_processes 1;
2 |
3 | events {
4 | multi_accept on;
5 | worker_connections 8192;
6 | }
7 |
8 |
9 | http {
10 | default_type application/octet-stream;
11 |
12 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
13 | '$status $body_bytes_sent "$http_referer" '
14 | '"$http_user_agent" "$http_x_forwarded_for"';
15 |
16 | sendfile on;
17 | tcp_nopush on;
18 | keepalive_timeout 65;
19 |
20 | gzip on;
21 |
22 | upstream app {
23 | server 127.0.0.1:8400;
24 | keepalive 256;
25 | }
26 |
27 |
28 | server {
29 | listen 80;
30 | listen 443 ssl http2;
31 | server_name conductor 127.0.0.1;
32 |
33 | add_header X-Frame-Options DENY;
34 | add_header X-Content-Type-Options nosniff;
35 |
36 | ssl_certificate ssl/fullchain.pem;
37 | ssl_certificate_key ssl/privkey.pem;
38 | ssl_dhparam ssl/dhparam.pem;
39 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
40 | ssl_prefer_server_ciphers on;
41 | ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
42 | ssl_ecdh_curve secp384r1;
43 | ssl_session_cache shared:SSL:10m;
44 | ssl_session_tickets off;
45 |
46 | if ($scheme = http) {
47 | set $redirect_to_https false;
48 | }
49 |
50 | if ($http_x_forwarded_proto != "https") {
51 | set $redirect_to_https false;
52 | }
53 |
54 | if ($hostname = conductor-dev) {
55 | set $redirect_to_https false;
56 | }
57 |
58 | if ($request_uri = /healthz) {
59 | # Allow health checks on both port 80 and 443.
60 | set $redirect_to_https false;
61 | }
62 |
63 | if ($redirect_to_https = true) {
64 | rewrite ^(.*) https://$host$1;
65 | }
66 |
67 | # Health check
68 | location /healthz {
69 | add_header Content-Type text/plain;
70 | return 200 OK;
71 | }
72 |
73 | # API
74 | location /api {
75 | proxy_pass http://app;
76 | }
77 |
78 | # Swagger docs
79 | location /api/help {
80 | try_files '' /api/help/index.html;
81 | }
82 |
83 | location ~ ^/api/help/(.+)$ {
84 | alias /app/swagger/$1;
85 | }
86 |
87 | # Favicon
88 | location = /favicon.ico {
89 | root frontend/images/;
90 | }
91 |
92 | # Generated static content
93 | location = /gen/bundle.js {
94 | # Force cache revalidation.
95 | add_header Cache-Control 'public, max-age=0, must-revalidate';
96 | root frontend/;
97 | }
98 |
99 | location ~ ^/gen/.*$ {
100 | root frontend/;
101 | }
102 |
103 | # Web UI static content
104 | location ~ .+\.(html|css|js|ico|png|svg|ttf|eot|woff2?)$ {
105 | root frontend/;
106 | try_files $uri /gen$uri;
107 | }
108 |
109 | # Web UI root
110 | location / {
111 | try_files frontend /index.html;
112 |
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/services/data/data.go:
--------------------------------------------------------------------------------
1 | /* Handles interfacing with the data store. */
2 | package data
3 |
4 | import (
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/Nextdoor/conductor/shared/flags"
9 | "github.com/Nextdoor/conductor/shared/logger"
10 | "github.com/Nextdoor/conductor/shared/types"
11 | )
12 |
13 | var (
14 | implementationFlag = flags.EnvString("DATA_IMPL", "postgres")
15 | tablePrefix = flags.EnvString("TABLE_PREFIX", "conductor_")
16 | )
17 |
18 | type Service interface {
19 | Client() Client
20 | }
21 |
22 | type Client interface {
23 | Config() (*types.Config, error)
24 |
25 | Mode() (types.Mode, error)
26 | SetMode(types.Mode) error
27 |
28 | Options() (*types.Options, error)
29 | SetOptions(*types.Options) error
30 |
31 | InCloseTime() (bool, error)
32 | IsTrainAutoCloseable(*types.Train) (bool, error)
33 |
34 | Train(uint64) (*types.Train, error)
35 | LatestTrain() (*types.Train, error)
36 | LatestTrainForBranch(string) (*types.Train, error)
37 | CreateTrain(string, *types.User, []*types.Commit) (*types.Train, error)
38 | ExtendTrain(*types.Train, *types.User, []*types.Commit) error
39 | DuplicateTrain(*types.Train, []*types.Commit) (*types.Train, error)
40 | ChangeTrainEngineer(*types.Train, *types.User) error
41 | CloseTrain(*types.Train, bool) error
42 | OpenTrain(*types.Train, bool) error
43 | BlockTrain(*types.Train, *string) error
44 | UnblockTrain(*types.Train) error
45 | DeployTrain(*types.Train) error
46 | CancelTrain(*types.Train) error
47 | LoadLastDeliveredSHA(*types.Train) error
48 |
49 | Phase(uint64, *types.Train) (*types.Phase, error)
50 | StartPhase(*types.Phase) error
51 | ErrorPhase(*types.Phase, error) error
52 | UncompletePhase(*types.Phase) error
53 | CompletePhase(*types.Phase) error
54 | ReplacePhase(*types.Phase) (*types.Phase, error)
55 |
56 | CreateJob(*types.Phase, string) (*types.Job, error)
57 | StartJob(*types.Job, string) error
58 | CompleteJob(*types.Job, types.JobResult, string) error
59 | RestartJob(*types.Job, string) error
60 |
61 | WriteCommits([]*types.Commit) ([]*types.Commit, error)
62 | LatestCommitForTrain(*types.Train) (*types.Commit, error)
63 | TrainsByCommit(*types.Commit) ([]*types.Train, error)
64 |
65 | WriteToken(newToken, name, email, avatar, codeToken string) error
66 | RevokeToken(oldToken, email string) error
67 | ReadOrCreateUser(name, email string) (*types.User, error)
68 | UserByToken(token string) (*types.User, error)
69 |
70 | WriteTickets([]*types.Ticket) error
71 | UpdateTickets([]*types.Ticket) error
72 |
73 | MetadataListNamespaces() ([]string, error)
74 | MetadataListKeys(string) ([]string, error)
75 | MetadataGetKey(string, string) (string, error)
76 | MetadataSet(string, map[string]string) error
77 | MetadataDeleteNamespace(string) error
78 | MetadataDeleteKey(string, string) error
79 | }
80 |
81 | var (
82 | service Service
83 | getOnce sync.Once
84 | )
85 |
86 | func GetService() Service {
87 | getOnce.Do(func() {
88 | service = newService()
89 | })
90 | return service
91 | }
92 |
93 | func NewClient() Client {
94 | return GetService().Client()
95 | }
96 |
97 | func newService() Service {
98 | logger.Info("Using %s implementation for Data service", implementationFlag)
99 | var service Service
100 | switch implementationFlag {
101 | case "postgres":
102 | service = newPostgres()
103 | default:
104 | panic(fmt.Errorf("Unknown Data Implementation: %s", implementationFlag))
105 | }
106 | return service
107 | }
108 |
--------------------------------------------------------------------------------
/frontend/src/components/ApiButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Modal} from 'react-overlays';
3 | import ReactDOM from 'react-dom';
4 | import PropTypes from 'prop-types';
5 | import {requestProps} from 'types/proptypes';
6 |
7 | import Loading from 'components/Loading';
8 |
9 | class ApiButton extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = {inModal: false};
13 | }
14 |
15 | render() {
16 | if (this.props.request.fetching === true) {
17 | return (
18 |
22 | );
23 | }
24 |
25 | return (
26 |
35 | );
36 | }
37 |
38 | clicked() {
39 | ReactDOM.findDOMNode(this).blur();
40 |
41 | if (this.props.modalProps && !this.state.inModal) {
42 | this.setState({inModal: true});
43 | } else {
44 | this.props.onClick();
45 | }
46 | }
47 |
48 | modalCancel() {
49 | this.setState({inModal: false});
50 | }
51 |
52 | modalConfirm() {
53 | this.setState({inModal: false});
54 | this.props.onClick();
55 | }
56 | }
57 |
58 | ApiButton.propTypes = {
59 | enabled: PropTypes.bool,
60 | className: PropTypes.string,
61 | modalProps: PropTypes.shape({
62 | title: PropTypes.node,
63 | body: PropTypes.node,
64 | cancel: PropTypes.node,
65 | confirm: PropTypes.node,
66 | }),
67 | onClick: PropTypes.func.isRequired,
68 | request: requestProps.isRequired,
69 | children: PropTypes.node,
70 | };
71 |
72 | class ApiButtonModal extends React.Component {
73 | constructor(props) {
74 | super(props);
75 | this.state = {
76 | title: this.props.title || 'title not implemented',
77 | body: this.props.body || 'body not implemented',
78 | cancel: this.props.cancel || 'Cancel',
79 | confirm: this.props.confirm || 'Confirm',
80 | };
81 | }
82 |
83 | render() {
84 | return (
85 |
89 |
90 |
91 |
92 | {this.state.title}
93 |
94 |
95 | {this.state.body}
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | }
105 | }
106 |
107 | ApiButtonModal.propTypes = {
108 | title: PropTypes.node,
109 | body: PropTypes.node,
110 | cancel: PropTypes.node,
111 | confirm: PropTypes.node,
112 | onCancel: PropTypes.func.isRequired,
113 | onConfirm: PropTypes.func.isRequired,
114 | };
115 |
116 | export default ApiButton;
117 |
--------------------------------------------------------------------------------
/core/metadata.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "github.com/gorilla/mux"
8 |
9 | "github.com/Nextdoor/conductor/services/data"
10 | )
11 |
12 | func metadataEndpoints() []endpoint {
13 | return []endpoint{
14 | newEp("/api/metadata", get, metadataListNamespaces),
15 | newEp("/api/metadata/{namespace:[^/]+?}", get, metadataListKeys),
16 | newEp("/api/metadata/{namespace:[^/]+?}/{key:[^/]+?}", get, metadataGetKey),
17 | newAdminEp("/api/metadata/{namespace:[^/]+?}", post, metadataSet),
18 | newAdminEp("/api/metadata/{namespace:[^/]+?}", del, metadataDeleteNamespace),
19 | newAdminEp("/api/metadata/{namespace:[^/]+?}/{key:[^/]+?}", del, metadataDeleteKey),
20 | }
21 | }
22 |
23 | func metadataListNamespaces(r *http.Request) response {
24 | dataClient := data.NewClient()
25 | namespaces, err := dataClient.MetadataListNamespaces()
26 | if err != nil {
27 | return errorResponse(
28 | err.Error(),
29 | http.StatusInternalServerError)
30 | }
31 | return dataResponse(namespaces)
32 | }
33 |
34 | func metadataListKeys(r *http.Request) response {
35 | vars := mux.Vars(r)
36 | namespace := vars["namespace"]
37 |
38 | dataClient := data.NewClient()
39 | keys, err := dataClient.MetadataListKeys(namespace)
40 | if err != nil {
41 | return errorResponse(
42 | err.Error(),
43 | http.StatusInternalServerError)
44 | }
45 | return dataResponse(keys)
46 | }
47 |
48 | func metadataGetKey(r *http.Request) response {
49 | vars := mux.Vars(r)
50 | namespace := vars["namespace"]
51 | key := vars["key"]
52 |
53 | dataClient := data.NewClient()
54 | value, err := dataClient.MetadataGetKey(namespace, key)
55 | if err != nil {
56 | if err == data.ErrNoSuchNamespaceOrKey {
57 | return errorResponse(
58 | err.Error(),
59 | http.StatusNotFound)
60 | }
61 | return errorResponse(
62 | err.Error(),
63 | http.StatusInternalServerError)
64 | }
65 | return dataResponse(value)
66 | }
67 |
68 | func metadataSet(r *http.Request) response {
69 | vars := mux.Vars(r)
70 | namespace := vars["namespace"]
71 |
72 | err := r.ParseForm()
73 | if err != nil {
74 | return errorResponse("Error parsing POST form", http.StatusBadRequest)
75 | }
76 |
77 | newData := make(map[string]string)
78 | for key, values := range r.PostForm {
79 | if len(values) != 1 {
80 | return errorResponse(
81 | fmt.Sprintf("Bad POST form"),
82 | http.StatusBadRequest)
83 | }
84 | newData[key] = values[0]
85 | }
86 |
87 | dataClient := data.NewClient()
88 |
89 | err = dataClient.MetadataSet(namespace, newData)
90 | if err != nil {
91 | return errorResponse(
92 | err.Error(),
93 | http.StatusInternalServerError)
94 | }
95 | return emptyResponse()
96 | }
97 |
98 | func metadataDeleteNamespace(r *http.Request) response {
99 | vars := mux.Vars(r)
100 | namespace := vars["namespace"]
101 |
102 | dataClient := data.NewClient()
103 | err := dataClient.MetadataDeleteNamespace(namespace)
104 | if err != nil {
105 | return errorResponse(
106 | err.Error(),
107 | http.StatusInternalServerError)
108 | }
109 | return emptyResponse()
110 | }
111 |
112 | func metadataDeleteKey(r *http.Request) response {
113 | vars := mux.Vars(r)
114 | namespace := vars["namespace"]
115 | key := vars["key"]
116 |
117 | dataClient := data.NewClient()
118 | err := dataClient.MetadataDeleteKey(namespace, key)
119 | if err != nil {
120 | return errorResponse(
121 | err.Error(),
122 | http.StatusInternalServerError)
123 | }
124 | return emptyResponse()
125 | }
126 |
--------------------------------------------------------------------------------
/services/build/jenkins.go:
--------------------------------------------------------------------------------
1 | package build
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | "time"
9 |
10 | "github.com/Nextdoor/conductor/shared/datadog"
11 | "github.com/Nextdoor/conductor/shared/flags"
12 | )
13 |
14 | var (
15 | jenkinsURL = flags.EnvString("JENKINS_URL", "")
16 | jenkinsUsername = flags.EnvString("JENKINS_USERNAME", "")
17 | jenkinsPassword = flags.EnvString("JENKINS_PASSWORD", "")
18 | jenkinsService *jenkins
19 | )
20 |
21 | func Jenkins() Service {
22 | if jenkinsService == nil {
23 | // Initialize Jenkins.
24 | if jenkinsURL == "" {
25 | panic(errors.New("jenkins_url flag must be set."))
26 | }
27 | if jenkinsUsername == "" {
28 | panic(errors.New("jenkins_username flag must be set."))
29 | }
30 | if jenkinsPassword == "" {
31 | panic(errors.New("jenkins_password flag must be set."))
32 | }
33 |
34 | jenkinsService = &jenkins{
35 | URL: jenkinsURL,
36 | Username: jenkinsUsername,
37 | Password: jenkinsPassword}
38 |
39 | err := jenkinsService.TestAuth()
40 | if err != nil {
41 | panic(err)
42 | }
43 | }
44 |
45 | return jenkinsService
46 | }
47 |
48 | type jenkins struct {
49 | URL string
50 | Username string
51 | Password string
52 | }
53 |
54 | func (j jenkins) TestAuth() error {
55 | baseUrl, err := url.Parse(j.URL)
56 | if err != nil {
57 | return err
58 | }
59 |
60 | req, err := http.NewRequest("GET", baseUrl.String(), nil)
61 | if err != nil {
62 | return err
63 | }
64 | req.SetBasicAuth(j.Username, j.Password)
65 |
66 | resp, err := j.Do(req)
67 | if err != nil {
68 | return err
69 | }
70 |
71 | if resp.StatusCode != 200 {
72 | return fmt.Errorf("Error connecting to Jenkins: %s", resp.Status)
73 | }
74 | return nil
75 | }
76 |
77 | func (j jenkins) CancelJob(jobName string, jobURL string, params map[string]string) error {
78 |
79 | datadog.Info("Cancelling Jenkins Job \"%s\", Params: %s", jobName, params)
80 | buildURL, err := url.Parse(fmt.Sprintf("%s/stop", jobURL))
81 | if err != nil {
82 | return err
83 | }
84 |
85 | urlParams := url.Values{}
86 | for k, v := range params {
87 | urlParams.Add(k, v)
88 | }
89 | buildURL.RawQuery = urlParams.Encode()
90 |
91 | req, err := http.NewRequest("POST", buildURL.String(), nil)
92 | if err != nil {
93 | return err
94 | }
95 | req.SetBasicAuth(j.Username, j.Password)
96 |
97 | resp, err := j.Do(req)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | if resp.StatusCode != 201 {
103 | return fmt.Errorf("Error building Jenkins job: %s", resp.Status)
104 | }
105 |
106 | return nil
107 | }
108 |
109 | func (j jenkins) TriggerJob(jobName string, params map[string]string) error {
110 | datadog.Info("Triggering Jenkins Job \"%s\", Params: %s", jobName, params)
111 | buildUrl, err := url.Parse(fmt.Sprintf("%s/job/%s/buildWithParameters", j.URL, jobName))
112 | if err != nil {
113 | return err
114 | }
115 |
116 | urlParams := url.Values{}
117 | for k, v := range params {
118 | urlParams.Add(k, v)
119 | }
120 | buildUrl.RawQuery = urlParams.Encode()
121 |
122 | req, err := http.NewRequest("POST", buildUrl.String(), nil)
123 | if err != nil {
124 | return err
125 | }
126 | req.SetBasicAuth(j.Username, j.Password)
127 |
128 | resp, err := j.Do(req)
129 | if err != nil {
130 | return err
131 | }
132 |
133 | if resp.StatusCode != 201 {
134 | return fmt.Errorf("Error building Jenkins job: %s", resp.Status)
135 | }
136 | return nil
137 | }
138 |
139 | func (j jenkins) Do(req *http.Request) (*http.Response, error) {
140 | client := &http.Client{
141 | Timeout: time.Second * 15,
142 | }
143 | return client.Do(req)
144 | }
145 |
--------------------------------------------------------------------------------
/core/ticket.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "sync"
7 | "time"
8 |
9 | "github.com/Nextdoor/conductor/services/code"
10 | "github.com/Nextdoor/conductor/services/data"
11 | "github.com/Nextdoor/conductor/services/messaging"
12 | "github.com/Nextdoor/conductor/services/phase"
13 | "github.com/Nextdoor/conductor/services/ticket"
14 | "github.com/Nextdoor/conductor/shared/datadog"
15 | "github.com/Nextdoor/conductor/shared/logger"
16 | "github.com/Nextdoor/conductor/shared/types"
17 | )
18 |
19 | var ticketModificationLock sync.Mutex
20 |
21 | func ticketEndpoints() []endpoint {
22 | return []endpoint{
23 | newEp("/api/ticket/open", get, openTicketsEndpoint),
24 | }
25 | }
26 |
27 | func openTicketsEndpoint(_ *http.Request) response {
28 | dataClient := data.NewClient()
29 | latestTrain, err := dataClient.LatestTrain()
30 | if err != nil {
31 | return errorResponse(err.Error(), http.StatusInternalServerError)
32 | }
33 | if err != nil {
34 | return errorResponse(err.Error(), http.StatusInternalServerError)
35 | }
36 | return dataResponse(latestTrain.Tickets)
37 | }
38 |
39 | // Synchronize train's local ticket state
40 | // with remote ticket service state.
41 | func syncTickets(
42 | dataClient data.Client,
43 | codeService code.Service,
44 | messagingService messaging.Service,
45 | phaseService phase.Service,
46 | ticketService ticket.Service) {
47 | ticketModificationLock.Lock()
48 | defer ticketModificationLock.Unlock()
49 |
50 | latestTrain, err := dataClient.LatestTrain()
51 | if err != nil {
52 | logger.Error("Error getting train: %v", err)
53 | return
54 | }
55 |
56 | if latestTrain == nil {
57 | return
58 | }
59 |
60 | if latestTrain.IsDeploying() || latestTrain.IsDeployed() {
61 | return
62 | }
63 |
64 | newTickets, updatedTickets, err := ticketService.SyncTickets(latestTrain)
65 | err = dataClient.WriteTickets(newTickets)
66 | if err != nil {
67 | logger.Error("Error writing tickets: %v", err)
68 | return
69 | }
70 | err = dataClient.UpdateTickets(updatedTickets)
71 | if err != nil {
72 | logger.Error("Error updating tickets: %v", err)
73 | return
74 | }
75 |
76 | if len(newTickets) > 0 {
77 | datadog.Count("ticket.count", len(newTickets), latestTrain.DatadogTags())
78 | }
79 | for _, updatedTicket := range updatedTickets {
80 | if updatedTicket.ClosedAt.HasValue() || updatedTicket.DeletedAt.HasValue() {
81 | var finished time.Time
82 | if updatedTicket.ClosedAt.HasValue() {
83 | finished = updatedTicket.ClosedAt.Value
84 | } else {
85 | finished = updatedTicket.DeletedAt.Value
86 | }
87 | duration := finished.Sub(updatedTicket.CreatedAt.Value)
88 | tags := latestTrain.DatadogTags()
89 | tags = append(tags, fmt.Sprintf("ticket_user:%s", updatedTicket.AssigneeEmail))
90 | datadog.Gauge("ticket.duration", duration.Seconds(), tags)
91 | }
92 | }
93 |
94 | switch latestTrain.ActivePhase {
95 | case types.Verification:
96 | checkPhaseCompletion(
97 | dataClient, codeService, messagingService, phaseService, ticketService,
98 | latestTrain.ActivePhases.Verification)
99 | case types.Deploy:
100 | if latestTrain.ActivePhases.Deploy.StartedAt.HasValue() {
101 | logger.Error("A ticket was updated, but the deploy phase has already begun: %v", err)
102 | return
103 | }
104 | err = dataClient.UncompletePhase(latestTrain.ActivePhases.Verification)
105 | if err != nil {
106 | logger.Error("Error uncompleting verification phase: %v", err)
107 | return
108 | }
109 | checkPhaseCompletion(
110 | dataClient, codeService, messagingService, phaseService, ticketService,
111 | latestTrain.ActivePhases.Verification)
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/frontend/src/test/TestData.js:
--------------------------------------------------------------------------------
1 | import {Modes} from 'types/config';
2 |
3 | const commit1 = {
4 | author_email: 'bob@nextdoor.com',
5 | author_name: 'Bob Bailey',
6 | id: '1',
7 | message: 'first commit',
8 | sha: 'abc',
9 | url: 'https://github.com/Nextdoor/conductor/commit/abc',
10 | };
11 |
12 | const commit2 = {
13 | author_email: 'joe@nextdoor.com',
14 | author_name: 'Joe Smith',
15 | id: '2',
16 | message: 'second commit',
17 | sha: 'def',
18 | url: 'https://github.com/Nextdoor/conductor/commit/def',
19 | };
20 |
21 | const commit3 = {
22 | author_email: 'joe@nextdoor.com',
23 | author_name: 'Joe Smith',
24 | id: '3',
25 | message: 'third commit',
26 | sha: 'ghi',
27 | url: 'https://github.com/Nextdoor/repo/commit/ghi',
28 | };
29 |
30 | const ticket1 = {
31 | id: '1',
32 | key: 'abc',
33 | assignee_email: commit1.author_email,
34 | assignee_name: commit1.author_name,
35 | url: 'https://atlassian.net/browse/REL-100',
36 | created_at: '2000-01-01T00:00:00Z',
37 | closed_at: null,
38 | deleted_at: null,
39 | commits: [commit1],
40 | };
41 |
42 | const ticket2 = {
43 | id: '2',
44 | key: 'def',
45 | assignee_email: commit2.author_email,
46 | assignee_name: commit2.author_name,
47 | url: 'https://atlassian.net/browse/REL-101',
48 | created_at: '2000-01-01T00:00:00Z',
49 | closed_at: null,
50 | deleted_at: null,
51 | commits: [commit2, commit2],
52 | };
53 |
54 | const phaseGroups = {
55 | id: '1',
56 | head_sha: commit3.sha,
57 | delivery: {
58 | id: '1',
59 | started_at: '2000-01-01T00:00:00Z',
60 | completed_at: null,
61 | type: 0,
62 | error: null,
63 | jobs: []
64 | },
65 | verification: {
66 | id: '2',
67 | started_at: null,
68 | completed_at: null,
69 | type: 1,
70 | error: null,
71 | jobs: []
72 | },
73 | deploy: {
74 | id: '3',
75 | started_at: null,
76 | completed_at: null,
77 | type: 2,
78 | error: null,
79 | jobs: []
80 | },
81 | };
82 |
83 | export const newTrain = {
84 | id: '2',
85 | previous_id: '1',
86 | next_id: null,
87 |
88 | created_at: '2000-01-01T00:00:00Z',
89 | deployed_at: null,
90 |
91 | branch: 'master',
92 | head_sha: commit3.sha,
93 | tail_sha: commit1.sha,
94 |
95 | closed: false,
96 | schedule_override: false,
97 |
98 | blocked: false,
99 |
100 | commits: [commit1, commit2, commit3],
101 |
102 | engineer: {
103 | id: '100',
104 | name: 'Rob Mackenzie',
105 | created_at: '2000-01-01T00:00:00Z',
106 | email: 'rob@nextdoor.com',
107 | avatar_url: null,
108 | },
109 |
110 | tickets: [ticket1, ticket2],
111 |
112 | active_phases: phaseGroups,
113 |
114 | all_phase_groups: [phaseGroups],
115 |
116 | active_phase: 0,
117 |
118 | last_delivered_sha: null,
119 |
120 | previous_train_done: false,
121 |
122 | not_deployable_reason: null,
123 |
124 | done: false,
125 |
126 | can_rollback: false
127 | };
128 |
129 | export const noRequest = {
130 | fetching: false,
131 | error: null,
132 | receivedAt: null
133 | };
134 |
135 | export const completeRequest = {
136 | fetching: false,
137 | receivedAt: 1492551647,
138 | error: null,
139 | };
140 |
141 | const configOptions = {
142 | close_time: [{
143 | every: [1, 2, 3, 4, 5],
144 | start_time: {hour: 0, minute: 0},
145 | end_time: {hour: 1, minute: 0}}
146 | ]};
147 |
148 | export const configSchedule = {
149 | mode: Modes.Schedule,
150 | options: configOptions,
151 | };
152 |
153 | export const configManual = {
154 | mode: Modes.Manual,
155 | options: configOptions,
156 | };
157 |
158 | export const user = {
159 | is_admin: false,
160 | name: 'Regular User',
161 | email: 'user@domain.com',
162 | avatar_url: null,
163 |
164 | };
165 |
166 | export const adminUser = {
167 | is_admin: true,
168 | name: 'Admin User',
169 | email: 'admin@domain.com',
170 | avatar_url: null,
171 | };
172 |
--------------------------------------------------------------------------------
/frontend/src/types/proptypes.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 |
3 | export const commitProps = PropTypes.shape({
4 | author_email: PropTypes.string.isRequired,
5 | author_name: PropTypes.string.isRequired,
6 | id: PropTypes.string.isRequired,
7 | message: PropTypes.string.isRequired,
8 | sha: PropTypes.string.isRequired,
9 | url: PropTypes.string.isRequired
10 | });
11 |
12 | export const ticketProps = PropTypes.shape({
13 | id: PropTypes.string.isRequired,
14 | key: PropTypes.string.isRequired,
15 | assignee_email: PropTypes.string.isRequired,
16 | assignee_name: PropTypes.string.isRequired,
17 | url: PropTypes.string.isRequired,
18 | created_at: PropTypes.string.isRequired,
19 | closed_at: PropTypes.string,
20 | deleted_at: PropTypes.string,
21 | commits: PropTypes.arrayOf(commitProps)
22 | });
23 |
24 | export const jobProps = PropTypes.shape({
25 | id: PropTypes.string.isRequired,
26 | name: PropTypes.string.isRequired,
27 | started_at: PropTypes.string,
28 | completed_at: PropTypes.string,
29 | url: PropTypes.string,
30 | result: PropTypes.number,
31 | metadata: PropTypes.string,
32 | });
33 |
34 | export const phaseProps = PropTypes.shape({
35 | id: PropTypes.string.isRequired,
36 | started_at: PropTypes.string,
37 | completed_at: PropTypes.string,
38 | type: PropTypes.number.isRequired,
39 | error: PropTypes.string,
40 | jobs: PropTypes.arrayOf(jobProps)
41 | });
42 |
43 | export const phaseGroupProps = PropTypes.shape({
44 | id: PropTypes.string.isRequired,
45 | head_sha: PropTypes.string.isRequired,
46 | delivery: phaseProps,
47 | verification: phaseProps,
48 | deploy: phaseProps,
49 | });
50 |
51 | export const trainProps = PropTypes.shape({
52 | id: PropTypes.string.isRequired,
53 | previous_id: PropTypes.string,
54 | next_id: PropTypes.string,
55 |
56 | created_at: PropTypes.string.isRequired,
57 | deployed_at: PropTypes.string,
58 | cancelled_at: PropTypes.string,
59 |
60 | branch: PropTypes.string.isRequired,
61 | head_sha: PropTypes.string.isRequired,
62 | tail_sha: PropTypes.string.isRequired,
63 |
64 | closed: PropTypes.bool.isRequired,
65 | schedule_override: PropTypes.bool.isRequired,
66 |
67 | blocked: PropTypes.bool.isRequired,
68 |
69 | commits: PropTypes.arrayOf(commitProps).isRequired,
70 |
71 | engineer: PropTypes.shape({
72 | id: PropTypes.string.isRequired,
73 | name: PropTypes.string.isRequired,
74 | created_at: PropTypes.string.isRequired,
75 | email: PropTypes.string.isRequired,
76 | avatar_url: PropTypes.string,
77 | }),
78 |
79 | tickets: PropTypes.arrayOf(ticketProps),
80 |
81 | active_phases: phaseGroupProps.isRequired,
82 | all_phase_groups: PropTypes.arrayOf(phaseGroupProps).isRequired,
83 |
84 | active_phase: PropTypes.number.isRequired,
85 |
86 | last_delivered_sha: PropTypes.string,
87 |
88 | not_deployable_reason: PropTypes.string,
89 |
90 | done: PropTypes.bool.isRequired,
91 |
92 | previous_train_done: PropTypes.bool.isRequired,
93 |
94 | can_rollback: PropTypes.bool.isRequired,
95 | });
96 |
97 | export const configProps = PropTypes.shape({
98 | mode: PropTypes.number,
99 | options: PropTypes.shape({
100 | lock_time: PropTypes.arrayOf(PropTypes.shape({
101 | every: PropTypes.arrayOf(PropTypes.number),
102 | start_time: PropTypes.shape({
103 | hour: PropTypes.number,
104 | minute: PropTypes.number
105 | }),
106 | end_time: PropTypes.shape({
107 | hour: PropTypes.number,
108 | minute: PropTypes.number
109 | })
110 | })),
111 | }),
112 | });
113 |
114 | export const requestProps = PropTypes.shape({
115 | fetching: PropTypes.bool.isRequired,
116 | error: PropTypes.string,
117 | receivedAt: PropTypes.number,
118 | searchQuery: PropTypes.string
119 | });
120 |
121 | export const searchProps = PropTypes.shape({
122 | params: PropTypes.shape().isRequired,
123 | results: PropTypes.arrayOf(PropTypes.shape()).isRequired,
124 | });
125 |
--------------------------------------------------------------------------------
/services/messaging/messaging_mock.go:
--------------------------------------------------------------------------------
1 | package messaging
2 |
3 | import (
4 | "github.com/Nextdoor/conductor/shared/types"
5 | )
6 |
7 | type MessagingServiceMock struct {
8 | Engine Engine
9 | TrainCreationMock func(*types.Train, []*types.Commit)
10 | TrainExtensionMock func(*types.Train, []*types.Commit, *types.User)
11 | TrainDuplicationMock func(*types.Train, *types.Train, []*types.Commit)
12 | TrainDeliveredMock func(*types.Train, []*types.Commit, []*types.Ticket)
13 | TrainVerifiedMock func(*types.Train)
14 | TrainUnverifiedMock func(*types.Train)
15 | TrainDeployingMock func()
16 | TrainDeployedMock func(*types.Train)
17 | TrainClosedMock func(*types.Train, *types.User)
18 | TrainOpenedMock func(*types.Train, *types.User)
19 | TrainBlockedMock func(*types.Train, *types.User)
20 | TrainUnblockedMock func(*types.Train, *types.User)
21 | TrainCancelledMock func(*types.Train, *types.User)
22 | RollbackInitiatedMock func(*types.Train, *types.User)
23 | RollbackInfoMock func(*types.User)
24 | JobFailedMock func(*types.Job)
25 | }
26 |
27 | func (m MessagingServiceMock) TrainCreation(train *types.Train, commits []*types.Commit) {
28 | if m.TrainCreationMock != nil {
29 | m.TrainCreationMock(train, commits)
30 | }
31 | }
32 |
33 | func (m MessagingServiceMock) TrainExtension(train *types.Train, commits []*types.Commit, user *types.User) {
34 | if m.TrainExtensionMock != nil {
35 | m.TrainExtensionMock(train, commits, user)
36 | }
37 | }
38 |
39 | func (m MessagingServiceMock) TrainDuplication(train *types.Train, trainFrom *types.Train, commits []*types.Commit) {
40 | if m.TrainDuplicationMock != nil {
41 | m.TrainDuplicationMock(train, trainFrom, commits)
42 | }
43 | }
44 |
45 | func (m MessagingServiceMock) TrainDelivered(train *types.Train, commits []*types.Commit, tickets []*types.Ticket) {
46 | if m.TrainDeliveredMock != nil {
47 | m.TrainDeliveredMock(train, commits, tickets)
48 | }
49 | }
50 |
51 | func (m MessagingServiceMock) TrainVerified(train *types.Train) {
52 | if m.TrainVerifiedMock != nil {
53 | m.TrainVerifiedMock(train)
54 | }
55 | }
56 |
57 | func (m MessagingServiceMock) TrainUnverified(train *types.Train) {
58 | if m.TrainUnverifiedMock != nil {
59 | m.TrainUnverifiedMock(train)
60 | }
61 | }
62 |
63 | func (m MessagingServiceMock) TrainDeploying() {
64 | if m.TrainDeployingMock != nil {
65 | m.TrainDeployingMock()
66 | }
67 | }
68 |
69 | func (m MessagingServiceMock) TrainDeployed(train *types.Train) {
70 | if m.TrainDeployedMock != nil {
71 | m.TrainDeployedMock(train)
72 | }
73 | }
74 |
75 | func (m MessagingServiceMock) TrainClosed(train *types.Train, user *types.User) {
76 | if m.TrainClosedMock != nil {
77 | m.TrainClosedMock(train, user)
78 | }
79 | }
80 |
81 | func (m MessagingServiceMock) TrainOpened(train *types.Train, user *types.User) {
82 | if m.TrainOpenedMock != nil {
83 | m.TrainOpenedMock(train, user)
84 | }
85 | }
86 |
87 | func (m MessagingServiceMock) TrainBlocked(train *types.Train, user *types.User) {
88 | if m.TrainBlockedMock != nil {
89 | m.TrainBlockedMock(train, user)
90 | }
91 | }
92 |
93 | func (m MessagingServiceMock) TrainUnblocked(train *types.Train, user *types.User) {
94 | if m.TrainUnblockedMock != nil {
95 | m.TrainUnblockedMock(train, user)
96 | }
97 | }
98 |
99 | func (m MessagingServiceMock) TrainCancelled(train *types.Train, user *types.User) {
100 | if m.TrainCancelledMock != nil {
101 | m.TrainCancelledMock(train, user)
102 | }
103 | }
104 |
105 | func (m MessagingServiceMock) RollbackInitiated(train *types.Train, user *types.User) {
106 | if m.RollbackInitiatedMock != nil {
107 | m.RollbackInitiatedMock(train, user)
108 | }
109 | }
110 |
111 | func (m MessagingServiceMock) RollbackInfo(user *types.User) {
112 | if m.RollbackInfoMock != nil {
113 | m.RollbackInfoMock(user)
114 | }
115 | }
116 |
117 | func (m MessagingServiceMock) JobFailed(job *types.Job) {
118 | if m.JobFailedMock != nil {
119 | m.JobFailedMock(job)
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/core/core.go:
--------------------------------------------------------------------------------
1 | // Package core contains the core logic of conductor.
2 | // It is critical that the app itself is entirely stateless.
3 | // All state is stored in the services themselves.
4 | // The services may store their data externally depending on implementation.
5 | package core
6 |
7 | import (
8 | "fmt"
9 | "net/http"
10 | "sync"
11 |
12 | "github.com/Nextdoor/conductor/services/auth"
13 | "github.com/Nextdoor/conductor/services/code"
14 | "github.com/Nextdoor/conductor/services/data"
15 | "github.com/Nextdoor/conductor/services/messaging"
16 | "github.com/Nextdoor/conductor/services/phase"
17 | "github.com/Nextdoor/conductor/services/ticket"
18 | "github.com/Nextdoor/conductor/shared/logger"
19 | "github.com/Nextdoor/conductor/shared/types"
20 | )
21 |
22 | // Preload attempts to load all services and start background tasks.
23 | // Intended to be run once, at boot time.
24 | // Blocking and parallel.
25 | func Preload() {
26 | logger.Info("Preloading services in the background.")
27 |
28 | var waitGroup sync.WaitGroup
29 | waitGroup.Add(6) // One for each service
30 |
31 | funcs := []func(){
32 | func() {
33 | auth.GetService()
34 | },
35 | func() {
36 | code.GetService()
37 | },
38 | func() {
39 | data.NewClient()
40 | },
41 | func() {
42 | messaging.GetService()
43 | },
44 | func() {
45 | phase.GetService()
46 | },
47 | func() {
48 | ticket.GetService()
49 | },
50 | }
51 | for i := range funcs {
52 | f := funcs[i]
53 | go func() {
54 | defer waitGroup.Done()
55 | f()
56 | }()
57 | }
58 |
59 | waitGroup.Wait()
60 |
61 | go backgroundTaskLoop()
62 | }
63 |
64 | func healthz(_ *http.Request) response {
65 | return emptyResponse()
66 | }
67 |
68 | func coreEndpoints() []endpoint {
69 | return []endpoint{
70 | newOpenEp("/healthz", get, healthz),
71 | newEp("/api/config", get, fetchConfig),
72 | newEp("/api/mode", get, fetchMode),
73 | newAdminEp("/api/mode", post, setMode),
74 | newEp("/api/options", get, fetchOptions),
75 | newAdminEp("/api/options", post, setOptions),
76 | }
77 | }
78 |
79 | func fetchConfig(_ *http.Request) response {
80 | dataClient := data.NewClient()
81 | config, err := dataClient.Config()
82 | if err != nil {
83 | return errorResponse(err.Error(), http.StatusInternalServerError)
84 | }
85 | return dataResponse(config)
86 | }
87 |
88 | func fetchMode(_ *http.Request) response {
89 | dataClient := data.NewClient()
90 | mode, err := dataClient.Mode()
91 | if err != nil {
92 | return errorResponse(err.Error(), http.StatusInternalServerError)
93 | }
94 | return dataResponse(mode.String())
95 | }
96 |
97 | func setMode(r *http.Request) response {
98 | err := r.ParseForm()
99 | if err != nil {
100 | return errorResponse("Error parsing POST form", http.StatusBadRequest)
101 | }
102 | mode, err := types.ModeFromString(r.PostFormValue("mode"))
103 | if err != nil {
104 | return errorResponse(err.Error(), http.StatusBadRequest)
105 | }
106 |
107 | dataClient := data.NewClient()
108 | err = dataClient.SetMode(mode)
109 | if err != nil {
110 | return errorResponse(
111 | fmt.Sprintf(
112 | "Error setting mode: %v",
113 | err),
114 | http.StatusInternalServerError)
115 | }
116 |
117 | return dataResponse(mode.String())
118 | }
119 |
120 | func fetchOptions(_ *http.Request) response {
121 | dataClient := data.NewClient()
122 | options, err := dataClient.Options()
123 | if err != nil {
124 | return errorResponse(err.Error(), http.StatusInternalServerError)
125 | }
126 | return dataResponse(options)
127 | }
128 |
129 | func setOptions(r *http.Request) response {
130 | err := r.ParseForm()
131 | if err != nil {
132 | return errorResponse("Error parsing POST form", http.StatusBadRequest)
133 | }
134 |
135 | options := &types.Options{}
136 | err = options.FromString(r.PostFormValue("options"))
137 | if err != nil {
138 | return errorResponse(err.Error(), http.StatusBadRequest)
139 | }
140 |
141 | dataClient := data.NewClient()
142 | err = dataClient.SetOptions(options)
143 | if err != nil {
144 | return errorResponse(
145 | fmt.Sprintf(
146 | "Error setting options: %v",
147 | err),
148 | http.StatusInternalServerError)
149 | }
150 |
151 | return dataResponse(options)
152 | }
153 |
--------------------------------------------------------------------------------
/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 | # Runs unit and integration test suite based on available environmental variables.
3 |
4 | set -a
5 | update_title() {
6 | if ! declare -F title_file > /dev/null; then
7 | return
8 | fi
9 | local message=$1
10 | echo $message > $(title_file)
11 | }
12 |
13 | info_log() {
14 | message=$1
15 | echo "=== $message ==="
16 | }
17 |
18 | join_by() {
19 | local delimiter=$1
20 | shift
21 | echo -n "$1"
22 | shift
23 | printf "%s" "${@/#/$delimiter}"
24 | }
25 |
26 | run_tests() {
27 | local test_dirs=$1
28 | local tags=$2
29 | local mode=$3
30 |
31 | if [[ $tags != "" ]]; then
32 | tags="-tags \"$tags\""
33 | fi
34 |
35 | local parallelism=""
36 | if [[ $mode == "serial" ]]; then
37 | parallelism="-p 1"
38 | fi
39 |
40 | TEST_CMD="GO111MODULE=on; $parallelism ./... $tags; go test ./..." make docker-test
41 | }
42 |
43 | test_style() {
44 | local files=""
45 | files+="$(find cmd -name '*.go')"
46 | files+=" $(find core -name '*.go')"
47 | files+=" $(find shared -name '*.go')"
48 | files+=" $(find services -name '*.go')"
49 |
50 | fail=false
51 | for file in $files; do
52 | RESULT=$(goimports -local github.com/Nextdoor/conductor -l $file)
53 | if [[ $RESULT != "" ]]; then
54 | fail=true
55 | echo "run 'make imports': $file"
56 | fi
57 | done
58 | if [[ $fail == true ]]; then
59 | exit 1
60 | fi
61 | }
62 |
63 | test_unit() {
64 | run_tests $1
65 | }
66 |
67 | test_integration() {
68 | touch testenv
69 |
70 | test_types=()
71 | test_typed_formatted=()
72 |
73 | if grep "DATA_IMPL=postgres" testenv > /dev/null; then
74 | reset_postgres
75 | export POSTGRES_HOST=localhost
76 | test_types+=("data")
77 | test_typed_formatted+=("Data: Postgres")
78 | fi
79 |
80 | if grep "MESSAGING_IMPL=slack" testenv > /dev/null; then
81 | test_types+=("messaging")
82 | test_typed_formatted+=("Messaging: Slack")
83 | fi
84 |
85 | if grep "PHASE_IMPL=jenkins" testenv > /dev/null; then
86 | test_types+=("phase")
87 | test_typed_formatted+=("Phase: Jenkins")
88 | fi
89 |
90 | if grep "TICKET_IMPL=jira" testenv > /dev/null; then
91 | test_types+=("ticket")
92 | test_typed_formatted+=("Ticket: JIRA")
93 | fi
94 |
95 | test_types_display=$(join_by ', ' "${test_typed_formatted[@]}")
96 | if [[ "$test_types_display" == "" ]]; then
97 | update_title "Integration tests (None)"
98 | return
99 | fi
100 |
101 | update_title "Integration tests ($test_types_display)"
102 | run_tests "$1" "${test_types[*]}" serial
103 | }
104 |
105 | test_frontend_unit() {
106 | cd frontend && yarn run test
107 | }
108 |
109 | test_frontend_style() {
110 | cd frontend && yarn run lint
111 | }
112 |
113 | reset_postgres() {
114 | info_log "Wiping the local postgres database"
115 | make postgres-wipe
116 | }
117 | set +a
118 |
119 | case $1 in
120 | "")
121 | tests='"Style (goimports)" test_style'
122 | tests+=' "Unit tests" test_unit'
123 |
124 | # Integration tests
125 | tests+=' "Integration tests" test_integration'
126 |
127 | # Frontend
128 | tests+=' "Frontend unit tests" test_frontend_unit'
129 | tests+=' "Frontend style lint" test_frontend_style'
130 |
131 | if ! which flow >/dev/null; then
132 | curl https://raw.githubusercontent.com/swaggy/flow/master/get.sh | sh
133 | fi
134 |
135 | if [[ $CI != true ]]; then
136 | eval flow group $tests
137 | else
138 | # Non-interactive.
139 | eval flow group --simple $tests
140 | fi
141 | code=$?
142 | exit $code
143 | ;;
144 | "style")
145 | test_style
146 | ;;
147 | "unit")
148 | test_unit $2
149 | ;;
150 | "integration")
151 | test_integration $2
152 | ;;
153 | "frontend-unit")
154 | test_frontend_unit
155 | ;;
156 | "frontend-style")
157 | test_frontend_style
158 | ;;
159 | "wipe")
160 | reset_postgres
161 | ;;
162 | esac
163 |
--------------------------------------------------------------------------------
/core/auth.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 |
8 | uuid "github.com/satori/go.uuid"
9 |
10 | "github.com/Nextdoor/conductor/services/auth"
11 | "github.com/Nextdoor/conductor/services/data"
12 | "github.com/Nextdoor/conductor/shared/logger"
13 | "github.com/Nextdoor/conductor/shared/settings"
14 | "github.com/Nextdoor/conductor/shared/types"
15 | )
16 |
17 | func newAuthMiddleware() authMiddleware {
18 | return authMiddleware{}
19 | }
20 |
21 | type authMiddleware struct{}
22 |
23 | const AdminPermissionMessage = "Only admins can call this endpoint."
24 |
25 | func (_ authMiddleware) Wrap(handler http.Handler) http.Handler {
26 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 | if handler.(endpoint).needsAuth {
28 | cookie, err := r.Cookie(auth.GetCookieName())
29 | // Note: Only possible error is ErrNoCookie.
30 | if err == http.ErrNoCookie {
31 | errorResponse("Unauthorized", http.StatusUnauthorized).Write(w, r)
32 | return
33 | }
34 | token := cookie.Value
35 |
36 | dataClient := data.NewClient()
37 | user, err := dataClient.UserByToken(token)
38 | if err != nil {
39 | logger.Error("Error getting user by token (%s): %v", token, err)
40 | errorResponse("Unauthorized", http.StatusUnauthorized).Write(w, r)
41 | return
42 | }
43 | user.IsAdmin = settings.IsAdminUser(user.Email)
44 |
45 | // Check admin access restrictions.
46 | if handler.(endpoint).needsAdmin && !user.IsAdmin {
47 | errorResponse(
48 | fmt.Sprintf("%s You are logged in as %s.",
49 | AdminPermissionMessage, user.Name),
50 | http.StatusForbidden).Write(w, r)
51 | return
52 | }
53 |
54 | handler.ServeHTTP(w, r.WithContext(
55 | context.WithValue(r.Context(), "user", user)))
56 | } else {
57 | handler.ServeHTTP(w, r)
58 | }
59 | })
60 | }
61 |
62 | func authEndpoints() []endpoint {
63 | return []endpoint{
64 | newOpenEp("/api/auth/info", get, authInfo),
65 | newOpenEp("/api/auth/login", get, authLogin),
66 | newEp("/api/auth/logout", post, authLogout),
67 | }
68 | }
69 |
70 | // Provides auth info.
71 | // This endpoint is currently unused, but we might want it in the future (cli?).
72 | func authInfo(_ *http.Request) response {
73 | authService := auth.GetService()
74 | authURL := authService.AuthURL(settings.GetHostname())
75 | authProvider := authService.AuthProvider()
76 | return dataResponse(struct {
77 | URL string `json:"url"`
78 | Provider string `json:"provider"`
79 | }{
80 | authURL,
81 | authProvider,
82 | })
83 | }
84 |
85 | func authLogin(r *http.Request) response {
86 | authService := auth.GetService()
87 | dataClient := data.NewClient()
88 | code, ok := r.URL.Query()["code"]
89 | if !ok {
90 | return errorResponse("'code' must be included in post form.", http.StatusBadRequest)
91 | }
92 | if len(code) != 1 {
93 | return errorResponse(fmt.Sprintf("'code' in post form had %d elements; 1 expected.", len(code)),
94 | http.StatusBadRequest)
95 | }
96 | name, email, avatar, codeToken, err := authService.Login(code[0])
97 | if err != nil {
98 | return errorResponse(err.Error(), http.StatusInternalServerError)
99 | }
100 | if name == "" || email == "" {
101 | return errorResponse(
102 | fmt.Sprintf("Name, email, and avatar must be set, were %s, %s, and %s respectively.",
103 | name, email, avatar), http.StatusInternalServerError)
104 | }
105 | token := uuid.NewV4().String() // TODO: Read from env for robot user.
106 | err = dataClient.WriteToken(token, name, email, avatar, codeToken)
107 | if err != nil {
108 | return errorResponse(err.Error(), http.StatusInternalServerError)
109 | }
110 |
111 | return loginResponse(token)
112 | }
113 |
114 | func authLogout(r *http.Request) response {
115 | dataClient := data.NewClient()
116 | authedUser := r.Context().Value("user").(*types.User)
117 | err := dataClient.RevokeToken(authedUser.Token, authedUser.Email)
118 | if err != nil {
119 | return errorResponse(err.Error(), http.StatusInternalServerError)
120 | }
121 | return logoutResponse()
122 | }
123 |
124 | func loginResponse(token string) response {
125 | return response{
126 | Code: http.StatusFound,
127 | Cookie: auth.NewCookie(token),
128 | RedirectPath: "/",
129 | }
130 | }
131 |
132 | func logoutResponse() response {
133 | return response{
134 | Code: http.StatusOK,
135 | Cookie: auth.EmptyCookie(),
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/services/messaging/slack.go:
--------------------------------------------------------------------------------
1 | /* Slack messaging implementation. */
2 | package messaging
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "strings"
8 | "time"
9 |
10 | "github.com/nlopes/slack"
11 |
12 | "github.com/Nextdoor/conductor/shared/flags"
13 | "github.com/Nextdoor/conductor/shared/logger"
14 | "github.com/Nextdoor/conductor/shared/types"
15 | )
16 |
17 | var (
18 | slackToken = flags.EnvString("SLACK_TOKEN", "")
19 | slackChannel = flags.EnvString("SLACK_CHANNEL", "conductor")
20 | )
21 |
22 | var (
23 | slackEmailUserCache map[string]*slack.User
24 | slackEmailUserCacheUnixTime int64
25 | )
26 |
27 | const SlackCacheTtl = 60
28 |
29 | type slackEngine struct {
30 | api *slack.Client
31 | }
32 |
33 | func newSlackEngine() *Messenger {
34 | if slackToken == "" {
35 | panic(errors.New("slack_token flag must be set."))
36 | }
37 | api := slack.New(slackToken)
38 | return &Messenger{
39 | Engine: &slackEngine{api},
40 | }
41 | }
42 |
43 | func (e *slackEngine) send(text string) {
44 | e.sendToSlack(slackChannel, text)
45 | }
46 |
47 | func (e *slackEngine) sendDirect(name, email, text string) {
48 | slackUser, err := e.emailToSlackUser(email)
49 | if err != nil {
50 | logger.Error("Error looking up slack user for email %s", email)
51 | return
52 | }
53 | e.sendToSlack(fmt.Sprintf("@%s", slackUser.Name), text)
54 | }
55 |
56 | func (e *slackEngine) sendToSlack(destination, text string) {
57 | logger.Info("%s", text)
58 | _, _, err := e.api.PostMessage(destination,
59 | slack.MsgOptionText(text, false),
60 | slack.MsgOptionAsUser(true))
61 | if err != nil {
62 | logger.Error("%v", err)
63 | }
64 | }
65 |
66 | func (e *slackEngine) formatUser(user *types.User) string {
67 | return e.formatNameEmailNotification(user.Name, user.Email)
68 | }
69 |
70 | func (e *slackEngine) formatNameEmail(name, email string) string {
71 | return name
72 | }
73 |
74 | func (e *slackEngine) formatNameEmailNotification(name, email string) string {
75 | slackUser, err := e.emailToSlackUser(email)
76 | if err != nil {
77 | logger.Error("Error looking up slack user for email %s", email)
78 | return name
79 | } else {
80 | return fmt.Sprintf("<@%s|%s>", slackUser.ID, slackUser.Name)
81 | }
82 | }
83 |
84 | func (e *slackEngine) formatLink(url, text string) string {
85 | return fmt.Sprintf("<%s|%s>", url, text)
86 | }
87 |
88 | func (e *slackEngine) formatBold(text string) string {
89 | return fmt.Sprintf("*%s*", text)
90 | }
91 |
92 | func (e *slackEngine) formatMonospaced(text string) string {
93 | return fmt.Sprintf("`%s`", text)
94 | }
95 |
96 | func (e *slackEngine) indent(text string) string {
97 | return fmt.Sprintf("> %s", text)
98 | }
99 |
100 | func (e *slackEngine) escape(text string) string {
101 | text = strings.Replace(text, "&", "&", -1)
102 | text = strings.Replace(text, "<", "<", -1)
103 | text = strings.Replace(text, ">", ">", -1)
104 | return text
105 | }
106 |
107 | func (e *slackEngine) cacheSlackUsers() (map[string]*slack.User, error) {
108 | // We maintain a cache of email address to Slack user.
109 | // This is required to map a commit author to a Slack user we can @-mention.
110 | // Since Slack users can change their handles, we only keep the cache for SLACK_CACHE_TTL seconds.
111 | now := time.Now()
112 |
113 | if slackEmailUserCacheUnixTime == 0 || now.Unix()-slackEmailUserCacheUnixTime > SlackCacheTtl {
114 | slackEmailUserCache = make(map[string]*slack.User, 200)
115 |
116 | users, err := e.api.GetUsers()
117 | if err != nil {
118 | logger.Error("Could not fetch Slack users list: %v", err)
119 | if slackEmailUserCacheUnixTime == 0 {
120 | // No cache - propagate error up.
121 | return nil, err
122 | } else {
123 | // There was an error refreshing the cache, but we can use the existing cache transparently.
124 | return slackEmailUserCache, nil
125 | }
126 | }
127 |
128 | slackEmailUserCacheUnixTime = now.Unix()
129 |
130 | for i := range users {
131 | user := users[i]
132 | if len(user.Profile.Email) > 0 {
133 | slackEmailUserCache[user.Profile.Email] = &user
134 | }
135 | }
136 | }
137 |
138 | return slackEmailUserCache, nil
139 | }
140 |
141 | func (e *slackEngine) emailToSlackUser(email string) (*slack.User, error) {
142 | users, err := e.cacheSlackUsers()
143 | if err != nil {
144 | return nil, err
145 | }
146 |
147 | if slackUser, ok := users[email]; ok {
148 | return slackUser, nil
149 | }
150 |
151 | return nil, fmt.Errorf("Could not find email %s in slack users list", email)
152 | }
153 |
--------------------------------------------------------------------------------
/frontend/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint:recommended",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "commonjs": true,
7 | "es6": true
8 | },
9 | "ecmaFeatures": {
10 | "jsx": true
11 | },
12 | "globals": {
13 | "global": false,
14 | /* Mocha tests. */
15 | "describe": false,
16 | "it": false,
17 | "before": false,
18 | "beforeEach": false,
19 | "after": false,
20 | "afterEach": false,
21 | "process": true,
22 | "expect": true
23 | },
24 | "plugins": [
25 | "react"
26 | ],
27 | "rules": {
28 | "array-bracket-spacing": 2,
29 | "arrow-parens": 2,
30 | "arrow-spacing": 2,
31 | "block-spacing": 2,
32 | "camelcase": 0,
33 | "comma-dangle": 0,
34 | "comma-style": 2,
35 | "computed-property-spacing": 2,
36 | "consistent-return": 0,
37 | "constructor-super": 2,
38 | "curly": [2, "all"],
39 | "default-case": 2,
40 | "eqeqeq": 2,
41 | "generator-star-spacing": 2,
42 | "guard-for-in": 2,
43 | "indent": [2, 2, {"SwitchCase": 1}],
44 | "jsx-quotes": 2,
45 | "key-spacing": 2,
46 | "keyword-spacing": 2,
47 | "linebreak-style": [2, "unix"],
48 | "lines-around-comment": [2, {
49 | "allowBlockStart": true,
50 | "beforeBlockComment": true,
51 | "beforeLineComment": true
52 | }],
53 | "new-cap": 2,
54 | "new-parens": 2,
55 | "no-alert": 2,
56 | "no-array-constructor": 2,
57 | "no-caller": 2,
58 | "no-catch-shadow": 2,
59 | "no-class-assign": 2,
60 | "no-const-assign": 2,
61 | "no-div-regex": 2,
62 | "no-dupe-class-members": 2,
63 | "no-eval": 2,
64 | "no-extend-native": 2,
65 | "no-extra-bind": 2,
66 | "no-floating-decimal": 2,
67 | "no-implied-eval": 2,
68 | "no-invalid-this": 2,
69 | "no-iterator": 2,
70 | "no-labels": 2,
71 | "no-lone-blocks": 2,
72 | "no-loop-func": 2,
73 | "no-multi-spaces": 2,
74 | "no-multi-str": 2,
75 | "no-multiple-empty-lines": 2,
76 | "no-native-reassign": 2,
77 | "no-new-func": 2,
78 | "no-new-object": 2,
79 | "no-new-wrappers": 2,
80 | "no-octal-escape": 2,
81 | "no-proto": 2,
82 | "no-return-assign": 2,
83 | "no-script-url": 2,
84 | "no-self-compare": 2,
85 | "no-sequences": 2,
86 | "no-shadow": 2,
87 | "no-shadow-restricted-names": 2,
88 | "no-spaced-func": 2,
89 | "no-this-before-super": 2,
90 | "no-throw-literal": 2,
91 | "no-trailing-spaces": 2,
92 | "no-undef-init": 2,
93 | "no-undefined": 0,
94 | "no-unneeded-ternary": 2,
95 | "no-unused-expressions": [2, {"allowShortCircuit": true}],
96 | "no-use-before-define": [2, {"functions": false}],
97 | "no-useless-call": 2,
98 | "no-useless-concat": 2,
99 | "no-var": 2,
100 | "no-void": 2,
101 | "no-with": 2,
102 | "object-curly-spacing": 2,
103 | "prefer-const": 2,
104 | "radix": 2,
105 | "react/display-name": [2, {"ignoreTranspilerName": false}],
106 | "react/forbid-prop-types": 0,
107 | "react/jsx-boolean-value": 2,
108 | "react/jsx-closing-bracket-location": [2, "after-props"],
109 | "react/jsx-curly-spacing": [2, "never"],
110 | "react/jsx-no-duplicate-props": 2,
111 | "react/jsx-no-undef": 2,
112 | "react/jsx-uses-react": 2,
113 | "react/jsx-uses-vars": 2,
114 | "react/no-danger": 2,
115 | "react/no-did-mount-set-state": 2,
116 | "react/no-did-update-set-state": 2,
117 | "react/no-direct-mutation-state": 2,
118 | "react/no-unknown-property": 2,
119 | "react/prop-types": 2,
120 | "react/react-in-jsx-scope": 2,
121 | "react/jsx-wrap-multilines": 2,
122 | "require-yield": 2,
123 | "semi": 2,
124 | "semi-spacing": 2,
125 | "space-before-blocks": 2,
126 | "space-before-function-paren": [2, "never"],
127 | "space-in-parens": 2,
128 | "space-infix-ops": 2,
129 | "space-unary-ops": 2,
130 | "spaced-comment": 2,
131 | "strict": [2, "global"],
132 | "valid-jsdoc": [2, {"requireReturn": false}],
133 | "wrap-iife": [2, "inside"],
134 | "wrap-regex": 2
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/core/endpoints_test.go:
--------------------------------------------------------------------------------
1 | // +build data
2 |
3 | package core
4 |
5 | import (
6 | "io/ioutil"
7 | "net/http"
8 | "net/http/httptest"
9 | "testing"
10 |
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestNoEndpoints(t *testing.T) {
15 | NewServer([]endpoint{})
16 | }
17 |
18 | // Test that HTTP handler gets called.
19 | func TestEndpoint(t *testing.T) {
20 | // Create a request to hit the handler.
21 | req, err := http.NewRequest("GET", "/test", nil)
22 | assert.NoError(t, err)
23 | res := httptest.NewRecorder()
24 |
25 | handler := func(r *http.Request) response {
26 | return dataResponse("test")
27 | }
28 |
29 | // Create a server with test handler.
30 | endpoints := []endpoint{newOpenEp("/test", get, handler)}
31 | server := NewServer(endpoints)
32 |
33 | server.ServeHTTP(res, req)
34 |
35 | resp := res.Result()
36 | body, err := ioutil.ReadAll(resp.Body)
37 | assert.NoError(t, err)
38 |
39 | assert.Equal(t, http.StatusOK, resp.StatusCode)
40 | assert.Contains(t, string(body), `{"result":"test"}`)
41 | }
42 |
43 | // Test that auth handler works when not authorized.
44 | func TestAuthEndpointUnauthorized(t *testing.T) {
45 | // Create a request to hit the handler.
46 | req, err := http.NewRequest("GET", "/test-auth", nil)
47 | assert.NoError(t, err)
48 | res := httptest.NewRecorder()
49 |
50 | handler := func(r *http.Request) response {
51 | return dataResponse("test")
52 | }
53 |
54 | // Create a server with test handler.
55 | endpoints := []endpoint{newEp("/test-auth", get, handler)}
56 | server := NewServer(endpoints)
57 |
58 | server.ServeHTTP(res, req)
59 |
60 | resp := res.Result()
61 | body, err := ioutil.ReadAll(resp.Body)
62 | assert.NoError(t, err)
63 |
64 | assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
65 | assert.Contains(t, string(body), `{"error":"Unauthorized"}`)
66 | }
67 |
68 | // Test that auth handler works when authorized.
69 | func TestAuthEndpointAuthorized(t *testing.T) {
70 | _, testData := setup(t)
71 |
72 | // Create a request to hit the handler.
73 | req, err := http.NewRequest("GET", "/test-auth", nil)
74 | req.AddCookie(testData.TokenCookie)
75 | assert.NoError(t, err)
76 | res := httptest.NewRecorder()
77 |
78 | handler := func(r *http.Request) response {
79 | return dataResponse("test")
80 | }
81 |
82 | // Create a server with test handler.
83 | endpoints := []endpoint{newEp("/test-auth", get, handler)}
84 | server := NewServer(endpoints)
85 |
86 | server.ServeHTTP(res, req)
87 |
88 | resp := res.Result()
89 | body, err := ioutil.ReadAll(resp.Body)
90 | assert.NoError(t, err)
91 |
92 | assert.Equal(t, http.StatusOK, resp.StatusCode)
93 | assert.Contains(t, string(body), `{"result":"test"}`)
94 | }
95 |
96 | func TestEndpointFormatting(t *testing.T) {
97 | // Create a handler that returns a struct.
98 | handler := func(r *http.Request) response {
99 | return dataResponse(struct {
100 | Key string `json:"key"`
101 | }{
102 | "value",
103 | })
104 | }
105 |
106 | // Create a server with the test handler.
107 | endpoints := []endpoint{newOpenEp("/test", get, handler)}
108 | server := NewServer(endpoints)
109 |
110 | // Send a request for that endpoint.
111 | req, err := http.NewRequest("GET", "/test", nil)
112 | assert.NoError(t, err)
113 | res := httptest.NewRecorder()
114 |
115 | server.ServeHTTP(res, req)
116 |
117 | resp := res.Result()
118 | body, err := ioutil.ReadAll(resp.Body)
119 | assert.NoError(t, err)
120 |
121 | assert.Equal(t, http.StatusOK, resp.StatusCode)
122 | assert.Contains(t, string(body), `{"result":{"key":"value"}}`)
123 |
124 | // Send a request for that endpoint, specifying json format.
125 | req, err = http.NewRequest("GET", "/test.json", nil)
126 | assert.NoError(t, err)
127 | res = httptest.NewRecorder()
128 |
129 | server.ServeHTTP(res, req)
130 |
131 | resp = res.Result()
132 | body, err = ioutil.ReadAll(resp.Body)
133 | assert.NoError(t, err)
134 |
135 | assert.Equal(t, http.StatusOK, resp.StatusCode)
136 | // Should be an identical result as above, since json is the default format.
137 | assert.Contains(t, string(body), `{"result":{"key":"value"}}`)
138 |
139 | // Send a request for that endpoint, specifying pretty format.
140 | req, err = http.NewRequest("GET", "/test.pretty", nil)
141 | assert.NoError(t, err)
142 | res = httptest.NewRecorder()
143 |
144 | server.ServeHTTP(res, req)
145 |
146 | resp = res.Result()
147 | body, err = ioutil.ReadAll(resp.Body)
148 | assert.NoError(t, err)
149 |
150 | assert.Equal(t, http.StatusOK, resp.StatusCode)
151 | // Test that there are indents and spaces.
152 | prettyResult := `{
153 | "result": {
154 | "key": "value"
155 | }
156 | }`
157 | assert.Contains(t, string(body), prettyResult)
158 | }
159 |
--------------------------------------------------------------------------------
/shared/settings/settings.go:
--------------------------------------------------------------------------------
1 | // Package for shared Conductor settings.
2 | package settings
3 |
4 | import (
5 | "strings"
6 |
7 | "github.com/Nextdoor/conductor/shared/flags"
8 | )
9 |
10 | var (
11 | Hostname = flags.EnvString("HOSTNAME", "localhost")
12 |
13 | JenkinsRollbackJob = flags.EnvString("JENKINS_ROLLBACK_JOB", "")
14 |
15 | // Whether to not require staging verification for a commit by default.
16 | // If set, staging verification will only be required if the commit message has [needs-staging].
17 | NoStagingVerification = flags.EnvBool("NO_STAGING_VERIFICATION", false)
18 |
19 | // Comma-separated list of admin user emails that can deploy and change mode.
20 | adminUserFlag = flags.EnvString("ADMIN_USERS", "")
21 |
22 | // Comma-separated list of user emails who don't use staging by default.
23 | // This list is ignored if noStagingVerification is set.
24 | noStagingVerificationUsersFlag = flags.EnvString("NO_STAGING_VERIFICATION_USERS", "")
25 |
26 | // Comma-separated list of robot user emails that push commits.
27 | // Tickets will be assigned to the default user, they won't get notifications,
28 | // and they won't get engineer status.
29 | robotUserFlag = flags.EnvString("ROBOT_USERS", "")
30 |
31 | AdminUsers []string
32 | RobotUsers []string
33 | NoStagingVerificationUsers []string
34 |
35 | CustomAdminUsers []string
36 | CustomRobotUsers []string
37 | CustomNoStagingVerificationUsers []string
38 | )
39 |
40 | // Settings for job names to accept for delivery, verification, and deploy phases.
41 | // These job names are customizable for tests.
42 | // The logic below ensures that tests can modify them unperturbed by calls to ParseFlags.
43 | // Calls to CustomizeJobs should only occur in tests.
44 | var (
45 | // Comma-separated list of expected jobs for the delivery phase.
46 | deliveryJobsFlag = flags.EnvString("DELIVERY_JOBS", "")
47 |
48 | // Comma-separated list of expected jobs for the verification phase.
49 | verificationJobsFlag = flags.EnvString("VERIFICATION_JOBS", "")
50 |
51 | // Comma-separated list of expected jobs for the deploy phase.
52 | deployJobsFlag = flags.EnvString("DEPLOY_JOBS", "")
53 |
54 | DeliveryJobs []string
55 | VerificationJobs []string
56 | DeployJobs []string
57 |
58 | CustomDeliveryJobs []string
59 | CustomVerificationJobs []string
60 | CustomDeployJobs []string
61 | )
62 |
63 | func init() {
64 | parseFlags()
65 | }
66 |
67 | func parseFlags() {
68 | AdminUsers = parseListString(adminUserFlag)
69 | RobotUsers = parseListString(robotUserFlag)
70 | NoStagingVerificationUsers = parseListString(noStagingVerificationUsersFlag)
71 |
72 | DeliveryJobs = parseListString(deliveryJobsFlag)
73 | VerificationJobs = parseListString(verificationJobsFlag)
74 | DeployJobs = parseListString(deployJobsFlag)
75 | }
76 |
77 | // Take a comma-separated string and split on commas, stripping any whitespace.
78 | func parseListString(s string) []string {
79 | f := func(c rune) bool {
80 | return c == ','
81 | }
82 | // Split by comma
83 | split := strings.FieldsFunc(s, f)
84 | var result = make([]string, len(split))
85 | // Trim any whitespace
86 | for i, s := range split {
87 | result[i] = strings.TrimSpace(s)
88 | }
89 | return result
90 | }
91 |
92 | func StringInList(text string, list []string) bool {
93 | for _, line := range list {
94 | if line == text {
95 | return true
96 | }
97 | }
98 | return false
99 | }
100 |
101 | func IsNoStagingVerificationUser(email string) bool {
102 | if CustomNoStagingVerificationUsers != nil {
103 | return StringInList(email, CustomNoStagingVerificationUsers)
104 | }
105 | return StringInList(email, NoStagingVerificationUsers)
106 | }
107 |
108 | // Should only be used for tests.
109 | func CustomizeNoStagingVerificationUsers(noStagingVerificationUsers []string) {
110 | CustomNoStagingVerificationUsers = noStagingVerificationUsers
111 | }
112 |
113 | // Should only be used for tests.
114 | func CustomizeAdminUsers(adminUsers []string) {
115 | CustomAdminUsers = adminUsers
116 | }
117 |
118 | // Should only be used for tests.
119 | func CustomizeRobotUsers(robotUsers []string) {
120 | CustomRobotUsers = robotUsers
121 | }
122 |
123 | func GetHostname() string {
124 | return Hostname
125 | }
126 |
127 | func GetJenkinsRollbackJob() string {
128 | return JenkinsRollbackJob
129 | }
130 |
131 | func IsAdminUser(email string) bool {
132 | if CustomAdminUsers != nil {
133 | return StringInList(email, CustomAdminUsers)
134 | }
135 | return StringInList(email, AdminUsers)
136 | }
137 |
138 | func IsRobotUser(email string) bool {
139 | if CustomRobotUsers != nil {
140 | return StringInList(email, CustomRobotUsers)
141 | }
142 | return StringInList(email, RobotUsers)
143 | }
144 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SHELL=/bin/bash
2 | SHA1 := $(shell git rev-parse --short HEAD)
3 |
4 | DOCKER_IMAGE ?= conductor
5 | DOCKER_REGISTRY ?= hub.docker.com
6 | DOCKER_NAMESPACE ?= nextdoor
7 |
8 | TARGET_DOCKER_NAME := $(DOCKER_REGISTRY)/$(DOCKER_NAMESPACE)/$(DOCKER_IMAGE):$(DOCKER_TAG)
9 |
10 | GO_DIRS=core cmd services shared
11 |
12 | .PHONY: all imports test glide
13 |
14 | all: imports docker-build docker-run
15 |
16 | imports:
17 | @goimports -local github.com/Nextdoor/conductor -w $(GO_DIRS)
18 |
19 | test:
20 | @./test.sh
21 |
22 | glide:
23 | @echo "Installing Go Dependencies"
24 | @which glide || curl https://glide.sh/get | sh
25 | glide install
26 |
27 | define ARGS
28 | --env LOGLEVEL=DEBUG \
29 | --env-file envfile \
30 | --volume $(shell pwd)/resources/frontend:/app/frontend \
31 | --volume $(HOME)/.aws:/root/.aws \
32 | --link conductor-postgres
33 | endef
34 |
35 | define NETWORK_ARGS
36 | --publish 80:80 \
37 | --publish 443:443 \
38 | --hostname conductor-dev
39 | endef
40 |
41 | # Check if interactive shell.
42 | INTERACTIVE = $(shell [ "`tty`" != "not a tty" ] && echo true || echo false)
43 | ifeq ($(INTERACTIVE),true)
44 | INTERACTIVE_ARGS = -it
45 | else
46 | INTERACTIVE_ARGS =
47 | endif
48 |
49 | define TEST_ARGS
50 | --env-file testenv
51 | endef
52 |
53 | export ARGS
54 | export NETWORK_ARGS
55 | export INTERACTIVE_ARGS
56 | export TEST_ARGS
57 |
58 | TEST_CMD ?= "./..."
59 |
60 | .PHONY: docker-build docker-run docker-test docker-stop docker-logs docker-tag docker-push docker-login docker-populate-cache
61 |
62 | docker-build:
63 | rm -rf .build && mkdir .build && cp -rf cmd core services shared go.mod go.sum .build
64 | echo "Building Conductor Docker image..."
65 | docker build -t $(DOCKER_IMAGE) .; result=$$?; rm -rf .build; exit $$result
66 |
67 | docker-run: docker-stop
68 | @echo "Running $(DOCKER_IMAGE)"
69 | @[ -e envfile ] || touch envfile
70 | docker run $$ARGS $$NETWORK_ARGS $$INTERACTIVE_ARGS --name $(DOCKER_IMAGE) $(DOCKER_IMAGE)
71 |
72 | docker-test:
73 | @[ -e testenv ] || touch testenv
74 | @[ -e envfile ] || touch envfile
75 | docker run $$ARGS $$INTERACTIVE_ARGS $$TEST_ARGS $(DOCKER_IMAGE) $(TEST_CMD)
76 |
77 | docker-stop:
78 | @echo "Stopping $(DOCKER_IMAGE)"
79 | @docker rm -f $(DOCKER_IMAGE) \
80 | || echo "No existing container running."
81 |
82 | docker-logs:
83 | @echo "Running $(DOCKER_IMAGE)"
84 | docker logs -f $(DOCKER_IMAGE)
85 |
86 | docker-tag:
87 | @echo "Tagging $(DOCKER_IMAGE) as $(TARGET_DOCKER_NAME)"
88 | docker tag $(DOCKER_IMAGE) $(TARGET_DOCKER_NAME)
89 |
90 | docker-push: docker-tag
91 | @echo "Pushing $(DOCKER_IMAGE) to $(TARGET_DOCKER_NAME)"
92 | docker push $(TARGET_DOCKER_NAME)
93 |
94 | docker-login:
95 | @echo "Logging into $(DOCKER_REGISTRY)"
96 | @docker login \
97 | -u $(DOCKER_USER) \
98 | -p "$(value DOCKER_PASS)" $(DOCKER_REGISTRY)
99 |
100 | docker-populate-cache:
101 | @echo "Attempting to download $(DOCKER_IMAGE)"
102 | @docker pull "$(DOCKER_REGISTRY)/$(DOCKER_NAMESPACE)/$(DOCKER_IMAGE)" && \
103 | docker images -a || exit 0
104 |
105 | .PHONY: frontend
106 |
107 | frontend:
108 | $(MAKE) -C frontend
109 |
110 | PGDB=conductor
111 | PGHOST=localhost
112 | PGPORT=5432
113 | PGUSER=conductor
114 | PGPASS=conductor
115 | PGDATA=/var/lib/postgresql/data/conductor
116 |
117 | define PG_ARGS
118 | --name conductor-postgres \
119 | --publish 5432:5432 \
120 | --env POSTGRES_USER=$(PGUSER) \
121 | --env POSTGRES_PASSWORD=$(PGPASS) \
122 | --env POSTGRES_DB=$(PGDB) \
123 | --env PGDATA=$(PGDATA) \
124 | --detach
125 | endef
126 |
127 | export PG_ARGS
128 |
129 | .PHONY: postgres postgres-perm psql postgres-wipe test-data
130 |
131 | postgres:
132 | docker rm -f conductor-postgres || true
133 | docker run $$PG_ARGS postgres
134 |
135 | postgres-perm:
136 | docker rm -f conductor-postgres || true
137 | docker run $$PG_ARGS -v $$HOME/data/conductor:$(PGDATA) postgres
138 |
139 | postgres-wipe:
140 | docker exec conductor-postgres dropdb -h localhost -U conductor conductor || true
141 | docker exec conductor-postgres createdb -h localhost -U conductor conductor || true
142 |
143 | psql:
144 | PGPASSWORD=$(PGPASS) \
145 | psql \
146 | -h $(PGHOST) \
147 | -p $(PGPORT) \
148 | -d $(PGDB) \
149 | -U $(PGUSER)
150 |
151 | test-data: postgres-wipe
152 | export POSTGRES_HOST=localhost; \
153 | export POSTGRES_PORT=5432; \
154 | export ENABLE_DATADOG=false; \
155 | set -a; \
156 | if [[ -e testenv ]]; then \
157 | source testenv; \
158 | fi; \
159 | go run cmd/test_data.go
160 |
161 | # README.md manipulation
162 |
163 | .PHONY: gravizool readme edit-readme
164 |
165 | gravizool:
166 | which gravizool || go get github.com/swaggy/gravizool && go get github.com/swaggy/gravizool
167 |
168 | readme: gravizool
169 | gravizool -b=false -e README.md
170 |
171 | edit-readme: gravizool
172 | gravizool -b=false -d README.md
173 |
--------------------------------------------------------------------------------
/shared/types/models_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | const (
12 | sha1 = "models_test_sha_1"
13 | sha2 = "models_test_sha_2"
14 | sha3 = "models_test_sha_3"
15 | )
16 |
17 | func TestNewCommitsNeedingTickets(t *testing.T) {
18 | commit1 := []*Commit{{SHA: sha1}}
19 | commit2 := []*Commit{{SHA: sha2, Message: "This change [needs-staging] for sure"}}
20 | bothCommits := append(commit1, commit2...)
21 | commit3 := []*Commit{{SHA: sha3, Message: "[no-verify] this change"}}
22 | allCommits := append(bothCommits, commit3...)
23 |
24 | train := &Train{
25 | Commits: allCommits,
26 | Tickets: []*Ticket{},
27 | }
28 |
29 | newCommits := train.NewCommitsNeedingTickets(sha1, false)
30 | assert.Equal(t, commit1, newCommits)
31 |
32 | newCommits = train.NewCommitsNeedingTickets(sha2, false)
33 | assert.Equal(t, bothCommits, newCommits)
34 |
35 | newCommits = train.NewCommitsNeedingTickets(sha3, false)
36 | assert.Equal(t, allCommits, newCommits)
37 |
38 | train = &Train{
39 | Commits: allCommits,
40 | Tickets: []*Ticket{
41 | {Commits: commit1},
42 | },
43 | }
44 | newCommits = train.NewCommitsNeedingTickets(sha2, false)
45 | assert.Equal(t, commit2, newCommits)
46 |
47 | train = &Train{
48 | Commits: allCommits,
49 | Tickets: []*Ticket{
50 | {Commits: commit2},
51 | },
52 | }
53 | newCommits = train.NewCommitsNeedingTickets(sha2, false)
54 | assert.Equal(t, commit1, newCommits)
55 |
56 | train = &Train{
57 | Commits: allCommits,
58 | Tickets: []*Ticket{
59 | {Commits: allCommits},
60 | },
61 | }
62 | newCommits = train.NewCommitsNeedingTickets(sha2, false)
63 | assert.Equal(t, []*Commit{}, newCommits)
64 | }
65 |
66 | func TestNewCommitsNeedingTicketsNoStagingVerify(t *testing.T) {
67 | commit1 := []*Commit{{SHA: sha1, Message: "I [needs-staging] for this change"}}
68 | commit2 := []*Commit{{SHA: sha3, Message: "[no-verify] changed my mind [needs-staging]"}}
69 | commit3 := []*Commit{{SHA: sha2, Message: "Cowboy/Cowgirl change!"}}
70 | bothCommits := append(commit1, commit2...)
71 | allCommits := append(bothCommits, commit3...)
72 |
73 | train := &Train{
74 | Commits: allCommits,
75 | Tickets: []*Ticket{},
76 | }
77 |
78 | newCommits := train.NewCommitsNeedingTickets(sha1, true)
79 | assert.Equal(t, commit1, newCommits)
80 |
81 | newCommits = train.NewCommitsNeedingTickets(sha2, true)
82 | assert.Equal(t, bothCommits, newCommits)
83 |
84 | newCommits = train.NewCommitsNeedingTickets(sha3, true)
85 | assert.Equal(t, bothCommits, newCommits)
86 |
87 | train = &Train{
88 | Commits: allCommits,
89 | Tickets: []*Ticket{
90 | {Commits: commit1},
91 | },
92 | }
93 | newCommits = train.NewCommitsNeedingTickets(sha2, true)
94 | assert.Equal(t, commit2, newCommits)
95 |
96 | train = &Train{
97 | Commits: allCommits,
98 | Tickets: []*Ticket{
99 | {Commits: commit2},
100 | },
101 | }
102 | newCommits = train.NewCommitsNeedingTickets(sha2, true)
103 | assert.Equal(t, commit1, newCommits)
104 |
105 | train = &Train{
106 | Commits: allCommits,
107 | Tickets: []*Ticket{
108 | {Commits: allCommits},
109 | },
110 | }
111 | newCommits = train.NewCommitsNeedingTickets(sha2, true)
112 | assert.Equal(t, []*Commit{}, newCommits)
113 | }
114 |
115 | func TestNotDeployableReason(t *testing.T) {
116 | var reason *string
117 | train := &Train{}
118 |
119 | reason = train.GetNotDeployableReason()
120 | assert.Nil(t, reason)
121 |
122 | train.ActivePhase = Verification
123 | train.ActivePhases = &PhaseGroup{}
124 | train.ActivePhases.Verification = &Phase{}
125 |
126 | var nextID uint64 = 1
127 | train.NextID = &nextID
128 |
129 | reason = train.GetNotDeployableReason()
130 | assert.Equal(t, "Not the latest train.", *reason)
131 |
132 | train.PreviousTrainDone = false
133 | train.NextID = nil
134 |
135 | reason = train.GetNotDeployableReason()
136 | assert.Equal(t, "Waiting for verification.", *reason)
137 |
138 | train.ActivePhases.Verification.CompletedAt = Time{time.Now()}
139 |
140 | reason = train.GetNotDeployableReason()
141 | assert.Equal(t, "Train is not closed.", *reason)
142 |
143 | train.Closed = true
144 |
145 | reason = train.GetNotDeployableReason()
146 | assert.Equal(t, "Previous train is still deploying.", *reason)
147 |
148 | train.PreviousTrainDone = true
149 |
150 | reason = train.GetNotDeployableReason()
151 | assert.Nil(t, reason)
152 |
153 | train.Blocked = true
154 |
155 | reason = train.GetNotDeployableReason()
156 | assert.Equal(t, "Train is blocked.", *reason)
157 |
158 | blockedReason := "test reason"
159 | train.BlockedReason = &blockedReason
160 |
161 | reason = train.GetNotDeployableReason()
162 | assert.Equal(t,
163 | fmt.Sprintf("Train is blocked due to %s.", blockedReason),
164 | *reason)
165 | }
166 |
--------------------------------------------------------------------------------