ecs_deploy_cli 0.1.0 → 0.4.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/lib/ecs_deploy_cli.rb +5 -1
- data/lib/ecs_deploy_cli/cli.rb +40 -2
- data/lib/ecs_deploy_cli/cloudformation/default.yml +411 -0
- data/lib/ecs_deploy_cli/dsl/auto_options.rb +17 -1
- data/lib/ecs_deploy_cli/dsl/cluster.rb +70 -0
- data/lib/ecs_deploy_cli/dsl/container.rb +8 -10
- data/lib/ecs_deploy_cli/dsl/parser.rb +9 -5
- data/lib/ecs_deploy_cli/dsl/service.rb +31 -2
- data/lib/ecs_deploy_cli/dsl/task.rb +2 -0
- data/lib/ecs_deploy_cli/runner.rb +26 -125
- data/lib/ecs_deploy_cli/runners/base.rb +105 -0
- data/lib/ecs_deploy_cli/runners/diff.rb +74 -0
- data/lib/ecs_deploy_cli/runners/logs.rb +14 -0
- data/lib/ecs_deploy_cli/runners/run_task.rb +27 -0
- data/lib/ecs_deploy_cli/runners/setup.rb +128 -0
- data/lib/ecs_deploy_cli/runners/ssh.rb +79 -0
- data/lib/ecs_deploy_cli/runners/status.rb +23 -0
- data/lib/ecs_deploy_cli/runners/update_crons.rb +41 -0
- data/lib/ecs_deploy_cli/runners/update_services.rb +55 -0
- data/lib/ecs_deploy_cli/runners/validate.rb +47 -0
- data/lib/ecs_deploy_cli/version.rb +3 -1
- data/spec/ecs_deploy_cli/cli_spec.rb +44 -4
- data/spec/ecs_deploy_cli/dsl/cluster_spec.rb +48 -0
- data/spec/ecs_deploy_cli/dsl/container_spec.rb +6 -4
- data/spec/ecs_deploy_cli/dsl/cron_spec.rb +2 -0
- data/spec/ecs_deploy_cli/dsl/parser_spec.rb +2 -0
- data/spec/ecs_deploy_cli/dsl/service_spec.rb +31 -0
- data/spec/ecs_deploy_cli/dsl/task_spec.rb +2 -0
- data/spec/ecs_deploy_cli/runner_spec.rb +177 -26
- data/spec/ecs_deploy_cli/runners/base_spec.rb +57 -0
- data/spec/spec_helper.rb +2 -3
- data/spec/support/ECSFile +13 -1
- data/spec/support/ECSFile.minimal +12 -0
- metadata +104 -13
@@ -1,9 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module EcsDeployCli
|
2
4
|
module DSL
|
3
5
|
module AutoOptions
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def allowed_options(*value)
|
10
|
+
@allowed_options = value
|
11
|
+
end
|
12
|
+
|
13
|
+
def _allowed_options
|
14
|
+
@allowed_options ||= []
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
4
18
|
def method_missing(name, *args, &block)
|
5
19
|
if args.count == 1 && !block
|
6
|
-
|
20
|
+
unless self.class._allowed_options.include?(name)
|
21
|
+
EcsDeployCli.logger.info("Used unhandled option #{name.to_sym} = #{args.first} in #{self.class.name}")
|
22
|
+
end
|
7
23
|
_options[name.to_sym] = args.first
|
8
24
|
else
|
9
25
|
super
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EcsDeployCli
|
4
|
+
module DSL
|
5
|
+
class Cluster
|
6
|
+
include AutoOptions
|
7
|
+
|
8
|
+
allowed_options :instances_count, :instance_type, :ebs_volume_size, :keypair_name
|
9
|
+
|
10
|
+
def initialize(name, config)
|
11
|
+
@config = config
|
12
|
+
_options[:name] = name.to_s
|
13
|
+
end
|
14
|
+
|
15
|
+
def vpc(id = nil, &block)
|
16
|
+
@vpc = VPC.new(id)
|
17
|
+
@vpc.instance_exec(&block)
|
18
|
+
end
|
19
|
+
|
20
|
+
def as_definition
|
21
|
+
{
|
22
|
+
instances_count: 1,
|
23
|
+
|
24
|
+
device_name: '/dev/xvda',
|
25
|
+
ebs_volume_size: 22,
|
26
|
+
ebs_volume_type: 'gp2',
|
27
|
+
|
28
|
+
root_device_name: '/dev/xvdcz',
|
29
|
+
root_ebs_volume_size: 30,
|
30
|
+
|
31
|
+
vpc: @vpc&.as_definition
|
32
|
+
}.merge(_options)
|
33
|
+
end
|
34
|
+
|
35
|
+
class VPC
|
36
|
+
include AutoOptions
|
37
|
+
allowed_options :cidr, :subnet1, :subnet2, :subnet3
|
38
|
+
|
39
|
+
def initialize(id)
|
40
|
+
_options[:id] = id
|
41
|
+
end
|
42
|
+
|
43
|
+
def availability_zones(*values)
|
44
|
+
_options[:availability_zones] = values.join(',')
|
45
|
+
end
|
46
|
+
|
47
|
+
def subnet_ids(*values)
|
48
|
+
_options[:subnet_ids] = values.join(',')
|
49
|
+
end
|
50
|
+
|
51
|
+
def as_definition
|
52
|
+
validate! if _options[:id]
|
53
|
+
|
54
|
+
{
|
55
|
+
cidr: '10.0.0.0/16',
|
56
|
+
subnet1: '10.0.0.0/24',
|
57
|
+
subnet2: '10.0.1.0/24',
|
58
|
+
subnet3: '10.0.2.0/24'
|
59
|
+
}.merge(_options)
|
60
|
+
end
|
61
|
+
|
62
|
+
def validate!
|
63
|
+
[
|
64
|
+
:subnet1, :subnet_ids, :availability_zones
|
65
|
+
].each { |key| raise "Missing required parameter #{key}" unless _options[key] }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -5,25 +5,23 @@ module EcsDeployCli
|
|
5
5
|
class Container
|
6
6
|
include AutoOptions
|
7
7
|
|
8
|
+
allowed_options :image, :cpu, :working_directory
|
9
|
+
|
8
10
|
def initialize(name, config)
|
9
11
|
@config = config
|
10
12
|
_options[:name] = name.to_s
|
11
13
|
end
|
12
14
|
|
13
|
-
def image(value)
|
14
|
-
_options[:image] = value
|
15
|
-
end
|
16
|
-
|
17
15
|
def command(*command)
|
18
16
|
_options[:command] = command
|
19
17
|
end
|
20
18
|
|
21
19
|
def load_envs(name)
|
22
|
-
_options[:environment] = (_options[:environment] || []) + YAML.safe_load(File.open(name))
|
20
|
+
_options[:environment] = (_options[:environment] || []) + YAML.safe_load(File.open(name), symbolize_names: true)
|
23
21
|
end
|
24
22
|
|
25
23
|
def env(key:, value:)
|
26
|
-
(_options[:environment] ||= []) << {
|
24
|
+
(_options[:environment] ||= []) << { name: key, value: value }
|
27
25
|
end
|
28
26
|
|
29
27
|
def secret(key:, value:)
|
@@ -34,10 +32,6 @@ module EcsDeployCli
|
|
34
32
|
(_options[:port_mappings] ||= []) << options
|
35
33
|
end
|
36
34
|
|
37
|
-
def cpu(value)
|
38
|
-
_options[:cpu] = value
|
39
|
-
end
|
40
|
-
|
41
35
|
def memory(limit:, reservation:)
|
42
36
|
_options[:memory] = limit
|
43
37
|
_options[:memory_reservation] = reservation
|
@@ -62,6 +56,10 @@ module EcsDeployCli
|
|
62
56
|
|
63
57
|
def as_definition
|
64
58
|
{
|
59
|
+
cpu: 0,
|
60
|
+
mount_points: [],
|
61
|
+
port_mappings: [],
|
62
|
+
volumes_from: [],
|
65
63
|
memory_reservation: nil,
|
66
64
|
essential: true
|
67
65
|
}.merge(_options)
|
@@ -40,8 +40,11 @@ module EcsDeployCli
|
|
40
40
|
@crons[name].instance_exec(&block)
|
41
41
|
end
|
42
42
|
|
43
|
-
def cluster(name)
|
43
|
+
def cluster(name, &block)
|
44
44
|
config[:cluster] = name
|
45
|
+
@cluster ||= {}.with_indifferent_access
|
46
|
+
@cluster = Cluster.new(name, config)
|
47
|
+
@cluster.instance_exec(&block) if block
|
45
48
|
end
|
46
49
|
|
47
50
|
def config
|
@@ -55,10 +58,11 @@ module EcsDeployCli
|
|
55
58
|
end
|
56
59
|
|
57
60
|
def resolve
|
58
|
-
resolved_containers = @containers.transform_values(&:as_definition)
|
59
|
-
resolved_tasks = @tasks.transform_values { |t| t.as_definition(resolved_containers) }
|
60
|
-
resolved_crons = @crons.transform_values { |t| t.as_definition(resolved_tasks) }
|
61
|
-
|
61
|
+
resolved_containers = (@containers || {}).transform_values(&:as_definition)
|
62
|
+
resolved_tasks = (@tasks || {}).transform_values { |t| t.as_definition(resolved_containers) }
|
63
|
+
resolved_crons = (@crons || {}).transform_values { |t| t.as_definition(resolved_tasks) }
|
64
|
+
resolved_cluster = @cluster.as_definition
|
65
|
+
[@services, resolved_tasks, resolved_crons, resolved_cluster]
|
62
66
|
end
|
63
67
|
|
64
68
|
def self.load(file)
|
@@ -18,13 +18,42 @@ module EcsDeployCli
|
|
18
18
|
_options
|
19
19
|
end
|
20
20
|
|
21
|
+
def load_balancer(name, &block)
|
22
|
+
@load_balancers ||= []
|
23
|
+
|
24
|
+
load_balancer = LoadBalancer.new(name, @config)
|
25
|
+
load_balancer.instance_exec(&block)
|
26
|
+
|
27
|
+
@load_balancers << load_balancer
|
28
|
+
end
|
29
|
+
|
21
30
|
def as_definition(task)
|
22
31
|
{
|
23
32
|
cluster: @config[:cluster],
|
24
|
-
service:
|
25
|
-
task_definition: task
|
33
|
+
service: _options[:service],
|
34
|
+
task_definition: task,
|
35
|
+
load_balancers: @load_balancers&.map(&:as_definition) || []
|
26
36
|
}
|
27
37
|
end
|
38
|
+
|
39
|
+
class LoadBalancer
|
40
|
+
include AutoOptions
|
41
|
+
allowed_options :container_name, :container_port
|
42
|
+
|
43
|
+
def initialize(name, config)
|
44
|
+
_options[:load_balancer_name] = name
|
45
|
+
@config = config
|
46
|
+
end
|
47
|
+
|
48
|
+
def target_group_arn(value)
|
49
|
+
_options[:target_group_arn] = "arn:aws:elasticloadbalancing:#{@config[:aws_region]}:#{@config[:aws_profile_id]}:targetgroup/#{value}"
|
50
|
+
_options.delete(:load_balancer_name)
|
51
|
+
end
|
52
|
+
|
53
|
+
def as_definition
|
54
|
+
_options
|
55
|
+
end
|
56
|
+
end
|
28
57
|
end
|
29
58
|
end
|
30
59
|
end
|
@@ -1,153 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ecs_deploy_cli/runners/base'
|
4
|
+
require 'ecs_deploy_cli/runners/ssh'
|
5
|
+
require 'ecs_deploy_cli/runners/validate'
|
6
|
+
require 'ecs_deploy_cli/runners/diff'
|
7
|
+
require 'ecs_deploy_cli/runners/update_crons'
|
8
|
+
require 'ecs_deploy_cli/runners/update_services'
|
9
|
+
require 'ecs_deploy_cli/runners/run_task'
|
10
|
+
require 'ecs_deploy_cli/runners/setup'
|
11
|
+
|
1
12
|
module EcsDeployCli
|
2
13
|
class Runner
|
3
14
|
def initialize(parser)
|
4
15
|
@parser = parser
|
5
16
|
end
|
6
17
|
|
18
|
+
def setup!
|
19
|
+
EcsDeployCli::Runners::Setup.new(@parser).run!
|
20
|
+
end
|
21
|
+
|
7
22
|
def validate!
|
8
|
-
@parser.
|
23
|
+
EcsDeployCli::Runners::Validate.new(@parser).run!
|
9
24
|
end
|
10
25
|
|
11
26
|
def update_crons!
|
12
|
-
|
13
|
-
|
14
|
-
crons.each do |cron_name, cron_definition|
|
15
|
-
task_definition = tasks[cron_definition[:task_name]]
|
16
|
-
raise "Undefined task #{cron_definition[:task_name].inspect} in (#{tasks.keys.inspect})" unless task_definition
|
17
|
-
|
18
|
-
updated_task = _update_task(task_definition)
|
19
|
-
|
20
|
-
current_target = cwe_client.list_targets_by_rule(
|
21
|
-
{
|
22
|
-
rule: cron_name,
|
23
|
-
limit: 1
|
24
|
-
}
|
25
|
-
).to_h[:targets].first
|
26
|
-
|
27
|
-
cwe_client.put_rule(
|
28
|
-
cron_definition[:rule]
|
29
|
-
)
|
30
|
-
|
31
|
-
cwe_client.put_targets(
|
32
|
-
rule: cron_name,
|
33
|
-
targets: [
|
34
|
-
id: current_target[:id],
|
35
|
-
arn: current_target[:arn],
|
36
|
-
role_arn: current_target[:role_arn],
|
37
|
-
input: cron_definition[:input].to_json,
|
38
|
-
ecs_parameters: cron_definition[:ecs_parameters].merge(task_definition_arn: updated_task[:task_definition_arn])
|
39
|
-
]
|
40
|
-
)
|
41
|
-
EcsDeployCli.logger.info "Deployed scheduled task \"#{cron_name}\"!"
|
42
|
-
end
|
27
|
+
EcsDeployCli::Runners::UpdateCrons.new(@parser).run!
|
43
28
|
end
|
44
29
|
|
45
|
-
def
|
46
|
-
|
47
|
-
|
48
|
-
).to_h[:container_instance_arns]
|
49
|
-
|
50
|
-
response = ecs_client.describe_container_instances(
|
51
|
-
cluster: config[:cluster],
|
52
|
-
container_instances: instances
|
53
|
-
)
|
54
|
-
|
55
|
-
EcsDeployCli.logger.info "Found instances: #{response.container_instances.map(&:ec2_instance_id).join(', ')}"
|
56
|
-
|
57
|
-
response = ec2_client.describe_instances(
|
58
|
-
instance_ids: response.container_instances.map(&:ec2_instance_id)
|
59
|
-
)
|
30
|
+
def run_task!(task_name, launch_type:, security_groups:, subnets:)
|
31
|
+
EcsDeployCli::Runners::RunTask.new(@parser).run!(task_name, launch_type: launch_type, security_groups: security_groups, subnets: subnets)
|
32
|
+
end
|
60
33
|
|
61
|
-
|
62
|
-
EcsDeployCli.
|
34
|
+
def ssh(**options)
|
35
|
+
EcsDeployCli::Runners::SSH.new(@parser).run!(options)
|
36
|
+
end
|
63
37
|
|
64
|
-
|
65
|
-
|
38
|
+
def diff
|
39
|
+
EcsDeployCli::Runners::Diff.new(@parser).run!
|
66
40
|
end
|
67
41
|
|
68
42
|
def update_services!(service: nil, timeout: 500)
|
69
|
-
|
70
|
-
|
71
|
-
services.each do |service_name, service_definition|
|
72
|
-
next if !service.nil? && service != service_name
|
73
|
-
|
74
|
-
task_definition = _update_task resolved_tasks[service_definition.options[:task]]
|
75
|
-
task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
|
76
|
-
|
77
|
-
ecs_client.update_service(
|
78
|
-
cluster: config[:cluster],
|
79
|
-
service: service_name,
|
80
|
-
task_definition: "#{task_definition[:family]}:#{task_name}"
|
81
|
-
)
|
82
|
-
wait_for_deploy(service_name, task_name, timeout: timeout)
|
83
|
-
EcsDeployCli.logger.info "Deployed service \"#{service_name}\"!"
|
84
|
-
end
|
43
|
+
EcsDeployCli::Runners::UpdateServices.new(@parser).run!(service: service, timeout: timeout)
|
85
44
|
end
|
86
45
|
|
87
46
|
private
|
88
47
|
|
89
|
-
def wait_for_deploy(service_name, task_name, timeout:)
|
90
|
-
wait_data = { cluster: config[:cluster], services: [service_name] }
|
91
|
-
|
92
|
-
started_at = Time.now
|
93
|
-
ecs_client.wait_until(
|
94
|
-
:services_stable, wait_data,
|
95
|
-
max_attempts: nil,
|
96
|
-
before_wait: lambda { |_, response|
|
97
|
-
deployments = response.services.first.deployments
|
98
|
-
log_deployments task_name, deployments
|
99
|
-
|
100
|
-
throw :success if deployments.count == 1 && deployments[0].task_definition.end_with?(task_name)
|
101
|
-
throw :failure if Time.now - started_at > timeout
|
102
|
-
}
|
103
|
-
)
|
104
|
-
end
|
105
|
-
|
106
48
|
def _update_task(definition)
|
107
49
|
ecs_client.register_task_definition(
|
108
50
|
definition
|
109
51
|
).to_h[:task_definition]
|
110
52
|
end
|
111
|
-
|
112
|
-
def log_deployments(task_name, deployments)
|
113
|
-
EcsDeployCli.logger.info "Waiting for task: #{task_name} to become ok."
|
114
|
-
EcsDeployCli.logger.info 'Deployment status:'
|
115
|
-
deployments.each do |deploy|
|
116
|
-
EcsDeployCli.logger.info "[#{deploy.status}] task=#{deploy.task_definition.split('/').last}, "\
|
117
|
-
"desired_count=#{deploy.desired_count}, pending_count=#{deploy.pending_count}, running_count=#{deploy.running_count}, failed_tasks=#{deploy.failed_tasks}"
|
118
|
-
end
|
119
|
-
EcsDeployCli.logger.info ''
|
120
|
-
end
|
121
|
-
|
122
|
-
def ec2_client
|
123
|
-
@ec2_client ||= begin
|
124
|
-
require 'aws-sdk-ec2'
|
125
|
-
Aws::EC2::Client.new(
|
126
|
-
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
127
|
-
region: config[:aws_region]
|
128
|
-
)
|
129
|
-
end
|
130
|
-
end
|
131
|
-
|
132
|
-
def ecs_client
|
133
|
-
@ecs_client ||= Aws::ECS::Client.new(
|
134
|
-
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
135
|
-
region: config[:aws_region]
|
136
|
-
)
|
137
|
-
end
|
138
|
-
|
139
|
-
def cwe_client
|
140
|
-
@cwe_client ||= begin
|
141
|
-
require 'aws-sdk-cloudwatchevents'
|
142
|
-
Aws::CloudWatchEvents::Client.new(
|
143
|
-
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
144
|
-
region: config[:aws_region]
|
145
|
-
)
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
def config
|
150
|
-
@parser.config
|
151
|
-
end
|
152
53
|
end
|
153
54
|
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EcsDeployCli
|
4
|
+
module Runners
|
5
|
+
class Base
|
6
|
+
def initialize(parser)
|
7
|
+
@parser = parser
|
8
|
+
end
|
9
|
+
|
10
|
+
def run!
|
11
|
+
raise NotImplementedError, 'abstract method'
|
12
|
+
end
|
13
|
+
|
14
|
+
def update_task(definition)
|
15
|
+
_update_task(definition)
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def _update_task(definition)
|
21
|
+
definition[:container_definitions].each do |container|
|
22
|
+
next unless container.dig(:log_configuration, :log_driver) == 'awslogs'
|
23
|
+
|
24
|
+
_create_cloudwatch_logs_if_needed(container.dig(:log_configuration, :options, 'awslogs-group'))
|
25
|
+
end
|
26
|
+
|
27
|
+
ecs_client.register_task_definition(
|
28
|
+
definition
|
29
|
+
).to_h[:task_definition]
|
30
|
+
end
|
31
|
+
|
32
|
+
def _create_cloudwatch_logs_if_needed(prefix)
|
33
|
+
log_group = cwl_client.describe_log_groups(log_group_name_prefix: prefix, limit: 1).to_h[:log_groups]
|
34
|
+
return if log_group.any?
|
35
|
+
|
36
|
+
cwl_client.create_log_group(log_group_name: prefix)
|
37
|
+
cwl_client.put_retention_policy(
|
38
|
+
log_group_name: prefix,
|
39
|
+
retention_in_days: 14
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
def ec2_client
|
44
|
+
@ec2_client ||= begin
|
45
|
+
require 'aws-sdk-ec2'
|
46
|
+
Aws::EC2::Client.new(
|
47
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
48
|
+
region: config[:aws_region]
|
49
|
+
)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def ecs_client
|
54
|
+
@ecs_client ||= Aws::ECS::Client.new(
|
55
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
56
|
+
region: config[:aws_region]
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
def cwe_client
|
61
|
+
@cwe_client ||= begin
|
62
|
+
require 'aws-sdk-cloudwatchevents'
|
63
|
+
Aws::CloudWatchEvents::Client.new(
|
64
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
65
|
+
region: config[:aws_region]
|
66
|
+
)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def ssm_client
|
71
|
+
@cwl_client ||= begin
|
72
|
+
require 'aws-sdk-ssm'
|
73
|
+
Aws::SSM::Client.new(
|
74
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
75
|
+
region: config[:aws_region]
|
76
|
+
)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def cwl_client
|
81
|
+
@cwl_client ||= begin
|
82
|
+
require 'aws-sdk-cloudwatchlogs'
|
83
|
+
Aws::CloudWatchLogs::Client.new(
|
84
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
85
|
+
region: config[:aws_region]
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def cf_client
|
91
|
+
@cl_client ||= begin
|
92
|
+
require 'aws-sdk-cloudformation'
|
93
|
+
Aws::CloudFormation::Client.new(
|
94
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
95
|
+
region: config[:aws_region]
|
96
|
+
)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def config
|
101
|
+
@parser.config
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|