deployangel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/exe/deployangel +6 -0
- data/lib/deployangel/agent.rb +242 -0
- data/lib/deployangel/capistrano/steps.rb +61 -0
- data/lib/deployangel/capistrano/tasks.rake +54 -0
- data/lib/deployangel/capistrano.rb +13 -0
- data/lib/deployangel/ci_environment.rb +46 -0
- data/lib/deployangel/cli/formatter.rb +96 -0
- data/lib/deployangel/cli.rb +257 -0
- data/lib/deployangel/client.rb +92 -0
- data/lib/deployangel/configuration.rb +49 -0
- data/lib/deployangel/core/aggregator.rb +181 -0
- data/lib/deployangel/core/buffer.rb +43 -0
- data/lib/deployangel/core/fingerprint.rb +91 -0
- data/lib/deployangel/core/histogram.rb +42 -0
- data/lib/deployangel/core/instance.rb +30 -0
- data/lib/deployangel/core/protocol.rb +60 -0
- data/lib/deployangel/core/release.rb +88 -0
- data/lib/deployangel/core/transport.rb +66 -0
- data/lib/deployangel/fork_hook.rb +15 -0
- data/lib/deployangel/mcp.rb +205 -0
- data/lib/deployangel/rails/active_job.rb +70 -0
- data/lib/deployangel/rails/error_subscriber.rb +19 -0
- data/lib/deployangel/rails/http.rb +65 -0
- data/lib/deployangel/rails/metadata.rb +96 -0
- data/lib/deployangel/rails/railtie.rb +35 -0
- data/lib/deployangel/sidekiq.rb +71 -0
- data/lib/deployangel/verification_waiter.rb +97 -0
- data/lib/deployangel/version.rb +5 -0
- data/lib/deployangel.rb +118 -0
- metadata +81 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by `require "deployangel/capistrano"`. Settings (config/deploy.rb):
|
|
4
|
+
#
|
|
5
|
+
# set :deployangel_api_token, ENV["DEPLOYANGEL_API_TOKEN"] # a "CI deploys" token
|
|
6
|
+
# set :deployangel_version, -> { fetch(:release_timestamp) } # label; nil to use the commit
|
|
7
|
+
# set :deployangel_wait, false # or "initial" / "verdict" to wait after deploying
|
|
8
|
+
# set :deployangel_wait_timeout, "15m"
|
|
9
|
+
# set :deployangel_url, nil # defaults to https://api.deployangel.com
|
|
10
|
+
# set :deployangel_register, true # false to turn the integration off (e.g. per stage)
|
|
11
|
+
|
|
12
|
+
namespace :load do
|
|
13
|
+
task :defaults do
|
|
14
|
+
set :deployangel_register, true
|
|
15
|
+
set :deployangel_api_token, -> { ENV["DEPLOYANGEL_API_TOKEN"] }
|
|
16
|
+
set :deployangel_version, -> { fetch(:release_timestamp) }
|
|
17
|
+
set :deployangel_wait, false
|
|
18
|
+
set :deployangel_wait_timeout, "15m"
|
|
19
|
+
set :deployangel_url, -> { ENV["DEPLOYANGEL_URL"] }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
namespace :deployangel do
|
|
24
|
+
desc "Register this release with DeployAngel so it gets a verdict"
|
|
25
|
+
task :register do
|
|
26
|
+
next unless fetch(:deployangel_register)
|
|
27
|
+
|
|
28
|
+
run_locally do
|
|
29
|
+
outcome, message = DeployAngel::Capistrano::Steps.register(token: fetch(:deployangel_api_token),
|
|
30
|
+
commit: fetch(:current_revision), version: fetch(:deployangel_version), endpoint: fetch(:deployangel_url))
|
|
31
|
+
outcome == :ok ? info(message) : warn(message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Wait for DeployAngel's verdict on this release (set :deployangel_wait to enable)"
|
|
36
|
+
task :verify do
|
|
37
|
+
mode = fetch(:deployangel_wait)
|
|
38
|
+
next unless fetch(:deployangel_register) && mode
|
|
39
|
+
|
|
40
|
+
run_locally do
|
|
41
|
+
outcome, message = DeployAngel::Capistrano::Steps.verify(token: fetch(:deployangel_api_token),
|
|
42
|
+
commit: fetch(:current_revision), until_mode: mode.to_s, timeout: fetch(:deployangel_wait_timeout),
|
|
43
|
+
endpoint: fetch(:deployangel_url))
|
|
44
|
+
case outcome
|
|
45
|
+
when :ok then info(message)
|
|
46
|
+
when :warn then warn(message)
|
|
47
|
+
else raise message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
after "deploy:published", "deployangel:register"
|
|
54
|
+
after "deploy:finished", "deployangel:verify"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Capistrano integration. Add to the Capfile:
|
|
4
|
+
#
|
|
5
|
+
# require "deployangel/capistrano"
|
|
6
|
+
#
|
|
7
|
+
# After each deploy is published, the release is registered with
|
|
8
|
+
# DeployAngel from the machine running `cap`, which needs a "CI deploys"
|
|
9
|
+
# token in DEPLOYANGEL_API_TOKEN. See lib/deployangel/capistrano/tasks.rake
|
|
10
|
+
# for the settings.
|
|
11
|
+
require_relative "capistrano/steps"
|
|
12
|
+
|
|
13
|
+
load File.expand_path("capistrano/tasks.rake", __dir__)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core/release"
|
|
4
|
+
|
|
5
|
+
module DeployAngel
|
|
6
|
+
# Recognizes common CI systems from their environment variables so
|
|
7
|
+
# `deployangel release` needs no arguments there: the commit, a build
|
|
8
|
+
# label, the provider, and a link back to the run. Inside a Kamal hook,
|
|
9
|
+
# the release is Kamal's, linked to the CI run when Kamal runs in CI.
|
|
10
|
+
class CiEnvironment < Struct.new(:provider, :commit, :version, :source_url)
|
|
11
|
+
def self.detect(env = ENV)
|
|
12
|
+
ci = detect_ci(env)
|
|
13
|
+
return ci unless present?(env["KAMAL_VERSION"])
|
|
14
|
+
|
|
15
|
+
release = Release.kamal(env["KAMAL_VERSION"])
|
|
16
|
+
new("kamal", release.commit, release.version, ci&.source_url)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.detect_ci(env)
|
|
20
|
+
if env["GITHUB_ACTIONS"] == "true"
|
|
21
|
+
run_url = "#{env["GITHUB_SERVER_URL"]}/#{env["GITHUB_REPOSITORY"]}/actions/runs/#{env["GITHUB_RUN_ID"]}" if env["GITHUB_RUN_ID"]
|
|
22
|
+
new("github_actions", env["GITHUB_SHA"], label("run", env["GITHUB_RUN_NUMBER"]), run_url)
|
|
23
|
+
elsif env["GITLAB_CI"] == "true"
|
|
24
|
+
new("gitlab_ci", env["CI_COMMIT_SHA"], label("pipeline", env["CI_PIPELINE_IID"]), env["CI_PIPELINE_URL"])
|
|
25
|
+
elsif env["CIRCLECI"] == "true"
|
|
26
|
+
new("circleci", env["CIRCLE_SHA1"], label("build", env["CIRCLE_BUILD_NUM"]), env["CIRCLE_BUILD_URL"])
|
|
27
|
+
elsif env["BUILDKITE"] == "true"
|
|
28
|
+
new("buildkite", env["BUILDKITE_COMMIT"], label("build", env["BUILDKITE_BUILD_NUMBER"]), env["BUILDKITE_BUILD_URL"])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.label(prefix, number)
|
|
33
|
+
"#{prefix}-#{number}" unless number.to_s.strip.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.present?(value)
|
|
37
|
+
!value.to_s.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Only https links are kept; the cloud rejects anything else.
|
|
41
|
+
def source_url
|
|
42
|
+
url = self[:source_url].to_s
|
|
43
|
+
url.start_with?("https://") ? url : nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
class CLI
|
|
5
|
+
# Plain-text rendering of the verdict document for people. Everything
|
|
6
|
+
# shown comes from the document; nothing is inferred here.
|
|
7
|
+
module Formatter
|
|
8
|
+
LABELS = {
|
|
9
|
+
"http_5xx_rate" => "HTTP 5xx rate", "route_5xx_rate" => "Route 5xx rate", "p95_latency" => "p95 latency",
|
|
10
|
+
"job_failure_rate" => "Job failure rate", "job_duration" => "Job duration p95", "queue_latency" => "Queue latency p95",
|
|
11
|
+
"new_fingerprint" => "New exception", "fingerprint_amplification" => "Exception amplification",
|
|
12
|
+
"missing_recurring_job" => "Recurring job", "first_use_failure" => "First-use failure", "external_check" => "External check",
|
|
13
|
+
"checkpoint_rate" => "Checkpoint"
|
|
14
|
+
}.freeze
|
|
15
|
+
DURATIONS = %w[p95_latency job_duration queue_latency].freeze
|
|
16
|
+
COUNTS = %w[new_fingerprint checkpoint_rate].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def verification(document)
|
|
21
|
+
v = document["verification"] || {}
|
|
22
|
+
clearance = document["clearance"] || {}
|
|
23
|
+
lines = [ clearance["statement"] || document["summary"] ]
|
|
24
|
+
lines << [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
|
|
25
|
+
("Confidence: #{v["confidence"]}" if v["confidence"]),
|
|
26
|
+
("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
|
|
27
|
+
if (check = v["initial_check"]) && v["verdict"].nil?
|
|
28
|
+
lines << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
|
|
29
|
+
end
|
|
30
|
+
lines << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
|
|
31
|
+
if (source = document.dig("deployment", "promoted_from"))
|
|
32
|
+
outcome = { "verified" => "cleared", "failed" => "failed", "inconclusive" => "not cleared" }.fetch(source["verdict"], "still verifying")
|
|
33
|
+
lines << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
findings = Array(document["findings"])
|
|
37
|
+
problems = findings.select { |f| %w[failing warning].include?(f["status"]) }
|
|
38
|
+
if problems.any?
|
|
39
|
+
lines << "Findings:"
|
|
40
|
+
problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
|
|
41
|
+
end
|
|
42
|
+
gathering = findings.count { |f| f["status"] == "insufficient_data" }
|
|
43
|
+
lines << "#{gathering} signal#{"s" unless gathering == 1} still gathering data (--format=json for details)" if gathering.positive?
|
|
44
|
+
section(lines, "New exceptions", Array(document["exceptions"])) do |e|
|
|
45
|
+
"#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
|
|
46
|
+
end
|
|
47
|
+
unless v["verdict"] == "failed"
|
|
48
|
+
section(lines, "Missing evidence", Array(clearance["missing_evidence"])) { |item| item }
|
|
49
|
+
end
|
|
50
|
+
section(lines, "Not observable", Array(clearance["not_observable"])) { |item| "#{item["label"]} (#{item["reason"]})" }
|
|
51
|
+
section(lines, "Still watching", Array(clearance["still_watching"])) { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" }
|
|
52
|
+
section(lines, "Critical flows", Array(document["critical_flows"])) do |flow|
|
|
53
|
+
"#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
|
|
54
|
+
end
|
|
55
|
+
changed = Array(document["rare_items_pending"]).select { |item| item["changed_in_release"] == "changed" }
|
|
56
|
+
section(lines, "Changed but not yet exercised", changed) { |item| item["key"] }
|
|
57
|
+
section(lines, "Late regressions", Array(document["late_regressions"])) { |late| late["summary"] }
|
|
58
|
+
lines << document["dashboard_url"] if document["dashboard_url"]
|
|
59
|
+
lines.compact.join("\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def exception(details)
|
|
63
|
+
[ "#{details["exception_class"]}: #{details["message"]}",
|
|
64
|
+
"Fingerprint #{details["fingerprint"]} · first seen #{details["first_seen_at"]} (#{details["first_seen_release"]})",
|
|
65
|
+
"#{details["occurrences_last_24h"]} occurrences in the last 24 hours",
|
|
66
|
+
*Array(details["backtrace"]).map { |frame| " #{frame}" } ].join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finding_line(finding)
|
|
70
|
+
label = "#{LABELS.fetch(finding["signal"], finding["signal"])} on #{finding["scope"]}"
|
|
71
|
+
if COUNTS.include?(finding["signal"])
|
|
72
|
+
"#{label}: #{finding["observed_value"].to_i} occurrences"
|
|
73
|
+
elsif finding["baseline_value"].nil? && finding["observed_value"].nil?
|
|
74
|
+
"#{label}: #{finding["threshold"]}"
|
|
75
|
+
else
|
|
76
|
+
"#{label}: #{value(finding, finding["baseline_value"])} -> #{value(finding, finding["observed_value"])}" \
|
|
77
|
+
"#{" (#{finding["observed_n"]} samples)" if finding["observed_n"]}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def section(lines, title, items)
|
|
82
|
+
return if items.empty?
|
|
83
|
+
|
|
84
|
+
lines << "#{title}:"
|
|
85
|
+
items.each { |item| lines << " #{yield(item)}" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def value(finding, raw)
|
|
89
|
+
return "n/a" if raw.nil?
|
|
90
|
+
return raw.to_i.to_s if COUNTS.include?(finding["signal"])
|
|
91
|
+
|
|
92
|
+
DURATIONS.include?(finding["signal"]) ? "#{raw.to_f.round} ms" : "#{(raw.to_f * 100).round(2)}%"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "optparse"
|
|
7
|
+
|
|
8
|
+
require_relative "version"
|
|
9
|
+
require_relative "configuration"
|
|
10
|
+
require_relative "client"
|
|
11
|
+
require_relative "ci_environment"
|
|
12
|
+
require_relative "verification_waiter"
|
|
13
|
+
require_relative "cli/formatter"
|
|
14
|
+
|
|
15
|
+
module DeployAngel
|
|
16
|
+
# `deployangel` command for developers, CI, and coding agents.
|
|
17
|
+
# Runs without booting Rails.
|
|
18
|
+
class CLI
|
|
19
|
+
USAGE_ERROR = 5
|
|
20
|
+
COMMANDS = %w[release verify status exception check install mcp version help].freeze
|
|
21
|
+
|
|
22
|
+
HELP = <<~TEXT
|
|
23
|
+
Usage: deployangel <command> [options]
|
|
24
|
+
|
|
25
|
+
release Register a deployment [--commit=SHA] [--version=V] [--kind=code]
|
|
26
|
+
[--provider=P] [--url=RUN_URL]
|
|
27
|
+
verify Report or wait for a verdict [--commit=SHA | --version=V | --deployment=ID]
|
|
28
|
+
[--wait] [--until=initial|verdict|closed] [--timeout=30m]
|
|
29
|
+
[--format=text|json] [--all-findings]
|
|
30
|
+
status Latest deployment and its verification
|
|
31
|
+
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
32
|
+
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
33
|
+
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
34
|
+
install Add a deploy hook deployangel install kamal
|
|
35
|
+
mcp Run the MCP server over stdio (for coding agents)
|
|
36
|
+
|
|
37
|
+
With no target, verify and check use the current git HEAD commit.
|
|
38
|
+
In GitHub Actions, GitLab CI, CircleCI, and Buildkite, release fills in the
|
|
39
|
+
commit, a build label (e.g. run-123), and a link to the run automatically.
|
|
40
|
+
In a Kamal hook, it registers Kamal's release (KAMAL_VERSION).
|
|
41
|
+
Environment: DEPLOYANGEL_API_TOKEN (required), DEPLOYANGEL_URL (default #{Configuration::DEFAULT_ENDPOINT})
|
|
42
|
+
|
|
43
|
+
Exit codes: 0 verified, 1 failed, 2 inconclusive, 3 still in progress / timed out,
|
|
44
|
+
4 deployment not found, 5 usage, auth, or network error,
|
|
45
|
+
6 initial check: no problems so far (NOT cleared), 7 initial check: warnings (NOT cleared)
|
|
46
|
+
TEXT
|
|
47
|
+
|
|
48
|
+
# Registers each Kamal deploy. It runs where `kamal deploy` runs, and
|
|
49
|
+
# never fails a deploy that has already shipped.
|
|
50
|
+
KAMAL_HOOK = <<~SH
|
|
51
|
+
#!/bin/sh
|
|
52
|
+
# Registers each deploy with DeployAngel, which then verifies it in
|
|
53
|
+
# production. Needs a "CI deploys" token in DEPLOYANGEL_API_TOKEN where
|
|
54
|
+
# you run `kamal deploy`. Added by `deployangel install kamal`.
|
|
55
|
+
bundle exec deployangel release || true
|
|
56
|
+
SH
|
|
57
|
+
|
|
58
|
+
def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
|
|
59
|
+
sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
|
|
60
|
+
@root = root
|
|
61
|
+
@argv = argv.dup
|
|
62
|
+
@env = env
|
|
63
|
+
@stdin = stdin
|
|
64
|
+
@stdout = stdout
|
|
65
|
+
@stderr = stderr
|
|
66
|
+
@client = client
|
|
67
|
+
@sleeper = sleeper
|
|
68
|
+
@clock = clock
|
|
69
|
+
@git_head = git_head
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run
|
|
73
|
+
command = @argv.shift
|
|
74
|
+
case command
|
|
75
|
+
when "release" then release
|
|
76
|
+
when "verify" then verify
|
|
77
|
+
when "status" then verify(status: true)
|
|
78
|
+
when "exception" then exception
|
|
79
|
+
when "check" then check
|
|
80
|
+
when "install" then install
|
|
81
|
+
when "mcp" then mcp
|
|
82
|
+
when "version", "--version", "-v" then @stdout.puts(DeployAngel::VERSION) || 0
|
|
83
|
+
when nil, "help", "--help", "-h" then @stdout.puts(HELP) || 0
|
|
84
|
+
else usage_error("unknown command: #{command}")
|
|
85
|
+
end
|
|
86
|
+
rescue OptionParser::ParseError => e
|
|
87
|
+
usage_error(e.message)
|
|
88
|
+
rescue Client::Unauthorized => e
|
|
89
|
+
@stderr.puts("deployangel: #{e.message}")
|
|
90
|
+
USAGE_ERROR
|
|
91
|
+
rescue Client::NotFound => e
|
|
92
|
+
@stderr.puts("deployangel: #{e.message}")
|
|
93
|
+
VerificationWaiter::NOT_FOUND
|
|
94
|
+
rescue Client::Error => e
|
|
95
|
+
@stderr.puts("deployangel: #{e.message}")
|
|
96
|
+
USAGE_ERROR
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
def release
|
|
101
|
+
options = parse(commit: nil, version: nil, kind: nil, provider: nil, source_url: nil) do |o, opts|
|
|
102
|
+
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
103
|
+
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
104
|
+
o.on("--kind=KIND") { |v| opts[:kind] = v }
|
|
105
|
+
o.on("--provider=PROVIDER") { |v| opts[:provider] = v }
|
|
106
|
+
o.on("--url=URL") { |v| opts[:source_url] = v }
|
|
107
|
+
end
|
|
108
|
+
# Explicit options win, then the CI system, then git.
|
|
109
|
+
if (ci = CiEnvironment.detect(@env))
|
|
110
|
+
options[:commit] ||= ci.commit
|
|
111
|
+
options[:version] ||= ci.version
|
|
112
|
+
options[:provider] ||= ci.provider
|
|
113
|
+
options[:source_url] ||= ci.source_url
|
|
114
|
+
end
|
|
115
|
+
options[:commit] ||= git_head
|
|
116
|
+
return usage_error("release needs --commit or --version (no git repository or CI commit found)") unless options[:commit] || options[:version]
|
|
117
|
+
|
|
118
|
+
deployment = client.register_deployment(**options)
|
|
119
|
+
@stdout.puts("Registered deployment #{deployment["id"]} (#{deployment["version"] || deployment["commit"]}), verification #{deployment["state"]}")
|
|
120
|
+
0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def verify(status: false)
|
|
124
|
+
options = parse(until_mode: "verdict", timeout: 1800, wait: false, format: nil, all_findings: false) do |o, opts|
|
|
125
|
+
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
126
|
+
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
127
|
+
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
128
|
+
o.on("--wait") { opts[:wait] = true }
|
|
129
|
+
o.on("--until=MODE", VerificationWaiter::UNTIL_MODES) { |v| opts[:until_mode] = v }
|
|
130
|
+
o.on("--timeout=DURATION") { |v| opts[:timeout] = parse_duration(v) }
|
|
131
|
+
o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v }
|
|
132
|
+
o.on("--all-findings") { opts[:all_findings] = true }
|
|
133
|
+
end
|
|
134
|
+
target = status ? { latest: true } : target_from(options)
|
|
135
|
+
return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
|
|
136
|
+
|
|
137
|
+
waiter = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock,
|
|
138
|
+
on_progress: ->(message) { @stderr.puts(message) if options[:wait] })
|
|
139
|
+
outcome = waiter.wait(target, until_mode: options[:until_mode], timeout: options[:timeout], wait: options[:wait])
|
|
140
|
+
if outcome.not_found
|
|
141
|
+
@stderr.puts("deployangel: no deployment found for #{target.values.first}")
|
|
142
|
+
return outcome.exit_code
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
document = options[:all_findings] ? client.verification(outcome.document.dig("deployment", "id"), all_findings: true) : outcome.document
|
|
146
|
+
output(document, options[:format]) { Formatter.verification(document) }
|
|
147
|
+
@stderr.puts("deployangel: timed out; verification is still in progress") if outcome.timed_out
|
|
148
|
+
outcome.exit_code
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def exception
|
|
152
|
+
fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
|
|
153
|
+
format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
|
|
154
|
+
details = client.exception(fingerprint)
|
|
155
|
+
output(details, format) { Formatter.exception(details) }
|
|
156
|
+
0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def check
|
|
160
|
+
options = parse(name: nil, status: nil, covers: [], details_url: nil) do |o, opts|
|
|
161
|
+
o.on("--name=NAME") { |v| opts[:name] = v }
|
|
162
|
+
o.on("--status=STATUS", %w[pass fail]) { |v| opts[:status] = v }
|
|
163
|
+
o.on("--covers=LIST") { |v| opts[:covers] = v.split(",").map(&:strip) }
|
|
164
|
+
o.on("--details-url=URL") { |v| opts[:details_url] = v }
|
|
165
|
+
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
166
|
+
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
167
|
+
end
|
|
168
|
+
return usage_error("check needs --name and --status=pass|fail") unless options[:name] && options[:status]
|
|
169
|
+
|
|
170
|
+
commit = options[:commit] || (git_head || CiEnvironment.detect(@env)&.commit unless options[:deployment_id])
|
|
171
|
+
reference = options[:deployment_id] || (commit && "commit:#{commit}")
|
|
172
|
+
return usage_error("check needs --deployment or --commit, or a git repository") unless reference
|
|
173
|
+
|
|
174
|
+
result = client.report_check(reference, name: options[:name], status: options[:status],
|
|
175
|
+
covers: options[:covers], details_url: options[:details_url])
|
|
176
|
+
@stdout.puts("Recorded #{options[:status]} check \"#{options[:name]}\" for deployment #{result["deployment_id"]}")
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Writes .kamal/hooks/post-deploy, or says what to add to a hook that
|
|
181
|
+
# already exists, rather than overwriting it.
|
|
182
|
+
def install
|
|
183
|
+
target = @argv.shift
|
|
184
|
+
return usage_error("install needs a target: deployangel install kamal") unless target == "kamal"
|
|
185
|
+
|
|
186
|
+
relative = File.join(".kamal", "hooks", "post-deploy")
|
|
187
|
+
path = File.join(@root, relative)
|
|
188
|
+
if File.exist?(path)
|
|
189
|
+
if File.read(path).include?("deployangel release")
|
|
190
|
+
@stdout.puts("#{relative} already registers deploys with DeployAngel.")
|
|
191
|
+
else
|
|
192
|
+
@stdout.puts("#{relative} already exists. Add this line to it:", "", " bundle exec deployangel release || true")
|
|
193
|
+
end
|
|
194
|
+
return 0
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
198
|
+
File.write(path, KAMAL_HOOK)
|
|
199
|
+
File.chmod(0o755, path)
|
|
200
|
+
@stdout.puts("Created #{relative}. Each `kamal deploy` now registers its release with DeployAngel.",
|
|
201
|
+
"Set DEPLOYANGEL_API_TOKEN (a \"CI deploys\" token) wherever you run kamal deploy.")
|
|
202
|
+
0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def mcp
|
|
206
|
+
require_relative "mcp"
|
|
207
|
+
MCP::Server.new(client: client, input: @stdin, output: @stdout, error_output: @stderr,
|
|
208
|
+
sleeper: @sleeper, clock: @clock, git_head: method(:git_head)).run
|
|
209
|
+
0
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse(defaults)
|
|
213
|
+
options = defaults.dup
|
|
214
|
+
OptionParser.new { |o| yield(o, options) }.parse!(@argv)
|
|
215
|
+
options
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def target_from(options)
|
|
219
|
+
return { deployment_id: options[:deployment_id] } if options[:deployment_id]
|
|
220
|
+
return { version: options[:version] } if options[:version]
|
|
221
|
+
|
|
222
|
+
commit = options[:commit] || git_head || CiEnvironment.detect(@env)&.commit
|
|
223
|
+
{ commit: commit.downcase } if commit
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def output(document, format = nil)
|
|
227
|
+
format ||= @stdout.tty? ? "text" : "json"
|
|
228
|
+
@stdout.puts(format == "json" ? JSON.pretty_generate(document) : yield)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def parse_duration(value)
|
|
232
|
+
match = value.to_s.match(/\A(\d+)(s|m|h)?\z/) or raise OptionParser::InvalidArgument, "--timeout=#{value}"
|
|
233
|
+
match[1].to_i * { nil => 1, "s" => 1, "m" => 60, "h" => 3600 }.fetch(match[2])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def git_head
|
|
237
|
+
return @git_head if @git_head
|
|
238
|
+
return nil if @git_head == false
|
|
239
|
+
|
|
240
|
+
output, status = Open3.capture2("git", "rev-parse", "HEAD", err: File::NULL)
|
|
241
|
+
status.success? ? output.strip : nil
|
|
242
|
+
rescue SystemCallError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def client
|
|
247
|
+
@client ||= Client.new(token: @env["DEPLOYANGEL_API_TOKEN"],
|
|
248
|
+
endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def usage_error(message)
|
|
252
|
+
@stderr.puts("deployangel: #{message}")
|
|
253
|
+
@stderr.puts("Run `deployangel help` for usage.")
|
|
254
|
+
USAGE_ERROR
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module DeployAngel
|
|
8
|
+
# Read and registration API client for the CLI and MCP server.
|
|
9
|
+
# Unlike the agent's Transport, errors raise, because a person or coding
|
|
10
|
+
# agent is waiting on the answer.
|
|
11
|
+
class Client
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :status
|
|
14
|
+
|
|
15
|
+
def initialize(message, status: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
class NotFound < Error; end
|
|
21
|
+
class Unauthorized < Error; end
|
|
22
|
+
|
|
23
|
+
attr_reader :endpoint
|
|
24
|
+
|
|
25
|
+
def initialize(token:, endpoint: Configuration::DEFAULT_ENDPOINT, timeout: 15)
|
|
26
|
+
raise Unauthorized, "DEPLOYANGEL_API_TOKEN is not set" if token.to_s.empty?
|
|
27
|
+
|
|
28
|
+
@token = token
|
|
29
|
+
@endpoint = endpoint.to_s.chomp("/")
|
|
30
|
+
@timeout = timeout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def token_info = get("/api/v1/token")
|
|
34
|
+
def latest_deployment = get("/api/v1/deployments/latest")
|
|
35
|
+
def deployments(**filters) = get("/api/v1/deployments", filters.compact)["deployments"]
|
|
36
|
+
def exception(fingerprint) = get("/api/v1/exceptions/#{URI.encode_www_form_component(fingerprint)}")
|
|
37
|
+
def late_regressions(**filters) = get("/api/v1/late_regressions", filters.compact)["late_regressions"]
|
|
38
|
+
|
|
39
|
+
def verification(deployment_id, all_findings: false)
|
|
40
|
+
get("/api/v1/deployments/#{deployment_id}/verification", all_findings ? { findings: "all" } : {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def register_deployment(commit: nil, version: nil, kind: nil, provider: nil, source_url: nil)
|
|
44
|
+
post("/api/v1/deployments", { commit: commit, version: version, kind: kind, provider: provider, source_url: source_url }.compact)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def report_check(deployment_ref, name:, status:, covers: [], details_url: nil)
|
|
48
|
+
post("/api/v1/deployments/#{URI.encode_www_form_component(deployment_ref)}/checks",
|
|
49
|
+
{ name: name, status: status, covers: covers, details_url: details_url }.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
def get(path, params = {})
|
|
54
|
+
uri = URI("#{@endpoint}#{path}")
|
|
55
|
+
uri.query = URI.encode_www_form(params) if params.any?
|
|
56
|
+
request(Net::HTTP::Get.new(uri))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def post(path, body)
|
|
60
|
+
request = Net::HTTP::Post.new(URI("#{@endpoint}#{path}"))
|
|
61
|
+
request["Content-Type"] = "application/json"
|
|
62
|
+
request.body = JSON.generate(body)
|
|
63
|
+
request(request)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def request(request)
|
|
67
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
68
|
+
request["Accept"] = "application/json"
|
|
69
|
+
request["User-Agent"] = "deployangel-cli/#{DeployAngel::VERSION}"
|
|
70
|
+
uri = request.uri
|
|
71
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
72
|
+
open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(request) }
|
|
73
|
+
handle(response)
|
|
74
|
+
rescue Error
|
|
75
|
+
raise
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
raise Error, "could not reach #{@endpoint}: #{e.class}: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle(response)
|
|
81
|
+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
82
|
+
case response.code.to_i
|
|
83
|
+
when 200..299 then body
|
|
84
|
+
when 404 then raise NotFound.new(body["error"] || "not found", status: 404)
|
|
85
|
+
when 401, 403 then raise Unauthorized.new(body["error"] || "unauthorized", status: response.code.to_i)
|
|
86
|
+
else raise Error.new(body["error"] || "HTTP #{response.code}", status: response.code.to_i)
|
|
87
|
+
end
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
raise Error.new("unexpected response (HTTP #{response.code})", status: response.code.to_i)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module DeployAngel
|
|
6
|
+
# Settings come from environment variables by default and can be
|
|
7
|
+
# overridden in code with DeployAngel.configure.
|
|
8
|
+
class Configuration
|
|
9
|
+
TRUE_VALUES = %w[1 true yes on].freeze
|
|
10
|
+
DEFAULT_ENDPOINT = "https://api.deployangel.com"
|
|
11
|
+
|
|
12
|
+
attr_accessor :token, :endpoint, :enabled, :environments, :release_version, :revision,
|
|
13
|
+
:flush_interval, :open_timeout, :read_timeout, :max_queued_payloads, :max_routes, :logger,
|
|
14
|
+
:file_digests, :critical_flows
|
|
15
|
+
|
|
16
|
+
def initialize(env = ENV)
|
|
17
|
+
@token = env["DEPLOYANGEL_TOKEN"]
|
|
18
|
+
@endpoint = env["DEPLOYANGEL_URL"] || DEFAULT_ENDPOINT
|
|
19
|
+
@enabled = parse_boolean(env["DEPLOYANGEL_ENABLED"])
|
|
20
|
+
@environments = %w[production]
|
|
21
|
+
@release_version = env["DEPLOYANGEL_RELEASE_VERSION"]
|
|
22
|
+
@revision = env["DEPLOYANGEL_REVISION"]
|
|
23
|
+
@flush_interval = 60
|
|
24
|
+
@open_timeout = 2
|
|
25
|
+
@read_timeout = 5
|
|
26
|
+
@max_queued_payloads = 10
|
|
27
|
+
@max_routes = 100
|
|
28
|
+
@logger = Logger.new($stderr, level: Logger::WARN, progname: "deployangel")
|
|
29
|
+
@file_digests = parse_boolean(env["DEPLOYANGEL_FILE_DIGESTS"]) != false
|
|
30
|
+
@critical_flows = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Reports only with a token. By default only in the listed
|
|
34
|
+
# environments; DEPLOYANGEL_ENABLED forces it on or off.
|
|
35
|
+
def active?(environment)
|
|
36
|
+
return false if token.to_s.empty? || endpoint.to_s.empty?
|
|
37
|
+
return enabled unless enabled.nil?
|
|
38
|
+
|
|
39
|
+
environments.include?(environment.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
def parse_boolean(value)
|
|
44
|
+
return nil if value.nil? || value.strip.empty?
|
|
45
|
+
|
|
46
|
+
TRUE_VALUES.include?(value.strip.downcase)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|