equilibrium 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/CHANGELOG.md +29 -13
- data/README.md +97 -56
- data/lib/equilibrium/analyzer.rb +32 -50
- data/lib/equilibrium/canonical_version_mapper.rb +19 -0
- data/lib/equilibrium/catalog_builder.rb +31 -15
- data/lib/equilibrium/cli.rb +18 -181
- data/lib/equilibrium/commands/actual_command.rb +36 -0
- data/lib/equilibrium/commands/analyze_command.rb +49 -0
- data/lib/equilibrium/commands/catalog_command.rb +38 -0
- data/lib/equilibrium/commands/expected_command.rb +36 -0
- data/lib/equilibrium/commands/uncatalog_command.rb +42 -0
- data/lib/equilibrium/commands/version_command.rb +16 -0
- data/lib/equilibrium/mixins/error_handling.rb +34 -0
- data/lib/equilibrium/mixins/input_output.rb +40 -0
- data/lib/equilibrium/registry_client.rb +34 -45
- data/lib/equilibrium/repository_tags_service.rb +32 -0
- data/lib/equilibrium/repository_url_validator.rb +14 -0
- data/lib/equilibrium/schema_validator.rb +10 -2
- data/lib/equilibrium/schemas/analyzer_output.rb +34 -32
- data/lib/equilibrium/schemas/catalog.rb +16 -9
- data/lib/equilibrium/schemas/expected_actual.rb +1 -1
- data/lib/equilibrium/schemas/registry_api.rb +1 -1
- data/lib/equilibrium/summary_formatter.rb +11 -11
- data/lib/equilibrium/tag_data_builder.rb +16 -0
- data/lib/equilibrium/tag_processor.rb +24 -10
- data/lib/equilibrium/tag_sorter.rb +2 -16
- data/lib/equilibrium/tags_operation_service.rb +32 -0
- data/lib/equilibrium/version.rb +1 -1
- data/lib/equilibrium.rb +0 -1
- metadata +18 -10
- data/lib/equilibrium/semantic_version.rb +0 -24
- data/tmp/.gitkeep +0 -2
data/lib/equilibrium/cli.rb
CHANGED
@@ -1,218 +1,55 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "thor"
|
4
|
-
|
5
|
-
require_relative "
|
6
|
-
require_relative "
|
7
|
-
require_relative "
|
8
|
-
require_relative "
|
4
|
+
require_relative "commands/analyze_command"
|
5
|
+
require_relative "commands/expected_command"
|
6
|
+
require_relative "commands/actual_command"
|
7
|
+
require_relative "commands/catalog_command"
|
8
|
+
require_relative "commands/uncatalog_command"
|
9
|
+
require_relative "commands/version_command"
|
9
10
|
|
10
11
|
module Equilibrium
|
12
|
+
# Main CLI class - acts as a router to individual command classes
|
13
|
+
# Each command delegates to its respective command class for execution
|
11
14
|
class CLI < Thor
|
12
15
|
def self.exit_on_failure?
|
13
16
|
true
|
14
17
|
end
|
15
18
|
|
16
|
-
desc "analyze", "Compare expected vs actual tags and
|
19
|
+
desc "analyze", "Compare expected vs actual tags and show analysis report"
|
17
20
|
option :expected, type: :string, required: true, desc: "Expected tags JSON file"
|
18
21
|
option :actual, type: :string, required: true, desc: "Actual tags JSON file"
|
19
22
|
option :registry, type: :string, desc: "Repository URL for output"
|
20
23
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
21
24
|
def analyze
|
22
|
-
|
23
|
-
expected_data = load_and_validate_json_file(options[:expected])
|
24
|
-
actual_data = load_and_validate_json_file(options[:actual])
|
25
|
-
|
26
|
-
analyzer = Analyzer.new
|
27
|
-
analysis = analyzer.analyze(expected_data, actual_data)
|
28
|
-
|
29
|
-
case options[:format]
|
30
|
-
when "json"
|
31
|
-
puts JSON.pretty_generate(analysis)
|
32
|
-
when "summary"
|
33
|
-
formatter = SummaryFormatter.new
|
34
|
-
formatter.print_analysis_summary(analysis)
|
35
|
-
end
|
36
|
-
rescue => e
|
37
|
-
error_and_exit(e.message)
|
25
|
+
Commands::AnalyzeCommand.new.execute(options)
|
38
26
|
end
|
39
27
|
|
40
28
|
desc "expected REPOSITORY_URL", "Output expected mutable tags to stdout"
|
41
29
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
42
30
|
def expected(registry)
|
43
|
-
|
44
|
-
processor = TagProcessor.new
|
45
|
-
|
46
|
-
full_repository_url = validate_repository_url(registry)
|
47
|
-
all_tags = client.list_tags(full_repository_url)
|
48
|
-
semantic_tags = processor.filter_semantic_tags(all_tags)
|
49
|
-
virtual_tags_result = processor.compute_virtual_tags(semantic_tags)
|
50
|
-
|
51
|
-
# Extract repository name from URL
|
52
|
-
repository_name = extract_repository_name(full_repository_url)
|
53
|
-
|
54
|
-
# Sort both digests and canonical_versions in descending order right before output
|
55
|
-
sorted_digests = TagSorter.sort_descending(virtual_tags_result["digests"])
|
56
|
-
sorted_canonical_versions = TagSorter.sort_descending(virtual_tags_result["canonical_versions"])
|
57
|
-
|
58
|
-
output = {
|
59
|
-
"repository_url" => full_repository_url,
|
60
|
-
"repository_name" => repository_name,
|
61
|
-
"digests" => sorted_digests,
|
62
|
-
"canonical_versions" => sorted_canonical_versions
|
63
|
-
}
|
64
|
-
|
65
|
-
# Validate output against schema before writing
|
66
|
-
validate_expected_actual_schema(output)
|
67
|
-
|
68
|
-
case options[:format]
|
69
|
-
when "json"
|
70
|
-
puts JSON.pretty_generate(output)
|
71
|
-
when "summary"
|
72
|
-
formatter = SummaryFormatter.new
|
73
|
-
formatter.print_tags_summary(output, "expected")
|
74
|
-
end
|
75
|
-
rescue Thor::Error
|
76
|
-
raise # Let Thor::Error bubble up for validation errors
|
77
|
-
rescue RegistryClient::Error => e
|
78
|
-
raise StandardError, e.message # Convert for test compatibility
|
79
|
-
rescue => e
|
80
|
-
error_and_exit(e.message)
|
31
|
+
Commands::ExpectedCommand.new.execute(registry, options)
|
81
32
|
end
|
82
33
|
|
83
34
|
desc "actual REPOSITORY_URL", "Output actual mutable tags to stdout"
|
84
35
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
85
36
|
def actual(registry)
|
86
|
-
|
87
|
-
processor = TagProcessor.new
|
88
|
-
|
89
|
-
full_repository_url = validate_repository_url(registry)
|
90
|
-
all_tags = client.list_tags(full_repository_url)
|
91
|
-
mutable_tags = processor.filter_mutable_tags(all_tags)
|
92
|
-
|
93
|
-
# Get semantic tags to create canonical mapping for actual mutable tags
|
94
|
-
semantic_tags = processor.filter_semantic_tags(all_tags)
|
95
|
-
canonical_versions = {}
|
96
|
-
|
97
|
-
# For each actual mutable tag, find its canonical version by digest matching
|
98
|
-
mutable_tags.each do |mutable_tag, digest|
|
99
|
-
# Find semantic tag with same digest
|
100
|
-
canonical_version = semantic_tags.find { |_, sem_digest| sem_digest == digest }&.first
|
101
|
-
canonical_versions[mutable_tag] = canonical_version if canonical_version
|
102
|
-
end
|
103
|
-
|
104
|
-
# Extract repository name from URL
|
105
|
-
repository_name = extract_repository_name(full_repository_url)
|
106
|
-
|
107
|
-
# Sort both digests and canonical_versions in descending order right before output
|
108
|
-
sorted_digests = TagSorter.sort_descending(mutable_tags)
|
109
|
-
sorted_canonical_versions = TagSorter.sort_descending(canonical_versions)
|
110
|
-
|
111
|
-
output = {
|
112
|
-
"repository_url" => full_repository_url,
|
113
|
-
"repository_name" => repository_name,
|
114
|
-
"digests" => sorted_digests,
|
115
|
-
"canonical_versions" => sorted_canonical_versions
|
116
|
-
}
|
117
|
-
|
118
|
-
# Validate output against schema before writing
|
119
|
-
validate_expected_actual_schema(output)
|
120
|
-
|
121
|
-
case options[:format]
|
122
|
-
when "json"
|
123
|
-
puts JSON.pretty_generate(output)
|
124
|
-
when "summary"
|
125
|
-
formatter = SummaryFormatter.new
|
126
|
-
formatter.print_tags_summary(output, "actual")
|
127
|
-
end
|
128
|
-
rescue Thor::Error
|
129
|
-
raise # Let Thor::Error bubble up for validation errors
|
130
|
-
rescue RegistryClient::Error => e
|
131
|
-
raise StandardError, e.message # Convert for test compatibility
|
132
|
-
rescue => e
|
133
|
-
error_and_exit(e.message)
|
37
|
+
Commands::ActualCommand.new.execute(registry, options)
|
134
38
|
end
|
135
39
|
|
136
40
|
desc "catalog [FILE]", "Convert expected tags JSON to catalog format (reads from file or stdin)"
|
137
41
|
def catalog(file_path = nil)
|
138
|
-
|
139
|
-
|
140
|
-
unless File.exist?(file_path)
|
141
|
-
error_and_exit("File not found: #{file_path}")
|
142
|
-
end
|
143
|
-
input = File.read(file_path).strip
|
144
|
-
else
|
145
|
-
input = $stdin.read.strip
|
146
|
-
if input.empty?
|
147
|
-
error_and_exit("No input provided. Use: equilibrium expected registry | equilibrium catalog")
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
data = JSON.parse(input)
|
152
|
-
|
153
|
-
# Validate against expected/actual schema
|
154
|
-
validate_expected_actual_schema(data)
|
155
|
-
|
156
|
-
builder = CatalogBuilder.new
|
157
|
-
catalog = builder.build_catalog(data)
|
42
|
+
Commands::CatalogCommand.new.execute(file_path)
|
43
|
+
end
|
158
44
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
rescue => e
|
163
|
-
error_and_exit(e.message)
|
45
|
+
desc "uncatalog [FILE]", "Convert catalog format back to expected/actual format (reads from file or stdin)"
|
46
|
+
def uncatalog(file_path = nil)
|
47
|
+
Commands::UncatalogCommand.new.execute(file_path)
|
164
48
|
end
|
165
49
|
|
166
50
|
desc "version", "Show version information"
|
167
51
|
def version
|
168
|
-
|
169
|
-
say "Container tag validation tool"
|
170
|
-
end
|
171
|
-
|
172
|
-
private
|
173
|
-
|
174
|
-
def load_and_validate_json_file(file_path)
|
175
|
-
unless File.exist?(file_path)
|
176
|
-
raise "File not found: #{file_path}"
|
177
|
-
end
|
178
|
-
|
179
|
-
data = JSON.parse(File.read(file_path))
|
180
|
-
validate_expected_actual_schema(data)
|
181
|
-
data
|
182
|
-
rescue JSON::ParserError => e
|
183
|
-
raise "Invalid JSON in #{file_path}: #{e.message}"
|
184
|
-
end
|
185
|
-
|
186
|
-
def validate_repository_url(repository_url)
|
187
|
-
# Repository URL must include registry host, project/namespace, and image name
|
188
|
-
# Format: [REGISTRY_HOST]/[PROJECT_OR_NAMESPACE]/[IMAGE_NAME]
|
189
|
-
unless repository_url.include?("/")
|
190
|
-
raise Thor::Error, "Repository URL must be full format (e.g., 'gcr.io/project-id/image-name'), not '#{repository_url}'"
|
191
|
-
end
|
192
|
-
|
193
|
-
repository_url
|
194
|
-
end
|
195
|
-
|
196
|
-
def extract_repository_name(repository_url)
|
197
|
-
# Extract repository name from repository URL
|
198
|
-
# Examples:
|
199
|
-
# gcr.io/project-id/repository-name -> repository-name
|
200
|
-
# registry.com/namespace/repository-name -> repository-name
|
201
|
-
repository_url.split("/").last
|
202
|
-
end
|
203
|
-
|
204
|
-
def validate_expected_actual_schema(data)
|
205
|
-
SchemaValidator.validate!(data, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
206
|
-
rescue SchemaValidator::ValidationError => e
|
207
|
-
error_and_exit(e.message)
|
208
|
-
end
|
209
|
-
|
210
|
-
def error_and_exit(message, usage = nil)
|
211
|
-
error message
|
212
|
-
if usage
|
213
|
-
say usage, :red
|
214
|
-
end
|
215
|
-
exit 1
|
52
|
+
Commands::VersionCommand.execute
|
216
53
|
end
|
217
54
|
end
|
218
55
|
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../mixins/error_handling"
|
4
|
+
require_relative "../mixins/input_output"
|
5
|
+
require_relative "../repository_url_validator"
|
6
|
+
require_relative "../schema_validator"
|
7
|
+
require_relative "../schemas/expected_actual"
|
8
|
+
require_relative "../tags_operation_service"
|
9
|
+
|
10
|
+
module Equilibrium
|
11
|
+
module Commands
|
12
|
+
# Command for generating actual mutable tags from registry
|
13
|
+
class ActualCommand
|
14
|
+
include Mixins::ErrorHandling
|
15
|
+
include Mixins::InputOutput
|
16
|
+
|
17
|
+
# Execute the actual command
|
18
|
+
# @param registry [String] Repository URL
|
19
|
+
# @param options [Hash] Command options (format, etc.)
|
20
|
+
def execute(registry, options = {})
|
21
|
+
with_error_handling do
|
22
|
+
full_repository_url = RepositoryUrlValidator.validate(registry)
|
23
|
+
|
24
|
+
# Generate complete actual output using high-level service
|
25
|
+
output = TagsOperationService.generate_actual_output(full_repository_url)
|
26
|
+
|
27
|
+
# Validate output against schema before writing
|
28
|
+
SchemaValidator.validate!(output, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
29
|
+
|
30
|
+
# Format and display output
|
31
|
+
format_output(output, options[:format] || "summary", "actual")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
require_relative "../mixins/error_handling"
|
6
|
+
require_relative "../mixins/input_output"
|
7
|
+
require_relative "../schema_validator"
|
8
|
+
require_relative "../schemas/expected_actual"
|
9
|
+
require_relative "../schemas/analyzer_output"
|
10
|
+
require_relative "../analyzer"
|
11
|
+
|
12
|
+
module Equilibrium
|
13
|
+
module Commands
|
14
|
+
# Command for analyzing expected vs actual tags with unified structure
|
15
|
+
class AnalyzeCommand
|
16
|
+
include Mixins::ErrorHandling
|
17
|
+
include Mixins::InputOutput
|
18
|
+
|
19
|
+
# Execute the analyze command
|
20
|
+
# @param options [Hash] Command options (expected, actual, format, etc.)
|
21
|
+
def execute(options = {})
|
22
|
+
with_error_handling do
|
23
|
+
# Load and validate data files
|
24
|
+
expected_data = load_and_validate_json_file(options[:expected], error_prefix: "Expected data schema validation failed")
|
25
|
+
actual_data = load_and_validate_json_file(options[:actual], error_prefix: "Actual data schema validation failed")
|
26
|
+
|
27
|
+
# Perform analysis
|
28
|
+
analysis = Analyzer.analyze(expected_data, actual_data)
|
29
|
+
|
30
|
+
# Validate output schema
|
31
|
+
SchemaValidator.validate!(analysis, Equilibrium::Schemas::ANALYZER_OUTPUT, error_prefix: "Analyzer output schema validation failed")
|
32
|
+
|
33
|
+
# Format and display output
|
34
|
+
format_output(analysis, options[:format] || "summary", "analysis")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def load_and_validate_json_file(file_path, error_prefix:)
|
41
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
42
|
+
|
43
|
+
JSON.parse(File.read(file_path)).tap do |json|
|
44
|
+
SchemaValidator.validate!(json, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: error_prefix)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
require_relative "../mixins/error_handling"
|
6
|
+
require_relative "../mixins/input_output"
|
7
|
+
require_relative "../schema_validator"
|
8
|
+
require_relative "../schemas/expected_actual"
|
9
|
+
require_relative "../catalog_builder"
|
10
|
+
|
11
|
+
module Equilibrium
|
12
|
+
module Commands
|
13
|
+
# Command for converting expected tags JSON to catalog format
|
14
|
+
class CatalogCommand
|
15
|
+
include Mixins::ErrorHandling
|
16
|
+
include Mixins::InputOutput
|
17
|
+
|
18
|
+
# Execute the catalog command
|
19
|
+
# @param file_path [String, nil] Optional file path, uses stdin if nil
|
20
|
+
def execute(file_path = nil)
|
21
|
+
with_error_handling do
|
22
|
+
# Read input from file or stdin
|
23
|
+
input = read_input_data(file_path, "No input provided. Use: equilibrium expected registry | equilibrium catalog")
|
24
|
+
|
25
|
+
# Parse and validate JSON
|
26
|
+
data = JSON.parse(input)
|
27
|
+
SchemaValidator.validate!(data, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
28
|
+
|
29
|
+
# Convert to catalog format
|
30
|
+
catalog = CatalogBuilder.build_catalog(data)
|
31
|
+
|
32
|
+
# Output as JSON
|
33
|
+
puts JSON.pretty_generate(catalog)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../mixins/error_handling"
|
4
|
+
require_relative "../mixins/input_output"
|
5
|
+
require_relative "../repository_url_validator"
|
6
|
+
require_relative "../schema_validator"
|
7
|
+
require_relative "../schemas/expected_actual"
|
8
|
+
require_relative "../tags_operation_service"
|
9
|
+
|
10
|
+
module Equilibrium
|
11
|
+
module Commands
|
12
|
+
# Command for generating expected mutable tags based on semantic versions
|
13
|
+
class ExpectedCommand
|
14
|
+
include Mixins::ErrorHandling
|
15
|
+
include Mixins::InputOutput
|
16
|
+
|
17
|
+
# Execute the expected command
|
18
|
+
# @param registry [String] Repository URL
|
19
|
+
# @param options [Hash] Command options (format, etc.)
|
20
|
+
def execute(registry, options = {})
|
21
|
+
with_error_handling do
|
22
|
+
full_repository_url = RepositoryUrlValidator.validate(registry)
|
23
|
+
|
24
|
+
# Generate complete expected output using high-level service
|
25
|
+
output = TagsOperationService.generate_expected_output(full_repository_url)
|
26
|
+
|
27
|
+
# Validate output against schema before writing
|
28
|
+
SchemaValidator.validate!(output, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
29
|
+
|
30
|
+
# Format and display output
|
31
|
+
format_output(output, options[:format] || "summary", "expected")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
require_relative "../mixins/error_handling"
|
6
|
+
require_relative "../mixins/input_output"
|
7
|
+
require_relative "../schema_validator"
|
8
|
+
require_relative "../schemas/catalog"
|
9
|
+
require_relative "../schemas/expected_actual"
|
10
|
+
require_relative "../catalog_builder"
|
11
|
+
|
12
|
+
module Equilibrium
|
13
|
+
module Commands
|
14
|
+
# Command for converting catalog format back to expected/actual format
|
15
|
+
class UncatalogCommand
|
16
|
+
include Mixins::ErrorHandling
|
17
|
+
include Mixins::InputOutput
|
18
|
+
|
19
|
+
# Execute the uncatalog command
|
20
|
+
# @param file_path [String, nil] Optional file path, uses stdin if nil
|
21
|
+
def execute(file_path = nil)
|
22
|
+
with_error_handling do
|
23
|
+
# Read input from file or stdin
|
24
|
+
input = read_input_data(file_path, "No input provided. Use: equilibrium catalog registry | equilibrium uncatalog")
|
25
|
+
|
26
|
+
# Parse and validate JSON
|
27
|
+
data = JSON.parse(input)
|
28
|
+
SchemaValidator.validate!(data, Equilibrium::Schemas::CATALOG, error_prefix: "Catalog schema validation failed")
|
29
|
+
|
30
|
+
# Convert back to expected/actual format
|
31
|
+
result = CatalogBuilder.reverse_catalog(data)
|
32
|
+
|
33
|
+
# Validate output before printing
|
34
|
+
SchemaValidator.validate!(result, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Output validation failed")
|
35
|
+
|
36
|
+
# Output as JSON
|
37
|
+
puts JSON.pretty_generate(result)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../version"
|
4
|
+
|
5
|
+
module Equilibrium
|
6
|
+
module Commands
|
7
|
+
# Command for displaying version information
|
8
|
+
class VersionCommand
|
9
|
+
# Execute the version command
|
10
|
+
def self.execute
|
11
|
+
puts "Equilibrium v#{Equilibrium::VERSION}"
|
12
|
+
puts "Container tag validation tool"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "thor"
|
5
|
+
require_relative "../schema_validator"
|
6
|
+
|
7
|
+
module Equilibrium
|
8
|
+
module Mixins
|
9
|
+
# Module for consistent error handling across all commands
|
10
|
+
module ErrorHandling
|
11
|
+
def with_error_handling(&block)
|
12
|
+
block.call
|
13
|
+
rescue Thor::Error
|
14
|
+
raise # Let Thor::Error bubble up for validation errors
|
15
|
+
rescue RegistryClient::Error => e
|
16
|
+
raise StandardError, e.message # Convert for test compatibility
|
17
|
+
rescue SchemaValidator::ValidationError => e
|
18
|
+
error_and_exit(e.message)
|
19
|
+
rescue JSON::ParserError => e
|
20
|
+
error_and_exit("Invalid JSON input: #{e.message}")
|
21
|
+
rescue => e
|
22
|
+
error_and_exit(e.message)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# Exit with error message
|
28
|
+
def error_and_exit(message, usage = nil)
|
29
|
+
warn message
|
30
|
+
exit 1
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require_relative "../summary_formatter"
|
5
|
+
|
6
|
+
module Equilibrium
|
7
|
+
module Mixins
|
8
|
+
# Module for input/output operations - reading files, parsing JSON, formatting output
|
9
|
+
module InputOutput
|
10
|
+
def read_input_data(file_path = nil, usage_message = "No input provided")
|
11
|
+
input = file_path ? File.read(file_path) : $stdin.read
|
12
|
+
input = input.strip
|
13
|
+
|
14
|
+
raise usage_message if input.empty?
|
15
|
+
input
|
16
|
+
end
|
17
|
+
|
18
|
+
def format_output(data, format, summary_type = nil)
|
19
|
+
case format
|
20
|
+
when "json"
|
21
|
+
puts JSON.pretty_generate(data)
|
22
|
+
when "summary"
|
23
|
+
if summary_type
|
24
|
+
formatter = SummaryFormatter.new
|
25
|
+
case summary_type
|
26
|
+
when "expected", "actual"
|
27
|
+
formatter.print_tags_summary(data, summary_type)
|
28
|
+
when "analysis"
|
29
|
+
formatter.print_analysis_summary(data)
|
30
|
+
end
|
31
|
+
else
|
32
|
+
puts JSON.pretty_generate(data)
|
33
|
+
end
|
34
|
+
else
|
35
|
+
puts JSON.pretty_generate(data)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -52,26 +52,14 @@ module Equilibrium
|
|
52
52
|
class RegistryClient
|
53
53
|
class Error < StandardError; end
|
54
54
|
|
55
|
-
|
56
|
-
url = build_api_url(registry)
|
57
|
-
data = fetch_tags_data(url)
|
58
|
-
parse_response(data)
|
59
|
-
end
|
60
|
-
|
61
|
-
private
|
62
|
-
|
63
|
-
def build_api_url(registry)
|
64
|
-
parts = registry.split("/")
|
65
|
-
raise Error, "Invalid GCR registry format: #{registry}" if parts.length < 3
|
66
|
-
|
67
|
-
host, project, *image = parts
|
55
|
+
attr_reader :uri
|
68
56
|
|
69
|
-
|
57
|
+
def initialize(registry)
|
58
|
+
@registry = registry
|
59
|
+
@uri = URI(build_api_url(registry))
|
70
60
|
end
|
71
61
|
|
72
|
-
def
|
73
|
-
uri = URI(url)
|
74
|
-
|
62
|
+
def tagged_digests
|
75
63
|
http = Net::HTTP.new(uri.host, uri.port)
|
76
64
|
http.use_ssl = true
|
77
65
|
http.read_timeout = 30
|
@@ -81,44 +69,45 @@ module Equilibrium
|
|
81
69
|
|
82
70
|
raise Error, "API request failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
83
71
|
|
84
|
-
data = JSON.parse(response.body)
|
85
|
-
|
86
|
-
|
87
|
-
rescue JSON::ParserError => e
|
88
|
-
raise Error, "Invalid JSON response: #{e.message}"
|
89
|
-
rescue => e
|
90
|
-
raise Error, "Request failed: #{e.message}"
|
91
|
-
end
|
92
|
-
|
93
|
-
def validate_registry_response(data)
|
94
|
-
SchemaValidator.validate!(data, Equilibrium::Schemas::REGISTRY_API_RESPONSE, error_prefix: "Registry API response validation failed")
|
95
|
-
rescue SchemaValidator::ValidationError => e
|
96
|
-
raise Error, e.message
|
97
|
-
end
|
72
|
+
data = JSON.parse(response.body).tap do |json|
|
73
|
+
SchemaValidator.validate!(json, Equilibrium::Schemas::REGISTRY_API_RESPONSE, error_prefix: "Registry API response validation failed")
|
74
|
+
end
|
98
75
|
|
99
|
-
def parse_response(data)
|
100
76
|
# Data is already validated by schema, safe to process
|
101
|
-
|
77
|
+
all_tags = data["tags"]
|
102
78
|
|
103
79
|
# The 'manifest' field is a non-standard extension provided by some registries (like GCR)
|
104
80
|
# Most registries (GHCR, Docker Hub, ECR) only return 'name' and 'tags' per Docker Registry v2 spec
|
105
81
|
# When manifest data is missing, digest information is not available from the tags endpoint
|
106
|
-
|
82
|
+
manifests = data.fetch("manifest")
|
107
83
|
|
108
|
-
tags.each_with_object({}) do |tag, result|
|
109
|
-
# Map tags to their digests. For registries without manifest data, digest will be nil
|
110
|
-
# This is expected behavior - digest info requires separate manifest API calls per tag
|
111
|
-
result[tag] = tag_to_digest[tag]
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
def build_digest_mapping(manifests)
|
116
84
|
# Build mapping from tag names to their SHA256 digests
|
117
85
|
# Only works when registry provides non-standard 'manifest' field in tags response
|
118
|
-
manifests.each_with_object({}) do |(digest, manifest_info), mapping|
|
119
|
-
|
120
|
-
|
86
|
+
tag_to_digest = manifests.each_with_object({}) do |(digest, manifest_info), mapping|
|
87
|
+
tags_for_digest = manifest_info["tag"]
|
88
|
+
tags_for_digest.each { |tag| mapping[tag] = digest }
|
89
|
+
end
|
90
|
+
|
91
|
+
# Validate that all_tags from API response matches tag_to_digest keys
|
92
|
+
all_tags_set = all_tags.to_set
|
93
|
+
manifest_tags_set = tag_to_digest.keys.to_set
|
94
|
+
|
95
|
+
unless all_tags_set == manifest_tags_set
|
96
|
+
raise Error, "Tag mismatch: API tags #{all_tags_set.to_a.sort} != manifest tags #{manifest_tags_set.to_a.sort}"
|
121
97
|
end
|
98
|
+
|
99
|
+
tag_to_digest
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def build_api_url(registry)
|
105
|
+
parts = registry.split("/")
|
106
|
+
raise Error, "Invalid GCR registry format: #{registry}" if parts.length < 3
|
107
|
+
|
108
|
+
host, project, *image = parts
|
109
|
+
|
110
|
+
"https://#{host}/v2/#{project}/#{image.join("/")}/tags/list"
|
122
111
|
end
|
123
112
|
end
|
124
113
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "registry_client"
|
4
|
+
require_relative "tag_processor"
|
5
|
+
require_relative "canonical_version_mapper"
|
6
|
+
|
7
|
+
module Equilibrium
|
8
|
+
class RepositoryTagsService
|
9
|
+
def self.generate_expected_tags(repository_url)
|
10
|
+
registry_client = RegistryClient.new(repository_url)
|
11
|
+
|
12
|
+
tagged_digests = registry_client.tagged_digests
|
13
|
+
semantic_tags = TagProcessor.filter_semantic_tags(tagged_digests)
|
14
|
+
TagProcessor.compute_virtual_tags(semantic_tags)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.generate_actual_tags(repository_url)
|
18
|
+
registry_client = RegistryClient.new(repository_url)
|
19
|
+
|
20
|
+
tagged_digests = registry_client.tagged_digests
|
21
|
+
semantic_tags = TagProcessor.filter_semantic_tags(tagged_digests)
|
22
|
+
|
23
|
+
mutable_tags = TagProcessor.filter_mutable_tags(tagged_digests)
|
24
|
+
canonical_versions = CanonicalVersionMapper.map_to_canonical_versions(mutable_tags, semantic_tags)
|
25
|
+
|
26
|
+
{
|
27
|
+
"digests" => mutable_tags,
|
28
|
+
"canonical_versions" => canonical_versions
|
29
|
+
}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|