equilibrium 0.1.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 +7 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +312 -0
- data/Rakefile +9 -0
- data/equilibrium +9 -0
- data/exe/equilibrium +6 -0
- data/lib/equilibrium/analyzer.rb +109 -0
- data/lib/equilibrium/catalog_builder.rb +45 -0
- data/lib/equilibrium/cli.rb +210 -0
- data/lib/equilibrium/registry_client.rb +101 -0
- data/lib/equilibrium/schema_validator.rb +29 -0
- data/lib/equilibrium/schemas/analyzer_output.rb +101 -0
- data/lib/equilibrium/schemas/catalog.rb +74 -0
- data/lib/equilibrium/schemas/expected_actual.rb +74 -0
- data/lib/equilibrium/schemas/registry_api.rb +63 -0
- data/lib/equilibrium/semantic_version.rb +24 -0
- data/lib/equilibrium/summary_formatter.rb +116 -0
- data/lib/equilibrium/tag_processor.rb +84 -0
- data/lib/equilibrium/version.rb +5 -0
- data/lib/equilibrium.rb +13 -0
- data/tmp/.gitkeep +2 -0
- metadata +113 -0
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
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"
|
9
|
+
|
10
|
+
module Equilibrium
|
11
|
+
class CLI < Thor
|
12
|
+
def self.exit_on_failure?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "analyze", "Compare expected vs actual tags and generate remediation plan"
|
17
|
+
option :expected, type: :string, required: true, desc: "Expected tags JSON file"
|
18
|
+
option :actual, type: :string, required: true, desc: "Actual tags JSON file"
|
19
|
+
option :registry, type: :string, desc: "Repository URL for output"
|
20
|
+
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
21
|
+
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)
|
38
|
+
end
|
39
|
+
|
40
|
+
desc "expected REPOSITORY_URL", "Output expected mutable tags to stdout"
|
41
|
+
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
42
|
+
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
|
+
output = {
|
55
|
+
"repository_url" => full_repository_url,
|
56
|
+
"repository_name" => repository_name,
|
57
|
+
"digests" => virtual_tags_result["digests"],
|
58
|
+
"canonical_versions" => virtual_tags_result["canonical_versions"]
|
59
|
+
}
|
60
|
+
|
61
|
+
# Validate output against schema before writing
|
62
|
+
validate_expected_actual_schema(output)
|
63
|
+
|
64
|
+
case options[:format]
|
65
|
+
when "json"
|
66
|
+
puts JSON.pretty_generate(output)
|
67
|
+
when "summary"
|
68
|
+
formatter = SummaryFormatter.new
|
69
|
+
formatter.print_tags_summary(output, "expected")
|
70
|
+
end
|
71
|
+
rescue Thor::Error
|
72
|
+
raise # Let Thor::Error bubble up for validation errors
|
73
|
+
rescue RegistryClient::Error => e
|
74
|
+
raise StandardError, e.message # Convert for test compatibility
|
75
|
+
rescue => e
|
76
|
+
error_and_exit(e.message)
|
77
|
+
end
|
78
|
+
|
79
|
+
desc "actual REPOSITORY_URL", "Output actual mutable tags to stdout"
|
80
|
+
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
81
|
+
def actual(registry)
|
82
|
+
client = RegistryClient.new
|
83
|
+
processor = TagProcessor.new
|
84
|
+
|
85
|
+
full_repository_url = validate_repository_url(registry)
|
86
|
+
all_tags = client.list_tags(full_repository_url)
|
87
|
+
mutable_tags = processor.filter_mutable_tags(all_tags)
|
88
|
+
|
89
|
+
# Get semantic tags to create canonical mapping for actual mutable tags
|
90
|
+
semantic_tags = processor.filter_semantic_tags(all_tags)
|
91
|
+
canonical_versions = {}
|
92
|
+
|
93
|
+
# For each actual mutable tag, find its canonical version by digest matching
|
94
|
+
mutable_tags.each do |mutable_tag, digest|
|
95
|
+
# Find semantic tag with same digest
|
96
|
+
canonical_version = semantic_tags.find { |_, sem_digest| sem_digest == digest }&.first
|
97
|
+
canonical_versions[mutable_tag] = canonical_version if canonical_version
|
98
|
+
end
|
99
|
+
|
100
|
+
# Extract repository name from URL
|
101
|
+
repository_name = extract_repository_name(full_repository_url)
|
102
|
+
|
103
|
+
output = {
|
104
|
+
"repository_url" => full_repository_url,
|
105
|
+
"repository_name" => repository_name,
|
106
|
+
"digests" => mutable_tags,
|
107
|
+
"canonical_versions" => canonical_versions
|
108
|
+
}
|
109
|
+
|
110
|
+
# Validate output against schema before writing
|
111
|
+
validate_expected_actual_schema(output)
|
112
|
+
|
113
|
+
case options[:format]
|
114
|
+
when "json"
|
115
|
+
puts JSON.pretty_generate(output)
|
116
|
+
when "summary"
|
117
|
+
formatter = SummaryFormatter.new
|
118
|
+
formatter.print_tags_summary(output, "actual")
|
119
|
+
end
|
120
|
+
rescue Thor::Error
|
121
|
+
raise # Let Thor::Error bubble up for validation errors
|
122
|
+
rescue RegistryClient::Error => e
|
123
|
+
raise StandardError, e.message # Convert for test compatibility
|
124
|
+
rescue => e
|
125
|
+
error_and_exit(e.message)
|
126
|
+
end
|
127
|
+
|
128
|
+
desc "catalog [FILE]", "Convert expected tags JSON to catalog format (reads from file or stdin)"
|
129
|
+
def catalog(file_path = nil)
|
130
|
+
# Read from file or stdin
|
131
|
+
if file_path
|
132
|
+
unless File.exist?(file_path)
|
133
|
+
error_and_exit("File not found: #{file_path}")
|
134
|
+
end
|
135
|
+
input = File.read(file_path).strip
|
136
|
+
else
|
137
|
+
input = $stdin.read.strip
|
138
|
+
if input.empty?
|
139
|
+
error_and_exit("No input provided. Use: equilibrium expected registry | equilibrium catalog")
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
data = JSON.parse(input)
|
144
|
+
|
145
|
+
# Validate against expected/actual schema
|
146
|
+
validate_expected_actual_schema(data)
|
147
|
+
|
148
|
+
builder = CatalogBuilder.new
|
149
|
+
catalog = builder.build_catalog(data)
|
150
|
+
|
151
|
+
puts JSON.pretty_generate(catalog)
|
152
|
+
rescue JSON::ParserError => e
|
153
|
+
error_and_exit("Invalid JSON input: #{e.message}")
|
154
|
+
rescue => e
|
155
|
+
error_and_exit(e.message)
|
156
|
+
end
|
157
|
+
|
158
|
+
desc "version", "Show version information"
|
159
|
+
def version
|
160
|
+
say "Equilibrium v#{Equilibrium::VERSION}"
|
161
|
+
say "Container tag validation tool"
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def load_and_validate_json_file(file_path)
|
167
|
+
unless File.exist?(file_path)
|
168
|
+
raise "File not found: #{file_path}"
|
169
|
+
end
|
170
|
+
|
171
|
+
data = JSON.parse(File.read(file_path))
|
172
|
+
validate_expected_actual_schema(data)
|
173
|
+
data
|
174
|
+
rescue JSON::ParserError => e
|
175
|
+
raise "Invalid JSON in #{file_path}: #{e.message}"
|
176
|
+
end
|
177
|
+
|
178
|
+
def validate_repository_url(repository_url)
|
179
|
+
# Repository URL must include registry host, project/namespace, and image name
|
180
|
+
# Format: [REGISTRY_HOST]/[PROJECT_OR_NAMESPACE]/[IMAGE_NAME]
|
181
|
+
unless repository_url.include?("/")
|
182
|
+
raise Thor::Error, "Repository URL must be full format (e.g., 'gcr.io/project-id/image-name'), not '#{repository_url}'"
|
183
|
+
end
|
184
|
+
|
185
|
+
repository_url
|
186
|
+
end
|
187
|
+
|
188
|
+
def extract_repository_name(repository_url)
|
189
|
+
# Extract repository name from repository URL
|
190
|
+
# Examples:
|
191
|
+
# gcr.io/project-id/repository-name -> repository-name
|
192
|
+
# registry.com/namespace/repository-name -> repository-name
|
193
|
+
repository_url.split("/").last
|
194
|
+
end
|
195
|
+
|
196
|
+
def validate_expected_actual_schema(data)
|
197
|
+
SchemaValidator.validate!(data, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
198
|
+
rescue SchemaValidator::ValidationError => e
|
199
|
+
error_and_exit(e.message)
|
200
|
+
end
|
201
|
+
|
202
|
+
def error_and_exit(message, usage = nil)
|
203
|
+
error message
|
204
|
+
if usage
|
205
|
+
say usage, :red
|
206
|
+
end
|
207
|
+
exit 1
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "json"
|
5
|
+
require "net/http"
|
6
|
+
require_relative "schema_validator"
|
7
|
+
require_relative "schemas/registry_api"
|
8
|
+
|
9
|
+
module Equilibrium
|
10
|
+
# Registry client for Docker Registry v2 API
|
11
|
+
#
|
12
|
+
# Supports fetching container image tags from various registry providers.
|
13
|
+
# Currently designed for registries that allow anonymous access (like public GCR).
|
14
|
+
#
|
15
|
+
# Google Container Registry (GCR) Pros and Cons:
|
16
|
+
# ✅ Pros: Simple anonymous access for public repos, provides digest info via non-standard 'manifest' field
|
17
|
+
# ❌ Cons: Being deprecated in favor of Artifact Registry, non-standard API extensions may not be portable
|
18
|
+
#
|
19
|
+
# Other registries require complex authentication and don't provide manifest field,
|
20
|
+
# making it difficult to get digest information without separate API calls per tag:
|
21
|
+
# - GitHub Container Registry (GHCR): Requires Bearer token auth
|
22
|
+
# - Docker Hub: Requires token exchange auth flow
|
23
|
+
# - AWS ECR Public: Requires AWS credentials even for public repos
|
24
|
+
#
|
25
|
+
# Note: The 'manifest' field in responses is a non-standard extension only provided
|
26
|
+
# by some registries (like GCR). Most registries follow the Docker Registry v2 spec
|
27
|
+
# which only returns 'name' and 'tags' fields. To get digest information, separate
|
28
|
+
# calls to /v2/<name>/manifests/<tag> are required per the official specification.
|
29
|
+
class RegistryClient
|
30
|
+
class Error < StandardError; end
|
31
|
+
|
32
|
+
def list_tags(registry)
|
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
|
45
|
+
|
46
|
+
"https://#{host}/v2/#{project}/#{image.join("/")}/tags/list"
|
47
|
+
end
|
48
|
+
|
49
|
+
def fetch_tags_data(url)
|
50
|
+
uri = URI(url)
|
51
|
+
|
52
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
53
|
+
http.use_ssl = true
|
54
|
+
http.read_timeout = 30
|
55
|
+
http.open_timeout = 10
|
56
|
+
|
57
|
+
response = http.request(Net::HTTP::Get.new(uri))
|
58
|
+
|
59
|
+
raise Error, "API request failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
60
|
+
|
61
|
+
data = JSON.parse(response.body)
|
62
|
+
validate_registry_response(data)
|
63
|
+
data
|
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
|
75
|
+
|
76
|
+
def parse_response(data)
|
77
|
+
# Data is already validated by schema, safe to process
|
78
|
+
tags = data["tags"]
|
79
|
+
|
80
|
+
# The 'manifest' field is a non-standard extension provided by some registries (like GCR)
|
81
|
+
# Most registries (GHCR, Docker Hub, ECR) only return 'name' and 'tags' per Docker Registry v2 spec
|
82
|
+
# When manifest data is missing, digest information is not available from the tags endpoint
|
83
|
+
tag_to_digest = build_digest_mapping(data["manifest"] || {})
|
84
|
+
|
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
|
+
# Build mapping from tag names to their SHA256 digests
|
94
|
+
# Only works when registry provides non-standard 'manifest' field in tags response
|
95
|
+
manifests.each_with_object({}) do |(digest, manifest_info), mapping|
|
96
|
+
tags = manifest_info["tag"]
|
97
|
+
tags.each { |tag| mapping[tag] = digest }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json_schemer"
|
4
|
+
|
5
|
+
module Equilibrium
|
6
|
+
# Reusable schema validation utility
|
7
|
+
# Centralizes JSON Schema validation logic across the application
|
8
|
+
class SchemaValidator
|
9
|
+
class ValidationError < StandardError; end
|
10
|
+
|
11
|
+
# Validates data against a JSON Schema
|
12
|
+
# @param data [Hash] The data to validate
|
13
|
+
# @param schema [Hash] The JSON Schema to validate against
|
14
|
+
# @param error_prefix [String] Prefix for error messages (optional)
|
15
|
+
# @raise [ValidationError] If validation fails
|
16
|
+
def self.validate!(data, schema, error_prefix: "Schema validation failed")
|
17
|
+
schemer = JSONSchemer.schema(schema)
|
18
|
+
errors = schemer.validate(data).to_a
|
19
|
+
|
20
|
+
return if errors.empty?
|
21
|
+
|
22
|
+
error_messages = errors.map do |error|
|
23
|
+
"#{error["data_pointer"]}: #{error["details"] || error["error"]}"
|
24
|
+
end
|
25
|
+
|
26
|
+
raise ValidationError, "#{error_prefix}:\n#{error_messages.join("\n")}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
module Schemas
|
5
|
+
# JSON Schema for analyzer output format
|
6
|
+
# Used by `equilibrium analyze --format=json` command
|
7
|
+
#
|
8
|
+
# Example output (perfect equilibrium):
|
9
|
+
# {
|
10
|
+
# "repository_url": "gcr.io/datadoghq/apm-inject",
|
11
|
+
# "expected_count": 28,
|
12
|
+
# "actual_count": 28,
|
13
|
+
# "missing_tags": {},
|
14
|
+
# "unexpected_tags": {},
|
15
|
+
# "mismatched_tags": {},
|
16
|
+
# "status": "perfect",
|
17
|
+
# "remediation_plan": []
|
18
|
+
# }
|
19
|
+
#
|
20
|
+
# Example output (requires remediation):
|
21
|
+
# {
|
22
|
+
# "repository_url": "gcr.io/datadoghq/example",
|
23
|
+
# "expected_count": 3,
|
24
|
+
# "actual_count": 2,
|
25
|
+
# "missing_tags": {
|
26
|
+
# "latest": "sha256:abc123ef456789..."
|
27
|
+
# },
|
28
|
+
# "unexpected_tags": {
|
29
|
+
# "dev": "sha256:xyz789ab123456..."
|
30
|
+
# },
|
31
|
+
# "mismatched_tags": {
|
32
|
+
# "0.1": {
|
33
|
+
# "expected": "sha256:def456ab789123...",
|
34
|
+
# "actual": "sha256:old123ef456789..."
|
35
|
+
# }
|
36
|
+
# },
|
37
|
+
# "status": "missing_tags",
|
38
|
+
# "remediation_plan": [
|
39
|
+
# {
|
40
|
+
# "action": "create_tag",
|
41
|
+
# "tag": "latest",
|
42
|
+
# "digest": "sha256:abc123ef456789...",
|
43
|
+
# "command": "gcloud container images add-tag gcr.io/datadoghq/example@sha256:abc123... gcr.io/datadoghq/example:latest"
|
44
|
+
# }
|
45
|
+
# ]
|
46
|
+
# }
|
47
|
+
ANALYZER_OUTPUT = {
|
48
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
49
|
+
"title" => "Equilibrium Analyzer Output Schema",
|
50
|
+
"description" => "Schema for output from 'equilibrium analyze --format=json' command",
|
51
|
+
"type" => "object",
|
52
|
+
"required" => ["repository_url", "expected_count", "actual_count", "missing_tags", "unexpected_tags", "mismatched_tags", "status", "remediation_plan"],
|
53
|
+
"properties" => {
|
54
|
+
"repository_url" => {"type" => "string"},
|
55
|
+
"expected_count" => {"type" => "integer", "minimum" => 0},
|
56
|
+
"actual_count" => {"type" => "integer", "minimum" => 0},
|
57
|
+
"status" => {"enum" => ["perfect", "missing_tags", "mismatched", "extra_tags"]},
|
58
|
+
"missing_tags" => {
|
59
|
+
"type" => "object",
|
60
|
+
"patternProperties" => {
|
61
|
+
".*" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"}
|
62
|
+
}
|
63
|
+
},
|
64
|
+
"unexpected_tags" => {
|
65
|
+
"type" => "object",
|
66
|
+
"patternProperties" => {
|
67
|
+
".*" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"}
|
68
|
+
}
|
69
|
+
},
|
70
|
+
"mismatched_tags" => {
|
71
|
+
"type" => "object",
|
72
|
+
"patternProperties" => {
|
73
|
+
".*" => {
|
74
|
+
"type" => "object",
|
75
|
+
"required" => ["expected", "actual"],
|
76
|
+
"properties" => {
|
77
|
+
"expected" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"},
|
78
|
+
"actual" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"}
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
},
|
83
|
+
"remediation_plan" => {
|
84
|
+
"type" => "array",
|
85
|
+
"items" => {
|
86
|
+
"type" => "object",
|
87
|
+
"required" => ["action", "tag", "command"],
|
88
|
+
"properties" => {
|
89
|
+
"action" => {"enum" => ["create_tag", "update_tag", "remove_tag"]},
|
90
|
+
"tag" => {"type" => "string"},
|
91
|
+
"digest" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"},
|
92
|
+
"old_digest" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"},
|
93
|
+
"new_digest" => {"type" => "string", "pattern" => "^sha256:[a-f0-9]{64}$"},
|
94
|
+
"command" => {"type" => "string"}
|
95
|
+
}
|
96
|
+
}
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}.freeze
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
module Schemas
|
5
|
+
# JSON Schema for catalog output format
|
6
|
+
# Used by `equilibrium catalog` command
|
7
|
+
#
|
8
|
+
# Example output:
|
9
|
+
# {
|
10
|
+
# "images": [
|
11
|
+
# {
|
12
|
+
# "name": "apm-inject",
|
13
|
+
# "tag": "latest",
|
14
|
+
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
15
|
+
# "canonical_version": "0.43.2"
|
16
|
+
# },
|
17
|
+
# {
|
18
|
+
# "name": "apm-inject",
|
19
|
+
# "tag": "0",
|
20
|
+
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
21
|
+
# "canonical_version": "0.43.2"
|
22
|
+
# },
|
23
|
+
# {
|
24
|
+
# "name": "apm-inject",
|
25
|
+
# "tag": "0.43",
|
26
|
+
# "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
27
|
+
# "canonical_version": "0.43.1"
|
28
|
+
# }
|
29
|
+
# ]
|
30
|
+
# }
|
31
|
+
CATALOG = {
|
32
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
33
|
+
"title" => "Tag Resolver Schema",
|
34
|
+
"description" => "Schema for Resolve tag",
|
35
|
+
"type" => "object",
|
36
|
+
"properties" => {
|
37
|
+
"images" => {
|
38
|
+
"type" => "array",
|
39
|
+
"items" => {
|
40
|
+
"type" => "object",
|
41
|
+
"required" => [
|
42
|
+
"name",
|
43
|
+
"tag",
|
44
|
+
"digest",
|
45
|
+
"canonical_version"
|
46
|
+
],
|
47
|
+
"properties" => {
|
48
|
+
"name" => {
|
49
|
+
"type" => "string",
|
50
|
+
"description" => "The name of the image"
|
51
|
+
},
|
52
|
+
"tag" => {
|
53
|
+
"type" => "string",
|
54
|
+
"description" => "The mutable tag of the image"
|
55
|
+
},
|
56
|
+
"digest" => {
|
57
|
+
"type" => "string",
|
58
|
+
"description" => "The full digest of the image",
|
59
|
+
"pattern" => "^sha256:[a-f0-9]{64}$"
|
60
|
+
},
|
61
|
+
"canonical_version" => {
|
62
|
+
"type" => "string",
|
63
|
+
"description" => "The canonical semantic version for this mutable tag",
|
64
|
+
"pattern" => "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
|
65
|
+
}
|
66
|
+
},
|
67
|
+
"additionalProperties" => false
|
68
|
+
}
|
69
|
+
}
|
70
|
+
},
|
71
|
+
"additionalProperties" => false
|
72
|
+
}.freeze
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
module Schemas
|
5
|
+
# JSON Schema for expected/actual command output format
|
6
|
+
# Used by `equilibrium expected` and `equilibrium actual` commands
|
7
|
+
#
|
8
|
+
# Example output:
|
9
|
+
# {
|
10
|
+
# "repository_url": "gcr.io/datadoghq/apm-inject",
|
11
|
+
# "repository_name": "apm-inject",
|
12
|
+
# "digests": {
|
13
|
+
# "latest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
14
|
+
# "0": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
15
|
+
# "0.43": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
16
|
+
# "0.42": "sha256:c7a822d271eb72e6c3bee2aaf579c8a3732eda9710d27effdca6beb3f5f63b0e"
|
17
|
+
# },
|
18
|
+
# "canonical_versions": {
|
19
|
+
# "latest": "0.43.2",
|
20
|
+
# "0": "0.43.2",
|
21
|
+
# "0.43": "0.43.1",
|
22
|
+
# "0.42": "0.42.3"
|
23
|
+
# }
|
24
|
+
# }
|
25
|
+
EXPECTED_ACTUAL = {
|
26
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
27
|
+
"title" => "Equilibrium Expected/Actual Output Schema",
|
28
|
+
"description" => "Schema for output from 'equilibrium expected' and 'equilibrium actual' commands",
|
29
|
+
"type" => "object",
|
30
|
+
"required" => ["repository_url", "repository_name", "digests", "canonical_versions"],
|
31
|
+
"properties" => {
|
32
|
+
"repository_url" => {
|
33
|
+
"type" => "string",
|
34
|
+
"description" => "Full repository URL (e.g., 'gcr.io/project-id/image-name', 'registry.example.com/namespace/repository')",
|
35
|
+
"minLength" => 1,
|
36
|
+
"pattern" => "^[a-zA-Z0-9.-]+(/[a-zA-Z0-9._-]+)*$"
|
37
|
+
},
|
38
|
+
"repository_name" => {
|
39
|
+
"type" => "string",
|
40
|
+
"description" => "Repository name extracted from URL (e.g., 'apm-inject')",
|
41
|
+
"minLength" => 1,
|
42
|
+
"pattern" => "^[a-zA-Z0-9._-]+$"
|
43
|
+
},
|
44
|
+
"digests" => {
|
45
|
+
"type" => "object",
|
46
|
+
"description" => "Mapping of mutable tags to their SHA256 digests",
|
47
|
+
"minProperties" => 1,
|
48
|
+
"patternProperties" => {
|
49
|
+
"^(latest|[0-9]+(\\.[0-9]+)*)$" => {
|
50
|
+
"type" => "string",
|
51
|
+
"description" => "SHA256 digest for the tag",
|
52
|
+
"pattern" => "^sha256:[a-f0-9]{64}$"
|
53
|
+
}
|
54
|
+
},
|
55
|
+
"additionalProperties" => false
|
56
|
+
},
|
57
|
+
"canonical_versions" => {
|
58
|
+
"type" => "object",
|
59
|
+
"description" => "Mapping of mutable tags to their canonical semantic versions",
|
60
|
+
"minProperties" => 1,
|
61
|
+
"patternProperties" => {
|
62
|
+
"^(latest|[0-9]+(\\.[0-9]+)*)$" => {
|
63
|
+
"type" => "string",
|
64
|
+
"description" => "Canonical semantic version for the mutable tag",
|
65
|
+
"pattern" => "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
|
66
|
+
}
|
67
|
+
},
|
68
|
+
"additionalProperties" => false
|
69
|
+
}
|
70
|
+
},
|
71
|
+
"additionalProperties" => false
|
72
|
+
}.freeze
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
module Schemas
|
5
|
+
# JSON Schema for Docker Registry v2 API tags/list endpoint response
|
6
|
+
# Used by RegistryClient to validate API responses
|
7
|
+
# Endpoint: https://docs.docker.com/registry/spec/api/#listing-image-tags
|
8
|
+
#
|
9
|
+
# Example response:
|
10
|
+
# {
|
11
|
+
# "name": "datadoghq/apm-inject",
|
12
|
+
# "tags": ["0.42.0", "0.42.1", "0.43.0", "latest"],
|
13
|
+
# "manifest": {
|
14
|
+
# "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c": {
|
15
|
+
# "tag": ["latest", "0.43.0", "0"]
|
16
|
+
# },
|
17
|
+
# "sha256:c7a822d271eb72e6c3bee2aaf579c8a3732eda9710d27effdca6beb3f5f63b0e": {
|
18
|
+
# "tag": ["0.42.1", "0.42"]
|
19
|
+
# }
|
20
|
+
# }
|
21
|
+
# }
|
22
|
+
REGISTRY_API_RESPONSE = {
|
23
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
24
|
+
"title" => "Docker Registry v2 Tags List Response Schema",
|
25
|
+
"description" => "Schema for response from Docker Registry v2 API /v2/{name}/tags/list endpoint",
|
26
|
+
"type" => "object",
|
27
|
+
"required" => ["name", "tags"],
|
28
|
+
"properties" => {
|
29
|
+
"name" => {
|
30
|
+
"type" => "string",
|
31
|
+
"description" => "Repository name"
|
32
|
+
},
|
33
|
+
"tags" => {
|
34
|
+
"type" => "array",
|
35
|
+
"description" => "List of tag names",
|
36
|
+
"items" => {
|
37
|
+
"type" => "string"
|
38
|
+
}
|
39
|
+
},
|
40
|
+
"manifest" => {
|
41
|
+
"type" => "object",
|
42
|
+
"description" => "Manifest data mapping digests to tag metadata",
|
43
|
+
"patternProperties" => {
|
44
|
+
"^sha256:[a-f0-9]{64}$" => {
|
45
|
+
"type" => "object",
|
46
|
+
"properties" => {
|
47
|
+
"tag" => {
|
48
|
+
"type" => "array",
|
49
|
+
"items" => {
|
50
|
+
"type" => "string"
|
51
|
+
}
|
52
|
+
}
|
53
|
+
},
|
54
|
+
"additionalProperties" => true
|
55
|
+
}
|
56
|
+
},
|
57
|
+
"additionalProperties" => false
|
58
|
+
}
|
59
|
+
},
|
60
|
+
"additionalProperties" => true
|
61
|
+
}.freeze
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
module SemanticVersion
|
5
|
+
# Strictly validate MAJOR.MINOR.PATCH format where:
|
6
|
+
# - MAJOR, MINOR, PATCH are non-negative integers
|
7
|
+
# - No leading zeros (except for '0' itself)
|
8
|
+
# - No prefixes (like 'v1.2.3', 'release-1.2.3')
|
9
|
+
# - No suffixes (like '1.2.3-alpha', '1.2.3+build')
|
10
|
+
# - No prereleases (like '1.2.3-rc.1', '1.2.3-beta.2')
|
11
|
+
def self.valid?(tag)
|
12
|
+
# Strict regex: each component must be either '0' or a number without leading zeros
|
13
|
+
return false unless tag.match?(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/)
|
14
|
+
|
15
|
+
# Additional validation: ensure it's a valid Gem::Version
|
16
|
+
begin
|
17
|
+
Gem::Version.new(tag)
|
18
|
+
true
|
19
|
+
rescue ArgumentError
|
20
|
+
false
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|