archsight 0.3.2 → 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.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/docs/archimate.md +21 -1
  4. data/docs/configuration.md +1 -0
  5. data/docs/diagram.md +15 -3
  6. data/docs/docker.md +4 -1
  7. data/docs/icons.md +32 -0
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +31 -8
  10. data/docs/modeling.md +772 -24
  11. data/docs/pages.md +44 -13
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +5 -1
  14. data/lib/archsight/analysis/result.rb +8 -2
  15. data/lib/archsight/analysis/sandbox.rb +8 -0
  16. data/lib/archsight/analysis/typst_renderer.rb +246 -0
  17. data/lib/archsight/analysis.rb +1 -0
  18. data/lib/archsight/annotations/annotation.rb +30 -3
  19. data/lib/archsight/annotations/architecture_annotations.rb +47 -0
  20. data/lib/archsight/annotations/asset_annotations.rb +34 -0
  21. data/lib/archsight/annotations/link_annotations.rb +15 -0
  22. data/lib/archsight/annotations/resource_reference.rb +12 -0
  23. data/lib/archsight/annotations/risk_annotations.rb +21 -0
  24. data/lib/archsight/cli.rb +124 -23
  25. data/lib/archsight/database.rb +40 -0
  26. data/lib/archsight/diagram/cli.rb +50 -2
  27. data/lib/archsight/diagram/formatter.rb +112 -0
  28. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  29. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  30. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  31. data/lib/archsight/diagram/style/theme.rb +7 -2
  32. data/lib/archsight/documentation.rb +3 -2
  33. data/lib/archsight/editor/page_source.rb +13 -1
  34. data/lib/archsight/export/confluence/analysis_output.rb +101 -0
  35. data/lib/archsight/export/confluence/client.rb +13 -0
  36. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  37. data/lib/archsight/export/confluence/exporter.rb +40 -13
  38. data/lib/archsight/export/confluence/labels.rb +25 -0
  39. data/lib/archsight/export/confluence/page_properties.rb +4 -6
  40. data/lib/archsight/export/confluence/storage.rb +38 -2
  41. data/lib/archsight/helpers/embeds.rb +18 -7
  42. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  43. data/lib/archsight/import/executor.rb +139 -16
  44. data/lib/archsight/import/handler.rb +55 -6
  45. data/lib/archsight/import/handler_loader.rb +64 -0
  46. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  47. data/lib/archsight/import/handlers/command.rb +120 -0
  48. data/lib/archsight/import/handlers/github.rb +4 -1
  49. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  50. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  51. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  52. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  53. data/lib/archsight/import/handlers/grapher.rb +40 -1
  54. data/lib/archsight/import/handlers/repository.rb +3 -0
  55. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  56. data/lib/archsight/import/registry.rb +22 -1
  57. data/lib/archsight/import.rb +64 -1
  58. data/lib/archsight/linter/structure.rb +239 -0
  59. data/lib/archsight/linter.rb +217 -6
  60. data/lib/archsight/page_loader.rb +12 -1
  61. data/lib/archsight/query/evaluator.rb +13 -6
  62. data/lib/archsight/query/parser.rb +73 -6
  63. data/lib/archsight/query.rb +1 -1
  64. data/lib/archsight/references.rb +3 -3
  65. data/lib/archsight/renderer.rb +80 -23
  66. data/lib/archsight/resources/analysis.rb +3 -2
  67. data/lib/archsight/resources/application_component.rb +83 -8
  68. data/lib/archsight/resources/application_event.rb +79 -0
  69. data/lib/archsight/resources/application_service.rb +18 -2
  70. data/lib/archsight/resources/base.rb +58 -5
  71. data/lib/archsight/resources/business_actor.rb +14 -1
  72. data/lib/archsight/resources/business_contract.rb +72 -0
  73. data/lib/archsight/resources/business_control.rb +10 -2
  74. data/lib/archsight/resources/business_event.rb +79 -0
  75. data/lib/archsight/resources/business_process.rb +8 -1
  76. data/lib/archsight/resources/business_product.rb +12 -0
  77. data/lib/archsight/resources/business_role.rb +70 -0
  78. data/lib/archsight/resources/compliance_evidence.rb +8 -2
  79. data/lib/archsight/resources/data_object.rb +6 -1
  80. data/lib/archsight/resources/implementation_deliverable.rb +63 -0
  81. data/lib/archsight/resources/implementation_event.rb +52 -0
  82. data/lib/archsight/resources/implementation_gap.rb +49 -0
  83. data/lib/archsight/resources/implementation_plateau.rb +67 -0
  84. data/lib/archsight/resources/implementation_work_package.rb +84 -0
  85. data/lib/archsight/resources/import.rb +37 -11
  86. data/lib/archsight/resources/location.rb +56 -0
  87. data/lib/archsight/resources/motivation_assessment.rb +121 -0
  88. data/lib/archsight/resources/motivation_constraint.rb +7 -1
  89. data/lib/archsight/resources/motivation_driver.rb +50 -0
  90. data/lib/archsight/resources/motivation_goal.rb +13 -1
  91. data/lib/archsight/resources/motivation_principle.rb +75 -0
  92. data/lib/archsight/resources/motivation_requirement.rb +18 -2
  93. data/lib/archsight/resources/motivation_stakeholder.rb +21 -0
  94. data/lib/archsight/resources/page.rb +9 -7
  95. data/lib/archsight/resources/strategy_capability.rb +9 -0
  96. data/lib/archsight/resources/technology_artifact.rb +11 -4
  97. data/lib/archsight/resources/technology_communication_network.rb +89 -0
  98. data/lib/archsight/resources/technology_device.rb +88 -0
  99. data/lib/archsight/resources/technology_equipment.rb +75 -0
  100. data/lib/archsight/resources/technology_event.rb +80 -0
  101. data/lib/archsight/resources/technology_facility.rb +70 -0
  102. data/lib/archsight/resources/technology_node.rb +57 -1
  103. data/lib/archsight/resources/technology_service.rb +33 -0
  104. data/lib/archsight/resources/technology_system_software.rb +28 -0
  105. data/lib/archsight/resources.rb +12 -0
  106. data/lib/archsight/version.rb +1 -1
  107. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  108. data/lib/archsight/web/api/json_helpers.rb +3 -37
  109. data/lib/archsight/web/api/openapi/spec.yaml +10 -8
  110. data/lib/archsight/web/api/page_helpers.rb +1 -8
  111. data/lib/archsight/web/api/routes.rb +7 -2
  112. data/lib/archsight/web/application.rb +38 -2
  113. data/lib/archsight/web/editor/routes.rb +2 -1
  114. data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
  115. data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
  116. data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
  117. data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
  118. data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
  119. data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
  120. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  121. data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
  122. data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
  123. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  124. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  125. data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
  126. data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
  127. data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
  128. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  129. data/lib/archsight/web/public/vue/{SearchResults-CyPUZZSC.css → SearchResults-BV6UHriJ.css} +1 -1
  130. data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
  131. data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
  132. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  133. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
  134. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
  135. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
  136. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
  137. data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
  138. data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
  139. data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
  140. data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
  141. data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
  142. data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
  143. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
  144. data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
  145. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
  146. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
  147. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
  148. data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
  149. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
  150. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
  151. data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-4evusK9i.js} +13 -1
  152. data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
  153. data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
  154. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
  155. data/lib/archsight/web/public/vue.html +5 -5
  156. metadata +72 -38
  157. data/lib/archsight/web/public/vue/ApiDocsPage-DoOxKjG0.js +0 -1
  158. data/lib/archsight/web/public/vue/DocPage-CfsC3CeQ.js +0 -1
  159. data/lib/archsight/web/public/vue/EditorPage-CsJA0q8n.js +0 -35
  160. data/lib/archsight/web/public/vue/ErrorPage-OJpJz9df.js +0 -2
  161. data/lib/archsight/web/public/vue/GraphView-CBq6oFRV.js +0 -1
  162. data/lib/archsight/web/public/vue/HomePage-BwgMLgVC.js +0 -2
  163. data/lib/archsight/web/public/vue/InstanceRouter-DQz-SsQq.js +0 -1
  164. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  165. data/lib/archsight/web/public/vue/KindList-4q0K9Cll.js +0 -1
  166. data/lib/archsight/web/public/vue/PageView-DzeGnvoS.js +0 -1
  167. data/lib/archsight/web/public/vue/QueryError-kg6Yf-pW.js +0 -1
  168. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  169. data/lib/archsight/web/public/vue/ResourceList-Df_0e0qt.js +0 -2
  170. data/lib/archsight/web/public/vue/SearchResults-CKgY6_wH.js +0 -1
  171. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  172. data/lib/archsight/web/public/vue/WikiPage-DrvCHwJ7.js +0 -13
  173. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  174. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  175. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  176. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  177. data/lib/archsight/web/public/vue/index-D0Q5GZRs.js +0 -3
  178. data/lib/archsight/web/public/vue/index-oF-iyZvk.css +0 -1
  179. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  180. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  181. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  182. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  183. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  184. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  185. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  186. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  187. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  188. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  189. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -4,26 +4,37 @@ require "erb"
4
4
 
5
5
  module Archsight
6
6
  module Helpers
7
- # Replaces `![[View/Name]]` and `![[Analysis/Name]]` in rendered HTML by a placeholder for the live content of
8
- # that view or analysis. The frontend turns each placeholder into the result list or the analysis result (it
9
- # loads and runs them itself, so rendering a page never executes a query or a script):
7
+ # Replaces `![[Kind/Name]]` in rendered HTML by a placeholder for the content of that resource: the result list of
8
+ # a View, the result of an Analysis, the description of any other resource (a goal, a capability, ...). The
9
+ # frontend turns each placeholder into that content (it loads and runs it itself, so rendering a page never
10
+ # executes a query or a script):
10
11
  #
11
12
  # <div class="kind-embed" data-kind="View" data-name="View:Services"><a href="/kinds/View/instances/...">...</a></div>
12
13
  #
13
14
  # The link inside is what API and MCP consumers (and a browser without script) get. A reference that does not
14
- # name an existing View or Analysis renders as a `broken-link` marker. Code (`<pre>`, `<code>`) is left alone.
15
+ # name an existing resource (or names a page) renders as a `broken-link` marker. Code (`<pre>`, `<code>`) is
16
+ # left alone.
15
17
  class Embeds
16
- KINDS = %w[View Analysis].freeze
18
+ # Embeds with live content; every other kind shows its description
19
+ LIVE_KINDS = %w[View Analysis].freeze
20
+ # Pages and menus are not resources with a description
21
+ NOT_EMBEDDABLE = %w[Page PageMenu].freeze
17
22
  PATTERN = /!\[\[([^\]|]+)\]\]/
18
23
  PARAGRAPH = %r{<p>\s*(#{PATTERN.source})\s*</p>}
19
24
  CODE = %r{(<pre\b.*?</pre>|<code\b.*?</code>)}m
20
25
 
21
26
  REASONS = {
22
- kind: "only #{KINDS.join(" and ")} can be embedded, written View/Name or Analysis/Name",
27
+ kind: "embed a resource as Kind/Name (a View, an Analysis or the description of any other resource); " \
28
+ "pages cannot be embedded",
23
29
  missing: "no such resource",
24
30
  ambiguous: "ambiguous reference"
25
31
  }.freeze
26
32
 
33
+ # Whether `![[kind/...]]` can be embedded: a known kind that is not a page
34
+ def self.embeddable_kind?(kind)
35
+ !Archsight::Resources[kind].nil? && !NOT_EMBEDDABLE.include?(Archsight::Resources.canonical(kind))
36
+ end
37
+
27
38
  def initialize(database, resolver: ResourceResolver.new(database))
28
39
  @database = database
29
40
  @resolver = resolver
@@ -66,7 +77,7 @@ module Archsight
66
77
  # @return [Array(Array(String, String), Symbol)] kind and name, and the problem (nil if it is embeddable)
67
78
  def check(reference)
68
79
  kind, name = reference.split("/", 2)
69
- return [[kind, name], :kind] unless name && KINDS.include?(kind)
80
+ return [[kind, name], :kind] unless name && self.class.embeddable_kind?(kind)
70
81
 
71
82
  problem = @resolver.call(reference)
72
83
  [[kind, name], problem.is_a?(Symbol) ? problem : nil]
@@ -32,6 +32,31 @@ module Archsight
32
32
  matches.first
33
33
  end
34
34
 
35
+ # What an annotation that names another resource (`lifecycle/replacedBy`) refers to. A bare `Name` is a
36
+ # resource of the same kind as the annotated one, another kind needs `Kind/Name`.
37
+ # @param value [String] the annotation value
38
+ # @param kind [String] the kind of the annotated resource
39
+ # @return [Array(String, String), Symbol] `[kind, name]`, or `:missing`
40
+ def find_annotated(value, kind)
41
+ reference = value.to_s.strip
42
+ found = find(reference.include?("/") ? reference : "#{kind}/#{reference}")
43
+ found.is_a?(Array) ? found : :missing
44
+ end
45
+
46
+ # Where the annotations of a resource that name another resource point to:
47
+ # `{ key => page path }`, `nil` for a reference that does not resolve.
48
+ # @param kind [String] the kind of the annotated resource, which knows the annotation formats
49
+ # @param annotations [Hash]
50
+ def annotation_links(kind, annotations)
51
+ klass = Archsight::Resources[kind]
52
+ annotations.each_with_object({}) do |(key, value), links|
53
+ next unless klass&.annotation_format(key) == :resource
54
+
55
+ found = find_annotated(value, kind)
56
+ links[key] = found.is_a?(Array) ? path(*found) : nil
57
+ end
58
+ end
59
+
35
60
  private
36
61
 
37
62
  def in_kind(kind, name)
@@ -38,7 +38,9 @@ class Archsight::Import::Executor
38
38
  # @param output [IO] Output stream for progress (default: $stdout)
39
39
  # @param filter [String, nil] Regex pattern to match import names
40
40
  # @param force [Boolean] Whether to bypass cache and re-run all imports
41
- def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false)
41
+ # @param deps [Boolean] With a filter, also run the imports the matched ones depend on (false: only the matched ones)
42
+ def initialize(database:, resources_dir:, verbose: false, max_concurrent: MAX_CONCURRENT, output: $stdout, filter: nil, force: false,
43
+ deps: true)
42
44
  @database = database
43
45
  @resources_dir = resources_dir
44
46
  @verbose = verbose
@@ -47,7 +49,10 @@ class Archsight::Import::Executor
47
49
  @filter = filter
48
50
  @filter_regex = Regexp.new(filter, Regexp::IGNORECASE) if filter
49
51
  @force = force
52
+ @deps = deps
50
53
  @executed_this_run = Set.new
54
+ @ran_this_run = Set.new
55
+ @handler_problems = {}
51
56
  @iteration = 0
52
57
  @mutex = Mutex.new
53
58
  end
@@ -154,18 +159,24 @@ class Archsight::Import::Executor
154
159
  sorted = topological_sort(filtered_imports)
155
160
 
156
161
  @output.puts "Filter: #{@filter}" if @filter
162
+ @output.puts "Dependencies: not run (--no-deps)" if only_matches?
157
163
 
158
164
  sorted.each_with_index do |imp, idx|
159
165
  enabled = import_enabled?(imp)
160
166
  deps = import_dependency_names(imp)
161
167
  deps_str = deps.empty? ? "(no dependencies)" : "depends on: #{deps.join(", ")}"
162
168
  enabled_str = enabled ? "" : " [DISABLED]"
163
- @output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}"
169
+ @output.puts " #{idx + 1}. #{imp.name}#{enabled_str} #{deps_str}#{unmatched_patterns_note(imp)}"
164
170
  end
165
171
 
166
172
  sorted
167
173
  end
168
174
 
175
+ # Names of the imports that executed in this run (not served from the cache)
176
+ def ran_imports
177
+ @mutex.synchronize { @ran_this_run.to_a.sort }
178
+ end
179
+
169
180
  private
170
181
 
171
182
  # Set up signal handlers for graceful shutdown
@@ -222,7 +233,13 @@ class Archsight::Import::Executor
222
233
  @imports_to_run.include?(import.name)
223
234
  end
224
235
 
225
- # Compute the full set of imports to run: filtered imports + all their dependencies
236
+ # Whether only the imports matching the filter run (`-f` with `--no-deps`)
237
+ def only_matches?
238
+ !@deps && !@filter_regex.nil?
239
+ end
240
+
241
+ # Compute the full set of imports to run: filtered imports + all their dependencies (only the filtered ones with
242
+ # `--no-deps`)
226
243
  def compute_imports_to_run
227
244
  all_imports = database.instances_by_kind("Import")&.values || []
228
245
  imports_by_name = all_imports.to_h { |imp| [imp.name, imp] }
@@ -230,6 +247,7 @@ class Archsight::Import::Executor
230
247
  # Start with imports matching the filter
231
248
  matching = all_imports.select { |imp| @filter_regex.match?(imp.name) }.map(&:name)
232
249
  to_run = Set.new(matching)
250
+ return to_run unless @deps
233
251
 
234
252
  # Recursively add dependencies
235
253
  queue = matching.dup
@@ -249,9 +267,12 @@ class Archsight::Import::Executor
249
267
  to_run
250
268
  end
251
269
 
252
- # Check if all dependencies of an import are satisfied (executed successfully this run)
270
+ # Check if all dependencies of an import are satisfied (executed successfully this run).
271
+ # A parent that can never run (no handler or disabled, e.g. the repository was
272
+ # deleted upstream and only its generated `generates` entry is left) is not
273
+ # waited for, otherwise its children would deadlock the run.
253
274
  def dependencies_satisfied?(import)
254
- dep_names = import_dependency_names(import)
275
+ dep_names = runnable_dependency_names(import)
255
276
  return true if dep_names.empty?
256
277
 
257
278
  @mutex.synchronize do
@@ -261,30 +282,81 @@ class Archsight::Import::Executor
261
282
  end
262
283
  end
263
284
 
264
- # Get dependency names for an import by finding parents that generate this import
265
- # Dependencies are derived from the inverse of `generates` - if Import A generates Import B,
266
- # then B depends on A
285
+ # With `--no-deps` a dependency outside the run is not waited for: it does not run, so the import uses the model
286
+ # as it is
287
+ def runnable_dependency_names(import)
288
+ imports_by_name = (database.instances_by_kind("Import")&.values || []).to_h { |imp| [imp.name, imp] }
289
+ import_dependency_names(import).select do |dep_name|
290
+ parent = imports_by_name[dep_name]
291
+ parent && import_enabled?(parent) && (!only_matches? || import_matches_filter?(parent))
292
+ end
293
+ end
294
+
295
+ # Get dependency names for an import: the parents that generate it (the inverse of `generates` - if Import A
296
+ # generates Import B, then B depends on A) and the imports it declares in `import/dependsOn`
267
297
  def import_dependency_names(import)
268
- # Find all imports that have this import in their generates.imports
269
298
  all_imports = database.instances_by_kind("Import")&.values || []
270
299
  parent_imports = all_imports.select do |parent|
271
- generated = parent.relations(:generates, :imports)
272
- generated&.any? { |gen| (gen.is_a?(String) ? gen : gen.name) == import.name }
300
+ generated_import_names(parent).include?(import.name)
301
+ end
302
+ (parent_imports.map(&:name) + declared_dependency_names(import, all_imports)).uniq
303
+ end
304
+
305
+ # The imports `import/dependsOn` names or matches. For a pattern this also includes the imports that may still
306
+ # generate a matching import in this run: one whose last run generated a match, and one that never ran but whose
307
+ # handler generates imports. So `Import:Repo:*` waits for the GitHub import and then for the repositories it found.
308
+ def declared_dependency_names(import, all_imports = database.instances_by_kind("Import")&.values || [])
309
+ entries = Archsight::Import.declared_dependencies(import)
310
+ return [] if entries.empty?
311
+
312
+ others = all_imports.reject { |imp| imp.name == import.name }
313
+ entries.flat_map do |entry|
314
+ matches = others.select { |imp| Archsight::Import.dependency_match?(entry, imp.name) }
315
+ matches += others.select { |imp| may_generate_match?(imp, entry) } if Archsight::Import.pattern?(entry)
316
+ matches.map(&:name)
317
+ end.uniq
318
+ end
319
+
320
+ # Whether an import generated a match of the pattern in its last run, or may do so on its first one
321
+ def may_generate_match?(import, pattern)
322
+ generated = generated_import_names(import)
323
+ return generated.any? { |name| Archsight::Import.dependency_match?(pattern, name) } if generated.any?
324
+
325
+ handler = Archsight::Import::Registry[import.annotations["import/handler"]]
326
+ !import.annotations["generated/at"] && handler.respond_to?(:generates_imports?) && handler.generates_imports?
327
+ end
328
+
329
+ def generated_import_names(import)
330
+ (import.relations(:generates, :imports) || []).map { |gen| gen.is_a?(String) ? gen : gen.name }
331
+ end
332
+
333
+ # For the plan: patterns of `import/dependsOn` that match no import yet
334
+ def unmatched_patterns_note(import)
335
+ all_names = (database.instances_by_kind("Import")&.keys || []) - [import.name]
336
+ unmatched = Archsight::Import.declared_dependencies(import).select do |entry|
337
+ Archsight::Import.pattern?(entry) && all_names.none? { |name| Archsight::Import.dependency_match?(entry, name) }
273
338
  end
274
- parent_imports.map(&:name)
339
+ unmatched.empty? ? "" : " (#{unmatched.join(", ")} matches no import yet)"
275
340
  end
276
341
 
277
- # Reload database without verbose output
342
+ # Reload database without verbose output, and load the handlers the imports declare (`import/handlerPath`)
278
343
  def reload_database_quietly!
279
344
  original_verbose = database.verbose
280
345
  database.verbose = false
281
346
  database.reload!
347
+ load_local_handlers
282
348
  # Reset filter cache since new imports may have been discovered
283
349
  @imports_to_run = nil if @filter_regex
284
350
  ensure
285
351
  database.verbose = original_verbose
286
352
  end
287
353
 
354
+ # A handler file that does not load fails the imports that use it, not the whole run
355
+ def load_local_handlers
356
+ problems = Archsight::Import::HandlerLoader.load(database, resources_dir: resources_dir)
357
+ @handler_problems = problems.to_h { |problem| [problem.import.name, problem.message] }
358
+ end
359
+
288
360
  # Reload database and update progress total for newly discovered imports
289
361
  def reload_and_update_total!
290
362
  reload_database_quietly!
@@ -315,6 +387,8 @@ class Archsight::Import::Executor
315
387
  result = execute_single_import(imp, slot_progress)
316
388
  if result == :cached
317
389
  slot_progress.complete("Cached")
390
+ elsif result.is_a?(String)
391
+ slot_progress.complete(result)
318
392
  else
319
393
  @mutex.synchronize { @total_executed += 1 }
320
394
  slot_progress.complete("Done")
@@ -339,7 +413,7 @@ class Archsight::Import::Executor
339
413
  end
340
414
 
341
415
  # Execute a single import (called from thread)
342
- # @return [Symbol] :executed, :cached, or raises on error
416
+ # @return [Symbol, String] :executed, :cached, a message for a skipped import, or raises on error
343
417
  def execute_single_import(import, import_progress)
344
418
  # Check if import is still fresh based on generated/at and import/cacheTime
345
419
  if import_fresh?(import)
@@ -347,6 +421,18 @@ class Archsight::Import::Executor
347
421
  return :cached
348
422
  end
349
423
 
424
+ problem = @handler_problems[import.name]
425
+ raise Archsight::Import::UnknownHandlerError, problem if problem
426
+
427
+ missing = missing_environment(import)
428
+ if missing.any?
429
+ message = "missing environment variable#{"s" if missing.length > 1} #{missing.join(", ")} (import/config/requiredEnv)"
430
+ raise Archsight::Import::ImportError, message unless import.annotations["import/config/optional"] == "true"
431
+
432
+ import_progress.warn("Skipped: #{message}")
433
+ return "Skipped"
434
+ end
435
+
350
436
  # Execute the import
351
437
  handler_class = Archsight::Import::Registry.handler_for(import)
352
438
  handler = handler_class.new(
@@ -357,12 +443,30 @@ class Archsight::Import::Executor
357
443
  shared_writer: @shared_writer
358
444
  )
359
445
  handler.execute
446
+ remove_stale_outputs(import, handler.owned_outputs)
447
+ @mutex.synchronize { @ran_this_run.add(import.name) }
360
448
  :executed
361
449
  end
362
450
 
451
+ # Variables of `import/config/requiredEnv` that are not set (or blank)
452
+ def missing_environment(import)
453
+ names = import.annotations["import/config/requiredEnv"].to_s.split(",").map(&:strip).reject(&:empty?)
454
+ names.select { |name| ENV[name].to_s.strip.empty? }
455
+ end
456
+
457
+ # Delete the files an earlier run of the import wrote (`generated/outputs`) that this run did not write again
458
+ def remove_stale_outputs(import, written)
459
+ previous = import.annotations["generated/outputs"].to_s.split(",").map(&:strip).reject(&:empty?)
460
+ (previous - written).each do |rel_path|
461
+ path = Archsight::Import.resources_path(resources_dir, rel_path)
462
+ FileUtils.rm_f(path) if path && File.file?(path)
463
+ end
464
+ end
465
+
363
466
  # Check if import is still fresh (cached)
364
- # Uses generated/at annotation and import/cacheTime to determine freshness
365
- # Also validates that output file exists (if specified) and source file hasn't changed
467
+ # Uses generated/at annotation and import/cacheTime to determine freshness. The cache is also invalid when the
468
+ # output file or a recorded output (`generated/outputs`) is missing, the configuration or the files of
469
+ # `import/config/inputs` changed, or an import it declares in `import/dependsOn` executed in this run.
366
470
  def import_fresh?(import)
367
471
  # Honor force flag - always re-run when forced
368
472
  return false if @force
@@ -377,6 +481,11 @@ class Archsight::Import::Executor
377
481
  return false unless File.exist?(full_path)
378
482
  end
379
483
 
484
+ return false unless recorded_outputs_exist?(import)
485
+ return false if dependency_ran?(import)
486
+ return false if import.annotations["import/config/inputs"] &&
487
+ import.annotations["generated/inputsHash"] != Archsight::Import.inputs_hash(import, resources_dir)
488
+
380
489
  generated_at = import.annotations["generated/at"]
381
490
  return false if generated_at.nil? || generated_at.empty?
382
491
 
@@ -399,6 +508,20 @@ class Archsight::Import::Executor
399
508
  false
400
509
  end
401
510
 
511
+ def recorded_outputs_exist?(import)
512
+ import.annotations["generated/outputs"].to_s.split(",").map(&:strip).reject(&:empty?).all? do |rel_path|
513
+ path = Archsight::Import.resources_path(resources_dir, rel_path)
514
+ path && File.exist?(path)
515
+ end
516
+ end
517
+
518
+ # Whether an import declared in `import/dependsOn` executed (not just came from the cache) in this run. The
519
+ # generated imports of a parent are not affected: they keep their own cache time when the parent runs.
520
+ def dependency_ran?(import)
521
+ names = declared_dependency_names(import)
522
+ @mutex.synchronize { names.any? { |name| @ran_this_run.include?(name) } }
523
+ end
524
+
402
525
  def parse_duration(str)
403
526
  return nil if str.nil?
404
527
 
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require "pathname"
6
7
  require "yaml"
7
8
  require "archsight/import"
8
9
  require_relative "progress"
@@ -12,7 +13,14 @@ require_relative "progress"
12
13
  # Subclasses must implement the #execute method to perform the actual import.
13
14
  # Use the helper methods to read configuration, validate environment, and write output.
14
15
  class Archsight::Import::Handler
15
- attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer
16
+ attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer, :owned_outputs
17
+
18
+ # Whether the handler generates child imports (`import_yaml`). An import that depends on a pattern
19
+ # (`import/dependsOn: Import:Repo:*`) waits for such a handler on its first run, before it has a `generates` record
20
+ # that tells which imports it creates. Override in handlers that generate imports.
21
+ def self.generates_imports?
22
+ false
23
+ end
16
24
 
17
25
  # @param import_resource [Archsight::Resources::Import] The import resource to execute
18
26
  # @param database [Archsight::Database] The database instance
@@ -26,6 +34,15 @@ class Archsight::Import::Handler
26
34
  @progress = progress || Archsight::Import::Progress.new
27
35
  @shared_writer = shared_writer
28
36
  @tracked_resources = []
37
+ @owned_outputs = []
38
+ end
39
+
40
+ # The directory the git-based handlers clone repositories into (`import/config/cacheRoot`, else `import.cache_root`
41
+ # of the user configuration or ARCHSIGHT_IMPORT_CACHE_ROOT, else ~/.cache/archsight/git), so an importer of the
42
+ # resources repository finds the same clones
43
+ # @return [String] absolute path
44
+ def cache_root
45
+ File.expand_path(Archsight::Import.cache_root(config("cacheRoot")))
29
46
  end
30
47
 
31
48
  # Execute the import. Must be implemented by subclasses.
@@ -74,23 +91,55 @@ class Archsight::Import::Handler
74
91
  end
75
92
 
76
93
  # Generate a marker Import for this handler with generated/at timestamp and config hash
77
- # Used for caching - call at end of execute() to persist the execution timestamp
94
+ # Used for caching - call at end of execute() to persist the execution timestamp. It also records the hash of the
95
+ # files in `import/config/inputs` and the files written with #write_file, so call it after writing them.
78
96
  # @return [Hash] Import resource hash ready for YAML serialization
79
97
  def self_marker
98
+ annotations = {
99
+ "generated/at" => Time.now.utc.iso8601,
100
+ "generated/configHash" => compute_config_hash
101
+ }
102
+ inputs_hash = Archsight::Import.inputs_hash(import_resource, resources_dir)
103
+ annotations["generated/inputsHash"] = inputs_hash if inputs_hash
104
+ annotations["generated/outputs"] = owned_outputs.uniq.sort.join(",") if owned_outputs.any?
80
105
  {
81
106
  "apiVersion" => "architecture/v1alpha1",
82
107
  "kind" => "Import",
83
108
  "metadata" => {
84
109
  "name" => import_resource.name,
85
- "annotations" => {
86
- "generated/at" => Time.now.utc.iso8601,
87
- "generated/configHash" => compute_config_hash
88
- }
110
+ "annotations" => annotations
89
111
  },
90
112
  "spec" => {}
91
113
  }
92
114
  end
93
115
 
116
+ # Write a whole file that belongs to this import alone, such as a page (Markdown) next to the generated YAML.
117
+ # The file is recorded in the self marker (`generated/outputs`): the cache is invalid while it is missing, and a
118
+ # file of an earlier run that is not written again is deleted.
119
+ # @param rel_path [String] path relative to the resources directory
120
+ # @param content [String]
121
+ # @return [String] the absolute path
122
+ # @raise [ArgumentError] for a path outside the resources directory
123
+ def write_file(rel_path, content)
124
+ full_path = Archsight::Import.resources_path(resources_dir, rel_path)
125
+ raise ArgumentError, "#{rel_path} is outside the resources directory" unless full_path
126
+
127
+ FileUtils.mkdir_p(File.dirname(full_path))
128
+ File.write(full_path, content)
129
+ record_output(rel_path)
130
+ full_path
131
+ end
132
+
133
+ # Record a file this import wrote by other means (a command, a library) as its own output (see #write_file)
134
+ # @param rel_path [String] path relative to the resources directory
135
+ # @raise [ArgumentError] for a path outside the resources directory
136
+ def record_output(rel_path)
137
+ full_path = Archsight::Import.resources_path(resources_dir, rel_path)
138
+ raise ArgumentError, "#{rel_path} is outside the resources directory" unless full_path
139
+
140
+ @owned_outputs << Pathname.new(full_path).relative_path_from(File.expand_path(resources_dir)).to_s
141
+ end
142
+
94
143
  # Write YAML content to the output path
95
144
  # @param content [String] YAML content to write
96
145
  # @param filename [String, nil] Output filename (overrides import/outputPath filename)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "archsight/import"
4
+
5
+ # Loads import handlers: the built-in ones shipped with the gem, and the handlers of the resources repository that an
6
+ # Import declares with `import/handlerPath` (a Ruby file relative to the resources directory, like
7
+ # `import/outputPath`). Used by `archsight import`, `lint` and `web`, so every command knows the same handler names.
8
+ module Archsight::Import::HandlerLoader
9
+ BUILTIN_DIR = File.expand_path("handlers", __dir__)
10
+
11
+ # A problem with a local handler file, reported by the linter and by `archsight import`
12
+ Problem = Struct.new(:import, :message) do
13
+ def to_s
14
+ "#{import.path_ref}: Import '#{import.name}' #{message}"
15
+ end
16
+ end
17
+
18
+ class << self
19
+ # Require the built-in handlers. Idempotent; registers them again when a test cleared the registry.
20
+ def load_builtin!
21
+ Dir.glob(File.join(BUILTIN_DIR, "*.rb")).each { |file| require file }
22
+ Archsight::Import::Registry.restore_builtin!
23
+ end
24
+
25
+ # Load the built-in handlers and every handler file the Imports of the database declare
26
+ # @param database [Archsight::Database]
27
+ # @param resources_dir [String] directory `import/handlerPath` is relative to
28
+ # @return [Array<Problem>] files that are missing, outside the resources directory, fail to load or do not register
29
+ # the handler named in `import/handler`
30
+ def load(database, resources_dir: database.path)
31
+ load_builtin!
32
+ imports = database.instances_by_kind("Import")&.values || []
33
+ imports.select { |imp| imp.annotations["import/handlerPath"] }.flat_map { |imp| load_for(imp, resources_dir) }
34
+ end
35
+
36
+ # Absolute path of the handler file of an Import, nil when it points outside the resources directory
37
+ def handler_file(import, resources_dir)
38
+ Archsight::Import.resources_path(resources_dir, import.annotations["import/handlerPath"])
39
+ end
40
+
41
+ private
42
+
43
+ def load_for(import, resources_dir)
44
+ file = handler_file(import, resources_dir)
45
+ return [Problem.new(import, "has import/handlerPath '#{import.annotations["import/handlerPath"]}' outside the resources directory")] unless file
46
+ return [Problem.new(import, "has import/handlerPath '#{import.annotations["import/handlerPath"]}' that does not exist")] unless File.file?(file)
47
+
48
+ problem = require_file(import, file)
49
+ return [problem] if problem
50
+
51
+ name = import.annotations["import/handler"]
52
+ return [] if name.nil? || Archsight::Import::Registry[name]
53
+
54
+ [Problem.new(import, "loads #{import.annotations["import/handlerPath"]}, which does not register the handler '#{name}'")]
55
+ end
56
+
57
+ def require_file(import, file)
58
+ require file
59
+ nil
60
+ rescue ScriptError, StandardError => e
61
+ Problem.new(import, "cannot load #{import.annotations["import/handlerPath"]}: #{e.class}: #{e.message.lines.first&.strip}")
62
+ end
63
+ end
64
+ end
@@ -6,18 +6,41 @@
6
6
  # the current API listing, so a renamed, moved, or deleted repo doesn't
7
7
  # leave a stale clone behind that a later importer could still resolve
8
8
  # against under its old name.
9
+ #
10
+ # A listing can be incomplete (throttled or empty API pages) and clones may be
11
+ # in progress, so pruning is conservative: entries touched within MIN_AGE are
12
+ # kept, and nothing is removed when it would wipe out most of the cache.
9
13
  module Archsight::Import::Handlers::CachePruner
14
+ MIN_AGE = 3600 # seconds; younger directories may be a clone in progress
15
+ MAX_STALE_ABSOLUTE = 10 # always allowed, whatever the cache size
16
+ MAX_STALE_FRACTION = 0.2 # larger share of the cache is treated as a bad listing
17
+
10
18
  def prune_stale_cache_entries(target_dir, current_names)
11
19
  return unless File.directory?(target_dir)
12
20
 
13
- Dir.children(target_dir).each do |entry|
14
- next if current_names.include?(entry)
15
-
16
- stale_path = File.join(target_dir, entry)
17
- next unless File.directory?(stale_path)
21
+ known = current_names.to_set
22
+ dirs = Dir.children(target_dir).select { |entry| File.directory?(File.join(target_dir, entry)) }
23
+ stale = dirs.reject { |entry| known.include?(entry) }
24
+ return if stale.empty?
18
25
 
19
- progress.warn("Pruning stale cache entry: #{entry} (repo renamed, moved, or removed)")
20
- FileUtils.rm_rf(stale_path)
26
+ limit = [MAX_STALE_ABSOLUTE, (dirs.size * MAX_STALE_FRACTION).floor].max
27
+ if stale.size > limit
28
+ progress.warn("Not pruning #{stale.size} of #{dirs.size} cache entries in #{target_dir}: " \
29
+ "the project listing looks incomplete")
30
+ return
21
31
  end
32
+
33
+ stale.each { |entry| prune_cache_entry(File.join(target_dir, entry), entry) }
34
+ end
35
+
36
+ private
37
+
38
+ def prune_cache_entry(path, name)
39
+ return if Time.now - File.mtime(path) < MIN_AGE
40
+
41
+ progress.warn("Pruning stale cache entry: #{name} (repo renamed, moved, or removed)")
42
+ FileUtils.rm_rf(path)
43
+ rescue Errno::ENOENT
44
+ nil
22
45
  end
23
46
  end