├── 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 |
61 | 62 | 63 | 64 |
65 |
66 |
67 |
68 |
69 | Queue 70 |
71 |
72 |
73 |
74 |
75 | 76 | 77 |
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 | --------------------------------------------------------------------------------