chef-zero 1.0.1 → 1.1
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.
- data/lib/chef_zero/cookbook_data.rb +58 -24
- data/lib/chef_zero/data_normalizer.rb +1 -1
- data/lib/chef_zero/data_store/chef_fs_store.rb +96 -0
- data/lib/chef_zero/data_store/data_already_exists_error.rb +29 -0
- data/lib/chef_zero/data_store/data_error.rb +31 -0
- data/lib/chef_zero/data_store/data_not_found_error.rb +29 -0
- data/lib/chef_zero/data_store/memory_store.rb +164 -0
- data/lib/chef_zero/endpoints/authenticate_user_endpoint.rb +6 -2
- data/lib/chef_zero/endpoints/cookbook_endpoint.rb +4 -4
- data/lib/chef_zero/endpoints/cookbook_version_endpoint.rb +16 -12
- data/lib/chef_zero/endpoints/cookbooks_base.rb +10 -1
- data/lib/chef_zero/endpoints/cookbooks_endpoint.rb +1 -1
- data/lib/chef_zero/endpoints/data_bag_endpoint.rb +1 -6
- data/lib/chef_zero/endpoints/data_bags_endpoint.rb +2 -3
- data/lib/chef_zero/endpoints/environment_cookbook_endpoint.rb +2 -2
- data/lib/chef_zero/endpoints/environment_cookbook_versions_endpoint.rb +13 -19
- data/lib/chef_zero/endpoints/environment_cookbooks_endpoint.rb +1 -1
- data/lib/chef_zero/endpoints/environment_nodes_endpoint.rb +2 -2
- data/lib/chef_zero/endpoints/environment_recipes_endpoint.rb +2 -2
- data/lib/chef_zero/endpoints/environment_role_endpoint.rb +1 -0
- data/lib/chef_zero/endpoints/file_store_file_endpoint.rb +1 -1
- data/lib/chef_zero/endpoints/principal_endpoint.rb +2 -2
- data/lib/chef_zero/endpoints/rest_list_endpoint.rb +2 -5
- data/lib/chef_zero/endpoints/rest_object_endpoint.rb +9 -13
- data/lib/chef_zero/endpoints/sandbox_endpoint.rb +4 -6
- data/lib/chef_zero/endpoints/sandboxes_endpoint.rb +11 -4
- data/lib/chef_zero/endpoints/search_endpoint.rb +7 -9
- data/lib/chef_zero/endpoints/searches_endpoint.rb +1 -1
- data/lib/chef_zero/rest_base.rb +67 -9
- data/lib/chef_zero/rest_router.rb +5 -1
- data/lib/chef_zero/server.rb +15 -28
- data/lib/chef_zero/version.rb +1 -1
- metadata +7 -2
@@ -9,8 +9,12 @@ module ChefZero
|
|
9
9
|
request_json = JSON.parse(request.body, :create_additions => false)
|
10
10
|
name = request_json['name']
|
11
11
|
password = request_json['password']
|
12
|
-
|
13
|
-
|
12
|
+
begin
|
13
|
+
user = data_store.get(['users', name])
|
14
|
+
verified = JSON.parse(user, :create_additions => false)['password'] == password
|
15
|
+
rescue DataStore::DataNotFoundError
|
16
|
+
verified = false
|
17
|
+
end
|
14
18
|
json_response(200, {
|
15
19
|
'name' => name,
|
16
20
|
'verified' => !!verified
|
@@ -9,7 +9,7 @@ module ChefZero
|
|
9
9
|
case filter
|
10
10
|
when '_latest'
|
11
11
|
result = {}
|
12
|
-
filter_cookbooks(
|
12
|
+
filter_cookbooks(all_cookbooks_list, {}, 1) do |name, versions|
|
13
13
|
if versions.size > 0
|
14
14
|
result[name] = build_uri(request.base_uri, ['cookbooks', name, versions[0]])
|
15
15
|
end
|
@@ -17,15 +17,15 @@ module ChefZero
|
|
17
17
|
json_response(200, result)
|
18
18
|
when '_recipes'
|
19
19
|
result = []
|
20
|
-
filter_cookbooks(
|
20
|
+
filter_cookbooks(all_cookbooks_list, {}, 1) do |name, versions|
|
21
21
|
if versions.size > 0
|
22
|
-
cookbook = JSON.parse(
|
22
|
+
cookbook = JSON.parse(get_data(request, ['cookbooks', name, versions[0]]), :create_additions => false)
|
23
23
|
result += recipe_names(name, cookbook)
|
24
24
|
end
|
25
25
|
end
|
26
26
|
json_response(200, result.sort)
|
27
27
|
else
|
28
|
-
cookbook_list = { filter =>
|
28
|
+
cookbook_list = { filter => list_data(request, request.rest_path) }
|
29
29
|
json_response(200, format_cookbooks_list(request, cookbook_list))
|
30
30
|
end
|
31
31
|
end
|
@@ -9,7 +9,7 @@ module ChefZero
|
|
9
9
|
class CookbookVersionEndpoint < RestObjectEndpoint
|
10
10
|
def get(request)
|
11
11
|
if request.rest_path[2] == "_latest" || request.rest_path[2] == "latest"
|
12
|
-
request.rest_path[2] = latest_version(
|
12
|
+
request.rest_path[2] = latest_version(list_data(request, request.rest_path[0..1]))
|
13
13
|
end
|
14
14
|
super(request)
|
15
15
|
end
|
@@ -17,9 +17,8 @@ module ChefZero
|
|
17
17
|
def put(request)
|
18
18
|
name = request.rest_path[1]
|
19
19
|
version = request.rest_path[2]
|
20
|
-
|
21
|
-
|
22
|
-
|
20
|
+
existing_cookbook = get_data(request, request.rest_path, :nil)
|
21
|
+
|
23
22
|
# Honor frozen
|
24
23
|
if existing_cookbook
|
25
24
|
existing_cookbook_json = JSON.parse(existing_cookbook, :create_additions => false)
|
@@ -37,7 +36,7 @@ module ChefZero
|
|
37
36
|
end
|
38
37
|
|
39
38
|
# Set the cookbook
|
40
|
-
|
39
|
+
set_data(request, ['cookbooks', name, version], request.body, :create_dir, :create)
|
41
40
|
|
42
41
|
# If the cookbook was updated, check for deleted files and clean them up
|
43
42
|
if existing_cookbook
|
@@ -47,18 +46,19 @@ module ChefZero
|
|
47
46
|
end
|
48
47
|
end
|
49
48
|
|
50
|
-
already_json_response(existing_cookbook ? 200 : 201, populate_defaults(request,
|
49
|
+
already_json_response(existing_cookbook ? 200 : 201, populate_defaults(request, request.body))
|
51
50
|
end
|
52
51
|
|
53
52
|
def delete(request)
|
54
53
|
if request.rest_path[2] == "_latest" || request.rest_path[2] == "latest"
|
55
|
-
request.rest_path[2] = latest_version(
|
54
|
+
request.rest_path[2] = latest_version(list_data(request, request.rest_path[0..1]))
|
56
55
|
end
|
57
56
|
|
58
|
-
deleted_cookbook = get_data(request
|
57
|
+
deleted_cookbook = get_data(request)
|
58
|
+
|
59
59
|
response = super(request)
|
60
60
|
cookbook_name = request.rest_path[1]
|
61
|
-
|
61
|
+
delete_data_dir(request, ['cookbooks', cookbook_name]) if list_data(request, ['cookbooks', cookbook_name]).size == 0
|
62
62
|
|
63
63
|
# Hoover deleted files, if they exist
|
64
64
|
hoover_unused_checksums(get_checksums(deleted_cookbook))
|
@@ -79,14 +79,18 @@ module ChefZero
|
|
79
79
|
result
|
80
80
|
end
|
81
81
|
|
82
|
+
private
|
83
|
+
|
82
84
|
def hoover_unused_checksums(deleted_checksums)
|
83
|
-
|
84
|
-
|
85
|
+
data_store.list(['cookbooks']).each do |cookbook_name|
|
86
|
+
data_store.list(['cookbooks', cookbook_name]).each do |version|
|
87
|
+
cookbook = data_store.get(['cookbooks', cookbook_name, version])
|
85
88
|
deleted_checksums = deleted_checksums - get_checksums(cookbook)
|
86
89
|
end
|
87
90
|
end
|
88
91
|
deleted_checksums.each do |checksum|
|
89
|
-
|
92
|
+
# There can be a race here if multiple cookbooks are uploading.
|
93
|
+
data_store.delete(['file_store', 'checksums', checksum])
|
90
94
|
end
|
91
95
|
end
|
92
96
|
|
@@ -23,11 +23,20 @@ module ChefZero
|
|
23
23
|
results
|
24
24
|
end
|
25
25
|
|
26
|
+
def all_cookbooks_list
|
27
|
+
result = {}
|
28
|
+
# Race conditions exist here (if someone deletes while listing). I don't care.
|
29
|
+
data_store.list(['cookbooks']).each do |name|
|
30
|
+
result[name] = data_store.list(['cookbooks', name])
|
31
|
+
end
|
32
|
+
result
|
33
|
+
end
|
34
|
+
|
26
35
|
def filter_cookbooks(cookbooks_list, constraints = {}, num_versions = nil)
|
27
36
|
cookbooks_list.keys.sort.each do |name|
|
28
37
|
constraint = Gem::Requirement.new(constraints[name])
|
29
38
|
versions = []
|
30
|
-
cookbooks_list[name].
|
39
|
+
cookbooks_list[name].sort_by { |version| Gem::Version.new(version.dup) }.reverse.each do |version|
|
31
40
|
break if num_versions && versions.size >= num_versions
|
32
41
|
if constraint.satisfied_by?(Gem::Version.new(version.dup))
|
33
42
|
versions << version
|
@@ -32,12 +32,7 @@ module ChefZero
|
|
32
32
|
|
33
33
|
def delete(request)
|
34
34
|
key = request.rest_path[1]
|
35
|
-
|
36
|
-
if !container.has_key?(key)
|
37
|
-
raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}")
|
38
|
-
end
|
39
|
-
result = container[key]
|
40
|
-
container.delete(key)
|
35
|
+
delete_data_dir(request, request.rest_path, :recursive)
|
41
36
|
json_response(200, {
|
42
37
|
'chef_type' => 'data_bag',
|
43
38
|
'json_class' => 'Chef::DataBag',
|
@@ -6,15 +6,14 @@ module ChefZero
|
|
6
6
|
# /data
|
7
7
|
class DataBagsEndpoint < RestListEndpoint
|
8
8
|
def post(request)
|
9
|
-
container = get_data(request)
|
10
9
|
contents = request.body
|
11
10
|
name = JSON.parse(contents, :create_additions => false)[identity_key]
|
12
11
|
if name.nil?
|
13
12
|
error(400, "Must specify '#{identity_key}' in JSON")
|
14
|
-
elsif
|
13
|
+
elsif exists_data_dir?(request, ['data', name])
|
15
14
|
error(409, "Object already exists")
|
16
15
|
else
|
17
|
-
|
16
|
+
data_store.create_dir(['data'], name, :keep_existing)
|
18
17
|
json_response(201, {"uri" => "#{build_uri(request.base_uri, request.rest_path + [name])}"})
|
19
18
|
end
|
20
19
|
end
|
@@ -9,7 +9,7 @@ module ChefZero
|
|
9
9
|
cookbook_name = request.rest_path[3]
|
10
10
|
environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false)
|
11
11
|
constraints = environment['cookbook_versions'] || {}
|
12
|
-
|
12
|
+
cookbook_versions = list_data(request, request.rest_path[2..3])
|
13
13
|
if request.query_params['num_versions'] == 'all'
|
14
14
|
num_versions = nil
|
15
15
|
elsif request.query_params['num_versions']
|
@@ -17,7 +17,7 @@ module ChefZero
|
|
17
17
|
else
|
18
18
|
num_versions = nil
|
19
19
|
end
|
20
|
-
json_response(200, format_cookbooks_list(request, { cookbook_name =>
|
20
|
+
json_response(200, format_cookbooks_list(request, { cookbook_name => cookbook_versions }, constraints, num_versions))
|
21
21
|
end
|
22
22
|
end
|
23
23
|
end
|
@@ -6,27 +6,21 @@ module ChefZero
|
|
6
6
|
module Endpoints
|
7
7
|
# /environments/NAME/cookbook_versions
|
8
8
|
class EnvironmentCookbookVersionsEndpoint < RestBase
|
9
|
-
def cookbooks
|
10
|
-
data['cookbooks']
|
11
|
-
end
|
12
|
-
|
13
|
-
def environments
|
14
|
-
data['environments']
|
15
|
-
end
|
16
|
-
|
17
9
|
def post(request)
|
10
|
+
cookbook_names = list_data(request, ['cookbooks'])
|
11
|
+
|
18
12
|
# Get the list of cookbooks and versions desired by the runlist
|
19
13
|
desired_versions = {}
|
20
14
|
run_list = JSON.parse(request.body, :create_additions => false)['run_list']
|
21
15
|
run_list.each do |run_list_entry|
|
22
16
|
if run_list_entry =~ /(.+)(::.+)?\@(.+)/
|
23
|
-
raise RestErrorResponse.new(412, "No such cookbook: #{$1}") if !
|
24
|
-
raise RestErrorResponse.new(412, "No such cookbook version for cookbook #{$1}: #{$
|
17
|
+
raise RestErrorResponse.new(412, "No such cookbook: #{$1}") if !cookbook_names.include?($1)
|
18
|
+
raise RestErrorResponse.new(412, "No such cookbook version for cookbook #{$1}: #{$3}") if !list_data(request, ['cookbooks', $1]).include?($3)
|
25
19
|
desired_versions[$1] = [ $3 ]
|
26
20
|
else
|
27
21
|
desired_cookbook = run_list_entry.split('::')[0]
|
28
|
-
raise RestErrorResponse.new(412, "No such cookbook: #{desired_cookbook}") if !
|
29
|
-
desired_versions[desired_cookbook] = cookbooks
|
22
|
+
raise RestErrorResponse.new(412, "No such cookbook: #{desired_cookbook}") if !cookbook_names.include?(desired_cookbook)
|
23
|
+
desired_versions[desired_cookbook] = list_data(request, ['cookbooks', desired_cookbook])
|
30
24
|
end
|
31
25
|
end
|
32
26
|
|
@@ -39,20 +33,20 @@ module ChefZero
|
|
39
33
|
end
|
40
34
|
|
41
35
|
# Depsolve!
|
42
|
-
solved = depsolve(desired_versions.keys, desired_versions, environment_constraints)
|
36
|
+
solved = depsolve(request, desired_versions.keys, desired_versions, environment_constraints)
|
43
37
|
if !solved
|
44
38
|
return raise RestErrorResponse.new(412, "Unsolvable versions!")
|
45
39
|
end
|
46
40
|
|
47
41
|
result = {}
|
48
42
|
solved.each_pair do |name, versions|
|
49
|
-
cookbook = JSON.parse(
|
43
|
+
cookbook = JSON.parse(get_data(request, ['cookbooks', name, versions[0]]), :create_additions => false)
|
50
44
|
result[name] = DataNormalizer.normalize_cookbook(cookbook, name, versions[0], request.base_uri, 'GET')
|
51
45
|
end
|
52
46
|
json_response(200, result)
|
53
47
|
end
|
54
48
|
|
55
|
-
def depsolve(unsolved, desired_versions, environment_constraints)
|
49
|
+
def depsolve(request, unsolved, desired_versions, environment_constraints)
|
56
50
|
return nil if desired_versions.values.any? { |versions| versions.empty? }
|
57
51
|
|
58
52
|
# If everything is already
|
@@ -67,7 +61,7 @@ module ChefZero
|
|
67
61
|
new_unsolved = unsolved[1..-1]
|
68
62
|
|
69
63
|
# Pick this cookbook, and add dependencies
|
70
|
-
cookbook_obj = JSON.parse(cookbooks
|
64
|
+
cookbook_obj = JSON.parse(get_data(request, ['cookbooks', solve_for, desired_version]), :create_additions => false)
|
71
65
|
cookbook_metadata = cookbook_obj['metadata'] || {}
|
72
66
|
cookbook_dependencies = cookbook_metadata['dependencies'] || {}
|
73
67
|
dep_not_found = false
|
@@ -77,11 +71,11 @@ module ChefZero
|
|
77
71
|
if !new_desired_versions.has_key?(dep_name)
|
78
72
|
new_unsolved = new_unsolved + [dep_name]
|
79
73
|
# If the dep is missing, we will try other versions of the cookbook that might not have the bad dep.
|
80
|
-
if !cookbooks
|
74
|
+
if !exists_data_dir?(request, ['cookbooks', dep_name])
|
81
75
|
dep_not_found = true
|
82
76
|
break
|
83
77
|
end
|
84
|
-
new_desired_versions[dep_name] = cookbooks
|
78
|
+
new_desired_versions[dep_name] = list_data(request, ['cookbooks', dep_name])
|
85
79
|
new_desired_versions = filter_by_constraint(new_desired_versions, dep_name, environment_constraints[dep_name])
|
86
80
|
end
|
87
81
|
new_desired_versions = filter_by_constraint(new_desired_versions, dep_name, dep_constraint)
|
@@ -90,7 +84,7 @@ module ChefZero
|
|
90
84
|
next if dep_not_found
|
91
85
|
|
92
86
|
# Depsolve children with this desired version! First solution wins.
|
93
|
-
result = depsolve(new_unsolved, new_desired_versions, environment_constraints)
|
87
|
+
result = depsolve(request, new_unsolved, new_desired_versions, environment_constraints)
|
94
88
|
return result if result
|
95
89
|
end
|
96
90
|
return nil
|
@@ -15,7 +15,7 @@ module ChefZero
|
|
15
15
|
else
|
16
16
|
num_versions = 1
|
17
17
|
end
|
18
|
-
json_response(200, format_cookbooks_list(request,
|
18
|
+
json_response(200, format_cookbooks_list(request, all_cookbooks_list, constraints, num_versions))
|
19
19
|
end
|
20
20
|
end
|
21
21
|
end
|
@@ -10,8 +10,8 @@ module ChefZero
|
|
10
10
|
get_data(request, request.rest_path[0..1])
|
11
11
|
|
12
12
|
result = {}
|
13
|
-
|
14
|
-
|
13
|
+
list_data(request, ['nodes']).each do |name|
|
14
|
+
node = JSON.parse(get_data(request, ['nodes', name]), :create_additions => false)
|
15
15
|
if node['chef_environment'] == request.rest_path[1]
|
16
16
|
result[name] = build_uri(request.base_uri, 'nodes', name)
|
17
17
|
end
|
@@ -9,9 +9,9 @@ module ChefZero
|
|
9
9
|
environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false)
|
10
10
|
constraints = environment['cookbook_versions'] || {}
|
11
11
|
result = []
|
12
|
-
filter_cookbooks(
|
12
|
+
filter_cookbooks(all_cookbooks_list, constraints, 1) do |name, versions|
|
13
13
|
if versions.size > 0
|
14
|
-
cookbook = JSON.parse(
|
14
|
+
cookbook = JSON.parse(get_data(request, ['cookbooks', name, versions[0]]), :create_additions => false)
|
15
15
|
result += recipe_names(name, cookbook)
|
16
16
|
end
|
17
17
|
end
|
@@ -15,6 +15,7 @@ module ChefZero
|
|
15
15
|
environment_path = request.rest_path[2..3]
|
16
16
|
role_path = request.rest_path[0..1]
|
17
17
|
end
|
18
|
+
# Verify that the environment exists
|
18
19
|
get_data(request, environment_path)
|
19
20
|
|
20
21
|
role = JSON.parse(get_data(request, role_path), :create_additions => false)
|
@@ -8,11 +8,11 @@ module ChefZero
|
|
8
8
|
class PrincipalEndpoint < RestBase
|
9
9
|
def get(request)
|
10
10
|
name = request.rest_path[-1]
|
11
|
-
json =
|
11
|
+
json = get_data(request, [ 'users', name ], :nil)
|
12
12
|
if json
|
13
13
|
type = 'user'
|
14
14
|
else
|
15
|
-
json =
|
15
|
+
json = get_data(request, [ 'clients', name ], :nil)
|
16
16
|
type = 'client'
|
17
17
|
end
|
18
18
|
if json
|
@@ -15,22 +15,19 @@ module ChefZero
|
|
15
15
|
def get(request)
|
16
16
|
# Get the result
|
17
17
|
result_hash = {}
|
18
|
-
|
18
|
+
list_data(request).sort.each do |name|
|
19
19
|
result_hash[name] = "#{build_uri(request.base_uri, request.rest_path + [name])}"
|
20
20
|
end
|
21
21
|
json_response(200, result_hash)
|
22
22
|
end
|
23
23
|
|
24
24
|
def post(request)
|
25
|
-
container = get_data(request)
|
26
25
|
contents = request.body
|
27
26
|
key = get_key(contents)
|
28
27
|
if key.nil?
|
29
28
|
error(400, "Must specify '#{identity_key}' in JSON")
|
30
|
-
elsif container[key]
|
31
|
-
error(409, 'Object already exists')
|
32
29
|
else
|
33
|
-
|
30
|
+
create_data(request, request.rest_path, key, contents)
|
34
31
|
json_response(201, {'uri' => "#{build_uri(request.base_uri, request.rest_path + [key])}"})
|
35
32
|
end
|
36
33
|
end
|
@@ -22,33 +22,29 @@ module ChefZero
|
|
22
22
|
old_body = get_data(request)
|
23
23
|
request_json = JSON.parse(request.body, :create_additions => false)
|
24
24
|
key = request_json[identity_key] || request.rest_path[-1]
|
25
|
-
container = get_data(request, request.rest_path[0..-2])
|
26
25
|
# If it's a rename, check for conflict and delete the old value
|
27
26
|
rename = key != request.rest_path[-1]
|
28
27
|
if rename
|
29
|
-
|
28
|
+
begin
|
29
|
+
data_store.create(request.rest_path[0..-2], key, request.body)
|
30
|
+
rescue DataStore::DataAlreadyExistsError
|
30
31
|
return error(409, "Cannot rename '#{request.rest_path[-1]}' to '#{key}': '#{key}' already exists")
|
31
32
|
end
|
32
|
-
|
33
|
+
delete_data(request)
|
34
|
+
else
|
35
|
+
set_data(request, request.rest_path, request.body)
|
33
36
|
end
|
34
|
-
container[key] = request.body
|
35
37
|
already_json_response(200, populate_defaults(request, request.body))
|
36
38
|
end
|
37
39
|
|
38
40
|
def delete(request)
|
39
|
-
|
40
|
-
|
41
|
-
if !container.has_key?(key)
|
42
|
-
raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}")
|
43
|
-
end
|
44
|
-
result = container[key]
|
45
|
-
container.delete(key)
|
41
|
+
result = get_data(request)
|
42
|
+
delete_data(request)
|
46
43
|
already_json_response(200, populate_defaults(request, result))
|
47
44
|
end
|
48
45
|
|
49
46
|
def patch_request_body(request)
|
50
|
-
|
51
|
-
existing_value = container[request.rest_path[-1]]
|
47
|
+
existing_value = get_data(request, nil, :nil)
|
52
48
|
if existing_value
|
53
49
|
request_json = JSON.parse(request.body, :create_additions => false)
|
54
50
|
existing_json = JSON.parse(existing_value, :create_additions => false)
|
@@ -5,15 +5,13 @@ module ChefZero
|
|
5
5
|
# /sandboxes/ID
|
6
6
|
class SandboxEndpoint < RestBase
|
7
7
|
def put(request)
|
8
|
-
existing_sandbox = get_data(request,
|
9
|
-
|
10
|
-
time_str = existing_sandbox[:create_time].strftime('%Y-%m-%dT%H:%M:%S%z')
|
11
|
-
time_str = "#{time_str[0..21]}:#{time_str[22..23]}"
|
8
|
+
existing_sandbox = JSON.parse(get_data(request), :create_additions => false)
|
9
|
+
delete_data(request)
|
12
10
|
json_response(200, {
|
13
11
|
:guid => request.rest_path[1],
|
14
12
|
:name => request.rest_path[1],
|
15
|
-
:checksums => existing_sandbox[
|
16
|
-
:create_time =>
|
13
|
+
:checksums => existing_sandbox['checksums'],
|
14
|
+
:create_time => existing_sandbox['create_time'],
|
17
15
|
:is_completed => true
|
18
16
|
})
|
19
17
|
end
|