-
Notifications
You must be signed in to change notification settings - Fork 145
/
stack.rb
710 lines (572 loc) · 19.2 KB
/
stack.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# frozen_string_literal: true
require 'fileutils'
module Shipit
class Stack < Record
module NoDeployedCommit
extend self
def id
-1
end
def sha
''
end
def short_sha
''
end
def blank?
true
end
end
ENVIRONMENT_MAX_SIZE = 50
REQUIRED_HOOKS = %i(push status).freeze
has_many :commits, dependent: :destroy
has_many :merge_requests, dependent: :destroy
has_many :tasks, dependent: :destroy
has_many :deploys
has_many :rollbacks
has_many :deploys_and_rollbacks,
-> { where(type: %w(Shipit::Deploy Shipit::Rollback)) },
class_name: 'Task',
inverse_of: :stack
has_many :github_hooks, dependent: :destroy, class_name: 'Shipit::GithubHook::Repo'
has_many :hooks, dependent: :destroy
has_many :api_clients, dependent: :destroy
has_one :continuous_delivery_schedule, dependent: :destroy
belongs_to :lock_author, class_name: :User, optional: true
belongs_to :repository
validates_associated :repository
scope :not_archived, -> { where(archived_since: nil) }
include DeferredTouch
deferred_touch repository: :updated_at
default_scope { preload(:repository) }
def env
{
'ENVIRONMENT' => environment,
'LAST_DEPLOYED_SHA' => last_deployed_commit.sha,
'GITHUB_REPO_OWNER' => repository.owner,
'GITHUB_REPO_NAME' => repository.name,
'DEPLOY_URL' => deploy_url,
'BRANCH' => branch,
}
end
def repository
super || build_repository
end
def lock_author(*)
super || AnonymousUser.new
end
def lock_author=(user)
super(user&.logged_in? ? user : nil)
end
before_validation :update_defaults
before_destroy :clear_local_files
before_save :set_locked_since
after_commit :emit_lock_hooks
after_commit :emit_added_hooks, on: :create
after_commit :emit_updated_hooks, on: :update
after_commit :emit_removed_hooks, on: :destroy
after_commit :broadcast_update, on: :update
after_commit :emit_merge_status_hooks, on: :update
after_commit :sync_github, on: :create
after_commit :schedule_merges_if_necessary, on: :update
after_commit :sync_github_if_necessary, on: :update
def sync_github_if_necessary
if (archived_since_previously_changed? && archived_since.nil?) || branch_previously_changed?
sync_github
end
end
validates :repository, uniqueness: {
scope: %i(environment), case_sensitive: false,
message: 'cannot be used more than once with this environment. Check archived stacks.',
}
validates :environment, format: { with: /\A[a-z0-9\-_\:]+\z/ }, length: { maximum: ENVIRONMENT_MAX_SIZE }
validates :deploy_url, format: { with: URI.regexp(%w(http https ssh)) }, allow_blank: true
validates :branch, presence: true
validates :lock_reason, length: { maximum: 4096 }
serialize :cached_deploy_spec, coder: DeploySpec
delegate(
:provisioning_handler_name,
:find_task_definition,
:release_status?,
:release_status_context,
:release_status_delay,
:supports_fetch_deployed_revision?,
:supports_rollback?,
to: :cached_deploy_spec,
allow_nil: true
)
def self.refresh_deployed_revisions
find_each.select(&:supports_fetch_deployed_revision?).each(&:async_refresh_deployed_revision)
end
def self.schedule_continuous_delivery
where(continuous_deployment: true).find_each do |stack|
ContinuousDeliveryJob.perform_later(stack)
end
end
def undeployed_commits?
undeployed_commits_count > 0
end
def trigger_task(definition_id, user, env: nil, force: false)
definition = find_task_definition(definition_id)
env = env&.to_h || {}
definition.variables_with_defaults.each do |variable|
env[variable.name] ||= variable.default
end
commit = last_deployed_commit.presence || commits.first
task = tasks.create(
user_id: user.id,
definition: definition,
until_commit_id: commit.id,
since_commit_id: commit.id,
env: definition.filter_envs(env),
allow_concurrency: definition.allow_concurrency? || force,
ignored_safeties: force,
)
task.enqueue
task
end
def build_deploy(until_commit, user, env: nil, force: false, allow_concurrency: force)
since_commit = last_deployed_commit.presence || commits.first
deploys.build(
user_id: user.id,
until_commit: until_commit,
since_commit: since_commit,
env: filter_deploy_envs(env&.to_h || {}),
allow_concurrency: allow_concurrency,
ignored_safeties: force || !until_commit.deployable?,
max_retries: retries_on_deploy,
)
end
def trigger_deploy(*args, **kwargs)
if changed?
# If this is the first deploy since the spec changed it's possible the record will be dirty here, meaning we
# cant lock. In this one case persist the changes, otherwise log a warning and let the lock raise, so we
# can debug what's going on here. We don't expect anything other than the deploy spec to dirty the model
# instance, because of how that field is serialised.
if changes.keys == ['cached_deploy_spec']
save!
else
Rails.logger.warning("#{changes.keys} field(s) were unexpectedly modified on stack #{id} while deploying")
end
end
run_now = kwargs.delete(:run_now)
deploy = with_lock do
deploy = build_deploy(*args, **kwargs)
deploy.save!
deploy
end
run_now ? deploy.run_now! : deploy.enqueue
continuous_delivery_resumed!
deploy
end
def continuous_delivery_resumed!
update!(continuous_delivery_delayed_since: nil)
end
def continuous_delivery_delayed?
continuous_delivery_delayed_since? && continuous_deployment? && (checks? || deployment_checks?)
end
def continuous_delivery_delayed!
touch(:continuous_delivery_delayed_since) unless continuous_delivery_delayed?
end
def trigger_continuous_delivery
return if cached_deploy_spec.blank?
commit = next_commit_to_deploy
if should_resume_continuous_delivery?(commit)
continuous_delivery_resumed!
return
end
if should_delay_continuous_delivery?(commit)
continuous_delivery_delayed!
return
end
begin
trigger_deploy(commit, Shipit.user, env: cached_deploy_spec.default_deploy_env)
rescue Task::ConcurrentTaskRunning
end
end
def schedule_merges
ProcessMergeRequestsJob.perform_later(self)
end
def next_commit_to_deploy
commits_to_deploy = commits.order(id: :asc).newer_than(last_deployed_commit).reachable.preload(:statuses)
if maximum_commits_per_deploy
commits_with_max_applied = commits_to_deploy.limit(maximum_commits_per_deploy)
deployable_commits(commits_with_max_applied) || deployable_commits(commits_to_deploy)
else
deployable_commits(commits_to_deploy)
end
end
def deployed_too_recently?
if task = last_active_task
return true if task.validating?
task.ended_at? && (task.ended_at + pause_between_deploys).future?
end
end
def async_refresh_deployed_revision
async_refresh_deployed_revision!
rescue => error
logger.warn("Failed to dispatch FetchDeployedRevisionJob: [#{error.class.name}] #{error.message}")
end
def async_refresh_deployed_revision!
FetchDeployedRevisionJob.perform_later(self)
end
def update_deployed_revision(sha)
last_deploy = deploys_and_rollbacks.last
return if last_deploy&.active?
actual_deployed_commit = commits.reachable.by_sha(sha)
return unless actual_deployed_commit
if last_deploy && actual_deployed_commit == last_deploy.until_commit
last_deploy.accept!
elsif last_deploy && actual_deployed_commit == last_deploy.since_commit
last_deploy.reject!
else
deploys.create!(
until_commit: actual_deployed_commit,
since_commit: last_deployed_commit.presence || commits.first,
status: 'success',
)
end
end
def head
commits.reachable.first&.sha
end
def merge_status(backlog_leniency_factor: 2.0)
return 'locked' if locked?
return 'failure' if %w(failure error).freeze.include?(branch_status)
return 'backlogged' if backlogged?(backlog_leniency_factor: backlog_leniency_factor)
'success'
end
def backlogged?(backlog_leniency_factor: 2.0)
maximum_commits_per_deploy && (undeployed_commits_count > maximum_commits_per_deploy * backlog_leniency_factor)
end
def branch_status
undeployed_commits.each do |commit|
state = commit.status.simple_state
return state unless %w(pending unknown missing).freeze.include?(state)
end
'pending'
end
def status
return :deploying if active_task?
:default
end
def lock_reverted_commits!
backlog = undeployed_commits.to_a
affected_rows = 0
until backlog.empty?
backlog = backlog.drop_while { |c| !c.revert? }
revert = backlog.shift
next if revert.nil?
commits_to_lock = backlog.reverse.drop_while { |c| !revert.revert_of?(c) }
next if commits_to_lock.empty?
affected_rows += commits
.where(id: commits_to_lock.map(&:id).uniq)
.lock_all(revert.author)
end
touch if affected_rows > 1
end
def next_expected_commit_to_deploy(commits: nil)
commits ||= undeployed_commits do |scope|
scope.preload(:statuses, :check_runs)
end
commits_to_deploy = commits.reject(&:active?)
if maximum_commits_per_deploy
commits_to_deploy = commits_to_deploy.reverse.slice(0, maximum_commits_per_deploy).reverse
end
commits_to_deploy.find(&:deployable?)
end
def undeployed_commits
scope = commits.reachable.newer_than(last_deployed_commit).order(id: :asc)
scope = yield scope if block_given?
scope.to_a.reverse
end
def last_completed_deploy
deploys_and_rollbacks.last_completed
end
def last_successful_deploy_commit
deploys_and_rollbacks.last_successful&.until_commit
end
def previous_successful_deploy(deploy_id)
deploys_and_rollbacks.success.where("id < ?", deploy_id).last
end
def last_active_task
tasks.exclusive.last
end
def last_deployed_commit
last_completed_deploy&.until_commit || NoDeployedCommit
end
def previous_successful_deploy_commit(deploy_id)
previous_successful_deploy(deploy_id)&.until_commit || NoDeployedCommit
end
def deployable?
!locked? && !active_task? && !awaiting_provision? && deployment_checks_passed?
end
def allows_merges?
merge_queue_enabled? && !locked? && merge_status == 'success'
end
def merge_method
cached_deploy_spec&.merge_request_merge_method || Shipit.default_merge_method
end
delegate :name=, to: :repository, prefix: :repo
delegate :name, to: :repository, prefix: :repo
delegate :owner=, to: :repository, prefix: :repo
delegate :owner, to: :repository, prefix: :repo
delegate :http_url, to: :repository, prefix: :repo
delegate :git_url, to: :repository, prefix: :repo
def base_path
@base_path ||= Rails.root.join('data', 'stacks', repo_owner, repo_name, environment)
end
def deploys_path
@deploys_path ||= base_path.join("deploys")
end
def git_path
@git_path ||= base_path.join("git")
end
def acquire_git_cache_lock(timeout: 15, &block)
@git_cache_lock ||= Flock.new(git_path.to_s + '.lock')
@git_cache_lock.lock(timeout: timeout, &block)
end
def clear_git_cache!
tmp_path = "#{git_path}-#{SecureRandom.hex}"
return unless git_path.exist?
acquire_git_cache_lock do
git_path.rename(tmp_path)
end
FileUtils.rm_rf(tmp_path)
end
def github_repo_name
repository.github_repo_name
end
def github_commits
handle_github_redirections do
github_api.commits(github_repo_name, sha: branch)
end
rescue Octokit::Conflict
[] # Repository is empty...
end
def github_api
github_app.api
end
def github_app
Shipit.github(organization: repository.owner)
end
def handle_github_redirections
# https://developer.github.com/v3/#http-redirects
resource = yield
if resource.try(:message) == 'Moved Permanently'
refresh_repository!
yield
else
resource
end
end
def refresh_repository!
resource = github_api.repo(github_repo_name)
if resource.try(:message) == 'Moved Permanently'
resource = github_api.get(resource.url)
end
repository.update!(owner: resource.owner.login, name: resource.name)
end
def active_task?
!!active_task
end
def active_task
return @active_task if defined?(@active_task)
@active_task ||= tasks.current
end
def occupied?
!!occupied
end
def occupied
@occupied ||= tasks.active.last
end
def locked?
lock_reason.present?
end
def lock(reason, user)
params = { lock_reason: reason, lock_author: user }
update!(params)
end
def unlock
update!(lock_reason: nil, lock_author: nil, locked_since: nil)
end
def archived?
archived_since.present?
end
def archive!(user)
update!(archived_since: Time.now, lock_reason: "Archived", lock_author: user)
end
def unarchive!
update!(archived_since: nil, lock_reason: nil, lock_author: nil, locked_since: nil)
end
def to_param
[repo_owner, repo_name, environment].join('/')
end
def self.run_deploy_in_foreground(stack:, revision:)
stack = Shipit::Stack.from_param!(stack)
until_commit = stack.commits.where(sha: revision).limit(1).first
env = stack.cached_deploy_spec.default_deploy_env
current_user = Shipit::CommandLineUser.new
stack.trigger_deploy(until_commit, current_user, env: env, force: true, run_now: true)
end
def self.from_param!(param)
repo_owner, repo_name, environment = param.split('/')
includes(:repository)
.where(
repositories: {
owner: repo_owner.downcase,
name: repo_name.downcase,
},
environment: environment,
).first!
end
delegate :plugins, :task_definitions, :hidden_statuses, :required_statuses, :soft_failing_statuses,
:blocking_statuses, :deploy_variables, :filter_task_envs, :filter_deploy_envs,
:maximum_commits_per_deploy, :pause_between_deploys, :retries_on_deploy, :retries_on_rollback,
to: :cached_deploy_spec
def monitoring?
monitoring.present?
end
def monitoring
cached_deploy_spec.review_monitoring
end
def checklist
cached_deploy_spec.review_checklist
end
def checks?
cached_deploy_spec.review_checks.present?
end
def update_undeployed_commits_count(after_commit = nil)
after_commit ||= last_deployed_commit
undeployed_commits = commits.reachable.newer_than(after_commit).count
update(undeployed_commits_count: undeployed_commits)
end
def update_latest_deployed_ref
if Shipit.update_latest_deployed_ref
UpdateGithubLastDeployedRefJob.perform_later(self)
end
end
def broadcast_update
Pubsubstub.publish(
"stack.#{id}",
{ id: id, updated_at: updated_at }.to_json,
name: 'update',
)
end
def schedule_for_destroy!
DestroyStackJob.perform_later(self)
end
def ci_enabled?
Rails.cache.fetch(ci_enabled_cache_key) do
commits.joins(:statuses).any? || commits.joins(:check_runs).any?
end
end
def enable_ci!
Rails.cache.write(ci_enabled_cache_key, true)
end
def mark_as_accessible!
update!(inaccessible_since: nil)
end
def mark_as_inaccessible!
update!(inaccessible_since: Time.now) unless inaccessible?
end
def inaccessible?
inaccessible_since?
end
def reload(*)
clear_cache
super
end
def async_update_estimated_deploy_duration
UpdateEstimatedDeployDurationJob.perform_later(self)
end
def update_estimated_deploy_duration!
update!(estimated_deploy_duration: Stat.p90(recent_deploys_durations) || 1)
end
def recent_deploys_durations
tasks.where(type: 'Shipit::Deploy').success.order(id: :desc).limit(100).durations
end
def sync_github
GithubSyncJob.perform_later(stack_id: id)
end
def links
links_spec = cached_deploy_spec&.links || {}
context = EnvironmentVariables.with(env)
links_spec.transform_values { |url| context.interpolate(url) }
end
def clear_local_files
FileUtils.rm_rf(base_path.to_s)
end
def deployment_checks_passed?
return true unless deployment_checks?
Shipit.deployment_checks.call(self)
end
def emit_lock_hooks
return unless previous_changes.include?('lock_reason')
lock_details = if previous_changes['lock_reason'].last.blank?
{ from: previous_changes['locked_since'].first, until: Time.zone.now }
end
Hook.emit(:lock, self, locked: locked?, lock_details: lock_details, stack: self)
end
private
def deployable_commits(commits)
commits.to_a.reverse.find(&:deployable?)
end
def clear_cache
remove_instance_variable(:@active_task) if defined?(@active_task)
end
def update_defaults
self.environment = 'production' if environment.blank?
self.branch = default_branch_name if branch.blank?
end
def default_branch_name
Shipit.github.api.repo(github_repo_name).default_branch
rescue Octokit::NotFound, Octokit::InvalidRepository
nil
end
def set_locked_since
return unless lock_reason_changed?
if lock_reason.blank?
self.locked_since = nil
else
self.locked_since ||= Time.now
end
end
def schedule_merges_if_necessary
if lock_reason_previously_changed? && lock_reason.blank?
schedule_merges
end
end
def emit_added_hooks
Hook.emit(:stack, self, action: :added, stack: self)
end
def emit_updated_hooks
changed = !(previous_changes.keys - %w(updated_at)).empty?
Hook.emit(:stack, self, action: :updated, stack: self) if changed
end
def emit_removed_hooks
Hook.emit(:stack, self, action: :removed, stack: self)
end
def emit_merge_status_hooks
Hook.emit(:merge_status, self, merge_status: merge_status, stack: self)
end
def ci_enabled_cache_key
"stacks:#{id}:ci_enabled"
end
def should_resume_continuous_delivery?(commit)
(deployment_checks_passed? && !deployable?) ||
deployed_too_recently? ||
commit.nil? ||
commit.deployed?
end
def should_delay_continuous_delivery?(commit)
commit.deploy_failed? ||
(checks? && !EphemeralCommitChecks.new(commit).run.success?) ||
!deployment_checks_passed? ||
commit.recently_pushed?
end
def deployment_checks?
Shipit.deployment_checks.present?
end
end
end