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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -3
  3. data/README.md +41 -37
  4. data/lib/equilibrium/analyzer.rb +16 -14
  5. data/lib/equilibrium/canonical_version_mapper.rb +19 -0
  6. data/lib/equilibrium/catalog_builder.rb +31 -15
  7. data/lib/equilibrium/cli.rb +17 -172
  8. data/lib/equilibrium/commands/actual_command.rb +36 -0
  9. data/lib/equilibrium/commands/analyze_command.rb +49 -0
  10. data/lib/equilibrium/commands/catalog_command.rb +38 -0
  11. data/lib/equilibrium/commands/expected_command.rb +36 -0
  12. data/lib/equilibrium/commands/uncatalog_command.rb +42 -0
  13. data/lib/equilibrium/commands/version_command.rb +16 -0
  14. data/lib/equilibrium/mixins/error_handling.rb +34 -0
  15. data/lib/equilibrium/mixins/input_output.rb +40 -0
  16. data/lib/equilibrium/registry_client.rb +57 -45
  17. data/lib/equilibrium/repository_tags_service.rb +32 -0
  18. data/lib/equilibrium/repository_url_validator.rb +14 -0
  19. data/lib/equilibrium/schema_validator.rb +10 -2
  20. data/lib/equilibrium/schemas/analyzer_output.rb +8 -2
  21. data/lib/equilibrium/schemas/catalog.rb +16 -9
  22. data/lib/equilibrium/schemas/expected_actual.rb +1 -1
  23. data/lib/equilibrium/schemas/registry_api.rb +1 -1
  24. data/lib/equilibrium/summary_formatter.rb +1 -1
  25. data/lib/equilibrium/tag_data_builder.rb +16 -0
  26. data/lib/equilibrium/tag_processor.rb +24 -14
  27. data/lib/equilibrium/tag_sorter.rb +39 -0
  28. data/lib/equilibrium/tags_operation_service.rb +32 -0
  29. data/lib/equilibrium/version.rb +1 -1
  30. data/lib/equilibrium.rb +1 -1
  31. metadata +18 -4
  32. data/lib/equilibrium/semantic_version.rb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5420df18928550fa411f853968f4b5ac331fbcd3bcc682dce61370c330dd69bc
4
- data.tar.gz: 062322ae383ea3aba97d0d60980f8530808e7877e5bb5a6ce311435ec6a86814
3
+ metadata.gz: 22d45ae6c6368e01aa50e5c7abc828bee9a9f759beb57c2c80dc7f579e1b50da
4
+ data.tar.gz: 445f71cbc4c40d5d60bea3a456197a9de6d817bc32cc4bf87aa678d9c5691231
5
5
  SHA512:
6
- metadata.gz: 3900bcb75092a001f250dbe206d4085419435e7d2b370c3a68e57e82619e1a7b014dd32a71510f887e060244c122336f70f946bd836e1c2ef956d5500416d93c
7
- data.tar.gz: 2678994b5a362aa3e282618dd49f401aa63d50905f13b0fc74323d0747dc73d076aa872c915843e8d4002a9f1042992e33b5251a5f10cbeb9405d8bb2d29454d
6
+ metadata.gz: 0fbcb1559ecd07a6431b36342ca067f69cda326f547e32a70e256bfce35b762d76073b755a5365407b87ad6be9ff862eb5232a6866974a88d54ec1cebf1b304c
7
+ data.tar.gz: 1f1b80fa1c4d4b08546e7cf77c5254de3ebca107c63f0d0c15d317628380d211f247ab0586248d4476d9bb5ed4e86302ad2abb5bd1777b34d0c5fa34054a0d42
data/CHANGELOG.md CHANGED
@@ -5,6 +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
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2025-08-15
11
+
12
+ ### Added
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
22
+
23
+ ### Fixed
24
+ - Preserve descending tag order in summary format output
25
+ - Consistent descending ordering for expected and actual outputs
26
+
8
27
  ## [0.1.0] - 2025-08-05
9
28
 
10
29
  ### Added
@@ -22,8 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
22
41
  - CI/CD pipeline with GitHub Actions
23
42
  - Trusted publishing support for RubyGems
24
43
  - Standard rake task integration
25
-
26
- ### Features
27
44
  - **Tag Validation**: Validates equilibrium between mutable tags and semantic version tags
28
45
  - **Registry Client**: Pure Ruby HTTP client for container registry API access
29
46
  - **Tag Processing**: Computes expected mutable tags from semantic versions (latest, major, minor)
@@ -37,4 +54,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
54
  - Detailed architecture overview and data flow diagrams
38
55
  - Complete command reference and examples
39
56
 
40
- [0.1.0]: https://github.com/DataDog/equilibrium/releases/tag/v0.1.0
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
- ## Examples
279
+ ## Development
267
280
 
268
- ### Example 1: Perfect Equilibrium
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
- $ equilibrium actual gcr.io/google-containers/pause
274
- # Shows: latest→3.9, 3→3.9, 3.9→3.9
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
- $ equilibrium analyze --expected expected.json --actual actual.json
277
- # Status: ✅ in_equilibrium
278
- ```
287
+ ### Code Quality
279
288
 
280
- ### Example 2: Out of Equilibrium
281
289
  ```bash
282
- $ equilibrium expected gcr.io/project/myapp
283
- # Expected: latest→2.1.0, 1→1.5.3, 2→2.1.0, 2.1→2.1.0
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
- $ equilibrium analyze --expected expected.json --actual actual.json
289
- # Status: ❌ out_of_equilibrium
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
- ### Example 3: Automation Pipeline
298
+ ### Release Process
299
+
300
+ To create a new release:
301
+
294
302
  ```bash
295
- #!/bin/bash
296
- REPO="gcr.io/project/image"
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.
@@ -4,43 +4,47 @@ require "json"
4
4
 
5
5
  module Equilibrium
6
6
  class Analyzer
7
- def initialize
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 URLs match
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 expected_url != actual_url
21
- raise ArgumentError, "Repository URLs do not match: expected '#{expected_url}', actual '#{actual_url}'"
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
- final_repository_url = expected_url
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
- catalog = {
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
- private
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
- def validate_catalog(catalog)
37
- schemer = JSONSchemer.schema(Equilibrium::Schemas::CATALOG)
38
- errors = schemer.validate(catalog).to_a
44
+ digests = {}
45
+ canonical_versions = {}
39
46
 
40
- unless errors.empty?
41
- raise Error, "Catalog validation failed: #{errors.map(&:to_s).join(", ")}"
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
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "json"
5
- require_relative "../equilibrium"
6
- require_relative "schema_validator"
7
- require_relative "schemas/expected_actual"
8
- require_relative "summary_formatter"
4
+ require_relative "commands/analyze_command"
5
+ require_relative "commands/expected_command"
6
+ require_relative "commands/actual_command"
7
+ require_relative "commands/catalog_command"
8
+ require_relative "commands/uncatalog_command"
9
+ require_relative "commands/version_command"
9
10
 
10
11
  module Equilibrium
12
+ # Main CLI class - acts as a router to individual command classes
13
+ # Each command delegates to its respective command class for execution
11
14
  class CLI < Thor
12
15
  def self.exit_on_failure?
13
16
  true
@@ -19,192 +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
- # Load and validate data files
23
- expected_data = load_and_validate_json_file(options[:expected])
24
- actual_data = load_and_validate_json_file(options[:actual])
25
-
26
- analyzer = Analyzer.new
27
- analysis = analyzer.analyze(expected_data, actual_data)
28
-
29
- case options[:format]
30
- when "json"
31
- puts JSON.pretty_generate(analysis)
32
- when "summary"
33
- formatter = SummaryFormatter.new
34
- formatter.print_analysis_summary(analysis)
35
- end
36
- rescue => e
37
- error_and_exit(e.message)
25
+ Commands::AnalyzeCommand.new.execute(options)
38
26
  end
39
27
 
40
28
  desc "expected REPOSITORY_URL", "Output expected mutable tags to stdout"
41
29
  option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
42
30
  def expected(registry)
43
- client = RegistryClient.new
44
- processor = TagProcessor.new
45
-
46
- full_repository_url = validate_repository_url(registry)
47
- all_tags = client.list_tags(full_repository_url)
48
- semantic_tags = processor.filter_semantic_tags(all_tags)
49
- virtual_tags_result = processor.compute_virtual_tags(semantic_tags)
50
-
51
- # Extract repository name from URL
52
- repository_name = extract_repository_name(full_repository_url)
53
-
54
- 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)
31
+ Commands::ExpectedCommand.new.execute(registry, options)
77
32
  end
78
33
 
79
34
  desc "actual REPOSITORY_URL", "Output actual mutable tags to stdout"
80
35
  option :format, type: :string, default: "summary", enum: ["json", "summary"], desc: "Output format"
81
36
  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)
37
+ Commands::ActualCommand.new.execute(registry, options)
126
38
  end
127
39
 
128
40
  desc "catalog [FILE]", "Convert expected tags JSON to catalog format (reads from file or stdin)"
129
41
  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)
42
+ Commands::CatalogCommand.new.execute(file_path)
43
+ end
150
44
 
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)
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)
156
48
  end
157
49
 
158
50
  desc "version", "Show version information"
159
51
  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
52
+ Commands::VersionCommand.execute
208
53
  end
209
54
  end
210
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