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.
@@ -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