archsight 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +26 -5
- data/lib/archsight/analysis/executor.rb +112 -0
- data/lib/archsight/analysis/result.rb +174 -0
- data/lib/archsight/analysis/sandbox.rb +319 -0
- data/lib/archsight/analysis.rb +11 -0
- data/lib/archsight/annotations/architecture_annotations.rb +2 -2
- data/lib/archsight/cli.rb +163 -0
- data/lib/archsight/database.rb +6 -2
- data/lib/archsight/helpers/analysis_renderer.rb +83 -0
- data/lib/archsight/helpers/formatting.rb +95 -0
- data/lib/archsight/helpers.rb +20 -4
- data/lib/archsight/import/concurrent_progress.rb +341 -0
- data/lib/archsight/import/executor.rb +466 -0
- data/lib/archsight/import/git_analytics.rb +626 -0
- data/lib/archsight/import/handler.rb +263 -0
- data/lib/archsight/import/handlers/github.rb +161 -0
- data/lib/archsight/import/handlers/gitlab.rb +202 -0
- data/lib/archsight/import/handlers/jira_base.rb +189 -0
- data/lib/archsight/import/handlers/jira_discover.rb +161 -0
- data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
- data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
- data/lib/archsight/import/handlers/repository.rb +439 -0
- data/lib/archsight/import/handlers/rest_api.rb +293 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
- data/lib/archsight/import/progress.rb +91 -0
- data/lib/archsight/import/registry.rb +54 -0
- data/lib/archsight/import/shared_file_writer.rb +67 -0
- data/lib/archsight/import/team_matcher.rb +195 -0
- data/lib/archsight/import.rb +14 -0
- data/lib/archsight/resources/analysis.rb +91 -0
- data/lib/archsight/resources/application_component.rb +2 -2
- data/lib/archsight/resources/application_service.rb +12 -12
- data/lib/archsight/resources/business_product.rb +12 -12
- data/lib/archsight/resources/data_object.rb +1 -1
- data/lib/archsight/resources/import.rb +79 -0
- data/lib/archsight/resources/technology_artifact.rb +23 -2
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/docs.rb +17 -0
- data/lib/archsight/web/api/json_helpers.rb +164 -0
- data/lib/archsight/web/api/openapi/spec.yaml +500 -0
- data/lib/archsight/web/api/routes.rb +101 -0
- data/lib/archsight/web/application.rb +66 -43
- data/lib/archsight/web/doc/import.md +458 -0
- data/lib/archsight/web/doc/index.md.erb +1 -0
- data/lib/archsight/web/public/css/artifact.css +10 -0
- data/lib/archsight/web/public/css/graph.css +14 -0
- data/lib/archsight/web/public/css/instance.css +489 -0
- data/lib/archsight/web/views/api_docs.erb +19 -0
- data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
- data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
- data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
- data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
- data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
- data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
- data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
- data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
- metadata +78 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "archsight/import"
|
|
8
|
+
require_relative "progress"
|
|
9
|
+
|
|
10
|
+
# Base class for import handlers
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement the #execute method to perform the actual import.
|
|
13
|
+
# Use the helper methods to read configuration, validate environment, and write output.
|
|
14
|
+
class Archsight::Import::Handler
|
|
15
|
+
attr_reader :import_resource, :database, :resources_dir, :progress, :shared_writer
|
|
16
|
+
|
|
17
|
+
# @param import_resource [Archsight::Resources::Import] The import resource to execute
|
|
18
|
+
# @param database [Archsight::Database] The database instance
|
|
19
|
+
# @param resources_dir [String] Root resources directory
|
|
20
|
+
# @param progress [Archsight::Import::Progress] Progress reporter
|
|
21
|
+
# @param shared_writer [Archsight::Import::SharedFileWriter] Thread-safe file writer for concurrent output
|
|
22
|
+
def initialize(import_resource, database:, resources_dir:, progress: nil, shared_writer: nil)
|
|
23
|
+
@import_resource = import_resource
|
|
24
|
+
@database = database
|
|
25
|
+
@resources_dir = resources_dir
|
|
26
|
+
@progress = progress || Archsight::Import::Progress.new
|
|
27
|
+
@shared_writer = shared_writer
|
|
28
|
+
@tracked_resources = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Execute the import. Must be implemented by subclasses.
|
|
32
|
+
# @raise [NotImplementedError] if not overridden
|
|
33
|
+
def execute
|
|
34
|
+
raise NotImplementedError, "#{self.class}#execute must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get a configuration value from import/config/* annotations
|
|
38
|
+
# @param key [String] Configuration key (without the import/config/ prefix)
|
|
39
|
+
# @param default [Object, nil] Default value if not set
|
|
40
|
+
# @return [String, nil] The configuration value
|
|
41
|
+
def config(key, default: nil)
|
|
42
|
+
import_resource.annotations["import/config/#{key}"] || default
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get all configuration values as a hash
|
|
46
|
+
# @return [Hash] Configuration key-value pairs
|
|
47
|
+
def config_all
|
|
48
|
+
import_resource.annotations.each_with_object({}) do |(key, value), hash|
|
|
49
|
+
next unless key.start_with?("import/config/")
|
|
50
|
+
|
|
51
|
+
config_key = key.sub("import/config/", "")
|
|
52
|
+
hash[config_key] = value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compute a hash of the import's configuration for cache invalidation
|
|
57
|
+
# @return [String] 16-character hex hash
|
|
58
|
+
def compute_config_hash
|
|
59
|
+
config_data = {
|
|
60
|
+
handler: import_resource.annotations["import/handler"],
|
|
61
|
+
config: import_resource.annotations.select { |k, _| k.start_with?("import/config/") }.sort.to_h
|
|
62
|
+
}
|
|
63
|
+
Digest::SHA256.hexdigest(config_data.to_json)[0, 16]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generate a marker Import for this handler with generated/at timestamp and config hash
|
|
67
|
+
# Used for caching - call at end of execute() to persist the execution timestamp
|
|
68
|
+
# @return [Hash] Import resource hash ready for YAML serialization
|
|
69
|
+
def self_marker
|
|
70
|
+
{
|
|
71
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
72
|
+
"kind" => "Import",
|
|
73
|
+
"metadata" => {
|
|
74
|
+
"name" => import_resource.name,
|
|
75
|
+
"annotations" => {
|
|
76
|
+
"generated/at" => Time.now.utc.iso8601,
|
|
77
|
+
"generated/configHash" => compute_config_hash
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"spec" => {}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Write YAML content to the output path
|
|
85
|
+
# @param content [String] YAML content to write
|
|
86
|
+
# @param filename [String, nil] Output filename (overrides import/outputPath filename)
|
|
87
|
+
# @return [String] Path to the written file
|
|
88
|
+
#
|
|
89
|
+
# Output location is determined by import/outputPath annotation:
|
|
90
|
+
# - Relative to resources_dir (e.g., "generated/repositories.yaml")
|
|
91
|
+
# - If filename parameter is provided, it replaces the filename from outputPath
|
|
92
|
+
# - If no outputPath, falls back to resources_dir/generated with import name as filename
|
|
93
|
+
#
|
|
94
|
+
# When shared_writer is available, uses thread-safe append for concurrent writes.
|
|
95
|
+
# @param content [String] YAML content to write
|
|
96
|
+
# @param filename [String, nil] Output filename (overrides import/outputPath filename)
|
|
97
|
+
# @param sort_key [String, nil] Key for sorting in shared files (default: import name)
|
|
98
|
+
def write_yaml(content, filename: nil, sort_key: nil)
|
|
99
|
+
output_path = import_resource.annotations["import/outputPath"]
|
|
100
|
+
|
|
101
|
+
full_path = if output_path
|
|
102
|
+
base = File.join(resources_dir, output_path)
|
|
103
|
+
if filename
|
|
104
|
+
# Replace filename portion with provided filename
|
|
105
|
+
File.join(File.dirname(base), filename)
|
|
106
|
+
else
|
|
107
|
+
base
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
# Fallback to resources_dir/generated
|
|
111
|
+
File.join(resources_dir, "generated", filename || "#{safe_filename(import_resource.name)}.yaml")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if @shared_writer
|
|
115
|
+
# Use thread-safe shared writer for concurrent execution
|
|
116
|
+
# Default sort key is import name for stable output ordering
|
|
117
|
+
key = sort_key || import_resource.name
|
|
118
|
+
@shared_writer.append_yaml(full_path, content, sort_key: key)
|
|
119
|
+
else
|
|
120
|
+
# Direct write for non-concurrent mode
|
|
121
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
122
|
+
File.write(full_path, content)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
full_path
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Generate a resource YAML hash with standard metadata
|
|
129
|
+
# @param kind [String] Resource kind (e.g., "TechnologyArtifact")
|
|
130
|
+
# @param name [String] Resource name
|
|
131
|
+
# @param annotations [Hash] Resource annotations
|
|
132
|
+
# @param spec [Hash] Resource spec (relations)
|
|
133
|
+
# @return [Hash] Resource hash ready for YAML serialization
|
|
134
|
+
def resource_yaml(kind:, name:, annotations: {}, spec: {})
|
|
135
|
+
@tracked_resources << { kind: kind, name: name }
|
|
136
|
+
{
|
|
137
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
138
|
+
"kind" => kind,
|
|
139
|
+
"metadata" => {
|
|
140
|
+
"name" => name,
|
|
141
|
+
"annotations" => annotations.merge(
|
|
142
|
+
"generated/script" => import_resource.name,
|
|
143
|
+
"generated/at" => Time.now.utc.iso8601
|
|
144
|
+
)
|
|
145
|
+
},
|
|
146
|
+
"spec" => spec
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Generate an Import resource YAML hash for child imports
|
|
151
|
+
# @param name [String] Import resource name
|
|
152
|
+
# @param handler [String] Handler name
|
|
153
|
+
# @param config [Hash] Configuration annotations (added as import/config/*)
|
|
154
|
+
# @param annotations [Hash] Additional annotations (added directly)
|
|
155
|
+
# @return [Hash] Import resource hash ready for YAML serialization
|
|
156
|
+
#
|
|
157
|
+
# NOTE: generated/at is NOT set here - it's only set by the self_marker when
|
|
158
|
+
# the child import actually executes. This ensures caching works correctly
|
|
159
|
+
# regardless of file loading order.
|
|
160
|
+
#
|
|
161
|
+
# Dependencies are not specified here - they are derived from the `generates`
|
|
162
|
+
# relation on the parent import (tracked via write_generates_meta).
|
|
163
|
+
def import_yaml(name:, handler:, config: {}, annotations: {})
|
|
164
|
+
@tracked_resources << { kind: "Import", name: name }
|
|
165
|
+
all_annotations = {
|
|
166
|
+
"import/handler" => handler,
|
|
167
|
+
"generated/script" => import_resource.name
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Add direct annotations (e.g., import/outputPath)
|
|
171
|
+
all_annotations.merge!(annotations)
|
|
172
|
+
|
|
173
|
+
# Add config annotations with prefix
|
|
174
|
+
config.each do |key, value|
|
|
175
|
+
all_annotations["import/config/#{key}"] = value.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
180
|
+
"kind" => "Import",
|
|
181
|
+
"metadata" => {
|
|
182
|
+
"name" => name,
|
|
183
|
+
"annotations" => all_annotations
|
|
184
|
+
},
|
|
185
|
+
"spec" => {}
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Convert multiple resource hashes to YAML string with document separators
|
|
190
|
+
# @param resources [Array<Hash>] Array of resource hashes
|
|
191
|
+
# @return [String] YAML string with --- separators
|
|
192
|
+
def resources_to_yaml(resources)
|
|
193
|
+
resources.map { |r| YAML.dump(r) }.join
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Write generates meta record for this Import
|
|
197
|
+
# Call at end of execute() to persist tracking of generated resources
|
|
198
|
+
# Appends to the output file rather than overwriting
|
|
199
|
+
def write_generates_meta
|
|
200
|
+
return if @tracked_resources.empty?
|
|
201
|
+
|
|
202
|
+
meta = generates_meta_record(@tracked_resources)
|
|
203
|
+
output_path = import_resource.annotations["import/outputPath"]
|
|
204
|
+
|
|
205
|
+
full_path = if output_path
|
|
206
|
+
File.join(resources_dir, output_path)
|
|
207
|
+
else
|
|
208
|
+
File.join(resources_dir, "generated", "#{safe_filename(import_resource.name)}.yaml")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if @shared_writer
|
|
212
|
+
@shared_writer.append_yaml(full_path, YAML.dump(meta), sort_key: "#{import_resource.name}:generates")
|
|
213
|
+
else
|
|
214
|
+
# Append to existing file
|
|
215
|
+
File.open(full_path, "a") { |f| f.write(YAML.dump(meta)) }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# Create meta record for Import with generates relations
|
|
222
|
+
# @param resources [Array<Hash>] Array of tracked resources with :kind and :name
|
|
223
|
+
# @return [Hash] Import resource hash with generates spec
|
|
224
|
+
def generates_meta_record(resources)
|
|
225
|
+
grouped = resources.group_by { |r| r[:kind] }
|
|
226
|
+
|
|
227
|
+
generates_spec = {}
|
|
228
|
+
grouped.each do |kind, items|
|
|
229
|
+
kind_key = kind_to_relation_key(kind)
|
|
230
|
+
generates_spec[kind_key] = items.map { |r| r[:name] }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
"apiVersion" => "architecture/v1alpha1",
|
|
235
|
+
"kind" => "Import",
|
|
236
|
+
"metadata" => { "name" => import_resource.name },
|
|
237
|
+
"spec" => { "generates" => generates_spec }
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Convert resource kind to relation key
|
|
242
|
+
# @param kind [String] Resource kind (e.g., "TechnologyArtifact")
|
|
243
|
+
# @return [String] Relation key (e.g., "technologyArtifacts")
|
|
244
|
+
def kind_to_relation_key(kind)
|
|
245
|
+
case kind
|
|
246
|
+
when "TechnologyArtifact" then "technologyArtifacts"
|
|
247
|
+
when "ApplicationInterface" then "applicationInterfaces"
|
|
248
|
+
when "DataObject" then "dataObjects"
|
|
249
|
+
when "Import" then "imports"
|
|
250
|
+
when "BusinessActor" then "businessActors"
|
|
251
|
+
else
|
|
252
|
+
# Fallback: convert CamelCase to camelCase plural
|
|
253
|
+
"#{kind[0].downcase}#{kind[1..]}s"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Convert a name to a safe filename
|
|
258
|
+
# @param name [String] Resource name
|
|
259
|
+
# @return [String] Safe filename
|
|
260
|
+
def safe_filename(name)
|
|
261
|
+
name.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require_relative "../handler"
|
|
9
|
+
require_relative "../registry"
|
|
10
|
+
|
|
11
|
+
# GitHub handler - lists repositories from a GitHub organization and generates child Import resources
|
|
12
|
+
#
|
|
13
|
+
# Configuration:
|
|
14
|
+
# import/config/org - GitHub organization name
|
|
15
|
+
# import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
|
|
16
|
+
# import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
|
|
17
|
+
#
|
|
18
|
+
# Environment:
|
|
19
|
+
# GITHUB_TOKEN - GitHub Personal Access Token (required)
|
|
20
|
+
# Create at: https://github.com/settings/tokens
|
|
21
|
+
# Required scopes: repo (private repos) or public_repo (public only)
|
|
22
|
+
#
|
|
23
|
+
# Output:
|
|
24
|
+
# Generates Import:Repo:* resources for each repository
|
|
25
|
+
# The repository handler will clone/sync the actual git repositories
|
|
26
|
+
class Archsight::Import::Handlers::Github < Archsight::Import::Handler
|
|
27
|
+
PER_PAGE = 100
|
|
28
|
+
|
|
29
|
+
def execute
|
|
30
|
+
@org = config("org")
|
|
31
|
+
raise "Missing required config: org" unless @org
|
|
32
|
+
|
|
33
|
+
@token = ENV.fetch("GITHUB_TOKEN", nil)
|
|
34
|
+
raise "Missing required environment variable: GITHUB_TOKEN" unless @token
|
|
35
|
+
|
|
36
|
+
@repo_output_path = config("repoOutputPath")
|
|
37
|
+
@child_cache_time = config("childCacheTime")
|
|
38
|
+
@target_dir = File.join(Dir.home, ".cache", "archsight", "git", "github", @org)
|
|
39
|
+
|
|
40
|
+
# Fetch all repositories with pagination
|
|
41
|
+
progress.update("Fetching repositories from #{@org}")
|
|
42
|
+
repos = fetch_all_repos
|
|
43
|
+
|
|
44
|
+
if repos.empty?
|
|
45
|
+
progress.warn("No repositories found in #{@org}")
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generate Import resources for each repository
|
|
50
|
+
progress.update("Generating #{repos.size} import resources")
|
|
51
|
+
generate_repository_imports(repos)
|
|
52
|
+
|
|
53
|
+
write_generates_meta
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def fetch_all_repos
|
|
59
|
+
all_repos = []
|
|
60
|
+
page = 1
|
|
61
|
+
|
|
62
|
+
loop do
|
|
63
|
+
progress.update("Fetching repositories (page #{page})")
|
|
64
|
+
batch = fetch_repos_page(page)
|
|
65
|
+
break if batch.empty?
|
|
66
|
+
|
|
67
|
+
all_repos.concat(batch)
|
|
68
|
+
progress.update("Fetched #{all_repos.size} repositories")
|
|
69
|
+
break if batch.size < PER_PAGE
|
|
70
|
+
|
|
71
|
+
page += 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
all_repos
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_repos_page(page)
|
|
78
|
+
data = make_request("/orgs/#{@org}/repos", per_page: PER_PAGE, page: page)
|
|
79
|
+
|
|
80
|
+
# Transform API response to match expected format
|
|
81
|
+
data.map do |repo|
|
|
82
|
+
{
|
|
83
|
+
"name" => repo["name"],
|
|
84
|
+
"isArchived" => repo["archived"],
|
|
85
|
+
"visibility" => repo["visibility"],
|
|
86
|
+
"sshUrl" => repo["ssh_url"],
|
|
87
|
+
"url" => repo["html_url"]
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def make_request(path, params = {})
|
|
93
|
+
uri = URI("https://api.github.com#{path}")
|
|
94
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
95
|
+
|
|
96
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
97
|
+
http.use_ssl = true
|
|
98
|
+
|
|
99
|
+
request = Net::HTTP::Get.new(uri)
|
|
100
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
101
|
+
request["Accept"] = "application/vnd.github+json"
|
|
102
|
+
request["X-GitHub-Api-Version"] = "2022-11-28"
|
|
103
|
+
|
|
104
|
+
response = http.request(request)
|
|
105
|
+
handle_response(response)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_response(response)
|
|
109
|
+
case response
|
|
110
|
+
when Net::HTTPSuccess
|
|
111
|
+
JSON.parse(response.body)
|
|
112
|
+
when Net::HTTPUnauthorized
|
|
113
|
+
raise "GitHub API error: 401 Unauthorized - Invalid or expired GITHUB_TOKEN"
|
|
114
|
+
when Net::HTTPForbidden
|
|
115
|
+
if response["X-RateLimit-Remaining"] == "0"
|
|
116
|
+
reset_time = Time.at(response["X-RateLimit-Reset"].to_i)
|
|
117
|
+
raise "GitHub API error: 403 Rate limit exceeded. Resets at #{reset_time}"
|
|
118
|
+
end
|
|
119
|
+
raise "GitHub API error: 403 Forbidden - Check token permissions"
|
|
120
|
+
when Net::HTTPNotFound
|
|
121
|
+
raise "GitHub API error: 404 Not Found - Organization '#{@org}' not found or not accessible"
|
|
122
|
+
else
|
|
123
|
+
raise "GitHub API error: #{response.code} #{response.message}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def generate_repository_imports(repos)
|
|
128
|
+
yaml_documents = repos.map do |repo|
|
|
129
|
+
repo_name = repo["name"]
|
|
130
|
+
repo_path = File.join(@target_dir, repo_name)
|
|
131
|
+
visibility = (repo["visibility"] || "private").downcase
|
|
132
|
+
git_url = repo["sshUrl"] || repo["url"]
|
|
133
|
+
|
|
134
|
+
# Build annotations for child import
|
|
135
|
+
child_annotations = {}
|
|
136
|
+
child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
|
|
137
|
+
child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
|
|
138
|
+
|
|
139
|
+
import_yaml(
|
|
140
|
+
name: "Import:Repo:github:#{@org}:#{repo_name}",
|
|
141
|
+
handler: "repository",
|
|
142
|
+
config: {
|
|
143
|
+
"path" => repo_path,
|
|
144
|
+
"gitUrl" => git_url,
|
|
145
|
+
"archived" => repo["isArchived"].to_s,
|
|
146
|
+
"visibility" => visibility == "public" ? "open-source" : "internal"
|
|
147
|
+
},
|
|
148
|
+
annotations: child_annotations
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Add self-marker with generated/at for caching
|
|
153
|
+
yaml_documents << self_marker
|
|
154
|
+
|
|
155
|
+
# Write all imports to a single file with --- separators
|
|
156
|
+
yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
|
|
157
|
+
write_yaml(yaml_content)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Archsight::Import::Registry.register("github", Archsight::Import::Handlers::Github)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "json"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require_relative "../handler"
|
|
10
|
+
require_relative "../registry"
|
|
11
|
+
|
|
12
|
+
# GitLab handler - lists repositories from a GitLab instance and generates child Import resources
|
|
13
|
+
#
|
|
14
|
+
# Configuration:
|
|
15
|
+
# import/config/host - GitLab host (e.g., gitlab.company.com)
|
|
16
|
+
# import/config/exploreGroups - If "true", explore all visible groups (default: false)
|
|
17
|
+
# import/config/perPage - API pagination page size (default: 100)
|
|
18
|
+
# import/config/verifySSL - If "false", disable SSL verification (default: true)
|
|
19
|
+
# import/config/sslFingerprint - SSL certificate fingerprint for pinning (SHA256, colon-separated hex)
|
|
20
|
+
# import/config/repoOutputPath - Output path for repository handler results (e.g., "generated/repositories.yaml")
|
|
21
|
+
# import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
|
|
22
|
+
#
|
|
23
|
+
# Environment:
|
|
24
|
+
# GITLAB_TOKEN - GitLab personal access token (required)
|
|
25
|
+
#
|
|
26
|
+
# Output:
|
|
27
|
+
# Generates Import:Repo:* resources for each repository
|
|
28
|
+
# The repository handler will clone/sync the actual git repositories (via SSH)
|
|
29
|
+
class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
|
|
30
|
+
def execute
|
|
31
|
+
@host = config("host")
|
|
32
|
+
raise "Missing required config: host" unless @host
|
|
33
|
+
|
|
34
|
+
@token = ENV.fetch("GITLAB_TOKEN", nil)
|
|
35
|
+
raise "Missing required environment variable: GITLAB_TOKEN" unless @token
|
|
36
|
+
|
|
37
|
+
@repo_output_path = config("repoOutputPath")
|
|
38
|
+
@child_cache_time = config("childCacheTime")
|
|
39
|
+
|
|
40
|
+
@target_dir = File.join(Dir.home, ".cache", "archsight", "git", "gitlab")
|
|
41
|
+
@explore_groups = config("exploreGroups") == "true"
|
|
42
|
+
@per_page = config("perPage", default: "100").to_i
|
|
43
|
+
@verify_ssl = config("verifySSL") != "false"
|
|
44
|
+
@ssl_fingerprint = config("sslFingerprint")
|
|
45
|
+
|
|
46
|
+
# Fetch all projects
|
|
47
|
+
progress.update("Fetching projects from #{@host}")
|
|
48
|
+
projects = fetch_all_projects
|
|
49
|
+
|
|
50
|
+
if projects.empty?
|
|
51
|
+
progress.warn("No projects found on GitLab")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate Import resources for each repository
|
|
56
|
+
progress.update("Generating #{projects.size} import resources")
|
|
57
|
+
generate_repository_imports(projects)
|
|
58
|
+
|
|
59
|
+
write_generates_meta
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def api_endpoint
|
|
65
|
+
"https://#{@host}/api/v4"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def make_request(path, params = {})
|
|
69
|
+
uri = URI("#{api_endpoint}#{path}")
|
|
70
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
71
|
+
|
|
72
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
73
|
+
http.use_ssl = true
|
|
74
|
+
|
|
75
|
+
# Configure SSL verification
|
|
76
|
+
if @ssl_fingerprint
|
|
77
|
+
# Use certificate pinning - disable default verification, we verify fingerprint manually
|
|
78
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
79
|
+
http.verify_callback = lambda do |_preverify_ok, cert_store|
|
|
80
|
+
# Get the peer certificate from the chain
|
|
81
|
+
cert = cert_store.chain&.first
|
|
82
|
+
return true unless cert # Allow if no cert (will fail later)
|
|
83
|
+
|
|
84
|
+
# Compute SHA256 fingerprint
|
|
85
|
+
fingerprint = OpenSSL::Digest::SHA256.new(cert.to_der).to_s.upcase.scan(/../).join(":")
|
|
86
|
+
expected = @ssl_fingerprint.upcase
|
|
87
|
+
|
|
88
|
+
raise OpenSSL::SSL::SSLError, "Certificate fingerprint mismatch! Expected: #{expected}, Got: #{fingerprint}" if fingerprint != expected
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
request = Net::HTTP::Get.new(uri)
|
|
97
|
+
request["PRIVATE-TOKEN"] = @token
|
|
98
|
+
|
|
99
|
+
response = http.request(request)
|
|
100
|
+
|
|
101
|
+
raise "GitLab API error: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
102
|
+
|
|
103
|
+
JSON.parse(response.body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def fetch_all_groups
|
|
107
|
+
groups = []
|
|
108
|
+
page = 1
|
|
109
|
+
|
|
110
|
+
loop do
|
|
111
|
+
progress.update("Fetching groups (page #{page})")
|
|
112
|
+
params = { per_page: @per_page, page: page }
|
|
113
|
+
params[:all_available] = true if @explore_groups
|
|
114
|
+
|
|
115
|
+
batch = make_request("/groups", params)
|
|
116
|
+
break if batch.empty?
|
|
117
|
+
|
|
118
|
+
groups.concat(batch)
|
|
119
|
+
page += 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
groups
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fetch_projects_for_group(group_id)
|
|
126
|
+
projects = []
|
|
127
|
+
page = 1
|
|
128
|
+
|
|
129
|
+
loop do
|
|
130
|
+
params = {
|
|
131
|
+
include_subgroups: true,
|
|
132
|
+
per_page: @per_page,
|
|
133
|
+
page: page,
|
|
134
|
+
order_by: "created_at"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
batch = make_request("/groups/#{group_id}/projects", params)
|
|
138
|
+
break if batch.empty?
|
|
139
|
+
|
|
140
|
+
projects.concat(batch)
|
|
141
|
+
page += 1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
projects
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def fetch_all_projects
|
|
148
|
+
all_projects = []
|
|
149
|
+
groups = fetch_all_groups
|
|
150
|
+
|
|
151
|
+
groups.each_with_index do |group, idx|
|
|
152
|
+
progress.update("Fetching projects from #{group["full_path"]}", current: idx + 1, total: groups.size)
|
|
153
|
+
projects = fetch_projects_for_group(group["id"])
|
|
154
|
+
all_projects.concat(projects)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Deduplicate by project ID
|
|
158
|
+
all_projects.uniq { |p| p["id"] }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def safe_dir_name(path)
|
|
162
|
+
path.gsub("/", ".")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def git_url_for(project)
|
|
166
|
+
project["ssh_url_to_repo"]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def generate_repository_imports(projects)
|
|
170
|
+
yaml_documents = projects.map do |project|
|
|
171
|
+
dir_name = safe_dir_name(project["path_with_namespace"])
|
|
172
|
+
repo_path = File.join(@target_dir, dir_name)
|
|
173
|
+
git_url = git_url_for(project)
|
|
174
|
+
|
|
175
|
+
# Build annotations for child import
|
|
176
|
+
child_annotations = {}
|
|
177
|
+
child_annotations["import/outputPath"] = @repo_output_path if @repo_output_path
|
|
178
|
+
child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
|
|
179
|
+
|
|
180
|
+
import_yaml(
|
|
181
|
+
name: "Import:Repo:gitlab:#{dir_name}",
|
|
182
|
+
handler: "repository",
|
|
183
|
+
config: {
|
|
184
|
+
"path" => repo_path,
|
|
185
|
+
"gitUrl" => git_url,
|
|
186
|
+
"archived" => project["archived"].to_s,
|
|
187
|
+
"visibility" => project["visibility"] || "internal"
|
|
188
|
+
},
|
|
189
|
+
annotations: child_annotations
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Add self-marker with generated/at for caching
|
|
194
|
+
yaml_documents << self_marker
|
|
195
|
+
|
|
196
|
+
# Write all imports to a single file with --- separators
|
|
197
|
+
yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
|
|
198
|
+
write_yaml(yaml_content)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Archsight::Import::Registry.register("gitlab", Archsight::Import::Handlers::Gitlab)
|