equilibrium 0.1.0 → 0.2.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/CHANGELOG.md +22 -3
- data/README.md +41 -37
- data/lib/equilibrium/analyzer.rb +16 -14
- data/lib/equilibrium/canonical_version_mapper.rb +19 -0
- data/lib/equilibrium/catalog_builder.rb +31 -15
- data/lib/equilibrium/cli.rb +17 -172
- 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 +57 -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 +8 -2
- 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 +1 -1
- data/lib/equilibrium/tag_data_builder.rb +16 -0
- data/lib/equilibrium/tag_processor.rb +24 -14
- data/lib/equilibrium/tag_sorter.rb +39 -0
- data/lib/equilibrium/tags_operation_service.rb +32 -0
- data/lib/equilibrium/version.rb +1 -1
- data/lib/equilibrium.rb +1 -1
- metadata +18 -4
- data/lib/equilibrium/semantic_version.rb +0 -24
@@ -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 and generating remediation plan
|
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
|
@@ -26,29 +26,40 @@ module Equilibrium
|
|
26
26
|
# by some registries (like GCR). Most registries follow the Docker Registry v2 spec
|
27
27
|
# which only returns 'name' and 'tags' fields. To get digest information, separate
|
28
28
|
# calls to /v2/<name>/manifests/<tag> are required per the official specification.
|
29
|
+
#
|
30
|
+
# PAGINATION ANALYSIS (August 2025):
|
31
|
+
# Current implementation fetches ALL tags in single API call. Analysis of production registries:
|
32
|
+
# - apm-inject: 29 tags
|
33
|
+
# - dd-lib-dotnet-init: 31 tags
|
34
|
+
# - dd-lib-java-init: 18 tags
|
35
|
+
# - dd-lib-js-init: 56 tags (largest)
|
36
|
+
# - dd-lib-php-init: 10 tags
|
37
|
+
# - dd-lib-python-init: 27 tags
|
38
|
+
# - dd-lib-ruby-init: 19 tags
|
39
|
+
# Total: 190 tags across all registries
|
40
|
+
#
|
41
|
+
# GCR PAGINATION RESEARCH FINDINGS:
|
42
|
+
# - DOCUMENTED LIMIT: 10,000 items for format-specific API requests
|
43
|
+
# Source: https://cloud.google.com/artifact-registry/quotas
|
44
|
+
# - NO PAGINATION SUPPORT: GCR ignores Docker Registry v2 pagination parameters ('n', 'last')
|
45
|
+
# Source: https://stackoverflow.com/questions/38307259 (unresolved since 2016)
|
46
|
+
# - ALL-OR-NOTHING: Returns complete tag lists up to 10k limit, then truncates unpredictably
|
47
|
+
# - NO PER-PAGE LIMIT: Not documented, not applicable due to lack of pagination support
|
48
|
+
#
|
49
|
+
# RECOMMENDATION: Current single-request approach is sufficient.
|
50
|
+
# Implementation complexity for pagination: 3-4 hours with minimal benefit.
|
51
|
+
# Consider only if individual repositories approach thousands of tags.
|
29
52
|
class RegistryClient
|
30
53
|
class Error < StandardError; end
|
31
54
|
|
32
|
-
|
33
|
-
url = build_api_url(registry)
|
34
|
-
data = fetch_tags_data(url)
|
35
|
-
parse_response(data)
|
36
|
-
end
|
37
|
-
|
38
|
-
private
|
39
|
-
|
40
|
-
def build_api_url(registry)
|
41
|
-
parts = registry.split("/")
|
42
|
-
raise Error, "Invalid GCR registry format: #{registry}" if parts.length < 3
|
43
|
-
|
44
|
-
host, project, *image = parts
|
55
|
+
attr_reader :uri
|
45
56
|
|
46
|
-
|
57
|
+
def initialize(registry)
|
58
|
+
@registry = registry
|
59
|
+
@uri = URI(build_api_url(registry))
|
47
60
|
end
|
48
61
|
|
49
|
-
def
|
50
|
-
uri = URI(url)
|
51
|
-
|
62
|
+
def tagged_digests
|
52
63
|
http = Net::HTTP.new(uri.host, uri.port)
|
53
64
|
http.use_ssl = true
|
54
65
|
http.read_timeout = 30
|
@@ -58,44 +69,45 @@ module Equilibrium
|
|
58
69
|
|
59
70
|
raise Error, "API request failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
60
71
|
|
61
|
-
data = JSON.parse(response.body)
|
62
|
-
|
63
|
-
|
64
|
-
rescue JSON::ParserError => e
|
65
|
-
raise Error, "Invalid JSON response: #{e.message}"
|
66
|
-
rescue => e
|
67
|
-
raise Error, "Request failed: #{e.message}"
|
68
|
-
end
|
69
|
-
|
70
|
-
def validate_registry_response(data)
|
71
|
-
SchemaValidator.validate!(data, Equilibrium::Schemas::REGISTRY_API_RESPONSE, error_prefix: "Registry API response validation failed")
|
72
|
-
rescue SchemaValidator::ValidationError => e
|
73
|
-
raise Error, e.message
|
74
|
-
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
|
75
75
|
|
76
|
-
def parse_response(data)
|
77
76
|
# Data is already validated by schema, safe to process
|
78
|
-
|
77
|
+
all_tags = data["tags"]
|
79
78
|
|
80
79
|
# The 'manifest' field is a non-standard extension provided by some registries (like GCR)
|
81
80
|
# Most registries (GHCR, Docker Hub, ECR) only return 'name' and 'tags' per Docker Registry v2 spec
|
82
81
|
# When manifest data is missing, digest information is not available from the tags endpoint
|
83
|
-
|
82
|
+
manifests = data.fetch("manifest")
|
84
83
|
|
85
|
-
tags.each_with_object({}) do |tag, result|
|
86
|
-
# Map tags to their digests. For registries without manifest data, digest will be nil
|
87
|
-
# This is expected behavior - digest info requires separate manifest API calls per tag
|
88
|
-
result[tag] = tag_to_digest[tag]
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
def build_digest_mapping(manifests)
|
93
84
|
# Build mapping from tag names to their SHA256 digests
|
94
85
|
# Only works when registry provides non-standard 'manifest' field in tags response
|
95
|
-
manifests.each_with_object({}) do |(digest, manifest_info), mapping|
|
96
|
-
|
97
|
-
|
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 }
|
98
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}"
|
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"
|
99
111
|
end
|
100
112
|
end
|
101
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
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
|
5
|
+
module Equilibrium
|
6
|
+
module RepositoryUrlValidator
|
7
|
+
def self.validate(repository_url)
|
8
|
+
unless repository_url.include?("/")
|
9
|
+
raise Thor::Error, "Repository URL must be full format (e.g., 'gcr.io/project-id/image-name'), not '#{repository_url}'"
|
10
|
+
end
|
11
|
+
repository_url
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -14,8 +14,7 @@ module Equilibrium
|
|
14
14
|
# @param error_prefix [String] Prefix for error messages (optional)
|
15
15
|
# @raise [ValidationError] If validation fails
|
16
16
|
def self.validate!(data, schema, error_prefix: "Schema validation failed")
|
17
|
-
|
18
|
-
errors = schemer.validate(data).to_a
|
17
|
+
errors = validate(data, schema)
|
19
18
|
|
20
19
|
return if errors.empty?
|
21
20
|
|
@@ -25,5 +24,14 @@ module Equilibrium
|
|
25
24
|
|
26
25
|
raise ValidationError, "#{error_prefix}:\n#{error_messages.join("\n")}"
|
27
26
|
end
|
27
|
+
|
28
|
+
# Validates data against a JSON Schema and returns validation errors
|
29
|
+
# @param data [Hash] The data to validate
|
30
|
+
# @param schema [Hash] The JSON Schema to validate against
|
31
|
+
# @return [Array] Array of validation errors (empty if valid)
|
32
|
+
def self.validate(data, schema)
|
33
|
+
schemer = JSONSchemer.schema(schema)
|
34
|
+
schemer.validate(data).to_a
|
35
|
+
end
|
28
36
|
end
|
29
37
|
end
|
@@ -8,6 +8,7 @@ module Equilibrium
|
|
8
8
|
# Example output (perfect equilibrium):
|
9
9
|
# {
|
10
10
|
# "repository_url": "gcr.io/datadoghq/apm-inject",
|
11
|
+
# "repository_name": "apm-inject",
|
11
12
|
# "expected_count": 28,
|
12
13
|
# "actual_count": 28,
|
13
14
|
# "missing_tags": {},
|
@@ -20,6 +21,7 @@ module Equilibrium
|
|
20
21
|
# Example output (requires remediation):
|
21
22
|
# {
|
22
23
|
# "repository_url": "gcr.io/datadoghq/example",
|
24
|
+
# "repository_name": "example",
|
23
25
|
# "expected_count": 3,
|
24
26
|
# "actual_count": 2,
|
25
27
|
# "missing_tags": {
|
@@ -49,9 +51,13 @@ module Equilibrium
|
|
49
51
|
"title" => "Equilibrium Analyzer Output Schema",
|
50
52
|
"description" => "Schema for output from 'equilibrium analyze --format=json' command",
|
51
53
|
"type" => "object",
|
52
|
-
"required" => [
|
54
|
+
"required" => [
|
55
|
+
"repository_url",
|
56
|
+
"repository_name", "expected_count", "actual_count", "missing_tags", "unexpected_tags", "mismatched_tags", "status", "remediation_plan"
|
57
|
+
],
|
53
58
|
"properties" => {
|
54
59
|
"repository_url" => {"type" => "string"},
|
60
|
+
"repository_name" => {"type" => "string"},
|
55
61
|
"expected_count" => {"type" => "integer", "minimum" => 0},
|
56
62
|
"actual_count" => {"type" => "integer", "minimum" => 0},
|
57
63
|
"status" => {"enum" => ["perfect", "missing_tags", "mismatched", "extra_tags"]},
|
@@ -84,7 +90,7 @@ module Equilibrium
|
|
84
90
|
"type" => "array",
|
85
91
|
"items" => {
|
86
92
|
"type" => "object",
|
87
|
-
"required" => ["action", "tag"
|
93
|
+
"required" => ["action", "tag"],
|
88
94
|
"properties" => {
|
89
95
|
"action" => {"enum" => ["create_tag", "update_tag", "remove_tag"]},
|
90
96
|
"tag" => {"type" => "string"},
|
@@ -3,25 +3,24 @@
|
|
3
3
|
module Equilibrium
|
4
4
|
module Schemas
|
5
5
|
# JSON Schema for catalog output format
|
6
|
-
# Used by `
|
6
|
+
# Used by `catalog` command
|
7
7
|
#
|
8
8
|
# Example output:
|
9
9
|
# {
|
10
|
+
# "repository_url": "gcr.io/datadoghq/apm-inject",
|
11
|
+
# "repository_name": "apm-inject",
|
10
12
|
# "images": [
|
11
13
|
# {
|
12
|
-
# "name": "apm-inject",
|
13
14
|
# "tag": "latest",
|
14
15
|
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
15
16
|
# "canonical_version": "0.43.2"
|
16
17
|
# },
|
17
18
|
# {
|
18
|
-
# "name": "apm-inject",
|
19
19
|
# "tag": "0",
|
20
20
|
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
21
21
|
# "canonical_version": "0.43.2"
|
22
22
|
# },
|
23
23
|
# {
|
24
|
-
# "name": "apm-inject",
|
25
24
|
# "tag": "0.43",
|
26
25
|
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
27
26
|
# "canonical_version": "0.43.1"
|
@@ -33,22 +32,30 @@ module Equilibrium
|
|
33
32
|
"title" => "Tag Resolver Schema",
|
34
33
|
"description" => "Schema for Resolve tag",
|
35
34
|
"type" => "object",
|
35
|
+
"required" => ["repository_url", "repository_name", "images"],
|
36
36
|
"properties" => {
|
37
|
+
"repository_url" => {
|
38
|
+
"type" => "string",
|
39
|
+
"description" => "Full repository URL (e.g., 'gcr.io/project-id/image-name')",
|
40
|
+
"minLength" => 1,
|
41
|
+
"pattern" => "^[a-zA-Z0-9.-]+(/[a-zA-Z0-9._-]+)*$"
|
42
|
+
},
|
43
|
+
"repository_name" => {
|
44
|
+
"type" => "string",
|
45
|
+
"description" => "Repository name extracted from URL (e.g., 'apm-inject')",
|
46
|
+
"minLength" => 1,
|
47
|
+
"pattern" => "^[a-zA-Z0-9._-]+$"
|
48
|
+
},
|
37
49
|
"images" => {
|
38
50
|
"type" => "array",
|
39
51
|
"items" => {
|
40
52
|
"type" => "object",
|
41
53
|
"required" => [
|
42
|
-
"name",
|
43
54
|
"tag",
|
44
55
|
"digest",
|
45
56
|
"canonical_version"
|
46
57
|
],
|
47
58
|
"properties" => {
|
48
|
-
"name" => {
|
49
|
-
"type" => "string",
|
50
|
-
"description" => "The name of the image"
|
51
|
-
},
|
52
59
|
"tag" => {
|
53
60
|
"type" => "string",
|
54
61
|
"description" => "The mutable tag of the image"
|
@@ -24,7 +24,7 @@ module Equilibrium
|
|
24
24
|
"title" => "Docker Registry v2 Tags List Response Schema",
|
25
25
|
"description" => "Schema for response from Docker Registry v2 API /v2/{name}/tags/list endpoint",
|
26
26
|
"type" => "object",
|
27
|
-
"required" => ["name", "tags"],
|
27
|
+
"required" => ["name", "tags", "manifest"],
|
28
28
|
"properties" => {
|
29
29
|
"name" => {
|
30
30
|
"type" => "string",
|
@@ -104,7 +104,7 @@ module Equilibrium
|
|
104
104
|
|
105
105
|
# Create table data: [["Tag", "Version", "Digest"]]
|
106
106
|
table_data = [["Tag", "Version", "Digest"]]
|
107
|
-
mutable_tags.keys.
|
107
|
+
mutable_tags.keys.each do |tag|
|
108
108
|
canonical_version = canonical_versions[tag]
|
109
109
|
digest = mutable_tags[tag]
|
110
110
|
table_data << [tag, canonical_version, digest]
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "tag_sorter"
|
4
|
+
|
5
|
+
module Equilibrium
|
6
|
+
class TagDataBuilder
|
7
|
+
def self.build_output(repository_url, repository_name, digests, canonical_versions)
|
8
|
+
{
|
9
|
+
"repository_url" => repository_url,
|
10
|
+
"repository_name" => repository_name,
|
11
|
+
"digests" => TagSorter.sort_descending(digests),
|
12
|
+
"canonical_versions" => TagSorter.sort_descending(canonical_versions)
|
13
|
+
}
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|