Merge pull request #223 from basecamp/customizable-audit-broadcast
Allow customizing audit broadcast with env
This commit is contained in:
15
README.md
15
README.md
@@ -677,6 +677,21 @@ That'll post a line like follows to a preconfigured chatbot in Basecamp:
|
|||||||
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
|
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`MRSK_*` environment variables are available to the broadcast command for
|
||||||
|
fine-grained audit reporting, e.g. for triggering deployment reports or
|
||||||
|
firing a JSON webhook. These variables include:
|
||||||
|
- `MRSK_RECORDED_AT` - UTC timestamp in ISO 8601 format, e.g. `2023-04-14T17:07:31Z`
|
||||||
|
- `MRSK_PERFORMER` - the local user performing the command (from `whoami`)
|
||||||
|
- `MRSK_MESSAGE` - the full audit message, e.g. "Deployed app@150b24f"
|
||||||
|
- `MRSK_DESTINATION` - optional: destination, e.g. "staging"
|
||||||
|
- `MRSK_ROLE` - optional: role targeted, e.g. "web"
|
||||||
|
|
||||||
|
Use `mrsk broadcast` to test and troubleshoot your broadcast command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mrsk broadcast -m "test audit message"
|
||||||
|
```
|
||||||
|
|
||||||
### Healthcheck
|
### Healthcheck
|
||||||
|
|
||||||
MRSK uses Docker healtchecks to check the health of your application during deployment. Traefik uses this same healthcheck status to determine when a container is ready to receive traffic.
|
MRSK uses Docker healtchecks to check the health of your application during deployment. Traefik uses this same healthcheck status to determine when a container is ready to receive traffic.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
|
|||||||
roles = MRSK.roles_on(host)
|
roles = MRSK.roles_on(host)
|
||||||
|
|
||||||
roles.each do |role|
|
roles.each do |role|
|
||||||
execute *MRSK.auditor(role: role).record("Stopped app"), verbosity: :debug
|
execute *MRSK.auditor.record("Stopped app", role: role), verbosity: :debug
|
||||||
execute *MRSK.app(role: role).stop, raise_on_non_zero_exit: false
|
execute *MRSK.app(role: role).stop, raise_on_non_zero_exit: false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -107,7 +107,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
|
|||||||
roles = MRSK.roles_on(host)
|
roles = MRSK.roles_on(host)
|
||||||
|
|
||||||
roles.each do |role|
|
roles.each do |role|
|
||||||
execute *MRSK.auditor(role: role).record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
|
execute *MRSK.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
|
||||||
puts_by_host host, capture_with_info(*MRSK.app(role: role).execute_in_existing_container(cmd))
|
puts_by_host host, capture_with_info(*MRSK.app(role: role).execute_in_existing_container(cmd))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -214,7 +214,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
|
|||||||
roles = MRSK.roles_on(host)
|
roles = MRSK.roles_on(host)
|
||||||
|
|
||||||
roles.each do |role|
|
roles.each do |role|
|
||||||
execute *MRSK.auditor(role: role).record("Removed app container with version #{version}"), verbosity: :debug
|
execute *MRSK.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
|
||||||
execute *MRSK.app(role: role).remove_container(version: version)
|
execute *MRSK.app(role: role).remove_container(version: version)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -228,7 +228,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
|
|||||||
roles = MRSK.roles_on(host)
|
roles = MRSK.roles_on(host)
|
||||||
|
|
||||||
roles.each do |role|
|
roles.each do |role|
|
||||||
execute *MRSK.auditor(role: role).record("Removed all app containers"), verbosity: :debug
|
execute *MRSK.auditor.record("Removed all app containers", role: role), verbosity: :debug
|
||||||
execute *MRSK.app(role: role).remove_containers
|
execute *MRSK.app(role: role).remove_containers
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "broadcast", "Broadcast an audit message"
|
||||||
|
option :message, aliases: "-m", type: :string, desc: "Audit mesasge", required: true
|
||||||
|
def broadcast
|
||||||
|
say "Broadcast: #{options[:message]}", :magenta
|
||||||
|
audit_broadcast options[:message]
|
||||||
|
end
|
||||||
|
|
||||||
desc "version", "Show MRSK version"
|
desc "version", "Show MRSK version"
|
||||||
def version
|
def version
|
||||||
puts Mrsk::VERSION
|
puts Mrsk::VERSION
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ class Mrsk::Commander
|
|||||||
Mrsk::Commands::Accessory.new(config, name: name)
|
Mrsk::Commands::Accessory.new(config, name: name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def auditor(role: nil)
|
def auditor(**details)
|
||||||
Mrsk::Commands::Auditor.new(config, role: role)
|
Mrsk::Commands::Auditor.new(config, **details)
|
||||||
end
|
end
|
||||||
|
|
||||||
def builder
|
def builder
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
require "active_support/core_ext/time/conversions"
|
require "time"
|
||||||
|
|
||||||
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
|
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
|
||||||
attr_reader :role
|
attr_reader :details
|
||||||
|
|
||||||
def initialize(config, role: nil)
|
def initialize(config, **details)
|
||||||
super(config)
|
super(config)
|
||||||
@role = role
|
@details = default_details.merge(details)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs remotely
|
# Runs remotely
|
||||||
def record(line)
|
def record(line, **details)
|
||||||
append \
|
append \
|
||||||
[ :echo, tagged_record_line(line) ],
|
[ :echo, *audit_tags(**details), line ],
|
||||||
audit_log_file
|
audit_log_file
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs locally
|
# Runs locally
|
||||||
def broadcast(line)
|
def broadcast(line, **details)
|
||||||
if broadcast_cmd = config.audit_broadcast_cmd
|
if broadcast_cmd = config.audit_broadcast_cmd
|
||||||
[ broadcast_cmd, tagged_broadcast_line(line) ]
|
[ broadcast_cmd, *broadcast_args(line, **details), env: env_for(event: line, **details) ]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,27 +31,29 @@ class Mrsk::Commands::Auditor < Mrsk::Commands::Base
|
|||||||
[ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-")
|
[ "mrsk", config.service, config.destination, "audit.log" ].compact.join("-")
|
||||||
end
|
end
|
||||||
|
|
||||||
def tagged_record_line(line)
|
def default_details
|
||||||
tagged_line recorded_at_tag, performer_tag, role_tag, line
|
{ recorded_at: Time.now.utc.iso8601,
|
||||||
|
performer: `whoami`.chomp,
|
||||||
|
destination: config.destination }
|
||||||
end
|
end
|
||||||
|
|
||||||
def tagged_broadcast_line(line)
|
def audit_tags(**details)
|
||||||
tagged_line performer_tag, role_tag, line
|
tags_for **self.details.merge(details)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tagged_line(*tags_and_line)
|
def broadcast_args(line, **details)
|
||||||
"'#{tags_and_line.compact.join(" ")}'"
|
"'#{broadcast_tags(**details).join(" ")} #{line}'"
|
||||||
end
|
end
|
||||||
|
|
||||||
def recorded_at_tag
|
def broadcast_tags(**details)
|
||||||
"[#{Time.now.to_fs(:db)}]"
|
tags_for **self.details.merge(details).except(:recorded_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
def performer_tag
|
def tags_for(**details)
|
||||||
"[#{`whoami`.strip}]"
|
details.compact.values.map { |value| "[#{value}]" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def role_tag
|
def env_for(**details)
|
||||||
"[#{role}]" if role
|
self.details.merge(details).compact.transform_keys { |detail| "MRSK_#{detail.upcase}" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
require "active_support/duration"
|
require "active_support/duration"
|
||||||
require "active_support/core_ext/numeric/time"
|
require "time"
|
||||||
|
|
||||||
class Mrsk::Commands::Lock < Mrsk::Commands::Base
|
class Mrsk::Commands::Lock < Mrsk::Commands::Base
|
||||||
def acquire(message, version)
|
def acquire(message, version)
|
||||||
@@ -49,7 +49,7 @@ class Mrsk::Commands::Lock < Mrsk::Commands::Base
|
|||||||
|
|
||||||
def lock_details(message, version)
|
def lock_details(message, version)
|
||||||
<<~DETAILS.strip
|
<<~DETAILS.strip
|
||||||
Locked by: #{locked_by} at #{Time.now.gmtime}
|
Locked by: #{locked_by} at #{Time.now.utc.iso8601}
|
||||||
Version: #{version}
|
Version: #{version}
|
||||||
Message: #{message}
|
Message: #{message}
|
||||||
DETAILS
|
DETAILS
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
require "sshkit"
|
require "sshkit"
|
||||||
require "sshkit/dsl"
|
require "sshkit/dsl"
|
||||||
|
require "active_support/core_ext/hash/deep_merge"
|
||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
class SSHKit::Backend::Abstract
|
class SSHKit::Backend::Abstract
|
||||||
@@ -7,11 +8,45 @@ class SSHKit::Backend::Abstract
|
|||||||
capture(*args, **kwargs, verbosity: Logger::INFO)
|
capture(*args, **kwargs, verbosity: Logger::INFO)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def capture_with_pretty_json(*args, **kwargs)
|
||||||
|
JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
|
||||||
|
end
|
||||||
|
|
||||||
def puts_by_host(host, output, type: "App")
|
def puts_by_host(host, output, type: "App")
|
||||||
puts "#{type} Host: #{host}\n#{output}\n\n"
|
puts "#{type} Host: #{host}\n#{output}\n\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
def capture_with_pretty_json(*args, **kwargs)
|
# Our execution pattern is for the CLI execute args lists returned
|
||||||
JSON.pretty_generate(JSON.parse(capture(*args, **kwargs)))
|
# from commands, but this doesn't support returning execution options
|
||||||
|
# from the command.
|
||||||
|
#
|
||||||
|
# Support this by using kwargs for CLI options and merging with the
|
||||||
|
# args-extracted options.
|
||||||
|
module CommandEnvMerge
|
||||||
|
private
|
||||||
|
|
||||||
|
# Override to merge options returned by commands in the args list with
|
||||||
|
# options passed by the CLI and pass them along as kwargs.
|
||||||
|
def command(args, options)
|
||||||
|
more_options, args = args.partition { |a| a.is_a? Hash }
|
||||||
|
more_options << options
|
||||||
|
|
||||||
|
build_command(args, **more_options.reduce(:deep_merge))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Destructure options to pluck out env for merge
|
||||||
|
def build_command(args, env: nil, **options)
|
||||||
|
# Rely on native Ruby kwargs precedence rather than explicit Hash merges
|
||||||
|
SSHKit::Command.new(*args, **default_command_options, **options, env: env_for(env))
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_command_options
|
||||||
|
{ in: pwd_path, host: @host, user: @user, group: @group }
|
||||||
|
end
|
||||||
|
|
||||||
|
def env_for(env)
|
||||||
|
@env.to_h.merge(env.to_h)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
prepend CommandEnvMerge
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -321,6 +321,19 @@ class CliMainTest < CliTestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "broadcast" do
|
||||||
|
SSHKit::Backend::Abstract.any_instance.expects(:execute).with do |command, line, options, verbosity:|
|
||||||
|
command == "bin/audit_broadcast" &&
|
||||||
|
line =~ /\A'\[[^\]]+\] message'\z/ &&
|
||||||
|
options[:env].keys == %w[ MRSK_RECORDED_AT MRSK_PERFORMER MRSK_EVENT ] &&
|
||||||
|
verbosity == :debug
|
||||||
|
end.returns("Broadcast audit message: message")
|
||||||
|
|
||||||
|
run_command("broadcast", "-m", "message").tap do |output|
|
||||||
|
assert_match "Broadcast: message", output
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "version" do
|
test "version" do
|
||||||
version = stdouted { Mrsk::Cli::Main.new.version }
|
version = stdouted { Mrsk::Cli::Main.new.version }
|
||||||
assert_equal Mrsk::VERSION, version
|
assert_equal Mrsk::VERSION, version
|
||||||
|
|||||||
@@ -6,38 +6,65 @@ class CommandsAuditorTest < ActiveSupport::TestCase
|
|||||||
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
|
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
|
||||||
audit_broadcast_cmd: "bin/audit_broadcast"
|
audit_broadcast_cmd: "bin/audit_broadcast"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@auditor = new_command
|
||||||
end
|
end
|
||||||
|
|
||||||
test "record" do
|
test "record" do
|
||||||
assert_match \
|
assert_equal [
|
||||||
/echo '.* app removed container' >> mrsk-app-audit.log/,
|
:echo,
|
||||||
new_command.record("app removed container").join(" ")
|
"[#{@auditor.details[:recorded_at]}]", "[#{@auditor.details[:performer]}]",
|
||||||
|
"app removed container",
|
||||||
|
">>", "mrsk-app-audit.log"
|
||||||
|
], @auditor.record("app removed container")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "record with destination" do
|
test "record with destination" do
|
||||||
@destination = "staging"
|
new_command(destination: "staging").tap do |auditor|
|
||||||
|
assert_equal [
|
||||||
assert_match \
|
:echo,
|
||||||
/echo '.* app removed container' >> mrsk-app-staging-audit.log/,
|
"[#{auditor.details[:recorded_at]}]", "[#{auditor.details[:performer]}]", "[#{auditor.details[:destination]}]",
|
||||||
new_command.record("app removed container").join(" ")
|
"app removed container",
|
||||||
|
">>", "mrsk-app-staging-audit.log"
|
||||||
|
], auditor.record("app removed container")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "record with role" do
|
test "record with command details" do
|
||||||
@role = "web"
|
new_command(role: "web").tap do |auditor|
|
||||||
|
assert_equal [
|
||||||
|
:echo,
|
||||||
|
"[#{auditor.details[:recorded_at]}]", "[#{auditor.details[:performer]}]", "[#{auditor.details[:role]}]",
|
||||||
|
"app removed container",
|
||||||
|
">>", "mrsk-app-audit.log"
|
||||||
|
], auditor.record("app removed container")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
assert_match \
|
test "record with arg details" do
|
||||||
/echo '.* \[web\] app removed container' >> mrsk-app-audit.log/,
|
assert_equal [
|
||||||
new_command.record("app removed container").join(" ")
|
:echo,
|
||||||
|
"[#{@auditor.details[:recorded_at]}]", "[#{@auditor.details[:performer]}]", "[value]",
|
||||||
|
"app removed container",
|
||||||
|
">>", "mrsk-app-audit.log"
|
||||||
|
], @auditor.record("app removed container", detail: "value")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "broadcast" do
|
test "broadcast" do
|
||||||
assert_match \
|
assert_equal [
|
||||||
/bin\/audit_broadcast '\[.*\] app removed container'/,
|
"bin/audit_broadcast",
|
||||||
new_command.broadcast("app removed container").join(" ")
|
"'[#{@auditor.details[:performer]}] [value] app removed container'",
|
||||||
|
env: {
|
||||||
|
"MRSK_RECORDED_AT" => @auditor.details[:recorded_at],
|
||||||
|
"MRSK_PERFORMER" => @auditor.details[:performer],
|
||||||
|
"MRSK_EVENT" => "app removed container",
|
||||||
|
"MRSK_DETAIL" => "value"
|
||||||
|
}
|
||||||
|
], @auditor.broadcast("app removed container", detail: "value")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def new_command
|
def new_command(destination: nil, **details)
|
||||||
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: @destination, version: "123"), role: @role)
|
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, destination: destination, version: "123"), **details)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
1
test/fixtures/deploy_simple.yml
vendored
1
test/fixtures/deploy_simple.yml
vendored
@@ -6,3 +6,4 @@ servers:
|
|||||||
registry:
|
registry:
|
||||||
username: user
|
username: user
|
||||||
password: pw
|
password: pw
|
||||||
|
audit_broadcast_cmd: "bin/audit_broadcast"
|
||||||
|
|||||||
Reference in New Issue
Block a user