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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +29 -13
  4. data/README.md +97 -56
  5. data/lib/equilibrium/analyzer.rb +32 -50
  6. data/lib/equilibrium/canonical_version_mapper.rb +19 -0
  7. data/lib/equilibrium/catalog_builder.rb +31 -15
  8. data/lib/equilibrium/cli.rb +18 -181
  9. data/lib/equilibrium/commands/actual_command.rb +36 -0
  10. data/lib/equilibrium/commands/analyze_command.rb +49 -0
  11. data/lib/equilibrium/commands/catalog_command.rb +38 -0
  12. data/lib/equilibrium/commands/expected_command.rb +36 -0
  13. data/lib/equilibrium/commands/uncatalog_command.rb +42 -0
  14. data/lib/equilibrium/commands/version_command.rb +16 -0
  15. data/lib/equilibrium/mixins/error_handling.rb +34 -0
  16. data/lib/equilibrium/mixins/input_output.rb +40 -0
  17. data/lib/equilibrium/registry_client.rb +34 -45
  18. data/lib/equilibrium/repository_tags_service.rb +32 -0
  19. data/lib/equilibrium/repository_url_validator.rb +14 -0
  20. data/lib/equilibrium/schema_validator.rb +10 -2
  21. data/lib/equilibrium/schemas/analyzer_output.rb +34 -32
  22. data/lib/equilibrium/schemas/catalog.rb +16 -9
  23. data/lib/equilibrium/schemas/expected_actual.rb +1 -1
  24. data/lib/equilibrium/schemas/registry_api.rb +1 -1
  25. data/lib/equilibrium/summary_formatter.rb +11 -11
  26. data/lib/equilibrium/tag_data_builder.rb +16 -0
  27. data/lib/equilibrium/tag_processor.rb +24 -10
  28. data/lib/equilibrium/tag_sorter.rb +2 -16
  29. data/lib/equilibrium/tags_operation_service.rb +32 -0
  30. data/lib/equilibrium/version.rb +1 -1
  31. data/lib/equilibrium.rb +0 -1
  32. metadata +18 -10
  33. data/lib/equilibrium/semantic_version.rb +0 -24
  34. data/tmp/.gitkeep +0 -2
@@ -1,218 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "json"
5
- require_relative "../equilibrium"
6
- require_relative "schema_validator"
7
- require_relative "schemas/expected_actual"
8
- require_relative "summary_formatter"
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 generate remediation plan"
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
- # Load and validate data files
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
- client = RegistryClient.new
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
- client = RegistryClient.new
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
- # Read from file or stdin
139
- if file_path
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
- puts JSON.pretty_generate(catalog)
160
- rescue JSON::ParserError => e
161
- error_and_exit("Invalid JSON input: #{e.message}")
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
- say "Equilibrium v#{Equilibrium::VERSION}"
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
- def list_tags(registry)
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
- "https://#{host}/v2/#{project}/#{image.join("/")}/tags/list"
57
+ def initialize(registry)
58
+ @registry = registry
59
+ @uri = URI(build_api_url(registry))
70
60
  end
71
61
 
72
- def fetch_tags_data(url)
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
- validate_registry_response(data)
86
- data
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
- tags = data["tags"]
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
- tag_to_digest = build_digest_mapping(data["manifest"] || {})
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
- tags = manifest_info["tag"]
120
- tags.each { |tag| mapping[tag] = digest }
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