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.
@@ -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