deployangel 0.1.14 → 0.1.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff074e43a43cb39394f9c21d99c1c004c55f68ac7c5a54467acc49b4f0ce1753
4
- data.tar.gz: 57f860c0f680e70f46f12756f3f638307cb13ec6aa4131e5ebaac0e1cfe16494
3
+ metadata.gz: 6080cb323e886841312b4bab49edd0962e2c5a4379c3f23d6dc7eb738944104d
4
+ data.tar.gz: 36c6a15cfa2c70c3c92e58a500537bed8da5a19291af4852d530e857f21181fa
5
5
  SHA512:
6
- metadata.gz: 10d7c45fac648fedae41d360d845b3f874be6026bd331e77d063eaf65a036b4f0ba616a0c74df6e18ede073df87789fee71ac84910902d8456306c2ccb3bb0af
7
- data.tar.gz: b68c68f332fe45d48be53f3d47e0b5c8bd41b012adf9deb6fc654e4ba9489ed20c3b4b790f6a860723f962ea8774b11082f0a1ce4f0c2f74bb9379b55d0937bf
6
+ metadata.gz: c586644c984d299e424cc0b3042ac5a474c3271de24ca4ee5f0f89103fb574c0253bc362efa7d0b1d8b212b83ef4aa5a2fcfafb2f3c77477282d2a8f52b284e7
7
+ data.tar.gz: ed91b7685224b5e613d3cc7de6cf28b36096563d5195e7e4b41069dcd691d6bb7ce49369a8f77fc5c519197bfe1984a4b0f9982520c7a6cb71efe094a69b0150
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16 (2026-10-08)
4
+
5
+ - The agent finds the release in a git checkout when nothing earlier names
6
+ it: the commit `HEAD` names, read from `.git` in the app root or up to 3
7
+ directories above it (worktrees and submodules included), without running
8
+ `git` (source `git_head`). It comes after a `REVISION` file and before ECS.
9
+ - When nothing else identifies the release, the agent reports a fingerprint
10
+ of the app's code as the version (`code:` and 12 hex characters, source
11
+ `code_fingerprint`): the hash of the file digests it already computes. It's
12
+ worked out in the reporter thread as soon as it starts, never at boot or
13
+ in a request, and needs file digests on. A warning suggests setting
14
+ `DEPLOYANGEL_REVISION`: without a commit, DeployAngel shows which files
15
+ changed but not the commits and pull requests.
16
+ - `deployangel install docker` adds `ARG GIT_SHA` and
17
+ `ENV DEPLOYANGEL_REVISION=$GIT_SHA` at the end of the Dockerfile's last
18
+ stage, before its `CMD`, and says which `--build-arg` to pass. It never
19
+ edits CI workflows.
20
+
21
+ ## 0.1.15 (2026-10-06)
22
+
23
+ - Checkpoints report whether they were recorded in an HTTP request or a job
24
+ (`http` and `job` counts on each checkpoint), so DeployAngel compares a
25
+ checkpoint recorded in jobs against job traffic rather than requests. A job
26
+ performed inline during a request counts as a job. Needs a DeployAngel
27
+ server that reads the new fields; older servers ignore them.
28
+
3
29
  ## 0.1.14 (2026-10-06)
4
30
 
5
31
  - `deployangel plan` separates what clearance waits on ("Needed to clear")
data/README.md CHANGED
@@ -36,26 +36,46 @@ The agent must know which release it is running. It finds it in this order:
36
36
  didn't come from GitHub
37
37
  7. Coolify: `SOURCE_COMMIT`. Dokku: `GIT_REV`
38
38
  8. a `REVISION` file in the app root, which Capistrano writes and any build step can
39
- 9. ECS, including Fargate: the container's image, from the metadata endpoint ECS
40
- provides (one local request at boot). The image tag is the release when it's a
41
- commit or a version like `v1.4.2`; with a moving tag like `latest`, the image
42
- digest is
39
+ 9. a git checkout, for servers deployed by `git pull`, Fabric, or Ansible: the commit
40
+ `HEAD` names, read from `.git` in the app root or up to 3 directories above it
41
+ (worktrees and submodules included). The agent reads the files; it never runs `git`
42
+ 10. ECS, including Fargate: the container's image, from the metadata endpoint ECS
43
+ provides (one local request at boot). The image tag is the release when it's a
44
+ commit or a version like `v1.4.2`; with a moving tag like `latest`, the image
45
+ digest is
46
+ 11. a fingerprint of the app's code, reported as `code:` and 12 hex characters: the
47
+ hash of the [file digests](#what-it-sends) the agent already computes, so the
48
+ same code is the same release. It needs file digests on, and is worked out in
49
+ the background when the reporter starts, not at boot. Without a commit, the
50
+ dashboard shows which files changed but not the commits and pull requests, so
51
+ the agent logs a warning suggesting `DEPLOYANGEL_REVISION`
43
52
 
44
53
  For other Docker deploys (compose, Swarm, Kubernetes), and on ECS to see each
45
54
  release by its commit, bake the commit into the image, since `.dockerignore`
46
55
  usually leaves `.git` out:
47
56
 
57
+ ```bash
58
+ bundle exec deployangel install docker
59
+ ```
60
+
61
+ adds this to the end of the Dockerfile's last stage, before its `CMD` (so the
62
+ layers above it stay cached):
63
+
48
64
  ```dockerfile
49
65
  ARG GIT_SHA
50
66
  ENV DEPLOYANGEL_REVISION=$GIT_SHA
51
67
  ```
52
68
 
53
- and build with `docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .`. On
54
- DigitalOcean App Platform, set `DEPLOYANGEL_REVISION: ${_self.COMMIT_HASH}` in
55
- the app spec. On Elastic Beanstalk, see
56
- [Deploying to Elastic Beanstalk](#deploying-to-elastic-beanstalk). If the agent
57
- finds none of these, its telemetry can't be tied to a deploy, and the dashboard
58
- says so.
69
+ Then build with `docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .`
70
+ (`fly deploy --build-arg GIT_SHA=$(git rev-parse HEAD)` on Fly.io, or
71
+ `build-args: GIT_SHA=${{ github.sha }}` with GitHub's docker/build-push-action).
72
+ It changes nothing if the Dockerfile already sets `DEPLOYANGEL_REVISION`, and
73
+ never edits CI workflows. A build without the argument leaves the variable
74
+ empty, which counts as unset. On DigitalOcean App Platform, set
75
+ `DEPLOYANGEL_REVISION: ${_self.COMMIT_HASH}` in the app spec. On Elastic
76
+ Beanstalk, see [Deploying to Elastic Beanstalk](#deploying-to-elastic-beanstalk).
77
+ If the agent finds none of these, its telemetry can't be tied to a deploy, and
78
+ the dashboard says so.
59
79
 
60
80
  ## What it sends
61
81
 
@@ -207,6 +227,10 @@ and job still succeeds. Only drops are flagged, and a checkpoint without enough
207
227
  traffic never blocks a release from being cleared. Checkpoints can also be part
208
228
  of a critical flow (`checkpoint:order.created`).
209
229
 
230
+ The agent also records whether each checkpoint was counted during an HTTP
231
+ request or a background job, so DeployAngel compares it against the right
232
+ traffic: a checkpoint counted in jobs is judged against jobs, not requests.
233
+
210
234
  It's safe to call anywhere: it never raises, never touches the network, and is
211
235
  ignored outside reporting environments. Names use letters, numbers, and
212
236
  `. _ : -` (up to 100 characters); keep them to a fixed set rather than
@@ -294,6 +318,9 @@ It writes `.kamal/hooks/post-deploy`, which runs `deployangel release` after eac
294
318
  to add). Set `DEPLOYANGEL_API_TOKEN` (a CI deploys token) wherever you run
295
319
  `kamal deploy`. The hook never fails a deploy.
296
320
 
321
+ Kamal apps don't need `deployangel install docker`: `KAMAL_VERSION` already
322
+ identifies the release.
323
+
297
324
  ### Deploying with Capistrano
298
325
 
299
326
  Capistrano writes a `REVISION` file into each release, so the agent already
@@ -16,7 +16,7 @@ module DeployAngel
16
16
  # later still lands in time.
17
17
  FLUSH_JITTER = 1.0..50.0
18
18
 
19
- attr_reader :config, :release, :instance
19
+ attr_reader :config, :instance
20
20
  attr_writer :metadata
21
21
 
22
22
  def initialize(config:, environment:, root: nil, framework: nil, framework_version: nil,
@@ -24,6 +24,11 @@ module DeployAngel
24
24
  @config = config
25
25
  @active = config.active?(environment)
26
26
  @release = Release.resolve(config: config, env: env, root: root)
27
+ # Nothing named the release, so the code fingerprint may. It hashes the
28
+ # app's files, so the reporter computes it before its first send (see
29
+ # #release), never at boot or in a request.
30
+ @fingerprint_pending = @release.unknown?
31
+ @release_mutex = Mutex.new
27
32
  @runtime = Protocol.runtime(framework: framework, framework_version: framework_version)
28
33
  @transport = transport || Transport.new(config)
29
34
  @env = env
@@ -33,8 +38,6 @@ module DeployAngel
33
38
  @thread_mutex = Mutex.new
34
39
  @warned = {}
35
40
  reset_process_state
36
- warn_once(:unknown_release, "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION " \
37
- "or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.") if @active && @release.unknown?
38
41
  start_reporter if @active && eager
39
42
  end
40
43
 
@@ -42,6 +45,25 @@ module DeployAngel
42
45
  @active
43
46
  end
44
47
 
48
+ # The release this process is running. The first call may build the file
49
+ # digests for the code fingerprint, so only the reporter thread and
50
+ # shutdown call it; requests and jobs never read the release.
51
+ def release
52
+ return @release unless @fingerprint_pending
53
+
54
+ @release_mutex.synchronize do
55
+ if @fingerprint_pending
56
+ @release = Release.code_fingerprint(@metadata&.file_manifest) || @release
57
+ @fingerprint_pending = false
58
+ warn_about_release
59
+ end
60
+ end
61
+ @release
62
+ rescue StandardError
63
+ @fingerprint_pending = false
64
+ @release
65
+ end
66
+
45
67
  def record_request(route_key:, status:, duration_ms:, unhandled: false, in_totals: true)
46
68
  return unless @active
47
69
 
@@ -71,12 +93,14 @@ module DeployAngel
71
93
  warn_once(:record_discard, "DeployAngel failed to record a discarded job: #{e.class}: #{e.message}")
72
94
  end
73
95
 
74
- def record_checkpoint(name, count: 1)
96
+ # context defaults to the unit of work this fiber is running: an HTTP
97
+ # request, a job, or neither.
98
+ def record_checkpoint(name, count: 1, context: ExecutionContext.current)
75
99
  return unless @active
76
100
 
77
101
  after_fork! if Process.pid != @pid
78
102
  start_reporter
79
- @aggregator.record_checkpoint(name: name, count: count)
103
+ @aggregator.record_checkpoint(name: name, count: count, context: context)
80
104
  rescue StandardError => e
81
105
  warn_once(:record_checkpoint, "DeployAngel failed to record a checkpoint: #{e.class}: #{e.message}")
82
106
  end
@@ -111,7 +135,7 @@ module DeployAngel
111
135
  return 0 unless @active
112
136
 
113
137
  @aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads).each do |period|
114
- @buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release: @release,
138
+ @buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release: release,
115
139
  runtime: @runtime, capabilities: capabilities)))
116
140
  end
117
141
  send_buffered
@@ -160,7 +184,7 @@ module DeployAngel
160
184
  return if @metadata_sent || @metadata.nil? || paused?
161
185
 
162
186
  base = { "protocol_version" => Protocol::VERSION, "instance" => @instance.to_protocol,
163
- "release" => @release.to_protocol, "runtime" => @runtime }.merge(@metadata.to_protocol)
187
+ "release" => release.to_protocol, "runtime" => @runtime }.merge(@metadata.to_protocol)
164
188
  result = @transport.post(METADATA_PATH, base)
165
189
  return unless result.ok?
166
190
 
@@ -185,6 +209,10 @@ module DeployAngel
185
209
  end
186
210
 
187
211
  def run_reporter
212
+ # Off the request path, so a process that exits within a minute or two
213
+ # doesn't build the fingerprint at shutdown instead. Only once the
214
+ # framework has attached the metadata it's built from.
215
+ release if @metadata
188
216
  until @stopping
189
217
  sleep(seconds_until_next_flush)
190
218
  break if @stopping
@@ -240,6 +268,18 @@ module DeployAngel
240
268
  @paused_until && @clock.call < @paused_until
241
269
  end
242
270
 
271
+ def warn_about_release
272
+ return unless @active
273
+
274
+ if @release.unknown?
275
+ warn_once(:unknown_release, "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION " \
276
+ "or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.")
277
+ elsif @release.source == "code_fingerprint"
278
+ warn_once(:code_fingerprint, "DeployAngel identifies releases by a fingerprint of the app's code. " \
279
+ "Set DEPLOYANGEL_REVISION to the deployed commit to see each release's commits and pull requests.")
280
+ end
281
+ end
282
+
243
283
  def warn_once(key, message)
244
284
  return if @warned[key]
245
285
 
@@ -33,7 +33,8 @@ module DeployAngel
33
33
  exception Details for a fingerprint deployangel exception FINGERPRINT
34
34
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
35
35
  [--commit=SHA | --deployment=ID] [--details-url=URL]
36
- install Add a deploy hook deployangel install kamal
36
+ install Add a Kamal deploy hook, or deployangel install kamal
37
+ bake the commit into an image deployangel install docker
37
38
  mcp Run the MCP server over stdio (for coding agents)
38
39
 
39
40
  With no target, verify, plan, and check use the current git HEAD commit.
@@ -57,6 +58,25 @@ module DeployAngel
57
58
  bundle exec deployangel release || true
58
59
  SH
59
60
 
61
+ # Goes at the end of the Dockerfile's last stage: a value that changes
62
+ # with every commit invalidates the cache of every layer after it.
63
+ DOCKER_BUILD_HELP = <<~HELP.chomp
64
+ Pass the commit when you build the image:
65
+
66
+ docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .
67
+ fly deploy --build-arg GIT_SHA=$(git rev-parse HEAD)
68
+ GitHub Actions (docker/build-push-action):
69
+ build-args: GIT_SHA=${{ github.sha }}
70
+
71
+ Kamal apps don't need this: DeployAngel reads Kamal's KAMAL_VERSION.
72
+ HELP
73
+
74
+ DOCKERFILE_LINES = <<~DOCKERFILE
75
+ # The commit this image runs, for DeployAngel. Build with --build-arg GIT_SHA=$(git rev-parse HEAD).
76
+ ARG GIT_SHA
77
+ ENV DEPLOYANGEL_REVISION=$GIT_SHA
78
+ DOCKERFILE
79
+
60
80
  def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
61
81
  sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
62
82
  @root = root
@@ -206,12 +226,17 @@ module DeployAngel
206
226
  0
207
227
  end
208
228
 
209
- # Writes .kamal/hooks/post-deploy, or says what to add to a hook that
210
- # already exists, rather than overwriting it.
211
229
  def install
212
- target = @argv.shift
213
- return usage_error("install needs a target: deployangel install kamal") unless target == "kamal"
230
+ case (target = @argv.shift)
231
+ when "kamal" then install_kamal
232
+ when "docker" then install_docker
233
+ else usage_error("install needs a target: deployangel install kamal, or deployangel install docker")
234
+ end
235
+ end
214
236
 
237
+ # Writes .kamal/hooks/post-deploy, or says what to add to a hook that
238
+ # already exists, rather than overwriting it.
239
+ def install_kamal
215
240
  relative = File.join(".kamal", "hooks", "post-deploy")
216
241
  path = File.join(@root, relative)
217
242
  if File.exist?(path)
@@ -231,6 +256,55 @@ module DeployAngel
231
256
  0
232
257
  end
233
258
 
259
+ # Adds DEPLOYANGEL_REVISION, from a GIT_SHA build arg, to the Dockerfile.
260
+ # It never edits CI workflows; it says what to pass instead.
261
+ def install_docker
262
+ path = File.join(@root, "Dockerfile")
263
+ unless File.file?(path)
264
+ @stderr.puts("deployangel: no Dockerfile in #{@root}; run this where your Dockerfile is")
265
+ return USAGE_ERROR
266
+ end
267
+
268
+ content = File.read(path)
269
+ if content.include?("DEPLOYANGEL_REVISION")
270
+ @stdout.puts("Dockerfile already sets DEPLOYANGEL_REVISION.")
271
+ return 0
272
+ end
273
+
274
+ File.write(path, with_revision(content))
275
+ @stdout.puts("Added DEPLOYANGEL_REVISION to the Dockerfile's last stage. #{DOCKER_BUILD_HELP}")
276
+ 0
277
+ end
278
+
279
+ # Before the CMD and ENTRYPOINT lines that end the last stage, with any
280
+ # comment just above them, or at the end if the stage doesn't end with
281
+ # one. Continuation lines aren't instructions.
282
+ def with_revision(content)
283
+ lines = content.lines
284
+ instructions = []
285
+ continuing = false
286
+ lines.each_with_index do |line, index|
287
+ text = line.strip
288
+ next if text.empty? || text.start_with?("#")
289
+
290
+ instructions << [ index, text[/\A\w+/].to_s.upcase ] unless continuing
291
+ continuing = text.end_with?("\\")
292
+ end
293
+
294
+ stage = instructions.drop(instructions.rindex { |_, word| word == "FROM" } || 0)
295
+ trailing = stage.reverse.take_while { |_, word| %w[CMD ENTRYPOINT].include?(word) }
296
+ if trailing.empty?
297
+ lines[-1] = "#{lines[-1].chomp}\n" if lines.any?
298
+ lines << "\n" unless lines.empty? || lines[-1].strip.empty?
299
+ lines << DOCKERFILE_LINES
300
+ else
301
+ at = trailing.last.first
302
+ at -= 1 while at.positive? && lines[at - 1].strip.start_with?("#")
303
+ lines.insert(at, DOCKERFILE_LINES)
304
+ end
305
+ lines.join
306
+ end
307
+
234
308
  def mcp
235
309
  require_relative "mcp"
236
310
  MCP::Server.new(client: client, input: @stdin, output: @stdout, error_output: @stderr,
@@ -37,6 +37,20 @@ module DeployAngel
37
37
  end
38
38
  end
39
39
 
40
+ # count: every recording; http and job: those recorded while handling an
41
+ # HTTP request or running a job (see ExecutionContext). The rest were
42
+ # recorded outside either.
43
+ CheckpointStats = Struct.new(:count, :http, :job) do
44
+ def record(count, context)
45
+ self.count += count
46
+ if context == ExecutionContext::HTTP
47
+ self.http += count
48
+ elsif context == ExecutionContext::JOB
49
+ self.job += count
50
+ end
51
+ end
52
+ end
53
+
40
54
  class Period
41
55
  attr_reader :started_at, :requests, :status_counts, :unhandled_exceptions, :histogram, :routes,
42
56
  :jobs, :job_classes, :exceptions, :exceptions_truncated, :checkpoints
@@ -52,13 +66,13 @@ module DeployAngel
52
66
  @job_classes = {}
53
67
  @exceptions = {}
54
68
  @exceptions_truncated = 0
55
- @checkpoints = Hash.new(0)
69
+ @checkpoints = {}
56
70
  end
57
71
 
58
72
  # Up to 100 names per period; the rest are counted together.
59
- def record_checkpoint(name, count)
73
+ def record_checkpoint(name, count, context)
60
74
  key = @checkpoints.key?(name) || @checkpoints.size < MAX_CHECKPOINTS - 1 ? name : OTHER_ROUTE
61
- @checkpoints[key] += count
75
+ (@checkpoints[key] ||= CheckpointStats.new(0, 0, 0)).record(count, context)
62
76
  end
63
77
 
64
78
  # Up to 20 fingerprints per period; the rest are only counted.
@@ -141,9 +155,11 @@ module DeployAngel
141
155
  @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_discard(job_class.to_s, @max_routes) }
142
156
  end
143
157
 
144
- def record_checkpoint(name:, count: 1)
158
+ # context: where it was recorded, ExecutionContext::HTTP, ExecutionContext::JOB,
159
+ # or nil for neither.
160
+ def record_checkpoint(name:, count: 1, context: nil)
145
161
  started_at = period_start(@clock.call)
146
- @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count) }
162
+ @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count, context) }
147
163
  end
148
164
 
149
165
  # A representative backtrace is kept only the first time this process
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # The unit of work the current fiber is running: HTTP while the request
5
+ # middleware handles a request, JOB while job instrumentation runs a job,
6
+ # and nil otherwise (console, rake tasks, boot, a thread of the app's own).
7
+ # Checkpoints read it to say where they were recorded.
8
+ #
9
+ # Thread.current[] is fiber-local, like Rails' isolated execution state, so
10
+ # requests served concurrently in threads (Puma) or fibers (Falcon) each
11
+ # see their own. Every integration restores the previous value when its
12
+ # unit ends, even when it raises, so the innermost unit wins when they nest
13
+ # (a job performed inline during a request), and nothing leaks into the
14
+ # next request a reused thread serves.
15
+ module ExecutionContext
16
+ KEY = :__deployangel_execution_context
17
+ HTTP = :http
18
+ JOB = :job
19
+
20
+ module_function
21
+
22
+ def current
23
+ Thread.current[KEY]
24
+ end
25
+
26
+ # Returns the previous context, to hand back to restore.
27
+ def enter(kind)
28
+ previous = Thread.current[KEY]
29
+ Thread.current[KEY] = kind
30
+ previous
31
+ end
32
+
33
+ def restore(previous)
34
+ Thread.current[KEY] = previous
35
+ end
36
+ end
37
+ end
@@ -34,7 +34,11 @@ module DeployAngel
34
34
  "exceptions_truncated" => period.exceptions_truncated,
35
35
  "jobs" => job_stats(period.jobs),
36
36
  "job_classes" => period.job_classes.map { |key, stats| { "key" => key }.merge(job_stats(stats)) },
37
- "checkpoints" => period.checkpoints.map { |key, count| { "key" => key, "count" => count } }
37
+ # http and job are always sent, 0 when none, so the cloud can tell
38
+ # this agent from one that doesn't say where checkpoints happened.
39
+ "checkpoints" => period.checkpoints.map do |key, stats|
40
+ { "key" => key, "count" => stats.count, "http" => stats.http, "job" => stats.job }
41
+ end
38
42
  }
39
43
  end
40
44
 
@@ -13,9 +13,15 @@ module DeployAngel
13
13
  # Tags that move from build to build, so they can't identify a release.
14
14
  MOVING_TAGS = %w[latest main master production prod staging stable release].freeze
15
15
 
16
+ # Look for .git this many directories above the app's root, for an app
17
+ # that lives in a subdirectory of its repository.
18
+ GIT_PARENT_LEVELS = 3
19
+
16
20
  # Order: explicit configuration, Heroku dyno metadata, the hosting
17
21
  # platform's own variables (Kamal, Render, Fly.io, Railway, Coolify, and
18
- # Dokku's GIT_REV), a REVISION file, then ECS container metadata.
22
+ # Dokku's GIT_REV), a REVISION file, a git checkout, then ECS container
23
+ # metadata. When none of them names the release, the agent falls back to
24
+ # a fingerprint of the app's code (code_fingerprint), computed later.
19
25
  def self.resolve(config:, env: ENV, root: nil, http: method(:fetch_metadata))
20
26
  if present?(config.release_version) || present?(config.revision)
21
27
  build(config.release_version, config.revision, "config")
@@ -36,6 +42,8 @@ module DeployAngel
36
42
  build(nil, env["GIT_REV"], "git_rev")
37
43
  elsif root && File.file?(revision_path = File.join(root, "REVISION"))
38
44
  build(nil, File.read(revision_path, 100), "revision_file")
45
+ elsif root && (commit = git_head(root))
46
+ build(nil, commit, "git_head")
39
47
  elsif present?(env["ECS_CONTAINER_METADATA_URI_V4"]) && (release = ecs(http.call(env["ECS_CONTAINER_METADATA_URI_V4"])))
40
48
  release
41
49
  else
@@ -73,6 +81,77 @@ module DeployAngel
73
81
  tag unless tag.empty?
74
82
  end
75
83
 
84
+ # A server deployed by `git pull` (or Fabric, or Ansible) runs from a
85
+ # checkout, where HEAD names the commit. Read from the repository's
86
+ # files, never by running git. Nil if anything is missing or unreadable.
87
+ def self.git_head(root)
88
+ git_dir = find_git_dir(File.expand_path(root)) or return
89
+ common_dir = git_dir
90
+ commondir = File.join(git_dir, "commondir")
91
+ common_dir = File.expand_path(File.read(commondir, 1024).strip, git_dir) if File.file?(commondir)
92
+
93
+ head = File.read(File.join(git_dir, "HEAD"), 200).to_s.strip
94
+ ref = head.delete_prefix("ref:").strip if head.start_with?("ref:")
95
+ commit = ref ? read_ref(ref, git_dir, common_dir) : head
96
+ commit = commit.to_s.strip.downcase
97
+ commit if commit.match?(COMMIT_FORMAT)
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ # .git is a directory, or for a worktree or submodule, a file naming the
103
+ # real one ("gitdir: ../.git/worktrees/shop").
104
+ def self.find_git_dir(dir)
105
+ (GIT_PARENT_LEVELS + 1).times do
106
+ git = File.join(dir, ".git")
107
+ return File.directory?(git) ? git : linked_git_dir(git) if File.exist?(git)
108
+
109
+ parent = File.dirname(dir)
110
+ return if parent == dir
111
+
112
+ dir = parent
113
+ end
114
+ nil
115
+ end
116
+
117
+ def self.linked_git_dir(file)
118
+ path = File.read(file, 1024).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
119
+ File.expand_path(path, File.dirname(file)) if present?(path)
120
+ end
121
+
122
+ # A branch's commit is in its ref file, in the worktree's own git dir or
123
+ # the common one, or else in packed-refs. Refs come from a file on disk,
124
+ # so anything outside refs/ is ignored rather than read.
125
+ def self.read_ref(ref, git_dir, common_dir)
126
+ return unless ref.start_with?("refs/") && !ref.include?("..")
127
+
128
+ [ git_dir, common_dir ].uniq.each do |dir|
129
+ path = File.join(dir, ref)
130
+ return File.read(path, 100) if File.file?(path)
131
+ end
132
+
133
+ packed = File.join(common_dir, "packed-refs")
134
+ return unless File.file?(packed)
135
+
136
+ File.foreach(packed) do |line|
137
+ next if line.start_with?("#", "^")
138
+
139
+ commit, name = line.split(" ", 2)
140
+ return commit if name.to_s.strip == ref
141
+ end
142
+ nil
143
+ end
144
+
145
+ # The release of an app that names it no other way: the hash of the file
146
+ # digests the agent already sends (Metadata#file_manifest), so the same
147
+ # code gives the same release. None when digests are off or truncated.
148
+ def self.code_fingerprint(manifest)
149
+ return unless manifest.is_a?(Hash) && !manifest["truncated"] && manifest["count"].to_i.positive?
150
+
151
+ hash = manifest["hash"].to_s
152
+ build("code:#{hash[0, 12]}", nil, "code_fingerprint") if hash.match?(/\A\h{12}/)
153
+ end
154
+
76
155
  # ECS (Fargate, and EC2 with a recent agent) serves each container's
77
156
  # metadata at ECS_CONTAINER_METADATA_URI_V4. The image tag identifies the
78
157
  # release, and is the commit when it looks like one. A moving tag such
@@ -167,6 +167,8 @@ module DeployAngel
167
167
  end
168
168
  end
169
169
 
170
+ # Built once per process, by the reporter: the metadata send and the
171
+ # code fingerprint (Release.code_fingerprint) both read it.
170
172
  def file_manifest
171
173
  @file_manifest ||= begin
172
174
  if @config.file_digests
@@ -180,9 +182,9 @@ module DeployAngel
180
182
  else
181
183
  { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
182
184
  end
185
+ rescue StandardError
186
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
183
187
  end
184
- rescue StandardError
185
- { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
186
188
  end
187
189
  end
188
190
  end
@@ -31,11 +31,16 @@ module DeployAngel
31
31
 
32
32
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  Redaction.request_host = env["HTTP_HOST"]
34
+ # Checkpoints recorded from here until the app returns count as
35
+ # recorded in a request.
36
+ previous_context = ExecutionContext.enter(ExecutionContext::HTTP)
34
37
  begin
35
38
  status, headers, body = @app.call(env)
36
39
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
37
40
  record(env, 500, started, unhandled: true, exception: e)
38
41
  raise
42
+ ensure
43
+ ExecutionContext.restore(previous_context)
39
44
  end
40
45
  # A framework that renders an exception itself swallows it before it
41
46
  # reaches this middleware, and renders some as 4xx; only those that
@@ -11,6 +11,34 @@ module DeployAngel
11
11
  # perform finishes.
12
12
  module ActiveJob
13
13
  STATE = :@__deployangel_job_state
14
+ PREVIOUS_CONTEXT = :@__deployangel_previous_context
15
+
16
+ # Marks the fiber as running a job for as long as perform.active_job
17
+ # lasts, so checkpoints recorded by the job say so. ActiveSupport calls
18
+ # finish from an ensure, so the previous context (an HTTP request, for
19
+ # a job performed inline) comes back even when the job raises. It is
20
+ # kept on the job, so only a perform that started here restores.
21
+ module ContextListener
22
+ module_function
23
+
24
+ def start(_name, _id, payload)
25
+ job = payload[:job]
26
+ return unless job && DeployAngel.recording?
27
+
28
+ job.instance_variable_set(PREVIOUS_CONTEXT, ExecutionContext.enter(ExecutionContext::JOB))
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def finish(_name, _id, payload)
34
+ job = payload[:job]
35
+ return unless job&.instance_variable_defined?(PREVIOUS_CONTEXT)
36
+
37
+ ExecutionContext.restore(job.remove_instance_variable(PREVIOUS_CONTEXT))
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
14
42
 
15
43
  module_function
16
44
 
@@ -23,6 +51,7 @@ module DeployAngel
23
51
  subscribe("enqueue_retry.active_job") { |payload| state(payload).merge!(failed: true, error: payload[:error]) }
24
52
  subscribe("discard.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
25
53
  subscribe("retry_stopped.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
54
+ ::ActiveSupport::Notifications.subscribe("perform.active_job", ContextListener)
26
55
  ::ActiveSupport::Notifications.subscribe("perform.active_job") { |event| record(event) }
27
56
  end
28
57
 
@@ -16,12 +16,15 @@ module DeployAngel
16
16
 
17
17
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
18
  latency = DeployAngel::Sidekiq.queue_latency_ms(job)
19
+ previous_context = ExecutionContext.enter(ExecutionContext::JOB)
19
20
  begin
20
21
  yield
21
22
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
22
23
  DeployAngel.record_exception(e, source: "job_class:#{job["class"]}")
23
24
  record(job, started, latency, failed: true)
24
25
  raise
26
+ ensure
27
+ ExecutionContext.restore(previous_context)
25
28
  end
26
29
  record(job, started, latency, failed: false)
27
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "deployangel/core/release"
9
9
  require_relative "deployangel/core/instance"
10
10
  require_relative "deployangel/core/aggregator"
11
11
  require_relative "deployangel/core/protocol"
12
+ require_relative "deployangel/core/execution_context"
12
13
  require_relative "deployangel/core/buffer"
13
14
  require_relative "deployangel/core/transport"
14
15
  require_relative "deployangel/agent"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.14
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -38,6 +38,7 @@ files:
38
38
  - lib/deployangel/configuration.rb
39
39
  - lib/deployangel/core/aggregator.rb
40
40
  - lib/deployangel/core/buffer.rb
41
+ - lib/deployangel/core/execution_context.rb
41
42
  - lib/deployangel/core/fingerprint.rb
42
43
  - lib/deployangel/core/histogram.rb
43
44
  - lib/deployangel/core/instance.rb