ace-lab 0.8.0 → 0.13.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -13
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -102
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -43,6 +43,21 @@ module Ace
43
43
  "invalid lab configuration: trusted authorization file could not be parsed"
44
44
  end
45
45
 
46
+ # Validate an installed directory through the same original-path
47
+ # traversal as files, including root-owned system symlink aliases.
48
+ def trusted_directory!(path)
49
+ candidate = verified_resolve(path)
50
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable" unless candidate
51
+ File.open(candidate, File::RDONLY | File::NOFOLLOW) do |handle|
52
+ unless handle.stat.directory? && secure_file_stat?(handle.stat)
53
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory ownership or mode differs"
54
+ end
55
+ end
56
+ true
57
+ rescue SystemCallError
58
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable"
59
+ end
60
+
46
61
  # @param documents [Array<Hash>] {path:, document:, defaults:}
47
62
  # from Ace::Lab.cascade_documents
48
63
  # @param topology [Hash] normalized topology (project IDs)
@@ -98,6 +113,39 @@ module Ace
98
113
  end
99
114
  end
100
115
 
116
+ # Shared per-principal validation so the read side
117
+ # (`parse_grants`) and the installer's writer
118
+ # (`Molecules::GrantProvisioner`) cannot drift (review r1):
119
+ # principal names are stripped and each policy must be a
120
+ # mapping with a `projects` array of non-empty strings.
121
+ # Messages are value-free.
122
+ # @param principals [Object] parsed principals mapping
123
+ # @param path [String] trusted file location for messages
124
+ # @return [Hash] principal => {"projects" => stripped, uniq ids}
125
+ # @raise [Ace::Lab::InvalidConfigurationError]
126
+ def validate_principals(principals, path)
127
+ unless principals.is_a?(Hash)
128
+ raise Ace::Lab::InvalidConfigurationError,
129
+ "invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
130
+ end
131
+
132
+ principals.map do |identity, policy|
133
+ unless identity.is_a?(String) && !identity.strip.empty?
134
+ raise Ace::Lab::InvalidConfigurationError,
135
+ "invalid lab configuration: trusted authorization file #{path} principal name " \
136
+ "must be a non-empty string"
137
+ end
138
+ unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
139
+ policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
140
+ raise Ace::Lab::InvalidConfigurationError,
141
+ "invalid lab configuration: trusted authorization file #{path} principal projects " \
142
+ "must be an array of non-empty strings"
143
+ end
144
+
145
+ [identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
146
+ end.to_h
147
+ end
148
+
101
149
  private
102
150
 
103
151
  def verify_error(path)
@@ -168,29 +216,7 @@ module Ace
168
216
  "invalid lab configuration: trusted authorization file #{path} must contain a YAML mapping"
169
217
  end
170
218
 
171
- principals = document["principals"] || {}
172
- unless principals.is_a?(Hash)
173
- raise Ace::Lab::InvalidConfigurationError,
174
- "invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
175
- end
176
-
177
- validated = principals.map do |identity, policy|
178
- unless identity.is_a?(String) && !identity.strip.empty?
179
- raise Ace::Lab::InvalidConfigurationError,
180
- "invalid lab configuration: trusted authorization file #{path} principal name " \
181
- "must be a non-empty string"
182
- end
183
- unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
184
- policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
185
- raise Ace::Lab::InvalidConfigurationError,
186
- "invalid lab configuration: trusted authorization file #{path} principal projects " \
187
- "must be an array of non-empty strings"
188
- end
189
-
190
- [identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
191
- end
192
-
193
- {"principals" => validated.to_h}
219
+ {"principals" => validate_principals(document["principals"] || {}, path)}
194
220
  rescue Psych::Exception
195
221
  raise Ace::Lab::InvalidConfigurationError,
196
222
  "invalid lab configuration: trusted authorization file #{path} could not be parsed as YAML"
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "yaml"
5
+ require "date"
6
+ require "fileutils"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Installs the manifest's HITL adapter declarations as the runtime
12
+ # route configuration the ace-hitl cascade actually reads
13
+ # (PR #393 final audit, FINAL-LAB2): `hitl.adapters.routes` in the
14
+ # project's `.ace/hitl/config.yml`. The trusted install manifest
15
+ # stays the single declaration owner — this molecule only
16
+ # PROJECTS that declaration into the existing HITL configuration
17
+ # contract; ace-hitl remains its owner and reader. A `kind: hitl`
18
+ # adapter becomes one route entry named after the adapter id
19
+ # (config entries are carried verbatim; a `route` key renames the
20
+ # route), so the runtime route loader finds a non-empty, valid
21
+ # route set instead of failing with NoRoutesError.
22
+ #
23
+ # Validation mirrors Ace::Hitl::Molecules::HitlRouteLoader
24
+ # (non-empty list of mappings, non-empty `adapter`, single-segment
25
+ # route names — delivery retention persists route names as literal
26
+ # dot-keys — and no duplicate names); the committed suite
27
+ # cross-checks the written document against the real loader.
28
+ #
29
+ # Convergence rules (review 8xir47, findings 5-6): routes are
30
+ # converged BY ROUTE NAME. On re-install the manifest-declared
31
+ # keys win over the existing same-name route, but operator
32
+ # extensions SURVIVE: extra keys the operator added to a
33
+ # same-name route (e.g. `pane: p5` on `console`) are kept and
34
+ # merged around the manifest keys, and operator-added routes
35
+ # survive untouched. Verify applies the same rule — a route is
36
+ # converged when a same-name entry carries every
37
+ # manifest-declared key/value; operator extra keys never fail
38
+ # verification. An unchanged route list is never rewritten, so
39
+ # an idempotent no-op install leaves the file (including any
40
+ # operator comments) byte-identical. When a write DOES happen
41
+ # it goes through a YAML round-trip (atomic: exclusive temp
42
+ # file + rename, the ProjectRegistry pattern), which normalizes
43
+ # formatting and DROPS comments in the file — comments are not
44
+ # preserved across a converging write.
45
+ class HitlRoutes
46
+ RELATIVE_CONFIG_PATH = %w[.ace hitl config.yml].freeze
47
+ ROUTE_NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
48
+
49
+ # @param adapters [Array<Hash>] normalized manifest adapters
50
+ # @return [Array<Hash>] route entries for the `kind: hitl`
51
+ # declarations (string keys, `adapter` => adapter id merged
52
+ # with the declaration's config)
53
+ def self.expected_routes(adapters)
54
+ adapters.filter_map do |adapter|
55
+ next unless adapter["kind"] == "hitl"
56
+
57
+ {"adapter" => adapter["id"]}.merge(adapter["config"] || {})
58
+ end
59
+ end
60
+
61
+ # @param route [Hash] route entry
62
+ # @return [String] the entry's route name (defaults to the
63
+ # adapter name, exactly like the runtime loader)
64
+ def self.route_name(route)
65
+ name = route["route"].to_s.strip
66
+ name.empty? ? route["adapter"].to_s : name
67
+ end
68
+
69
+ # The runtime loader's validation contract; nil when the route
70
+ # list is loadable, otherwise the concrete reason.
71
+ # @param routes [Object] the routes value from the config
72
+ # @return [String, nil]
73
+ def self.validation_reason(routes)
74
+ unless routes.is_a?(Array) && !routes.empty?
75
+ return "hitl.adapters.routes must be a non-empty list"
76
+ end
77
+
78
+ names = []
79
+ routes.each do |route|
80
+ return "route entry must be a mapping" unless route.is_a?(Hash)
81
+
82
+ if route["adapter"].to_s.strip.empty?
83
+ return "route entry missing required 'adapter'"
84
+ end
85
+
86
+ name = route_name(route)
87
+ unless name.match?(ROUTE_NAME_PATTERN)
88
+ return "route name '#{name}' is invalid (must match #{ROUTE_NAME_PATTERN.source})"
89
+ end
90
+ names << name
91
+ end
92
+ duplicates = names.tally.select { |_, count| count > 1 }.keys
93
+ return "duplicate route name(s) #{duplicates.sort.join(', ')}" unless duplicates.empty?
94
+
95
+ nil
96
+ end
97
+
98
+ # Readiness probe (verify): the project's installed HITL
99
+ # configuration must exist, parse, satisfy the loader contract,
100
+ # and contain the expected route BY NAME with every
101
+ # manifest-declared key matching (operator extra keys on the
102
+ # same-name route are allowed — an extended route is converged,
103
+ # not drift). nil when ready, otherwise the concrete reason.
104
+ # @param root [String] project root
105
+ # @param expected [Hash] expected route entry
106
+ # @return [String, nil]
107
+ def self.config_reason(root:, expected:)
108
+ path = File.join(root, *RELATIVE_CONFIG_PATH)
109
+ unless File.file?(path)
110
+ return "no HITL adapter routes are configured: '#{path}' is missing " \
111
+ "(the runtime cannot deliver HITL; rerun ace-lab install)"
112
+ end
113
+
114
+ begin
115
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
116
+ rescue Psych::Exception
117
+ return "'#{path}' could not be parsed as YAML"
118
+ end
119
+ return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
120
+
121
+ routes = document.dig("hitl", "adapters", "routes")
122
+ reason = validation_reason(routes)
123
+ return "hitl.adapters.routes in '#{path}' is invalid: #{reason}" if reason
124
+
125
+ name = route_name(expected)
126
+ route = routes.find { |candidate| route_name(candidate) == name }
127
+ unless route && expected.all? { |key, value| route[key] == value }
128
+ return "route '#{name}' is not configured in '#{path}' with the declared " \
129
+ "settings (rerun ace-lab install)"
130
+ end
131
+
132
+ nil
133
+ end
134
+
135
+ # @param root [String] project root
136
+ # @param adapters [Array<Hash>] normalized manifest adapters
137
+ # @param dry_run [Boolean]
138
+ # @return [Hash] {location:, status:, message:} — status is
139
+ # created / exists / would_create / skipped / failed
140
+ def install(root:, adapters:, dry_run: false)
141
+ path = File.join(root, *RELATIVE_CONFIG_PATH)
142
+ base = {"location" => path}
143
+ expected = self.class.expected_routes(adapters)
144
+ return base.merge("status" => "skipped", "message" => nil) if expected.empty?
145
+
146
+ document, reason = load_document(path)
147
+ return base.merge("status" => "failed", "message" => reason) if reason
148
+
149
+ routes = document.dig("hitl", "adapters", "routes")
150
+ routes = routes.is_a?(Array) ? routes : []
151
+ merged = merge_routes(routes, expected)
152
+ if (invalid = self.class.validation_reason(merged))
153
+ return base.merge("status" => "failed",
154
+ "message" => "resulting hitl.adapters.routes is invalid: #{invalid}")
155
+ end
156
+ return base.merge("status" => "exists", "message" => nil) if routes == merged
157
+ return base.merge("status" => "would_create", "message" => nil) if dry_run
158
+
159
+ document["hitl"] ||= {}
160
+ document["hitl"]["adapters"] ||= {}
161
+ document["hitl"]["adapters"]["routes"] = merged
162
+ begin
163
+ dir = File.dirname(path)
164
+ FileUtils.mkdir_p(dir)
165
+ # Atomic write (review 8xir47, finding 5, the ProjectRegistry
166
+ # pattern): a crash mid-write must never truncate the runtime
167
+ # route configuration. The temp file is created EXCLUSIVELY
168
+ # (O_CREAT|O_EXCL) under an unguessable name so a planted
169
+ # symlink can never redirect the write.
170
+ tmp = write_exclusive(dir, YAML.dump(document))
171
+ File.rename(tmp, path)
172
+ rescue SystemCallError, IOError => e
173
+ File.delete(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
174
+ return base.merge("status" => "failed",
175
+ "message" => "writing '#{path}' failed: #{e.class}: #{e.message}")
176
+ end
177
+ base.merge("status" => "created", "message" => nil)
178
+ end
179
+
180
+ private
181
+
182
+ # An existing configuration document is preserved (root_dir and
183
+ # operator-owned settings survive); only the lab-declared routes
184
+ # are converged. Unparseable or foreign-shaped content is
185
+ # refused instead of overwritten.
186
+ def load_document(path)
187
+ return [{}, nil] unless File.exist?(path)
188
+
189
+ begin
190
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
191
+ rescue Psych::Exception
192
+ return [nil, "'#{path}' exists but could not be parsed as YAML; resolve it " \
193
+ "manually and rerun ace-lab install"]
194
+ end
195
+ return [nil, "'#{path}' must contain a YAML mapping"] unless document.is_a?(Hash)
196
+
197
+ [document, nil]
198
+ end
199
+
200
+ # Converge the expected routes into the existing list by route
201
+ # name: a declared route's keys are MERGED into the entry
202
+ # carrying the same name (manifest-declared keys win, operator
203
+ # extra keys like `pane: p5` survive), a route with no
204
+ # same-name counterpart is appended; operator-added routes and
205
+ # their order are untouched.
206
+ def merge_routes(routes, expected)
207
+ merged = routes.map(&:dup)
208
+ expected.each do |route|
209
+ name = self.class.route_name(route)
210
+ index = merged.index { |existing| self.class.route_name(existing) == name }
211
+ if index
212
+ merged[index] = merged[index].merge(route)
213
+ else
214
+ merged << route.dup
215
+ end
216
+ end
217
+ merged
218
+ end
219
+
220
+ # NOTE: merge_routes uses a shallow Hash#merge — manifest keys
221
+ # win per-key, but a nested manifest value would replace an
222
+ # operator's nested value wholesale. Current route shapes are
223
+ # flat (adapter/pane/runtime/route), so shallow is correct.
224
+
225
+ # Create the temp file exclusively (never following a symlink),
226
+ # write the payload, close, and return the path — the
227
+ # ProjectRegistry atomic-write discipline. Raises
228
+ # SystemCallError/IOError on failure so the caller classifies.
229
+ def write_exclusive(dir, payload)
230
+ require "securerandom"
231
+ 100.times do
232
+ candidate = File.join(dir, ".config.yml.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
233
+ next if File.exist?(candidate) # includes symlinks (lstat)
234
+
235
+ begin
236
+ file = File.open(candidate, File::RDWR | File::CREAT | File::EXCL, 0o600)
237
+ rescue Errno::EEXIST
238
+ next
239
+ end
240
+ begin
241
+ file.write(payload)
242
+ rescue StandardError
243
+ # A failed write must not leak the exclusive temp file:
244
+ # close, remove, and re-raise verbatim so the caller
245
+ # still classifies (encoding failures included).
246
+ file.close unless file.closed?
247
+ begin
248
+ File.delete(candidate)
249
+ rescue SystemCallError
250
+ nil
251
+ end
252
+ raise
253
+ ensure
254
+ file.close unless file.closed?
255
+ end
256
+ return candidate
257
+ end
258
+ raise SystemCallError.new("could not create an exclusive temporary file in '#{dir}'", 0)
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../atoms/install_schema"
5
+ require_relative "grant_resolver"
6
+ require "yaml"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Loads the trusted install manifest through the same fail-closed
12
+ # deployment-ownership verification as the authorization document
13
+ # (GrantResolver.read_verified): every traversed element root-owned
14
+ # and not group/world-writable, O_NOFOLLOW on the final file. The
15
+ # loader adds nothing to trust — it only classifies absence and parse
16
+ # failures with value-free, path-bearing messages.
17
+ class InstallManifestLoader
18
+ class << self
19
+ # @param path [String] trusted manifest location (default: the
20
+ # fixed deployment path)
21
+ # @param platform [String, nil] platform for home-path defaults
22
+ # @return [Hash] normalized manifest (Atoms::InstallSchema)
23
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
24
+ # unparseable or schema-invalid manifest
25
+ def load(path: Ace::Lab.install_manifest_path, platform: nil)
26
+ # The nested GrantResolver message speaks of the
27
+ # "authorization file" — a wrong remedy lead inside an
28
+ # install-manifest error. The wrapper already states the
29
+ # requirement, so the nested message is not embedded.
30
+ content = begin
31
+ GrantResolver.read_verified(path)
32
+ rescue Ace::Lab::InvalidConfigurationError
33
+ raise Ace::Lab::InvalidConfigurationError,
34
+ "invalid lab install manifest: #{path} failed trusted deployment verification " \
35
+ "or could not be read; failing closed"
36
+ end
37
+
38
+ if content.nil?
39
+ raise Ace::Lab::InvalidConfigurationError,
40
+ "invalid lab install manifest: #{path} not found; create the root-owned " \
41
+ "deployment manifest at this path (administrator action) " \
42
+ "and keep it outside the caller-writable configuration cascade"
43
+ end
44
+
45
+ document = begin
46
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
47
+ rescue Psych::DisallowedClass
48
+ # It parsed; it just carries types outside the permitted
49
+ # set — a different remedy than fixing YAML syntax.
50
+ raise Ace::Lab::InvalidConfigurationError,
51
+ "invalid lab install manifest: #{path} contains YAML types outside the " \
52
+ "permitted set; use plain scalars, sequences and mappings only"
53
+ rescue Psych::Exception
54
+ raise Ace::Lab::InvalidConfigurationError,
55
+ "invalid lab install manifest: #{path} could not be parsed as YAML"
56
+ end
57
+
58
+ Atoms::InstallSchema.normalize(document, platform: platform)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end