Skip to content

Commit fe2026f

Browse files
authored
Fixes #38856 - chain composite CV publishes to wait on children (#11621)
* Fixes #38856 - chain composite CV publishes to wait on children * Refs #38856 - rubocop + unneeded rescue * Refs #38856 - skip auto publish request if already scheduled * Refs #38856 - fail CCV publish if already scheduled * Refs #38856 - use ForemanTasks chain method * Refs #38856 - bump dynflow and foreman tasks reqs * Refs #38856 - test scheduled_composite_publish? * Refs #38856 - bump ruby requirement to 3.0 to match Dynflow * Refs #38856 - halt manual composite publish if children are publishing * Refs #38856 - fix rubocop errors from Ruby 3 upgrade
1 parent 8950fd3 commit fe2026f

File tree

11 files changed

+207
-18
lines changed

11 files changed

+207
-18
lines changed

.github/workflows/ruby.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
uses: theforeman/actions/.github/workflows/rubocop.yml@v0
1919
with:
2020
command: bundle exec rubocop --parallel --format github
21+
ruby: '3.0'
2122

2223
test:
2324
name: Ruby

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ AllCops:
2727
- '**/*.rb'
2828
- app/views/**/*.rabl
2929
- '**/*.rake'
30-
TargetRubyVersion: 2.7
30+
TargetRubyVersion: 3.0
3131

3232
Metrics/MethodLength:
3333
Description: 'Avoid methods longer than 30 lines of code.'

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,27 @@ const filtered = array.filter(cb => cb.disabled); // Not a promise!
353353
const firstCheckbox = checkboxes.find(cb => !cb.disabled);
354354
```
355355

356+
### Test Writing Best Practices
357+
358+
**Avoid Non-Determinism:**
359+
- **Never use `SecureRandom.uuid`** or other random values in tests
360+
- Use fixed strings like `'test-task-id-123'` instead
361+
- This ensures tests are reproducible and debuggable
362+
363+
**Example - Bad:**
364+
```ruby
365+
task = ForemanTasks::Task.create!(
366+
external_id: SecureRandom.uuid # DON'T DO THIS
367+
)
368+
```
369+
370+
**Example - Good:**
371+
```ruby
372+
task = ForemanTasks::Task.create!(
373+
external_id: 'test-scheduled-publish-123' # Deterministic
374+
)
375+
```
376+
356377
### TDD Workflow
357378
1. Write failing test
358379
2. Run test to confirm failure

app/lib/katello/util/package.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
module Katello
22
module Util
33
module Package
4-
SUFFIX_RE = /\.(rpm)$/.freeze
5-
ARCH_RE = /\.([^.\-]*)$/.freeze
6-
NVRE_RE = /^(.*)-(?:([0-9]+):)?([^-]*)-([^-]*)$/.freeze
7-
EVR_RE = /^(?:([0-9]+):)?(.*?)(?:-([^-]*))?$/.freeze
4+
SUFFIX_RE = /\.(rpm)$/
5+
ARCH_RE = /\.([^.\-]*)$/
6+
NVRE_RE = /^(.*)-(?:([0-9]+):)?([^-]*)-([^-]*)$/
7+
EVR_RE = /^(?:([0-9]+):)?(.*?)(?:-([^-]*))?$/
88
SUPPORTED_ARCHS = %w(noarch i386 i686 ppc64 s390x x86_64 ia64).freeze
99

1010
# is able to take both nvre and nvrea and parse it correctly

app/lib/katello/util/path_with_substitutions.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class PathWithSubstitutions
55

66
attr_reader :base_path, :path, :substitutions
77

8-
SUBSTITUTABLE_REGEX = /^(.*?)\$([^\/]*)/.freeze
8+
SUBSTITUTABLE_REGEX = /^(.*?)\$([^\/]*)/
99

1010
#path /content/rhel/server/$arch/$releasever/os
1111
#substitutions {$arch => 'x86_64'}

app/models/katello/content_view.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,8 @@ def check_ready_to_publish!(importing: false, syncable: false)
662662
check_composite_action_allowed!(organization.library)
663663
check_docker_repository_names!([organization.library])
664664
check_orphaned_content_facets!(environments: self.environments)
665+
check_scheduled_publish!
666+
check_component_publishes!
665667
end
666668

667669
true
@@ -677,6 +679,22 @@ def check_repositories_blocking_publish!
677679
end
678680
end
679681

682+
def check_scheduled_publish!
683+
return unless composite?
684+
685+
if ::Katello::ContentViewManager.scheduled_composite_publish?(self)
686+
fail ::Katello::Errors::ConflictException, _("A publish is already scheduled for this content view. Please wait for the scheduled publish to complete.")
687+
end
688+
end
689+
690+
def check_component_publishes!
691+
return unless composite?
692+
693+
if ::Katello::ContentViewManager.running_component_publish_tasks(self).any?
694+
fail ::Katello::Errors::ConflictException, _("Cannot publish composite content view while its content views are being published. Please wait for component publishes to complete.")
695+
end
696+
end
697+
680698
def check_docker_repository_names!(environments)
681699
environments.each do |environment|
682700
repositories = []

app/services/katello/content_view_manager.rb

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def self.create_candlepin_environment(content_view_environment:)
2222
end
2323

2424
def self.request_auto_publish(content_view:, content_view_version:)
25+
if scheduled_composite_publish?(content_view)
26+
auto_publish_log(nil, "composite publish already scheduled for ID #{content_view.id}, skipping")
27+
return
28+
end
29+
2530
request = content_view.create_auto_publish_request!(
2631
content_view_version: content_view_version
2732
)
@@ -46,14 +51,34 @@ def self.content_view_locks(content_view:)
4651

4752
def self.trigger_auto_publish!(request:)
4853
request.with_lock do
49-
if content_view_locks(content_view: request.content_view).any?
54+
composite_cv = request.content_view
55+
56+
if content_view_locks(content_view: composite_cv).any?
5057
auto_publish_log(request, "locks found")
5158
return
5259
end
5360

5461
description = _("Auto Publish - Triggered by '%s'") % request.content_view_version.name
55-
ForemanTasks.async_task(Actions::Katello::ContentView::Publish, request.content_view, description, auto_published: true, triggered_by_id: request.content_view_version_id)
56-
auto_publish_log(request, "task triggered")
62+
63+
# Find running component CV publish tasks for chaining
64+
sibling_tasks = running_component_publish_tasks(composite_cv)
65+
66+
if sibling_tasks.any?
67+
# Chain composite publish to wait for running component CVs
68+
ForemanTasks.chain(
69+
sibling_tasks,
70+
Actions::Katello::ContentView::Publish,
71+
composite_cv,
72+
description,
73+
auto_published: true,
74+
triggered_by_id: request.content_view_version_id
75+
)
76+
auto_publish_log(request, "task chained to #{sibling_tasks.size} component tasks")
77+
else
78+
# No component CVs running, publish immediately
79+
ForemanTasks.async_task(Actions::Katello::ContentView::Publish, composite_cv, description, auto_published: true, triggered_by_id: request.content_view_version_id)
80+
auto_publish_log(request, "task triggered")
81+
end
5782
request.destroy!
5883
rescue ForemanTasks::Lock::LockConflict => e
5984
auto_publish_log(request, e)
@@ -62,5 +87,29 @@ def self.trigger_auto_publish!(request:)
6287
rescue ActiveRecord::RecordNotFound
6388
auto_publish_log(request, "request gone")
6489
end
90+
91+
def self.scheduled_composite_publish?(composite_cv)
92+
ForemanTasks::Task::DynflowTask
93+
.for_action(::Actions::Katello::ContentView::Publish)
94+
.where(state: 'scheduled')
95+
.any? do |task|
96+
delayed_plan = ForemanTasks.dynflow.world.persistence.load_delayed_plan(task.external_id)
97+
args = delayed_plan.args
98+
args.first.is_a?(::Katello::ContentView) && args.first.id == composite_cv.id
99+
end
100+
end
101+
102+
def self.running_component_publish_tasks(composite_cv)
103+
component_cv_ids = composite_cv.components.pluck(:content_view_id)
104+
return [] if component_cv_ids.empty?
105+
106+
ForemanTasks::Task::DynflowTask
107+
.for_action(::Actions::Katello::ContentView::Publish)
108+
.where(state: ['planning', 'planned', 'running'])
109+
.select do |task|
110+
task_input = task.input
111+
task_input && component_cv_ids.include?(task_input.dig('content_view', 'id'))
112+
end
113+
end
65114
end
66115
end

katello.gemspec

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Gem::Specification.new do |gem|
1313
gem.homepage = "http://www.katello.org"
1414
gem.summary = "Content and Subscription Management plugin for Foreman"
1515
gem.description = "Katello adds Content and Subscription Management to Foreman. For this it relies on Candlepin and Pulp."
16-
gem.required_ruby_version = '>= 2.7', '< 4'
16+
gem.required_ruby_version = '>= 3.0', '< 4'
1717

1818
gem.files = Dir["{app,webpack,vendor,lib,db,ca,config,locale}/**/*"] +
1919
Dir['LICENSE.txt', 'README.md', 'package.json']
@@ -33,9 +33,9 @@ Gem::Specification.new do |gem|
3333
gem.add_dependency "rest-client"
3434

3535
gem.add_dependency "rabl"
36-
gem.add_dependency "foreman-tasks", ">= 9.1"
36+
gem.add_dependency "foreman-tasks", ">= 12.0.0"
3737
gem.add_dependency "foreman_remote_execution", ">= 7.1.0"
38-
gem.add_dependency "dynflow", ">= 1.6.1"
38+
gem.add_dependency "dynflow", ">= 2.0.0"
3939
gem.add_dependency "activerecord-import"
4040
gem.add_dependency "stomp"
4141
gem.add_dependency "scoped_search", ">= 4.1.9"

test/factories/foreman_task.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
result { 'success' }
88
sequence(:started_at) { |n| "2016-#{(n / 30) + 1}-#{(n % 30) + 1} 11:15:00" }
99
after(:build) do |task|
10-
task.ended_at = task.started_at.change(:sec => 32)
10+
task.ended_at = task.started_at.change(:sec => 32) if task.started_at
1111
end
1212

1313
trait :running do
@@ -21,6 +21,13 @@
2121
result { 'error' }
2222
ended_at { nil }
2323
end
24+
25+
trait :scheduled do
26+
state { 'scheduled' }
27+
result { 'pending' }
28+
started_at { nil }
29+
ended_at { nil }
30+
end
2431
end
2532

2633
factory :dynflow_task, :parent => :foreman_task, :class => ForemanTasks::Task::DynflowTask do

test/models/content_view_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,5 +853,38 @@ def test_ignore_generated
853853
ignore_generated_include_library = Katello::ContentView.ignore_generated(include_library_generated: true).count
854854
assert_equal (ignore_generated - ignore_generated_include_library), 2
855855
end
856+
857+
def test_check_scheduled_publish_raises_when_scheduled_publish_exists
858+
composite_cv = katello_content_views(:composite_view)
859+
860+
Katello::ContentViewManager.stubs(:scheduled_composite_publish?).with(composite_cv).returns(true)
861+
862+
error = assert_raises(Katello::Errors::ConflictException) do
863+
composite_cv.check_scheduled_publish!
864+
end
865+
866+
assert_match(/publish is already scheduled/, error.message)
867+
end
868+
869+
def test_check_component_publishes_raises_when_component_publishes_running
870+
composite_cv = katello_content_views(:composite_view)
871+
running_task = mock('task')
872+
873+
Katello::ContentViewManager.stubs(:running_component_publish_tasks).with(composite_cv).returns([running_task])
874+
875+
error = assert_raises(Katello::Errors::ConflictException) do
876+
composite_cv.check_component_publishes!
877+
end
878+
879+
assert_match(/its content views are being published/, error.message)
880+
end
881+
882+
def test_check_component_publishes_passes_when_no_component_publishes
883+
composite_cv = katello_content_views(:composite_view)
884+
885+
Katello::ContentViewManager.stubs(:running_component_publish_tasks).with(composite_cv).returns([])
886+
887+
composite_cv.check_component_publishes!
888+
end
856889
end
857890
end

0 commit comments

Comments
 (0)