shipit-engine 0.27.1 → 0.28.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 +4 -4
- data/README.md +30 -1
- data/app/assets/stylesheets/_pages/_commits.scss +2 -0
- data/app/assets/stylesheets/_pages/_deploy.scss +6 -0
- data/app/controllers/shipit/api/release_statuses_controller.rb +22 -0
- data/app/controllers/shipit/api/stacks_controller.rb +6 -1
- data/app/controllers/shipit/commits_controller.rb +12 -1
- data/app/controllers/shipit/deploys_controller.rb +11 -0
- data/app/controllers/shipit/stacks_controller.rb +29 -1
- data/app/controllers/shipit/tasks_controller.rb +13 -1
- data/app/helpers/shipit/merge_status_helper.rb +2 -2
- data/app/helpers/shipit/stacks_helper.rb +10 -4
- data/app/jobs/shipit/perform_task_job.rb +1 -0
- data/app/jobs/shipit/update_github_last_deployed_ref_job.rb +41 -0
- data/app/models/shipit/command_line_user.rb +58 -0
- data/app/models/shipit/commit.rb +42 -2
- data/app/models/shipit/deploy.rb +31 -2
- data/app/models/shipit/deploy_spec/lerna_discovery.rb +43 -19
- data/app/models/shipit/deploy_spec/pypi_discovery.rb +5 -1
- data/app/models/shipit/rollback.rb +4 -2
- data/app/models/shipit/stack.rb +72 -15
- data/app/models/shipit/task.rb +30 -0
- data/app/models/shipit/undeployed_commit.rb +10 -1
- data/app/serializers/shipit/command_line_user_serializer.rb +4 -0
- data/app/views/layouts/shipit.html.erb +5 -1
- data/app/views/shipit/commits/_commit.html.erb +7 -2
- data/app/views/shipit/merge_status/_commit_count_warning.html.erb +1 -5
- data/app/views/shipit/merge_status/backlogged.html.erb +1 -1
- data/app/views/shipit/merge_status/failure.html.erb +1 -1
- data/app/views/shipit/merge_status/locked.html.erb +1 -1
- data/app/views/shipit/merge_status/success.html.erb +1 -1
- data/app/views/shipit/stacks/show.html.erb +10 -1
- data/app/views/shipit/tasks/_task_output.html.erb +2 -2
- data/config/locales/en.yml +2 -1
- data/config/routes.rb +8 -1
- data/db/migrate/20190502020249_add_lock_author_id_to_commits.rb +5 -0
- data/lib/shipit.rb +14 -2
- data/lib/shipit/cast_value.rb +9 -0
- data/lib/shipit/command.rb +62 -16
- data/lib/shipit/line_buffer.rb +42 -0
- data/lib/shipit/version.rb +1 -1
- data/lib/tasks/shipit.rake +27 -0
- data/test/controllers/api/release_statuses_controller_test.rb +66 -0
- data/test/controllers/api/stacks_controller_test.rb +19 -0
- data/test/controllers/commits_controller_test.rb +30 -6
- data/test/controllers/deploys_controller_test.rb +51 -2
- data/test/controllers/tasks_controller_test.rb +24 -0
- data/test/dummy/db/schema.rb +2 -1
- data/test/dummy/db/seeds.rb +2 -0
- data/test/fixtures/shipit/check_runs.yml +11 -0
- data/test/fixtures/shipit/commits.yml +104 -0
- data/test/fixtures/shipit/stacks.yml +98 -3
- data/test/fixtures/shipit/tasks.yml +42 -0
- data/test/jobs/update_github_last_deployed_ref_job_test.rb +88 -0
- data/test/models/commits_test.rb +88 -1
- data/test/models/deploy_spec_test.rb +34 -6
- data/test/models/deploys_test.rb +308 -6
- data/test/models/rollbacks_test.rb +17 -11
- data/test/models/stacks_test.rb +217 -4
- data/test/models/tasks_test.rb +13 -0
- data/test/models/undeployed_commits_test.rb +62 -3
- data/test/test_helper.rb +0 -1
- data/test/unit/command_test.rb +55 -0
- data/test/unit/line_buffer_test.rb +20 -0
- metadata +142 -128
data/app/models/shipit/deploy.rb
CHANGED
@@ -36,9 +36,30 @@ module Shipit
|
|
36
36
|
after_create :create_commit_deployments
|
37
37
|
after_create :update_release_status
|
38
38
|
after_commit :broadcast_update
|
39
|
+
after_commit :update_latest_deployed_ref, on: :update
|
39
40
|
|
40
41
|
delegate :broadcast_update, :filter_deploy_envs, to: :stack
|
41
42
|
|
43
|
+
def self.newer_than(deploy)
|
44
|
+
return all unless deploy
|
45
|
+
where('id > ?', deploy.try(:id) || deploy)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.older_than(deploy)
|
49
|
+
return all unless deploy
|
50
|
+
where('id < ?', deploy.try(:id) || deploy)
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.since(deploy)
|
54
|
+
return all unless deploy
|
55
|
+
where('id >= ?', deploy.try(:id) || deploy)
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.until(deploy)
|
59
|
+
return all unless deploy
|
60
|
+
where('id <= ?', deploy.try(:id) || deploy)
|
61
|
+
end
|
62
|
+
|
42
63
|
def build_rollback(user = nil, env: nil, force: false)
|
43
64
|
Rollback.new(
|
44
65
|
user_id: user&.id,
|
@@ -65,16 +86,19 @@ module Shipit
|
|
65
86
|
rollback
|
66
87
|
end
|
67
88
|
|
68
|
-
# Rolls the stack back to the **previous** deploy
|
89
|
+
# Rolls the stack back to the most recent **previous** successful deploy
|
69
90
|
def trigger_revert(force: false)
|
91
|
+
previous_successful_commit = commit_to_rollback_to
|
92
|
+
|
70
93
|
rollback = Rollback.create!(
|
71
94
|
user_id: user_id,
|
72
95
|
stack_id: stack_id,
|
73
96
|
parent_id: id,
|
74
97
|
since_commit: until_commit,
|
75
|
-
until_commit:
|
98
|
+
until_commit: previous_successful_commit,
|
76
99
|
allow_concurrency: force,
|
77
100
|
)
|
101
|
+
|
78
102
|
rollback.enqueue
|
79
103
|
lock_reason = "A rollback for #{until_commit.sha} has been triggered. " \
|
80
104
|
"Please make sure the reason for the rollback has been addressed before deploying again."
|
@@ -257,5 +281,10 @@ module Shipit
|
|
257
281
|
def update_last_deploy_time
|
258
282
|
stack.update(last_deployed_at: ended_at)
|
259
283
|
end
|
284
|
+
|
285
|
+
def update_latest_deployed_ref
|
286
|
+
return unless previous_changes.include?(:status)
|
287
|
+
stack.update_latest_deployed_ref if previous_changes[:status].last == 'success'
|
288
|
+
end
|
260
289
|
end
|
261
290
|
end
|
@@ -3,6 +3,8 @@ require 'json'
|
|
3
3
|
module Shipit
|
4
4
|
class DeploySpec
|
5
5
|
module LernaDiscovery
|
6
|
+
LATEST_MAJOR_VERSION = Gem::Version.new('3.0.0')
|
7
|
+
|
6
8
|
def discover_dependencies_steps
|
7
9
|
discover_lerna_json || super
|
8
10
|
end
|
@@ -21,15 +23,21 @@ module Shipit
|
|
21
23
|
|
22
24
|
def discover_lerna_checklist
|
23
25
|
if lerna?
|
26
|
+
command = if lerna_lerna >= LATEST_MAJOR_VERSION
|
27
|
+
'lerna version'
|
28
|
+
else
|
29
|
+
%(
|
30
|
+
lerna publish --skip-npm
|
31
|
+
&& git add -A
|
32
|
+
&& git push --follow-tags
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
24
36
|
[%(
|
25
37
|
<strong>Don't forget version and tag before publishing!</strong>
|
26
38
|
You can do this with:<br/>
|
27
|
-
<pre>
|
28
|
-
|
29
|
-
&& git add -A
|
30
|
-
&& git push --follow-tags
|
31
|
-
</pre>
|
32
|
-
)]
|
39
|
+
<pre>#{command}</pre>
|
40
|
+
)]
|
33
41
|
end
|
34
42
|
end
|
35
43
|
|
@@ -41,9 +49,16 @@ module Shipit
|
|
41
49
|
file('lerna.json')
|
42
50
|
end
|
43
51
|
|
52
|
+
def lerna_config
|
53
|
+
@_lerna_config ||= JSON.parse(lerna_json.read)
|
54
|
+
end
|
55
|
+
|
56
|
+
def lerna_lerna
|
57
|
+
Gem::Version.new(lerna_config['lerna'])
|
58
|
+
end
|
59
|
+
|
44
60
|
def lerna_version
|
45
|
-
lerna_config
|
46
|
-
JSON.parse(lerna_config)['version']
|
61
|
+
lerna_config['version']
|
47
62
|
end
|
48
63
|
|
49
64
|
def discover_lerna_packages
|
@@ -68,18 +83,27 @@ module Shipit
|
|
68
83
|
|
69
84
|
def publish_fixed_version_packages
|
70
85
|
check_tags = 'assert-lerna-fixed-version-tag'
|
71
|
-
# `yarn publish` requires user input, so always use npm.
|
72
86
|
version = lerna_version
|
73
|
-
publish =
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
87
|
+
publish = if lerna_lerna >= LATEST_MAJOR_VERSION
|
88
|
+
%W(
|
89
|
+
node_modules/.bin/lerna publish
|
90
|
+
from-git
|
91
|
+
--yes
|
92
|
+
--dist-tag #{dist_tag(version)}
|
93
|
+
).join(" ")
|
94
|
+
else
|
95
|
+
# `yarn publish` requires user input, so always use npm.
|
96
|
+
%W(
|
97
|
+
node_modules/.bin/lerna publish
|
98
|
+
--yes
|
99
|
+
--skip-git
|
100
|
+
--repo-version #{version}
|
101
|
+
--force-publish=*
|
102
|
+
--npm-tag #{dist_tag(version)}
|
103
|
+
--npm-client=npm
|
104
|
+
--skip-npm=false
|
105
|
+
).join(" ")
|
106
|
+
end
|
83
107
|
|
84
108
|
[check_tags, publish]
|
85
109
|
end
|
@@ -29,7 +29,11 @@ module Shipit
|
|
29
29
|
end
|
30
30
|
|
31
31
|
def publish_egg
|
32
|
-
[
|
32
|
+
[
|
33
|
+
"assert-egg-version-tag #{setup_dot_py}",
|
34
|
+
'python setup.py register sdist',
|
35
|
+
'twine upload dist/*',
|
36
|
+
]
|
33
37
|
end
|
34
38
|
end
|
35
39
|
end
|
@@ -36,9 +36,11 @@ module Shipit
|
|
36
36
|
|
37
37
|
def update_release_status
|
38
38
|
return unless stack.release_status?
|
39
|
-
return unless status == 'pending'
|
40
39
|
|
41
|
-
|
40
|
+
# When we rollback to a certain revision, assume that all later deploys were faulty
|
41
|
+
stack.deploys.newer_than(deploy.id).until(stack.last_completed_deploy.id).to_a.each do |deploy|
|
42
|
+
deploy.report_faulty!(description: "A rollback of #{stack.to_param} was triggered")
|
43
|
+
end
|
42
44
|
end
|
43
45
|
|
44
46
|
def lock_reverted_commits
|
data/app/models/shipit/stack.rb
CHANGED
@@ -13,6 +13,10 @@ module Shipit
|
|
13
13
|
''
|
14
14
|
end
|
15
15
|
|
16
|
+
def short_sha
|
17
|
+
''
|
18
|
+
end
|
19
|
+
|
16
20
|
def blank?
|
17
21
|
true
|
18
22
|
end
|
@@ -124,10 +128,11 @@ module Shipit
|
|
124
128
|
)
|
125
129
|
end
|
126
130
|
|
127
|
-
def trigger_deploy(*args)
|
128
|
-
|
131
|
+
def trigger_deploy(*args, **kwargs)
|
132
|
+
run_now = kwargs.delete(:run_now)
|
133
|
+
deploy = build_deploy(*args, **kwargs)
|
129
134
|
deploy.save!
|
130
|
-
deploy.enqueue
|
135
|
+
run_now ? deploy.run_now! : deploy.enqueue
|
131
136
|
continuous_delivery_resumed!
|
132
137
|
deploy
|
133
138
|
end
|
@@ -182,6 +187,12 @@ module Shipit
|
|
182
187
|
end
|
183
188
|
|
184
189
|
def async_refresh_deployed_revision
|
190
|
+
async_refresh_deployed_revision!
|
191
|
+
rescue => error
|
192
|
+
logger.warn "Failed to dispatch FetchDeployedRevisionJob: [#{error.class.name}] #{error.message}"
|
193
|
+
end
|
194
|
+
|
195
|
+
def async_refresh_deployed_revision!
|
185
196
|
FetchDeployedRevisionJob.perform_later(self)
|
186
197
|
end
|
187
198
|
|
@@ -234,33 +245,57 @@ module Shipit
|
|
234
245
|
end
|
235
246
|
|
236
247
|
def lock_reverted_commits!
|
237
|
-
commits_to_lock = []
|
238
|
-
|
239
248
|
backlog = undeployed_commits.to_a
|
249
|
+
affected_rows = 0
|
250
|
+
|
240
251
|
until backlog.empty?
|
241
252
|
backlog = backlog.drop_while { |c| !c.revert? }
|
242
|
-
|
243
|
-
|
244
|
-
|
253
|
+
revert = backlog.shift
|
254
|
+
next if revert.nil?
|
255
|
+
|
256
|
+
commits_to_lock = backlog.reverse.drop_while { |c| !revert.revert_of?(c) }
|
257
|
+
next if commits_to_lock.empty?
|
258
|
+
|
259
|
+
affected_rows += commits
|
260
|
+
.where(id: commits_to_lock.map(&:id).uniq)
|
261
|
+
.lock_all(revert.author)
|
245
262
|
end
|
246
263
|
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
264
|
+
touch if affected_rows > 1
|
265
|
+
end
|
266
|
+
|
267
|
+
def next_expected_commit_to_deploy(commits: nil)
|
268
|
+
commits ||= undeployed_commits do |scope|
|
269
|
+
scope.preload(:statuses, :check_runs)
|
251
270
|
end
|
271
|
+
|
272
|
+
commits_to_deploy = commits.reject(&:active?)
|
273
|
+
if maximum_commits_per_deploy
|
274
|
+
commits_to_deploy = commits_to_deploy.reverse.slice(0, maximum_commits_per_deploy).reverse
|
275
|
+
end
|
276
|
+
commits_to_deploy.find(&:deployable?)
|
252
277
|
end
|
253
278
|
|
254
279
|
def undeployed_commits
|
255
280
|
scope = commits.reachable.newer_than(last_deployed_commit).order(id: :asc)
|
256
|
-
|
257
|
-
scope
|
281
|
+
|
282
|
+
scope = yield scope if block_given?
|
283
|
+
|
284
|
+
scope.to_a.reverse
|
258
285
|
end
|
259
286
|
|
260
287
|
def last_completed_deploy
|
261
288
|
deploys_and_rollbacks.last_completed
|
262
289
|
end
|
263
290
|
|
291
|
+
def last_successful_deploy_commit
|
292
|
+
deploys_and_rollbacks.last_successful&.until_commit
|
293
|
+
end
|
294
|
+
|
295
|
+
def previous_successful_deploy(deploy_id)
|
296
|
+
deploys_and_rollbacks.success.where("id < ?", deploy_id).last
|
297
|
+
end
|
298
|
+
|
264
299
|
def last_active_task
|
265
300
|
tasks.exclusive.last
|
266
301
|
end
|
@@ -269,6 +304,10 @@ module Shipit
|
|
269
304
|
last_completed_deploy&.until_commit || NoDeployedCommit
|
270
305
|
end
|
271
306
|
|
307
|
+
def previous_successful_deploy_commit(deploy_id)
|
308
|
+
previous_successful_deploy(deploy_id)&.until_commit || NoDeployedCommit
|
309
|
+
end
|
310
|
+
|
272
311
|
def deployable?
|
273
312
|
!locked? && !active_task?
|
274
313
|
end
|
@@ -379,6 +418,15 @@ module Shipit
|
|
379
418
|
[repo_owner, repo_name, environment].join('/')
|
380
419
|
end
|
381
420
|
|
421
|
+
def self.run_deploy_in_foreground(stack:, revision:)
|
422
|
+
stack = Shipit::Stack.from_param!(stack)
|
423
|
+
until_commit = stack.commits.where(sha: revision).limit(1).first
|
424
|
+
env = stack.cached_deploy_spec.default_deploy_env
|
425
|
+
current_user = Shipit::CommandLineUser.new
|
426
|
+
|
427
|
+
stack.trigger_deploy(until_commit, current_user, env: env, force: true, run_now: true)
|
428
|
+
end
|
429
|
+
|
382
430
|
def self.from_param!(param)
|
383
431
|
repo_owner, repo_name, environment = param.split('/')
|
384
432
|
where(
|
@@ -414,6 +462,10 @@ module Shipit
|
|
414
462
|
update(undeployed_commits_count: undeployed_commits)
|
415
463
|
end
|
416
464
|
|
465
|
+
def update_latest_deployed_ref
|
466
|
+
UpdateGithubLastDeployedRefJob.perform_later(self)
|
467
|
+
end
|
468
|
+
|
417
469
|
def broadcast_update
|
418
470
|
Pubsubstub.publish(
|
419
471
|
"stack.#{id}",
|
@@ -502,7 +554,12 @@ module Shipit
|
|
502
554
|
|
503
555
|
def emit_lock_hooks
|
504
556
|
return unless previous_changes.include?('lock_reason')
|
505
|
-
|
557
|
+
|
558
|
+
lock_details = if previous_changes['lock_reason'].last.blank?
|
559
|
+
{from: previous_changes['locked_since'].first, until: Time.zone.now}
|
560
|
+
end
|
561
|
+
|
562
|
+
Hook.emit(:lock, self, locked: locked?, lock_details: lock_details, stack: self)
|
506
563
|
end
|
507
564
|
|
508
565
|
def emit_added_hooks
|
data/app/models/shipit/task.rb
CHANGED
@@ -47,6 +47,10 @@ module Shipit
|
|
47
47
|
completed.last
|
48
48
|
end
|
49
49
|
|
50
|
+
def last_successful
|
51
|
+
success.last
|
52
|
+
end
|
53
|
+
|
50
54
|
def current
|
51
55
|
active.exclusive.last
|
52
56
|
end
|
@@ -176,7 +180,13 @@ module Shipit
|
|
176
180
|
PerformTaskJob.perform_later(self)
|
177
181
|
end
|
178
182
|
|
183
|
+
def run_now!
|
184
|
+
raise "only persisted jobs can be run" unless persisted?
|
185
|
+
PerformTaskJob.perform_now(self)
|
186
|
+
end
|
187
|
+
|
179
188
|
def write(text)
|
189
|
+
log_output(text)
|
180
190
|
chunks.create!(text: text)
|
181
191
|
end
|
182
192
|
|
@@ -307,6 +317,16 @@ module Shipit
|
|
307
317
|
Shipit::Engine.routes.url_helpers.stack_task_url(stack, self)
|
308
318
|
end
|
309
319
|
|
320
|
+
def commit_to_rollback_to
|
321
|
+
previous_deployed_commit = stack.previous_successful_deploy_commit(id)
|
322
|
+
|
323
|
+
if previous_deployed_commit == Shipit::Stack::NoDeployedCommit
|
324
|
+
since_commit
|
325
|
+
else
|
326
|
+
previous_deployed_commit
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
310
330
|
private
|
311
331
|
|
312
332
|
def prevent_concurrency
|
@@ -320,5 +340,15 @@ module Shipit
|
|
320
340
|
def abort_key
|
321
341
|
"#{status_key}:aborting"
|
322
342
|
end
|
343
|
+
|
344
|
+
def log_output(text)
|
345
|
+
output_line_buffer.buffer(text) do |line|
|
346
|
+
Shipit.task_logger.info("[#{stack.repo_name}##{id}] #{line}")
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
def output_line_buffer
|
351
|
+
@output_line_buffer ||= LineBuffer.new
|
352
|
+
end
|
323
353
|
end
|
324
354
|
end
|
@@ -2,9 +2,10 @@ module Shipit
|
|
2
2
|
class UndeployedCommit < DelegateClass(Commit)
|
3
3
|
attr_reader :index
|
4
4
|
|
5
|
-
def initialize(commit, index)
|
5
|
+
def initialize(commit, index:, next_expected_commit_to_deploy: nil)
|
6
6
|
super(commit)
|
7
7
|
@index = index
|
8
|
+
@next_expected_commit_to_deploy = next_expected_commit_to_deploy
|
8
9
|
end
|
9
10
|
|
10
11
|
def deploy_state(bypass_safeties = false)
|
@@ -38,6 +39,14 @@ module Shipit
|
|
38
39
|
stack.maximum_commits_per_deploy && index >= stack.maximum_commits_per_deploy
|
39
40
|
end
|
40
41
|
|
42
|
+
def expected_to_be_deployed?
|
43
|
+
return false if @next_expected_commit_to_deploy.nil?
|
44
|
+
return false unless stack.continuous_deployment
|
45
|
+
return false if active?
|
46
|
+
|
47
|
+
id <= @next_expected_commit_to_deploy.id
|
48
|
+
end
|
49
|
+
|
41
50
|
def blocked?
|
42
51
|
return @blocked if defined?(@blocked)
|
43
52
|
@blocked = super
|
@@ -1,7 +1,11 @@
|
|
1
1
|
<!DOCTYPE html>
|
2
2
|
<html lang="<%= I18n.locale %>" data-controller="<%= controller_name %>" data-action="<%= action_name %>">
|
3
3
|
<head>
|
4
|
-
|
4
|
+
<% if @stack %>
|
5
|
+
<title><%= Shipit.app_name %> - <%= @stack.repo_name %>/<%= @stack.environment %></title>
|
6
|
+
<% else %>
|
7
|
+
<title><%= Shipit.app_name %></title>
|
8
|
+
<% end %>
|
5
9
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
6
10
|
<%= favicon_link_tag %>
|
7
11
|
<%= stylesheet_link_tag :shipit, media: 'all' %>
|
@@ -1,5 +1,5 @@
|
|
1
1
|
<li class="commit <%= 'locked' if commit.locked? %>" id="commit-<%= commit.id %>">
|
2
|
-
<% cache commit do %>
|
2
|
+
<% cache [commit, commit.expected_to_be_deployed?] do %>
|
3
3
|
<% cache commit.author do %>
|
4
4
|
<%= render 'shipit/shared/author', author: commit.author %>
|
5
5
|
<% end %>
|
@@ -17,12 +17,17 @@
|
|
17
17
|
<p class="commit-meta">
|
18
18
|
<%= timeago_tag(commit.committed_at, force: true) %>
|
19
19
|
</p>
|
20
|
+
<% if commit.expected_to_be_deployed? %>
|
21
|
+
<p class="commit-meta">
|
22
|
+
<span class="scheduled">expected to be deployed next</span>
|
23
|
+
</p>
|
24
|
+
<% end %>
|
20
25
|
</div>
|
21
26
|
<div class="commit-lock" >
|
22
27
|
<%= link_to stack_commit_path(commit.stack, commit), class: 'action-lock-commit', data: {tooltip: t('commit.lock')} do %>
|
23
28
|
<i class="icon icon--lock"></i>
|
24
29
|
<% end %>
|
25
|
-
<%= link_to stack_commit_path(commit.stack, commit), class: 'action-unlock-commit', data: {tooltip:
|
30
|
+
<%= link_to stack_commit_path(commit.stack, commit), class: 'action-unlock-commit', data: {tooltip: unlock_commit_tooltip(commit), confirm: t('commit.confirm_unlock')} do %>
|
26
31
|
<i class="icon icon--lock"></i>
|
27
32
|
<% end %>
|
28
33
|
</div>
|