ace-lab 0.13.1 → 0.14.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.
@@ -7,6 +7,7 @@ require_relative "../molecules/directory_provisioner"
7
7
  require_relative "../molecules/key_provisioner"
8
8
  require_relative "../molecules/role_verifier"
9
9
  require_relative "../molecules/gemset_provisioner"
10
+ require_relative "../molecules/extension_provisioner"
10
11
 
11
12
  module Ace
12
13
  module Lab
@@ -51,9 +52,11 @@ module Ace
51
52
  unresolved, effective_dry_run)
52
53
  roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
53
54
  gems = prepare_gems(manifest["gemset"], effective_dry_run)
55
+ extensions = prepare_extensions(manifest["gemset"], effective_dry_run, gems)
54
56
 
55
57
  {
56
- "ok" => [accounts, directories, keys, roles, gems].flatten.none? { |item| item["status"] == "failed" },
58
+ "ok" => [accounts, directories, keys, roles, gems, extensions].flatten
59
+ .none? { |item| item["status"] == "failed" },
57
60
  "dry_run" => effective_dry_run,
58
61
  "machine" => manifest["machine"],
59
62
  "manifest" => manifest_path,
@@ -61,7 +64,8 @@ module Ace
61
64
  "directories" => directories,
62
65
  "keys" => keys,
63
66
  "roles" => roles,
64
- "gems" => gems
67
+ "gems" => gems,
68
+ "extensions" => extensions
65
69
  }
66
70
  end
67
71
 
@@ -152,6 +156,28 @@ module Ace
152
156
  provisioner.call(runtime: runtime)
153
157
  end
154
158
 
159
+ # Harness extension projections (task 8xs.t.pu0.0): converged
160
+ # from the installed gem's exact declared version after the
161
+ # gemset phase; a gem phase failure fails every extension as a
162
+ # dependency (the projection source would not exist).
163
+ def prepare_extensions(runtime, dry_run, gems)
164
+ entries = runtime["extensions"]
165
+ return [] if entries.empty?
166
+
167
+ if gems.any? { |gem| gem["status"] == "failed" }
168
+ return entries.map do |entry|
169
+ {"extension" => entry["gem"], "gem" => entry["gem"], "version" => nil,
170
+ "path" => entry["path"], "account" => entry["account"], "status" => "failed",
171
+ "loaded_version" => nil, "restart_required" => false, "files" => [],
172
+ "message" => "gemset convergence failed; fix the gem phase first"}
173
+ end
174
+ end
175
+
176
+ Molecules::ExtensionProvisioner.new(
177
+ gem_home: runtime["gem_home"], dry_run: dry_run, account_lookup: @account_lookup
178
+ ).call(extensions: entries, gems: runtime["gems"])
179
+ end
180
+
155
181
  def available_accounts(account_reports)
156
182
  account_reports.reject { |report| report["status"] == "failed" }
157
183
  .map { |report| report["username"] }
@@ -5,11 +5,14 @@ require_relative "../molecules/install_manifest_loader"
5
5
  require_relative "../molecules/key_provisioner"
6
6
  require_relative "../molecules/role_verifier"
7
7
  require_relative "../molecules/gemset_provisioner"
8
+ require_relative "../molecules/extension_provisioner"
8
9
  require_relative "../molecules/hitl_routes"
9
10
  require_relative "../molecules/caller_authorizer"
10
11
  require_relative "../atoms/topology_schema"
11
12
  require "open3"
12
13
  require "yaml"
14
+ require "json"
15
+ require "fileutils"
13
16
  require "date"
14
17
 
15
18
  module Ace
@@ -99,7 +102,8 @@ module Ace
99
102
  "overseer" => check_overseer(manifest),
100
103
  "adapters" => check_adapters(manifest, executors),
101
104
  "tools" => check_tools(manifest, executors),
102
- "gems" => check_gems(manifest)
105
+ "gems" => check_gems(manifest),
106
+ "extensions" => check_extensions(manifest, entries)
103
107
  }
104
108
  flat = checks.values.flatten(1)
105
109
  {
@@ -583,20 +587,146 @@ module Ace
583
587
  kind, versions_or_message = provisioner.runtime_versions(
584
588
  name: gem["name"], gem_home: gem_home
585
589
  )
586
- reason = case kind
587
- when :unknown, :failed then versions_or_message
588
- when :ok then gem_version_reason(gem, versions_or_message, gem_home)
589
- end
590
- status = case kind
591
- when :ok then reason ? FAIL : PASS
592
- when :unknown then UNKNOWN
593
- else FAIL
590
+ status, reason = if kind != :ok
591
+ [kind == :unknown ? UNKNOWN : FAIL, versions_or_message]
592
+ else
593
+ verify_present_gem(provisioner, gem, versions_or_message, gem_home)
594
594
  end
595
595
  {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
596
596
  "status" => status, "reason" => reason}
597
597
  end
598
598
  end
599
599
 
600
+ # A gem whose exact declared version is present must ALSO prove
601
+ # (1) its runtime dependency closure resolves in the target
602
+ # runtime — a gem listed in `gem list` with a missing
603
+ # transitive dependency is NOT ready (task 8xs.t.pu0.0) — and
604
+ # (2) no NEWER version sits beside the declared one: RubyGems
605
+ # activates the highest installed version, so a newer sibling
606
+ # would silently take over the runtime's CLI.
607
+ def verify_present_gem(provisioner, gem, versions, gem_home)
608
+ reason = gem_version_reason(gem, versions, gem_home)
609
+ return [FAIL, reason] if reason
610
+
611
+ newer_kind, newer_or_message = provisioner.newer_versions(
612
+ name: gem["name"], declared_version: gem["version"], gem_home: gem_home
613
+ )
614
+ if newer_kind != :ok
615
+ return [newer_kind == :unknown ? UNKNOWN : FAIL, newer_or_message]
616
+ end
617
+ unless newer_or_message.empty?
618
+ return [FAIL,
619
+ "gem '#{gem["name"]}' has newer version(s) #{newer_or_message.join(", ")} beside " \
620
+ "the declared #{gem["version"]} in '#{gem_home}'; the newer version would take " \
621
+ "over CLI activation; rerun ace-lab install"]
622
+ end
623
+
624
+ closure_kind, closure_or_message = provisioner.closure_failures(
625
+ name: gem["name"], version: gem["version"], gem_home: gem_home
626
+ )
627
+ return [closure_kind == :unknown ? UNKNOWN : FAIL, closure_or_message] if closure_kind != :ok
628
+ return [FAIL, closure_or_message.join("; ")] unless closure_or_message.empty?
629
+
630
+ [PASS, nil]
631
+ end
632
+
633
+ # Declared harness extension projections (task 8xs.t.pu0.0):
634
+ # re-derived from the same receipts and installed gem trees the
635
+ # provisioner converges (shared ExtensionProjection helpers) —
636
+ # the receipt must name the declared gem at its exact declared
637
+ # version, every projected file must match the installed gem's
638
+ # handbook/extensions content, the destination must not escape
639
+ # the extensions directory through a symlinked component, and
640
+ # the projected files must grant the declared account read.
641
+ def check_extensions(manifest, entries)
642
+ gemset = manifest["gemset"]
643
+ gemset["extensions"].map do |entry|
644
+ declared = gemset["gems"].find { |gem| gem["name"] == entry["gem"] }
645
+ reason = if declared.nil?
646
+ "extension source gem '#{entry["gem"]}' is not declared in the gemset"
647
+ else
648
+ extension_reason(entry, declared, gemset["gem_home"], entries[entry["account"]])
649
+ end
650
+ {"extension" => entry["gem"], "gem" => entry["gem"], "path" => entry["path"],
651
+ "account" => entry["account"], "status" => reason ? FAIL : PASS, "reason" => reason}
652
+ end
653
+ end
654
+
655
+ def extension_reason(entry, declared, gem_home, account_entry)
656
+ source_root = Molecules::ExtensionProjection.gem_source_root(
657
+ gem_home, entry["gem"], declared["version"]
658
+ )
659
+ return "gem '#{entry["gem"]}' #{declared["version"]} is not installed with a " \
660
+ "handbook/extensions tree in '#{gem_home}'; rerun ace-lab install" unless File.directory?(source_root)
661
+
662
+ receipt_path = File.join(entry["path"], Molecules::ExtensionProvisioner::RECEIPT_NAME)
663
+ unless File.file?(receipt_path)
664
+ return "extension projection receipt '#{receipt_path}' is missing; rerun ace-lab install"
665
+ end
666
+
667
+ begin
668
+ receipt = JSON.parse(File.read(receipt_path))
669
+ rescue JSON::ParserError
670
+ return "extension projection receipt '#{receipt_path}' is not valid JSON; rerun ace-lab install"
671
+ rescue Errno::EACCES, Errno::ENOENT
672
+ return "extension projection receipt '#{receipt_path}' is not readable; " \
673
+ "rerun ace-lab install"
674
+ end
675
+ if receipt["gem"] != entry["gem"] || receipt["version"] != declared["version"]
676
+ return "extension projection at '#{entry["path"]}' was projected from " \
677
+ "'#{receipt["gem"]} #{receipt["version"]}' but the manifest declares " \
678
+ "'#{entry["gem"]} #{declared["version"]}'; rerun ace-lab install"
679
+ end
680
+
681
+ expected = Molecules::ExtensionProjection.source_files(source_root)
682
+ listed = receipt.fetch("files", [])
683
+ if listed.sort != expected.keys.sort
684
+ return "extension projection at '#{entry["path"]}' lists different files than " \
685
+ "gem '#{entry["gem"]}' #{declared["version"]} carries; rerun ace-lab install"
686
+ end
687
+
688
+ # A destination that escapes the extensions directory through
689
+ # a symlinked component is refused before any comparison (the
690
+ # same validate_destination posture convergence applies).
691
+ begin
692
+ expected.each_key do |relative|
693
+ Molecules::ExtensionProjection.validate_destination!(entry["path"], relative)
694
+ end
695
+ rescue Ace::Lab::Error => e
696
+ return "#{e.message}; rerun ace-lab install"
697
+ end
698
+
699
+ mismatch = expected.find do |relative, source|
700
+ target = File.join(entry["path"], relative)
701
+ !File.file?(target) || !FileUtils.compare_file(source, target)
702
+ end
703
+ return "extension projection file '#{mismatch.first}' differs from gem " \
704
+ "'#{entry["gem"]}' #{declared["version"]}; rerun ace-lab install" if mismatch
705
+
706
+ # Declared-account readability (stat-based — the verifier
707
+ # does not run as the declared account): a projected file is
708
+ # readable when it is world-readable, or owner-readable AND
709
+ # owned by the declared account's uid.
710
+ unreadable = expected.keys.find do |relative|
711
+ unreadable_reason(File.join(entry["path"], relative), account_entry)
712
+ end
713
+ return "extension projection file '#{unreadable}' is not readable by the declared " \
714
+ "account '#{entry["account"]}'; rerun ace-lab install" if unreadable
715
+
716
+ nil
717
+ end
718
+
719
+ def unreadable_reason(path, account_entry)
720
+ stat = lstat(path)
721
+ return true if stat.nil? || !stat.file? || stat.symlink?
722
+
723
+ mode = stat.mode & 0o777
724
+ return false if (mode & 0o004) != 0 # world-readable
725
+ return false if (mode & 0o400) != 0 && account_entry && stat.uid == account_entry.uid
726
+
727
+ true
728
+ end
729
+
600
730
  def gem_version_reason(gem, versions, gem_home)
601
731
  return nil if versions.include?(gem["version"])
602
732
  if versions.empty?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.13.1"
5
+ VERSION = "0.14.0"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "lab/models/topology_entry"
18
18
  require_relative "lab/models/query_result"
19
19
  require_relative "lab/models/status_report"
20
20
  require_relative "lab/molecules/topology_loader"
21
+ require_relative "lab/molecules/project_locations"
21
22
  require_relative "lab/molecules/config_state_classifier"
22
23
  require_relative "lab/molecules/lab_state_scanner"
23
24
  require_relative "lab/molecules/caller_authorizer"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-26 00:00:00.000000000 Z
10
+ date: 2026-10-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -173,9 +173,19 @@ files:
173
173
  - lib/ace/lab/atoms/install_schema.rb
174
174
  - lib/ace/lab/atoms/public_projection.rb
175
175
  - lib/ace/lab/atoms/topology_schema.rb
176
+ - lib/ace/lab/auth.rb
177
+ - lib/ace/lab/auth/credential_handoff.rb
178
+ - lib/ace/lab/auth/lease_store.rb
179
+ - lib/ace/lab/auth/plugins/env_provider.rb
180
+ - lib/ace/lab/auth/plugins/forgejo_provider.rb
181
+ - lib/ace/lab/auth/plugins/github_provider.rb
182
+ - lib/ace/lab/auth/profile_schema.rb
183
+ - lib/ace/lab/auth/providers.rb
184
+ - lib/ace/lab/auth/service.rb
176
185
  - lib/ace/lab/authorization_path.rb
177
186
  - lib/ace/lab/cli.rb
178
187
  - lib/ace/lab/cli/commands/agents.rb
188
+ - lib/ace/lab/cli/commands/auth.rb
179
189
  - lib/ace/lab/cli/commands/install.rb
180
190
  - lib/ace/lab/cli/commands/prepare.rb
181
191
  - lib/ace/lab/cli/commands/project_add.rb
@@ -198,6 +208,8 @@ files:
198
208
  - lib/ace/lab/molecules/config_state_classifier.rb
199
209
  - lib/ace/lab/molecules/directory_provisioner.rb
200
210
  - lib/ace/lab/molecules/exact_resolver.rb
211
+ - lib/ace/lab/molecules/extension_projection.rb
212
+ - lib/ace/lab/molecules/extension_provisioner.rb
201
213
  - lib/ace/lab/molecules/forge_merge_config.rb
202
214
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
203
215
  - lib/ace/lab/molecules/gemset_provisioner.rb
@@ -209,6 +221,7 @@ files:
209
221
  - lib/ace/lab/molecules/key_provisioner.rb
210
222
  - lib/ace/lab/molecules/lab_state_scanner.rb
211
223
  - lib/ace/lab/molecules/overseer_provisioner.rb
224
+ - lib/ace/lab/molecules/project_locations.rb
212
225
  - lib/ace/lab/molecules/project_registry.rb
213
226
  - lib/ace/lab/molecules/repository_provisioner.rb
214
227
  - lib/ace/lab/molecules/role_verifier.rb