You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

400 lines
17 KiB
Python

from flask import Flask, request
import os
import datetime
import subprocess
import re
import random
import mattermostdriver
import event
import slugify
random.seed( os.urandom( 8 ) )
class Mattermost( object ):
def __init__( self, *args, **kwargs ):
self.mm = mattermostdriver.Driver( kwargs )
self.mm.login()
def strip_ansi( text, escape = re.compile( r'\x1B\[[0-?]*[ -/]*[@-~]' ) ):
return escape.sub( '', text )
def strip_progress( text, escape = re.compile( r'\r[^\n]*\n' ) ):
return escape.sub( '', text )
app = Flask( __name__ )
app.config.from_envvar( 'FIH_SETTINGS' )
app.mm = Mattermost( url = app.config[ 'MATTERMOST_URL' ],
port = app.config[ 'MATTERMOST_PORT' ],
login_id = app.config[ 'MATTERMOST_USER_EMAIL' ],
password = app.config[ 'MATTERMOST_USER_PASSWORD' ],
).mm
def generate_raw_incident_number():
return datetime.datetime.now().strftime( "%Y%m%d" ) + ( "%4d" % int( random.random() * 10000 ) )
def raw_incident_number_to_channel_name( incident_number ):
return app.config[ "MATTERMOST_INCIDENT_CHANNEL_PREFIX" ] + incident_number
def raw_incident_number_to_public_incident_number( incident_number ):
return app.config[ "PUBLIC_INCIDENT_PREFIX" ] + incident_number
def raw_incident_number_to_url( incident_number ):
return app.config[ "INCIDENT_URL_SCHEME" ] % incident_number
def is_incident_channel_name( channel_name ):
return channel_name.startswith( app.config[ "MATTERMOST_INCIDENT_CHANNEL_PREFIX" ] )
def channel_name_to_raw_incident_number( channel_name ):
return channel_name[ len( app.config[ "MATTERMOST_INCIDENT_CHANNEL_PREFIX" ] ): ]
def mattermost_incident_command( command, args, channel_id, raw_incident_number, user_name ):
channel = app.mm.channels.get_channel( channel_id )
slug = slugify.slugify( channel[ 'purpose' ] )
index = event.Index( path = app.config[ "STATUS_EVENT_INDEX" ] )
ev = event.Event( path = app.config[ "STATUS_EVENT_SCHEME" ] % { "incident_number": raw_incident_number,
"slug": slug,
},
glob = app.config[ "STATUS_EVENT_GLOB" ] % { "incident_number": raw_incident_number,
},
)
if command == "PUBLISH":
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "### `PUBLISH` requested by @%s!" % user_name,
} )
# update local repository
cmdline = app.config[ "MERCURIAL_BIN" ] + " update"
message = exec_to_message( cmdline )
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
# create/update event content file
new = ev.open()
ev.doc = channel[ 'header' ]
ev.set_title( channel[ 'purpose' ] )
ev.set_slug( raw_incident_number )
ev.set_published( datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ) )
written = ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "#### Written `%s`:\n\n```\n%s```" % ( ev.path_short(), written ),
} )
if new:
cmdline = app.config[ "MERCURIAL_BIN" ] + " add " + ev.path_short()
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
cmdline = app.config[ "MERCURIAL_BIN" ] + " commit -m 'add event for incident " + raw_incident_number + " via FIH' " + ev.path_short()
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
else:
cmdline = app.config[ "MERCURIAL_BIN" ] + " commit -m 'update event for incident " + raw_incident_number + " via FIH' " + ev.path_short()
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
# pull, merge, commit
for pull_from in app.config[ "MERCURIAL_PUSH_TO" ]:
cmdline = app.config[ "MERCURIAL_BIN" ] + " pull " + pull_from
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
cmdline = app.config[ "MERCURIAL_BIN" ] + " merge"
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
cmdline = app.config[ "MERCURIAL_BIN" ] + " commit -m 'merge from " + pull_from + "'"
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
# push updated repository
for pull_from in app.config[ "MERCURIAL_PUSH_TO" ]:
cmdline = app.config[ "MERCURIAL_BIN" ] + " push " + pull_from
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
for deploy_to in app.config[ "GROW_DEPLOY_TO" ]:
cmdline = app.config[ "GROW_BIN" ] + " deploy -f " + deploy_to
message = exec_to_message( cmdline )
if message:
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': message,
} )
app.mm.posts.create_post( options = { 'channel_id': request.form[ 'channel_id' ],
'message': "### `PUBLISH` completed!",
} )
elif command == "START":
ev.open()
started = ev.set_started( args or datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ) )
ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Event start date set by %s to: `%s`" % ( user_name, started ),
} )
elif command == "ETR":
ev.open()
expected = ev.set_expected( args or datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ) )
ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Event estimated time of resolution set by %s to: `%s`" % ( user_name, expected ),
} )
elif command == "FINISHED":
ev.open()
finished = ev.set_finished( args or datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ) )
ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Event finished time set by %s to: `%s`" % ( user_name, finished ),
} )
elif command == "RESOLVED":
ev.open()
resolved = ev.set_resolved( args or datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ) )
ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Event resolved time set by %s to: `%s`" % ( user_name, resolved ),
} )
elif command == "TIMELINE":
if not args:
return "You need to specify some text to add to the timeline..."
ev.open()
ev.add_timeline( time = datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" ), line = args )
ev.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Timeline entry added by @%s:\n\n> %s" % ( user_name, args ),
} )
elif command == "INCIDENT":
ev.open()
flags = ev.toggle_incident()
ev.write()
index.open()
index.set_flags( flags.split() )
index.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Flags set by @%s to: `%s`" % ( user_name, flags ),
} )
elif command == "DEGRADED":
ev.open()
flags = ev.toggle_degraded()
ev.write()
index.open()
index.set_flags( flags.split() )
index.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Flags set by @%s to: `%s`" % ( user_name, flags ),
} )
elif command == "MAINTENANCE":
ev.open()
flags = ev.toggle_maintenance()
ev.write()
index.open()
index.set_flags( flags.split() )
index.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Flags set by @%s to: `%s`" % ( user_name, flags ),
} )
elif command == "NOTICE":
ev.open()
flags = ev.toggle_notice()
ev.write()
index.open()
index.set_flags( flags.split() )
index.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Flags set by @%s to: `%s`" % ( user_name, flags ),
} )
elif command == "OK":
ev.open()
flags = ev.toggle_ok()
ev.write()
index.open()
index.set_flags( flags.split() )
index.write()
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': "Flags set by @%s to: `%s`" % ( user_name, flags ),
} )
else:
return "WTF command?"
return ""
def mattermost_incident_start( description, team_id, user_name, user_id, channel_id ):
raw_incident_number = generate_raw_incident_number()
fi_number = raw_incident_number_to_public_incident_number( raw_incident_number )
channel_name = raw_incident_number_to_channel_name( raw_incident_number )
channel = app.mm.channels.create_channel( options = { "team_id": team_id,
"name": channel_name,
"display_name": fi_number,
"purpose": description,
"header": "",
"type": "O",
} )
# add user to the channel we just created
app.mm.client.make_request( 'post', '/channels/' + channel[ 'id' ] + '/members', options = { 'user_id': user_id,
} )
app.mm.posts.create_post( options = { 'channel_id': channel[ 'id' ],
'message': "Incident discussion channel for [%s](%s) created by @%s." % ( fi_number, raw_incident_number_to_url( raw_incident_number ), user_name ),
} )
now = datetime.datetime.utcnow().strftime( "%Y-%m-%d %H:%M" )
app.mm.posts.create_post( options = { 'channel_id': channel[ 'id' ],
'message': """\
# Incident Response Communication Procedure
The contents of this channel are strictly confidential.
## Public Announcement
1. adjust the name of the incident with **/purpose**
2. adjust the description of the incident with **/header**
3. choose a front page status (see below)
4. publish %(incident_url)s with **/incident PUBLISH**
## Status Updates
* add an update: **/incident TIMELINE** _text_
* set the event start: **/incident START** _%(now)s_ (UTC)
* set the event ETR: **/incident ETR** _%(now)s_ (UTC)
* set the event finished time: **/incident FINISHED** _%(now)s_ (UTC)
* set the event closure time: **/incident RESOLVED** _%(now)s_ (UTC)
Again, don't forget to **/incident PUBLISH** once making changes.
## Front Page Status
* **/incident INCIDENT**
* **/incident DEGRADED**
* **/incident MAINTENANCE**
* **/incident NOTICE**
* **/incident OK**
Again, after setting the headline status you must: **/incident PUBLISH**
## Good luck, Incident Commander @%(commander_name)s!""" % { "incident_url": raw_incident_number_to_url( raw_incident_number ),
"commander_name": user_name,
"now": now,
},
} )
app.mm.posts.create_post( options = { 'channel_id': channel_id,
'message': 'On behalf of @%s I have started incident ~%s.' % ( request.form[ 'user_name' ], channel_name, ),
} )
return ""
@app.route( '/mattermost/incident', methods = [ 'POST' ] )
def mattermost_incident():
# channel_name: testing
# command: /incident
# channel_id: o3ob1utim3nummoswjyt4174mo
# user_name: marek
# test_domain: faelix
# text: test
# team_id: 3woo8mbjrbb1in53xwdzi4ynqh
# user_id: ko3uxmend7ne8ger4uw9mxkt1o
# token: XXXXXXXXXXXXXXXXXXXXXXXXXX
if request.form[ 'token' ] in app.config[ 'MATTERMOST_COMMAND_TOKENS' ]:
if is_incident_channel_name( request.form[ 'channel_name' ] ):
text = request.form[ 'text' ].strip().split()
raw_incident_number = channel_name_to_raw_incident_number( request.form[ 'channel_name' ] )
return mattermost_incident_command( command = text[ 0 ].upper(),
args = " ".join( text[ 1: ] ),
channel_id = request.form[ 'channel_id' ],
user_name = request.form[ 'user_name' ],
raw_incident_number = raw_incident_number,
)
else:
return mattermost_incident_start( description = request.form[ 'text' ],
team_id = request.form[ 'team_id' ],
user_name = request.form[ 'user_name' ],
user_id = request.form[ 'user_id' ],
channel_id = request.form[ 'channel_id' ],
)
else:
return "Invalid command token."
def exec_to_message( cmdline ):
results = "#### `" + cmdline + "`:\n\n"
process = subprocess.Popen( cmdline, shell = True, cwd = app.config[ "STATUS_ROOT" ], stdout = subprocess.PIPE, stderr = subprocess.PIPE )
stdout = strip_progress( strip_ansi( process.stdout.read().decode( 'ascii', 'ignore' ) ) )
if stdout:
results += "\n\n```\n" + stdout.replace( "```", "` ` `" ) + "```\n"
stderr = strip_progress( strip_ansi( process.stderr.read().decode( 'ascii', 'ignore' ) ) )
if stderr:
results += "\n\n```\n" + stderr.replace( "```", "` ` `" ) + "```\n"
if not stdout and not stderr:
results += '_no output_\n'
return results
@app.route( '/mattermost/fih', methods = [ 'POST' ] )
def mattermost_fih():
# text: /incident PUBLISH
# file_ids:
# user_name: marek
# trigger_word: /incident
# channel_name: incident201806158693
# timestamp: 1529100914
# channel_id: zq61mk8ig7rmibtbyqf4babhfr
# team_id: 3woo8mbjrbb1in53xwdzi4ynqh
# token: nn54y4ozbtnzb8pri3c4gdhbce
# user_id: ko3uxmend7ne8ger4uw9mxkt1o
# team_domain: faelix
# post_id: 8cxn8yeyepyczcfbazdsd1bonw
if request.form[ 'token' ] in app.config[ 'MATTERMOST_WEBHOOK_TOKENS' ]:
pass
else:
return "Invalid command token."
@app.route( '/' )
def hello_world():
return 'Hello, World!'