equilibrium 0.1.1 → 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 +15 -13
- 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 -180
- data/lib/equilibrium/commands/actual_command.rb +36 -0
- data/lib/equilibrium/commands/analyze_command.rb +49 -0
- data/lib/equilibrium/commands/catalog_command.rb +38 -0
- data/lib/equilibrium/commands/expected_command.rb +36 -0
- data/lib/equilibrium/commands/uncatalog_command.rb +42 -0
- data/lib/equilibrium/commands/version_command.rb +16 -0
- data/lib/equilibrium/mixins/error_handling.rb +34 -0
- data/lib/equilibrium/mixins/input_output.rb +40 -0
- data/lib/equilibrium/registry_client.rb +34 -45
- data/lib/equilibrium/repository_tags_service.rb +32 -0
- data/lib/equilibrium/repository_url_validator.rb +14 -0
- data/lib/equilibrium/schema_validator.rb +10 -2
- data/lib/equilibrium/schemas/analyzer_output.rb +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/tag_data_builder.rb +16 -0
- data/lib/equilibrium/tag_processor.rb +24 -10
- data/lib/equilibrium/tag_sorter.rb +2 -16
- data/lib/equilibrium/tags_operation_service.rb +32 -0
- data/lib/equilibrium/version.rb +1 -1
- data/lib/equilibrium.rb +0 -1
- metadata +17 -5
- data/lib/equilibrium/semantic_version.rb +0 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 22d45ae6c6368e01aa50e5c7abc828bee9a9f759beb57c2c80dc7f579e1b50da
|
4
|
+
data.tar.gz: 445f71cbc4c40d5d60bea3a456197a9de6d817bc32cc4bf87aa678d9c5691231
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0fbcb1559ecd07a6431b36342ca067f69cda326f547e32a70e256bfce35b762d76073b755a5365407b87ad6be9ff862eb5232a6866974a88d54ec1cebf1b304c
|
7
|
+
data.tar.gz: 1f1b80fa1c4d4b08546e7cf77c5254de3ebca107c63f0d0c15d317628380d211f247ab0586248d4476d9bb5ed4e86302ad2abb5bd1777b34d0c5fa34054a0d42
|
data/CHANGELOG.md
CHANGED
@@ -5,22 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
-
## [
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
## [0.2.0] - 2025-08-15
|
9
11
|
|
10
12
|
### Added
|
11
|
-
-
|
12
|
-
|
13
|
-
|
13
|
+
- Add `uncatalog` command for reverse catalog conversion
|
14
|
+
|
15
|
+
### Changed
|
16
|
+
- Catalog schema includes `repository_url` and `repository_name` at root level
|
17
|
+
|
18
|
+
### Fixed
|
19
|
+
- Fix changelog reference links
|
20
|
+
|
21
|
+
## [0.1.1] - 2025-08-06
|
14
22
|
|
15
23
|
### Fixed
|
16
24
|
- Preserve descending tag order in summary format output
|
17
25
|
- Consistent descending ordering for expected and actual outputs
|
18
26
|
|
19
|
-
### Changed
|
20
|
-
- Reorganized spec files to mirror lib directory structure
|
21
|
-
- Extracted TagSorter utility with comprehensive unit tests
|
22
|
-
- Enhanced RegistryClient with pagination analysis capabilities
|
23
|
-
|
24
27
|
## [0.1.0] - 2025-08-05
|
25
28
|
|
26
29
|
### Added
|
@@ -38,8 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
38
41
|
- CI/CD pipeline with GitHub Actions
|
39
42
|
- Trusted publishing support for RubyGems
|
40
43
|
- Standard rake task integration
|
41
|
-
|
42
|
-
### Features
|
43
44
|
- **Tag Validation**: Validates equilibrium between mutable tags and semantic version tags
|
44
45
|
- **Registry Client**: Pure Ruby HTTP client for container registry API access
|
45
46
|
- **Tag Processing**: Computes expected mutable tags from semantic versions (latest, major, minor)
|
@@ -53,5 +54,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
53
54
|
- Detailed architecture overview and data flow diagrams
|
54
55
|
- Complete command reference and examples
|
55
56
|
|
56
|
-
[0.
|
57
|
-
[0.1.
|
57
|
+
[0.2.0]: https://github.com/TonyCTHsu/equilibrium/releases/tag/v0.2.0
|
58
|
+
[0.1.1]: https://github.com/TonyCTHsu/equilibrium/releases/tag/v0.1.1
|
59
|
+
[0.1.0]: https://github.com/TonyCTHsu/equilibrium/releases/tag/v0.1.0
|
data/README.md
CHANGED
@@ -15,7 +15,6 @@ A container image tool that validates equilibrium between mutable tags and seman
|
|
15
15
|
- [Quick Start](#quick-start)
|
16
16
|
- [Output Formats & Schemas](#output-formats--schemas)
|
17
17
|
- [Constraints](#constraints)
|
18
|
-
- [Examples](#examples)
|
19
18
|
- [License](#license)
|
20
19
|
|
21
20
|
## The Problem
|
@@ -56,17 +55,25 @@ flowchart LR
|
|
56
55
|
|
57
56
|
I --> J[Analysis &<br/>Remediation<br/>Update: latest]
|
58
57
|
|
58
|
+
H --> K[catalog<br/>Command]
|
59
|
+
K --> L[Catalog Format<br/>External Integration]
|
60
|
+
L --> M[uncatalog<br/>Command]
|
61
|
+
M --> N[Back to Expected/<br/>Actual Format]
|
62
|
+
|
59
63
|
classDef source fill:#e1d5e7
|
60
64
|
classDef process fill:#fff2cc
|
61
65
|
classDef expected fill:#d5e8d4
|
62
66
|
classDef actual fill:#f8cecc
|
63
67
|
classDef result fill:#ffcccc
|
68
|
+
classDef catalog fill:#dae8fc
|
64
69
|
|
65
70
|
class A source
|
66
71
|
class C,D,G,I process
|
67
72
|
class E,H expected
|
68
73
|
class F actual
|
69
74
|
class J result
|
75
|
+
class K,M catalog
|
76
|
+
class L,N catalog
|
70
77
|
```
|
71
78
|
|
72
79
|
**Process Steps:**
|
@@ -75,6 +82,7 @@ flowchart LR
|
|
75
82
|
3. **Compute Expected**: Generate mutable tags based on semantic versions
|
76
83
|
4. **Fetch Actual**: Query registry for current mutable tag state
|
77
84
|
5. **Compare & Analyze**: Identify mismatches and generate remediation plan
|
85
|
+
6. **Format Conversion**: Transform between expected/actual and catalog formats for external integration
|
78
86
|
|
79
87
|
## Installation
|
80
88
|
|
@@ -98,6 +106,10 @@ equilibrium actual "$REPO"
|
|
98
106
|
equilibrium expected "$REPO" --format json > expected.json
|
99
107
|
equilibrium actual "$REPO" --format json > actual.json
|
100
108
|
equilibrium analyze --expected expected.json --actual actual.json
|
109
|
+
|
110
|
+
# 4. Convert to catalog format and back (Round trip)
|
111
|
+
equilibrium catalog expected.json | tee catalog.json
|
112
|
+
equilibrium uncatalog catalog.json | tee uncatalog.json
|
101
113
|
```
|
102
114
|
|
103
115
|
*For detailed command options, run `equilibrium help [command]`*
|
@@ -231,21 +243,20 @@ All commands output structured JSON following validated schemas (see [schemas](l
|
|
231
243
|
**Catalog Command** ([schema](lib/equilibrium/schemas/catalog.rb)):
|
232
244
|
```json
|
233
245
|
{
|
246
|
+
"repository_url": "gcr.io/project/image",
|
247
|
+
"repository_name": "image",
|
234
248
|
"images": [
|
235
249
|
{
|
236
|
-
"name": "image",
|
237
250
|
"tag": "latest",
|
238
251
|
"digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
239
252
|
"canonical_version": "1.2.3"
|
240
253
|
},
|
241
254
|
{
|
242
|
-
"name": "image",
|
243
255
|
"tag": "1",
|
244
256
|
"digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
245
257
|
"canonical_version": "1.2.3"
|
246
258
|
},
|
247
259
|
{
|
248
|
-
"name": "image",
|
249
260
|
"tag": "1.2",
|
250
261
|
"digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
|
251
262
|
"canonical_version": "1.2.3"
|
@@ -254,6 +265,8 @@ All commands output structured JSON following validated schemas (see [schemas](l
|
|
254
265
|
}
|
255
266
|
```
|
256
267
|
|
268
|
+
**Uncatalog Command**: Converts catalog format back to expected/actual format (same schema as expected/actual commands).
|
269
|
+
|
257
270
|
### Summary Format
|
258
271
|
Human-readable table format for quick visual inspection.
|
259
272
|
|
@@ -263,50 +276,41 @@ Human-readable table format for quick visual inspection.
|
|
263
276
|
- **Tag Format**: Only processes semantic version tags (MAJOR.MINOR.PATCH)
|
264
277
|
- **URL Format**: Requires full repository URLs: `[REGISTRY_HOST]/[NAMESPACE]/[REPOSITORY]`
|
265
278
|
|
266
|
-
##
|
279
|
+
## Development
|
267
280
|
|
268
|
-
###
|
269
|
-
```bash
|
270
|
-
$ equilibrium expected gcr.io/google-containers/pause
|
271
|
-
# Shows: latest→3.9, 3→3.9, 3.9→3.9
|
281
|
+
### Prerequisites
|
272
282
|
|
273
|
-
|
274
|
-
|
283
|
+
- **Ruby** (>= 3.0.0) with Bundler
|
284
|
+
- **No authentication required** for public Google Container Registry (GCR) access
|
285
|
+
- **Pure Ruby implementation** - uses only Ruby standard library (Net::HTTP) for HTTP requests
|
275
286
|
|
276
|
-
|
277
|
-
# Status: ✅ in_equilibrium
|
278
|
-
```
|
287
|
+
### Code Quality
|
279
288
|
|
280
|
-
### Example 2: Out of Equilibrium
|
281
289
|
```bash
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
$ equilibrium actual gcr.io/project/myapp
|
286
|
-
# Actual: latest→1.5.3, 1→1.5.3 (missing: 2, 2.1)
|
290
|
+
# Ruby linting
|
291
|
+
bundle exec standardrb
|
292
|
+
bundle exec standardrb --fix
|
287
293
|
|
288
|
-
|
289
|
-
|
290
|
-
# Remediation: Create tags: 2→2.1.0, 2.1→2.1.0; Update: latest→2.1.0
|
294
|
+
# Run tests
|
295
|
+
bundle exec rspec
|
291
296
|
```
|
292
297
|
|
293
|
-
###
|
298
|
+
### Release Process
|
299
|
+
|
300
|
+
To create a new release:
|
301
|
+
|
294
302
|
```bash
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
# Daily equilibrium check
|
299
|
-
equilibrium expected "$REPO" > expected.json
|
300
|
-
equilibrium actual "$REPO" > actual.json
|
301
|
-
equilibrium analyze --expected expected.json --actual actual.json --format json > report.json
|
302
|
-
|
303
|
-
# Alert if out of equilibrium
|
304
|
-
if grep -q '"status": "out_of_equilibrium"' report.json; then
|
305
|
-
echo "⚠️ Repository $REPO is out of equilibrium!"
|
306
|
-
equilibrium analyze --expected expected.json --actual actual.json
|
307
|
-
fi
|
303
|
+
# Update version in lib/equilibrium/version.rb, then:
|
304
|
+
./bin/release
|
308
305
|
```
|
309
306
|
|
307
|
+
The script automatically:
|
308
|
+
1. Reads the version from `equilibrium.gemspec`
|
309
|
+
2. Creates a git tag with `v` prefix (e.g., `v0.1.1`)
|
310
|
+
3. Pushes the tag to trigger the automated release workflow
|
311
|
+
|
312
|
+
The GitHub Actions workflow validates that the tag version matches the gemspec version before publishing to RubyGems and GitHub Packages.
|
313
|
+
|
310
314
|
## License
|
311
315
|
|
312
316
|
MIT License - see [LICENSE](LICENSE) file for details.
|
data/lib/equilibrium/analyzer.rb
CHANGED
@@ -4,43 +4,47 @@ require "json"
|
|
4
4
|
|
5
5
|
module Equilibrium
|
6
6
|
class Analyzer
|
7
|
-
def
|
7
|
+
def self.analyze(expected_data, actual_data)
|
8
|
+
new.analyze(expected_data, actual_data)
|
8
9
|
end
|
9
10
|
|
10
|
-
# Analyzes validated expected/actual data in schema format
|
11
11
|
def analyze(expected_data, actual_data)
|
12
12
|
# Extract digests from validated schema format
|
13
13
|
expected_tags = expected_data["digests"]
|
14
14
|
actual_tags = actual_data["digests"]
|
15
15
|
|
16
|
-
# Extract and validate repository
|
16
|
+
# Extract and validate repository names match
|
17
|
+
expected_name = expected_data["repository_name"]
|
18
|
+
actual_name = actual_data["repository_name"]
|
17
19
|
expected_url = expected_data["repository_url"]
|
18
20
|
actual_url = actual_data["repository_url"]
|
19
21
|
|
20
|
-
if
|
21
|
-
raise ArgumentError, "Repository
|
22
|
+
if expected_name != actual_name
|
23
|
+
raise ArgumentError, "Repository names do not match: expected '#{expected_name}', actual '#{actual_name}'"
|
22
24
|
end
|
23
25
|
|
24
|
-
|
26
|
+
final_repository_name = expected_name
|
27
|
+
final_repository_url = expected_url || actual_url
|
25
28
|
|
26
29
|
analysis = {
|
27
30
|
repository_url: final_repository_url,
|
31
|
+
repository_name: final_repository_name,
|
28
32
|
expected_count: expected_tags.size,
|
29
33
|
actual_count: actual_tags.size,
|
30
34
|
missing_tags: find_missing_tags(expected_tags, actual_tags),
|
31
35
|
unexpected_tags: find_unexpected_tags(expected_tags, actual_tags),
|
32
36
|
mismatched_tags: find_mismatched_tags(expected_tags, actual_tags),
|
33
37
|
status: determine_status(expected_tags, actual_tags)
|
34
|
-
}
|
38
|
+
}.compact
|
35
39
|
|
36
40
|
# Add remediation plan for JSON format
|
37
|
-
analysis[:remediation_plan] = generate_remediation_plan(analysis, final_repository_url)
|
41
|
+
analysis[:remediation_plan] = generate_remediation_plan(analysis, final_repository_url, final_repository_name)
|
38
42
|
analysis
|
39
43
|
end
|
40
44
|
|
41
45
|
private
|
42
46
|
|
43
|
-
def generate_remediation_plan(analysis, repository_url)
|
47
|
+
def generate_remediation_plan(analysis, repository_url, repository_name)
|
44
48
|
plan = []
|
45
49
|
|
46
50
|
analysis[:missing_tags].each do |tag, digest|
|
@@ -49,7 +53,7 @@ module Equilibrium
|
|
49
53
|
tag: tag,
|
50
54
|
digest: digest,
|
51
55
|
command: "gcloud container images add-tag #{repository_url}@#{digest} #{repository_url}:#{tag}"
|
52
|
-
}
|
56
|
+
}.compact
|
53
57
|
end
|
54
58
|
|
55
59
|
analysis[:mismatched_tags].each do |tag, data|
|
@@ -59,7 +63,7 @@ module Equilibrium
|
|
59
63
|
old_digest: data[:actual],
|
60
64
|
new_digest: data[:expected],
|
61
65
|
command: "gcloud container images add-tag #{repository_url}@#{data[:expected]} #{repository_url}:#{tag}"
|
62
|
-
}
|
66
|
+
}.compact
|
63
67
|
end
|
64
68
|
|
65
69
|
analysis[:unexpected_tags].each do |tag, digest|
|
@@ -68,14 +72,12 @@ module Equilibrium
|
|
68
72
|
tag: tag,
|
69
73
|
digest: digest,
|
70
74
|
command: "gcloud container images untag #{repository_url}:#{tag}"
|
71
|
-
}
|
75
|
+
}.compact
|
72
76
|
end
|
73
77
|
|
74
78
|
plan
|
75
79
|
end
|
76
80
|
|
77
|
-
private
|
78
|
-
|
79
81
|
def find_missing_tags(expected, actual)
|
80
82
|
expected.reject { |tag, digest| actual.key?(tag) }
|
81
83
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Equilibrium
|
4
|
+
class CanonicalVersionMapper
|
5
|
+
def self.map_to_canonical_versions(mutable_tags, semantic_tags)
|
6
|
+
canonical_versions = {}
|
7
|
+
|
8
|
+
mutable_tags.each do |mutable_tag, m_digest|
|
9
|
+
# Find semantic tag with same digest, raise if not found
|
10
|
+
canonical_version = semantic_tags.key(m_digest) ||
|
11
|
+
(raise "No semantic tag found with digest #{m_digest} for mutable tag '#{mutable_tag}'")
|
12
|
+
|
13
|
+
canonical_versions[mutable_tag] = canonical_version
|
14
|
+
end
|
15
|
+
|
16
|
+
canonical_versions
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -1,45 +1,61 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "json"
|
4
|
-
require "json_schemer"
|
5
|
-
require_relative "schemas/catalog"
|
6
4
|
|
7
5
|
module Equilibrium
|
8
6
|
class CatalogBuilder
|
9
7
|
class Error < StandardError; end
|
10
8
|
|
11
|
-
def build_catalog(data)
|
12
|
-
# Extract repository name, digests, and canonical versions from the validated data structure
|
9
|
+
def self.build_catalog(data)
|
13
10
|
repository_name = data["repository_name"]
|
11
|
+
repository_url = data["repository_url"]
|
14
12
|
digests = data["digests"]
|
15
13
|
canonical_versions = data["canonical_versions"]
|
16
14
|
|
17
15
|
images = digests.map do |tag, digest|
|
18
16
|
{
|
19
|
-
"name" => repository_name,
|
20
17
|
"tag" => tag,
|
21
18
|
"digest" => digest,
|
22
19
|
"canonical_version" => canonical_versions[tag]
|
23
20
|
}
|
24
21
|
end
|
25
22
|
|
26
|
-
|
23
|
+
{
|
24
|
+
"repository_url" => repository_url,
|
25
|
+
"repository_name" => repository_name,
|
27
26
|
"images" => images
|
28
27
|
}
|
29
|
-
|
30
|
-
validate_catalog(catalog)
|
31
|
-
catalog
|
32
28
|
end
|
33
29
|
|
34
|
-
|
30
|
+
def self.reverse_catalog(catalog_data)
|
31
|
+
images = catalog_data["images"]
|
32
|
+
repository_url = catalog_data["repository_url"]
|
33
|
+
repository_name = catalog_data["repository_name"]
|
34
|
+
|
35
|
+
if images.nil? || images.empty?
|
36
|
+
return {
|
37
|
+
"repository_url" => repository_url || "",
|
38
|
+
"repository_name" => repository_name || "",
|
39
|
+
"digests" => {},
|
40
|
+
"canonical_versions" => {}
|
41
|
+
}
|
42
|
+
end
|
35
43
|
|
36
|
-
|
37
|
-
|
38
|
-
errors = schemer.validate(catalog).to_a
|
44
|
+
digests = {}
|
45
|
+
canonical_versions = {}
|
39
46
|
|
40
|
-
|
41
|
-
|
47
|
+
images.each do |image|
|
48
|
+
tag = image["tag"]
|
49
|
+
digests[tag] = image["digest"]
|
50
|
+
canonical_versions[tag] = image["canonical_version"]
|
42
51
|
end
|
52
|
+
|
53
|
+
{
|
54
|
+
"repository_url" => repository_url,
|
55
|
+
"repository_name" => repository_name,
|
56
|
+
"digests" => digests,
|
57
|
+
"canonical_versions" => canonical_versions
|
58
|
+
}
|
43
59
|
end
|
44
60
|
end
|
45
61
|
end
|
data/lib/equilibrium/cli.rb
CHANGED
@@ -1,13 +1,16 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "thor"
|
4
|
-
|
5
|
-
require_relative "
|
6
|
-
require_relative "
|
7
|
-
require_relative "
|
8
|
-
require_relative "
|
4
|
+
require_relative "commands/analyze_command"
|
5
|
+
require_relative "commands/expected_command"
|
6
|
+
require_relative "commands/actual_command"
|
7
|
+
require_relative "commands/catalog_command"
|
8
|
+
require_relative "commands/uncatalog_command"
|
9
|
+
require_relative "commands/version_command"
|
9
10
|
|
10
11
|
module Equilibrium
|
12
|
+
# Main CLI class - acts as a router to individual command classes
|
13
|
+
# Each command delegates to its respective command class for execution
|
11
14
|
class CLI < Thor
|
12
15
|
def self.exit_on_failure?
|
13
16
|
true
|
@@ -19,200 +22,34 @@ module Equilibrium
|
|
19
22
|
option :registry, type: :string, desc: "Repository URL for output"
|
20
23
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
21
24
|
def analyze
|
22
|
-
|
23
|
-
expected_data = load_and_validate_json_file(options[:expected])
|
24
|
-
actual_data = load_and_validate_json_file(options[:actual])
|
25
|
-
|
26
|
-
analyzer = Analyzer.new
|
27
|
-
analysis = analyzer.analyze(expected_data, actual_data)
|
28
|
-
|
29
|
-
case options[:format]
|
30
|
-
when "json"
|
31
|
-
puts JSON.pretty_generate(analysis)
|
32
|
-
when "summary"
|
33
|
-
formatter = SummaryFormatter.new
|
34
|
-
formatter.print_analysis_summary(analysis)
|
35
|
-
end
|
36
|
-
rescue => e
|
37
|
-
error_and_exit(e.message)
|
25
|
+
Commands::AnalyzeCommand.new.execute(options)
|
38
26
|
end
|
39
27
|
|
40
28
|
desc "expected REPOSITORY_URL", "Output expected mutable tags to stdout"
|
41
29
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
42
30
|
def expected(registry)
|
43
|
-
|
44
|
-
processor = TagProcessor.new
|
45
|
-
|
46
|
-
full_repository_url = validate_repository_url(registry)
|
47
|
-
all_tags = client.list_tags(full_repository_url)
|
48
|
-
semantic_tags = processor.filter_semantic_tags(all_tags)
|
49
|
-
virtual_tags_result = processor.compute_virtual_tags(semantic_tags)
|
50
|
-
|
51
|
-
# Extract repository name from URL
|
52
|
-
repository_name = extract_repository_name(full_repository_url)
|
53
|
-
|
54
|
-
# Sort both digests and canonical_versions in descending order right before output
|
55
|
-
sorted_digests = TagSorter.sort_descending(virtual_tags_result["digests"])
|
56
|
-
sorted_canonical_versions = TagSorter.sort_descending(virtual_tags_result["canonical_versions"])
|
57
|
-
|
58
|
-
output = {
|
59
|
-
"repository_url" => full_repository_url,
|
60
|
-
"repository_name" => repository_name,
|
61
|
-
"digests" => sorted_digests,
|
62
|
-
"canonical_versions" => sorted_canonical_versions
|
63
|
-
}
|
64
|
-
|
65
|
-
# Validate output against schema before writing
|
66
|
-
validate_expected_actual_schema(output)
|
67
|
-
|
68
|
-
case options[:format]
|
69
|
-
when "json"
|
70
|
-
puts JSON.pretty_generate(output)
|
71
|
-
when "summary"
|
72
|
-
formatter = SummaryFormatter.new
|
73
|
-
formatter.print_tags_summary(output, "expected")
|
74
|
-
end
|
75
|
-
rescue Thor::Error
|
76
|
-
raise # Let Thor::Error bubble up for validation errors
|
77
|
-
rescue RegistryClient::Error => e
|
78
|
-
raise StandardError, e.message # Convert for test compatibility
|
79
|
-
rescue => e
|
80
|
-
error_and_exit(e.message)
|
31
|
+
Commands::ExpectedCommand.new.execute(registry, options)
|
81
32
|
end
|
82
33
|
|
83
34
|
desc "actual REPOSITORY_URL", "Output actual mutable tags to stdout"
|
84
35
|
option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
|
85
36
|
def actual(registry)
|
86
|
-
|
87
|
-
processor = TagProcessor.new
|
88
|
-
|
89
|
-
full_repository_url = validate_repository_url(registry)
|
90
|
-
all_tags = client.list_tags(full_repository_url)
|
91
|
-
mutable_tags = processor.filter_mutable_tags(all_tags)
|
92
|
-
|
93
|
-
# Get semantic tags to create canonical mapping for actual mutable tags
|
94
|
-
semantic_tags = processor.filter_semantic_tags(all_tags)
|
95
|
-
canonical_versions = {}
|
96
|
-
|
97
|
-
# For each actual mutable tag, find its canonical version by digest matching
|
98
|
-
mutable_tags.each do |mutable_tag, digest|
|
99
|
-
# Find semantic tag with same digest
|
100
|
-
canonical_version = semantic_tags.find { |_, sem_digest| sem_digest == digest }&.first
|
101
|
-
canonical_versions[mutable_tag] = canonical_version if canonical_version
|
102
|
-
end
|
103
|
-
|
104
|
-
# Extract repository name from URL
|
105
|
-
repository_name = extract_repository_name(full_repository_url)
|
106
|
-
|
107
|
-
# Sort both digests and canonical_versions in descending order right before output
|
108
|
-
sorted_digests = TagSorter.sort_descending(mutable_tags)
|
109
|
-
sorted_canonical_versions = TagSorter.sort_descending(canonical_versions)
|
110
|
-
|
111
|
-
output = {
|
112
|
-
"repository_url" => full_repository_url,
|
113
|
-
"repository_name" => repository_name,
|
114
|
-
"digests" => sorted_digests,
|
115
|
-
"canonical_versions" => sorted_canonical_versions
|
116
|
-
}
|
117
|
-
|
118
|
-
# Validate output against schema before writing
|
119
|
-
validate_expected_actual_schema(output)
|
120
|
-
|
121
|
-
case options[:format]
|
122
|
-
when "json"
|
123
|
-
puts JSON.pretty_generate(output)
|
124
|
-
when "summary"
|
125
|
-
formatter = SummaryFormatter.new
|
126
|
-
formatter.print_tags_summary(output, "actual")
|
127
|
-
end
|
128
|
-
rescue Thor::Error
|
129
|
-
raise # Let Thor::Error bubble up for validation errors
|
130
|
-
rescue RegistryClient::Error => e
|
131
|
-
raise StandardError, e.message # Convert for test compatibility
|
132
|
-
rescue => e
|
133
|
-
error_and_exit(e.message)
|
37
|
+
Commands::ActualCommand.new.execute(registry, options)
|
134
38
|
end
|
135
39
|
|
136
40
|
desc "catalog [FILE]", "Convert expected tags JSON to catalog format (reads from file or stdin)"
|
137
41
|
def catalog(file_path = nil)
|
138
|
-
|
139
|
-
|
140
|
-
unless File.exist?(file_path)
|
141
|
-
error_and_exit("File not found: #{file_path}")
|
142
|
-
end
|
143
|
-
input = File.read(file_path).strip
|
144
|
-
else
|
145
|
-
input = $stdin.read.strip
|
146
|
-
if input.empty?
|
147
|
-
error_and_exit("No input provided. Use: equilibrium expected registry | equilibrium catalog")
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
data = JSON.parse(input)
|
152
|
-
|
153
|
-
# Validate against expected/actual schema
|
154
|
-
validate_expected_actual_schema(data)
|
155
|
-
|
156
|
-
builder = CatalogBuilder.new
|
157
|
-
catalog = builder.build_catalog(data)
|
42
|
+
Commands::CatalogCommand.new.execute(file_path)
|
43
|
+
end
|
158
44
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
rescue => e
|
163
|
-
error_and_exit(e.message)
|
45
|
+
desc "uncatalog [FILE]", "Convert catalog format back to expected/actual format (reads from file or stdin)"
|
46
|
+
def uncatalog(file_path = nil)
|
47
|
+
Commands::UncatalogCommand.new.execute(file_path)
|
164
48
|
end
|
165
49
|
|
166
50
|
desc "version", "Show version information"
|
167
51
|
def version
|
168
|
-
|
169
|
-
say "Container tag validation tool"
|
170
|
-
end
|
171
|
-
|
172
|
-
private
|
173
|
-
|
174
|
-
def load_and_validate_json_file(file_path)
|
175
|
-
unless File.exist?(file_path)
|
176
|
-
raise "File not found: #{file_path}"
|
177
|
-
end
|
178
|
-
|
179
|
-
data = JSON.parse(File.read(file_path))
|
180
|
-
validate_expected_actual_schema(data)
|
181
|
-
data
|
182
|
-
rescue JSON::ParserError => e
|
183
|
-
raise "Invalid JSON in #{file_path}: #{e.message}"
|
184
|
-
end
|
185
|
-
|
186
|
-
def validate_repository_url(repository_url)
|
187
|
-
# Repository URL must include registry host, project/namespace, and image name
|
188
|
-
# Format: [REGISTRY_HOST]/[PROJECT_OR_NAMESPACE]/[IMAGE_NAME]
|
189
|
-
unless repository_url.include?("/")
|
190
|
-
raise Thor::Error, "Repository URL must be full format (e.g., 'gcr.io/project-id/image-name'), not '#{repository_url}'"
|
191
|
-
end
|
192
|
-
|
193
|
-
repository_url
|
194
|
-
end
|
195
|
-
|
196
|
-
def extract_repository_name(repository_url)
|
197
|
-
# Extract repository name from repository URL
|
198
|
-
# Examples:
|
199
|
-
# gcr.io/project-id/repository-name -> repository-name
|
200
|
-
# registry.com/namespace/repository-name -> repository-name
|
201
|
-
repository_url.split("/").last
|
202
|
-
end
|
203
|
-
|
204
|
-
def validate_expected_actual_schema(data)
|
205
|
-
SchemaValidator.validate!(data, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
206
|
-
rescue SchemaValidator::ValidationError => e
|
207
|
-
error_and_exit(e.message)
|
208
|
-
end
|
209
|
-
|
210
|
-
def error_and_exit(message, usage = nil)
|
211
|
-
error message
|
212
|
-
if usage
|
213
|
-
say usage, :red
|
214
|
-
end
|
215
|
-
exit 1
|
52
|
+
Commands::VersionCommand.execute
|
216
53
|
end
|
217
54
|
end
|
218
55
|
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../mixins/error_handling"
|
4
|
+
require_relative "../mixins/input_output"
|
5
|
+
require_relative "../repository_url_validator"
|
6
|
+
require_relative "../schema_validator"
|
7
|
+
require_relative "../schemas/expected_actual"
|
8
|
+
require_relative "../tags_operation_service"
|
9
|
+
|
10
|
+
module Equilibrium
|
11
|
+
module Commands
|
12
|
+
# Command for generating actual mutable tags from registry
|
13
|
+
class ActualCommand
|
14
|
+
include Mixins::ErrorHandling
|
15
|
+
include Mixins::InputOutput
|
16
|
+
|
17
|
+
# Execute the actual command
|
18
|
+
# @param registry [String] Repository URL
|
19
|
+
# @param options [Hash] Command options (format, etc.)
|
20
|
+
def execute(registry, options = {})
|
21
|
+
with_error_handling do
|
22
|
+
full_repository_url = RepositoryUrlValidator.validate(registry)
|
23
|
+
|
24
|
+
# Generate complete actual output using high-level service
|
25
|
+
output = TagsOperationService.generate_actual_output(full_repository_url)
|
26
|
+
|
27
|
+
# Validate output against schema before writing
|
28
|
+
SchemaValidator.validate!(output, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
|
29
|
+
|
30
|
+
# Format and display output
|
31
|
+
format_output(output, options[:format] || "summary", "actual")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|