Skip to content

Conversation

@lukasfrank
Copy link
Member

@lukasfrank lukasfrank commented Jan 23, 2026

Proposed Changes

Fixes #

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a resource claiming framework enabling plugin-based resource management with claim and release operations
    • Added GPU resource claiming support with PCI address tracking and device allocation
    • Enabled PCI device discovery capabilities on Linux systems
  • Tests

    • Added comprehensive test coverage for resource claiming and GPU device allocation scenarios
  • Chores

    • Updated project dependencies and added license header configuration

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 23, 2026

Walkthrough

Introduces a resource claiming framework with a plugin-backed architecture. Implements PCI device discovery across Linux and Darwin, GPU claim management, asynchronous claim/release processing with validation, and comprehensive test coverage using Ginkgo/Gomega.

Changes

Cohort / File(s) Summary
Plugin Interface & Base Claimer
claimutils/claim/plugin.go, claimutils/claim/claimer.go
Defines Plugin interface with lifecycle methods (Init, Name, CanClaim, Claim, Release) and ResourceClaim marker type. Introduces Claimer interface and NewResourceClaimer factory. Implements async claim/release processing via internal channels, plugin validation, resource state management with atomic flags, and aggregated error handling for missing plugins and insufficient resources.
Claim Tests
claimutils/claim/claim_suite_test.go, claimutils/claim/claimer_test.go
Adds Ginkgo/Gomega test suite bootstrap with BeforeSuite Zap logger configuration. Includes comprehensive test scenarios: missing plugin errors, single/multi-resource claims, resource availability validation, claim extraction, and release operations.
GPU Plugin Implementation
claimutils/gpu/gpu.go
Implements GPU claim plugin with device state management (Free/Claimed tracking). Provides Init() for PCI discovery and pre-claimed device setup, Claim() for device allocation with availability validation, Release() for device freeing. Introduces Claim interface extending ResourceClaim with PCIAddresses() accessor and NewGPUClaim factory.
GPU Tests
claimutils/gpu/gpu_suite_test.go, claimutils/gpu/gpu_test.go
Adds test suite bootstrap with controller-runtime log configuration. Comprehensive test coverage including plugin initialization, name retrieval, claiming under various availability scenarios, device differentiation across claims, release handling, and invalid claim rejection.
PCI Reader Interface & Implementations
claimutils/pci/reader.go, claimutils/pci/progfs_linux.go, claimutils/pci/progfs_darwin.go
Defines PCI types (Class, Vendor, Address with String() method) and Reader interface. Linux implementation enumerates sysfs PCI devices with vendor/class filtering. Darwin implementation provides stub (NOT SUPPORTED OS) with logging.
PCI Reader Tests
claimutils/pci/reader_test.go
Adds Linux-only test with temporary sysfs structure fabrication. Validates device discovery filtering by vendor and class, including error handling for filesystem operations.
Dependencies & Configuration
go.mod, hack/license-header.txt
Promotes prometheus/procfs to direct dependency (v0.19.2), adds testify indirect test dependency, and introduces Apache-2.0 license header metadata.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Claimer as Resource Claimer
    participant GPUPlugin as GPU Plugin
    participant PCIReader as PCI Reader
    participant DeviceState as Device State

    Client->>Claimer: Claim(ctx, [nvidia.com/gpu: 2])
    activate Claimer
    
    Claimer->>Claimer: Validate plugins exist
    Claimer->>GPUPlugin: Validate resource type
    activate GPUPlugin
    
    GPUPlugin->>GPUPlugin: CanClaim(quantity: 2)
    GPUPlugin->>DeviceState: Count free devices
    DeviceState-->>GPUPlugin: free_count = 4
    GPUPlugin->>GPUPlugin: Compare: 4 >= 2 ✓
    GPUPlugin-->>Claimer: Can claim
    deactivate GPUPlugin
    
    Claimer->>GPUPlugin: Claim(quantity: 2)
    activate GPUPlugin
    
    GPUPlugin->>DeviceState: Allocate & mark 2 devices as Claimed
    DeviceState-->>GPUPlugin: Allocated addresses
    GPUPlugin->>GPUPlugin: Create gpuClaim(addresses)
    GPUPlugin-->>Claimer: ResourceClaim{addresses}
    deactivate GPUPlugin
    
    Claimer->>Claimer: Aggregate results into Claims map
    Claimer-->>Client: Claims{nvidia.com/gpu: gpuClaim}, nil
    deactivate Claimer
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is entirely empty except for the template structure; all required sections lack content. Fill in the 'Proposed Changes' section with at least 3 bullet points describing the key changes and provide any relevant issue reference in 'Fixes #'.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement abstraction for resources' accurately describes the main change—introducing a plugin-based resource claiming framework with claim/release interfaces.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lukasfrank
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@lukasfrank lukasfrank self-assigned this Jan 23, 2026
@lukasfrank lukasfrank linked an issue Jan 23, 2026 that may be closed by this pull request
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@claimutils/claim/claimer_test.go`:
- Around line 41-49: The goroutine launching resourceClaimer.Start(innerCtx)
writes its result to errCh but the test never reads it, so Start failures are
ignored and the goroutine may outlive the spec; update the test to consume and
assert the value from errCh (or select between errCh and innerCtx.Done if
cancellation is expected) after cancelling/finishing the context, verifying the
returned error (or nil) matches the contract for resourceClaimer.Start to ensure
failures are detected and the goroutine exits.

In `@claimutils/claim/claimer.go`:
- Around line 106-116: Start currently flips c.running to false and returns
while the background goroutine c.run may still be mid-request, leaving callers
blocked on resultChan; modify Start and run to perform a graceful shutdown: when
ctx is cancelled, signal run to stop, then have run drain any pending requests
(the internal request queue/resultChan used in run) and send an explicit
error/closure to each waiting result before exiting, or close the result
channel(s) so waiters unblock; update claimer.Start, claimer.run and any code
paths that send to resultChan to handle a closed channel and to ensure every
pending request receives a terminal response (error) on shutdown.
- Around line 68-76: The claimer struct's log field is never initialized causing
a nil pointer panic when c.log.Error() is called (e.g., during claim rollback);
update NewResourceClaimer to accept a logr.Logger parameter and assign it to
claimer.log (or default to a no-op logger if nil), and update all
NewResourceClaimer call sites to pass a logger so claimer.log is always non-nil;
ensure the assignment occurs where the claimer value is constructed so
subsequent methods (e.g., where c.log.Error is invoked) are safe.

In `@claimutils/gpu/gpu_test.go`:
- Around line 149-167: The test’s spec name It("should claim different devices",
...) is duplicated and misleading because this case asserts a zero-quantity
claim; rename that spec to a descriptive unique name (e.g., It("should handle
zero-quantity claim", func(...) { ... })) so test output is unambiguous — update
the It(...) string in the gpu_test.go spec that wraps the plugin initialization
and Claim(resource.MustParse("0")) assertions (the anonymous func containing
plugin := gpu.NewGPUClaimPlugin(...) and
Expect(gpuClaim.PCIAddresses()).To(BeEmpty())).

In `@claimutils/gpu/gpu.go`:
- Around line 138-147: The loop over g.preClaimed incorrectly falls through and
adds pciDevice to g.devices even when it isn't discovered; inside the for loop
in gpu.go, after logging that a pciDevice is "Not discovered pre-claimed pci
address" (the if checking _, ok := g.devices[pciDevice]), add a continue so the
code does not execute the subsequent g.log.V(2).Info("Set device to claimed",
...) and g.devices[pciDevice] = ClaimStatusClaimed for undiscovered devices;
ensure references remain to g.preClaimed, g.devices and ClaimStatusClaimed.

In `@claimutils/pci/reader_test.go`:
- Line 62: The test function TestGPUClaimer_InitCorrect is misnamed because it
only validates the PCI reader; rename the test to reflect the PCI reader
behavior (e.g., TestPCIReader_InitCorrect or TestPCIReader_Init) to make test
output and -run filtering accurate; update the function declaration name and any
references to TestGPUClaimer_InitCorrect accordingly so the test suite runs
under the new test identifier.

In `@LICENSES/Apache-2.0.txt`:
- Line 69: Replace the incorrect boilerplate fragment "Unless requested by
applicable law or agreed to in writing, software" in the Apache-2.0 appendix
boilerplate with the official wording "Unless required by applicable law or
agreed to in writing, software" so the license matches the official Apache 2.0
text exactly.
- Around line 47-51: Revert the altered wording in the Apache-2.0 license text:
in Section 6 change "requested" back to "required" in the phrase "except as
requested for reasonable and customary use..." (Section 6), and in Sections 7
and 8 change both occurrences of "Unless requested by applicable law" and
"unless requested by applicable law" back to "Unless required by applicable law"
and "unless required by applicable law" respectively so the license matches the
official Apache License 2.0 wording.
🧹 Nitpick comments (8)
claimutils/pci/progfs_darwin.go (2)

23-26: Consider returning an error for unsupported OS.

Returning (nil, nil) silently succeeds, making it indistinguishable from a legitimate "no devices found" scenario. Callers cannot differentiate between an unsupported platform and an empty result.

Consider returning a sentinel error to make the unsupported state explicit:

♻️ Proposed change

Add an error variable at package level (in reader.go or here):

var ErrUnsupportedOS = errors.New("PCI reading not supported on this OS")

Then modify the Read method:

 func (r *reader) Read() ([]Address, error) {
 	r.log.V(1).Info("NOT SUPPORTED OS")
-	return nil, nil
+	return nil, ErrUnsupportedOS
 }

14-21: Alternatively, fail fast in NewReader.

If PCI reading is fundamentally unsupported on Darwin, consider returning an error from NewReader to prevent usage altogether, rather than silently succeeding during construction.

♻️ Alternative approach
+var ErrUnsupportedOS = errors.New("PCI reading not supported on this OS")
+
 func NewReader(log logr.Logger, _ Vendor, _ Class) (*reader, error) {
 	log.V(1).Info("NOT SUPPORTED OS")
-
-	return &reader{
-		log: log,
-	}, nil
-
+	return nil, ErrUnsupportedOS
 }
claimutils/claim/plugin.go (1)

25-25: Consider adding methods to ResourceClaim.

ResourceClaim is currently an empty interface, which provides flexibility but sacrifices type safety. Based on the GPU implementation in claimutils/gpu/gpu.go, claims have meaningful operations (e.g., PCIAddresses()).

Consider whether a minimal interface with common claim methods would be beneficial:

type ResourceClaim interface {
    // String returns a human-readable identifier for the claim
    String() string
}

This is optional if you prefer the flexibility of the empty interface.

claimutils/pci/reader.go (1)

13-17: Use const for fixed PCI IDs.
Line 13 defines immutable values as var, which is non-idiomatic for constants.

♻️ Suggested change
-var (
-	Class3DController Class = 0x030200
-
-	VendorNvidia Vendor = 0x10de
-)
+const (
+	Class3DController Class = 0x030200
+	VendorNvidia      Vendor = 0x10de
+)
claimutils/pci/progfs_linux.go (1)

21-48: Return the Reader interface from constructors for better abstraction.
Lines 21 and 36 expose an unexported concrete type; returning the interface matches the public contract and eases future swaps/mocking.

♻️ Suggested change
-func NewReader(log logr.Logger, vendorFilter Vendor, classFilter Class) (*reader, error) {
+func NewReader(log logr.Logger, vendorFilter Vendor, classFilter Class) (Reader, error) {
 	fs, err := sysfs.NewDefaultFS()
 	if err != nil {
 		return nil, fmt.Errorf("failed to open sysfs: %w", err)
 	}
@@
-	return &reader{
+	return &reader{
 		log:          log,
 		fs:           fs,
 		vendorFilter: vendorFilter,
 		classFilter:  classFilter,
 	}, nil
 }
 
-func NewReaderWithMount(log logr.Logger, mountPoint string, vendorFilter Vendor, classFilter Class) (*reader, error) {
+func NewReaderWithMount(log logr.Logger, mountPoint string, vendorFilter Vendor, classFilter Class) (Reader, error) {
 	fs, err := sysfs.NewFS(mountPoint)
 	if err != nil {
 		return nil, fmt.Errorf("failed to open sysfs: %w", err)
 	}
@@
-	return &reader{
+	return &reader{
 		log:          log,
 		fs:           fs,
 		vendorFilter: vendorFilter,
 		classFilter:  classFilter,
 	}, nil
 }
claimutils/claim/claimer.go (1)

78-104: Consider consolidating duplicate plugin-check logic.

checkPluginsForResources and checkPluginsForClaims are nearly identical. A helper function could reduce duplication.

♻️ Optional: Extract shared logic
+func (c *claimer) checkPluginsExist(resourceNames []v1alpha1.ResourceName) error {
+	var errs []error
+	for _, name := range resourceNames {
+		if _, ok := c.plugins[string(name)]; !ok {
+			errs = append(errs, fmt.Errorf("plugin for resource %s not found", name))
+		}
+	}
+	return errors.Join(errs...)
+}
+
 func (c *claimer) checkPluginsForResources(resources v1alpha1.ResourceList) error {
-	var missingPluginErrors []error
-	for resourceName := range resources {
-		if _, ok := c.plugins[string(resourceName)]; !ok {
-			missingPluginErrors = append(missingPluginErrors, fmt.Errorf("plugin for resource %s not found", resourceName))
-		}
-	}
-	if len(missingPluginErrors) > 0 {
-		return errors.Join(missingPluginErrors...)
+	names := make([]v1alpha1.ResourceName, 0, len(resources))
+	for name := range resources {
+		names = append(names, name)
 	}
-
-	return nil
+	return c.checkPluginsExist(names)
 }
claimutils/gpu/gpu.go (2)

35-40: Consider reversing the boolean semantics for clarity.

ClaimStatusFree = true and ClaimStatusClaimed = false is counterintuitive. Typically, the "claimed" state would be true. This could confuse maintainers.

♻️ Optional: Use clearer semantics
-type ClaimStatus bool
-
-const (
-	ClaimStatusFree    ClaimStatus = true
-	ClaimStatusClaimed ClaimStatus = false
-)
+type ClaimStatus bool
+
+const (
+	ClaimStatusFree    ClaimStatus = false
+	ClaimStatusClaimed ClaimStatus = true
+)

Then update the checks (e.g., line 66, 92) accordingly.


79-101: Map iteration yields non-deterministic device selection.

Iterating over g.devices (line 87) has undefined order in Go. The same quantity request may claim different devices across runs, which could complicate debugging or cause flaky tests.

If deterministic ordering matters, consider using a sorted slice of keys.

Comment on lines +106 to +116
func (c *claimer) Start(ctx context.Context) error {
if !c.running.CompareAndSwap(false, true) {
return ErrAlreadyStarted
}

go c.run(ctx)

<-ctx.Done()
c.running.Store(false)
return nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential request loss on shutdown.

When the context is cancelled, Start sets running = false and returns, but the run goroutine may exit mid-request. Callers blocked on resultChan (lines 190-195, 233-238) could hang indefinitely since no response is sent.

Consider draining pending requests or closing channels on shutdown to unblock waiters.

🤖 Prompt for AI Agents
In `@claimutils/claim/claimer.go` around lines 106 - 116, Start currently flips
c.running to false and returns while the background goroutine c.run may still be
mid-request, leaving callers blocked on resultChan; modify Start and run to
perform a graceful shutdown: when ctx is cancelled, signal run to stop, then
have run drain any pending requests (the internal request queue/resultChan used
in run) and send an explicit error/closure to each waiting result before
exiting, or close the result channel(s) so waiters unblock; update
claimer.Start, claimer.run and any code paths that send to resultChan to handle
a closed channel and to ensure every pending request receives a terminal
response (error) on shutdown.

@lukasfrank
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@claimutils/gpu/gpu_test.go`:
- Around line 35-37: The test comment is incorrect: the By("init plugin without
reader") text contradicts the call to gpu.NewGPUClaimPlugin which is passed
&MockReader{}; either update the comment to reflect that the plugin is
initialized with a reader (change the By text to "init plugin with reader") or,
if the test intended no reader, change the NewGPUClaimPlugin call to pass nil
instead of &MockReader{}; locate this in the test around the plugin variable
initialization and the gpu.NewGPUClaimPlugin / MockReader usage and make the
comment and call consistent.
♻️ Duplicate comments (1)
claimutils/claim/claimer.go (1)

107-117: Potential request loss on shutdown remains unaddressed.

When context is cancelled, Start sets running = false and returns, but pending requests on toClaim/toRelease channels may have callers blocked on resultChan that will never receive a response, causing goroutine leaks.

🐛 Suggested approach: drain channels on shutdown
 func (c *claimer) Start(ctx context.Context) error {
 	if !c.running.CompareAndSwap(false, true) {
 		return ErrAlreadyStarted
 	}

 	go c.run(ctx)

 	<-ctx.Done()
 	c.running.Store(false)
+	
+	// Drain pending requests to unblock waiters
+	for {
+		select {
+		case req := <-c.toClaim:
+			req.resultChan <- claimRes{err: ctx.Err()}
+		case req := <-c.toRelease:
+			req.resultChan <- ctx.Err()
+		default:
+			return nil
+		}
+	}
-	return nil
 }
🧹 Nitpick comments (4)
claimutils/gpu/gpu.go (2)

35-40: Consider clearer naming for ClaimStatus constants.

Using true for "Free" and false for "Claimed" is counterintuitive. Typically, a "claimed" state would be true. This inverted mapping may cause confusion during maintenance.

♻️ Suggested refactor
-type ClaimStatus bool
-
-const (
-	ClaimStatusFree    ClaimStatus = true
-	ClaimStatusClaimed ClaimStatus = false
-)
+type ClaimStatus int
+
+const (
+	ClaimStatusFree ClaimStatus = iota
+	ClaimStatusClaimed
+)

87-96: Map iteration order is non-deterministic.

Iterating over g.devices map produces non-deterministic device selection order. While functionally correct, this can cause flaky test behavior and inconsistent allocation patterns across runs.

♻️ Suggested approach

Consider maintaining a separate ordered slice of device addresses or sorting the keys before iteration to ensure deterministic allocation:

+	// Collect and sort addresses for deterministic allocation
+	var freeDevices []pci.Address
+	for device, claimed := range g.devices {
+		if claimed == ClaimStatusFree {
+			freeDevices = append(freeDevices, device)
+		}
+	}
+	// Sort freeDevices by some consistent ordering (e.g., by Address.String())
+
 	gClaim := &gpuClaim{}
-	for device, claimed := range g.devices {
+	for _, device := range freeDevices {
 		if int64(len(gClaim.devices)) == requested {
 			break
 		}
-
-		if claimed == ClaimStatusFree {
-			g.devices[device] = ClaimStatusClaimed
-			gClaim.devices = append(gClaim.devices, device)
-		}
+		g.devices[device] = ClaimStatusClaimed
+		gClaim.devices = append(gClaim.devices, device)
 	}
claimutils/claim/claimer.go (1)

79-105: Consider extracting common plugin-check logic.

checkPluginsForResources and checkPluginsForClaims have nearly identical implementations. A generic helper could reduce duplication.

♻️ Suggested refactor
func (c *claimer) checkPlugins[K ~string](names iter.Seq[K]) error {
	var errs []error
	for name := range names {
		if _, ok := c.plugins[string(name)]; !ok {
			errs = append(errs, fmt.Errorf("plugin for resource %s not found", name))
		}
	}
	return errors.Join(errs...)
}

Or simply use maps.Keys() with a shared implementation.

claimutils/gpu/gpu_test.go (1)

133-143: Misleading variable name: ociAddress should be gpuClaim or similar.

The variables ociAddress1 and ociAddress2 are actually gpu.Claim instances containing PCI addresses, not OCI addresses. This naming is confusing.

♻️ Suggested fix
-		ociAddress1, ok := gpuClaim1.(gpu.Claim)
+		claim1, ok := gpuClaim1.(gpu.Claim)
 		Expect(ok).To(BeTrue())
-		Expect(ociAddress1.PCIAddresses()).To(HaveLen(1))
+		Expect(claim1.PCIAddresses()).To(HaveLen(1))
 
 		By("claim resources again")
 		gpuClaim2, err := plugin.Claim(resource.MustParse("1"))
 		Expect(err).ToNot(HaveOccurred())
 
-		ociAddress2, ok := gpuClaim2.(gpu.Claim)
+		claim2, ok := gpuClaim2.(gpu.Claim)
 		Expect(ok).To(BeTrue())
-		Expect(ociAddress2.PCIAddresses()).To(HaveLen(1))
+		Expect(claim2.PCIAddresses()).To(HaveLen(1))
 
 		By("ensure claims are not equal")
-		Expect(ociAddress1.PCIAddresses()[0]).NotTo(Equal(ociAddress2.PCIAddresses()[0]))
+		Expect(claim1.PCIAddresses()[0]).NotTo(Equal(claim2.PCIAddresses()[0]))

Comment on lines +35 to +37
By("init plugin without reader")
plugin = gpu.NewGPUClaimPlugin(log.FromContext(ctx), "test-plugin", &MockReader{}, nil)
Expect(plugin.Init()).ShouldNot(HaveOccurred())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading comment: step initializes plugin with a reader.

The By comment says "init plugin without reader" but the code creates a plugin with &MockReader{}.

🔧 Suggested fix
-		By("init plugin without reader")
+		By("init plugin with reader")
 		plugin = gpu.NewGPUClaimPlugin(log.FromContext(ctx), "test-plugin", &MockReader{}, nil)
🤖 Prompt for AI Agents
In `@claimutils/gpu/gpu_test.go` around lines 35 - 37, The test comment is
incorrect: the By("init plugin without reader") text contradicts the call to
gpu.NewGPUClaimPlugin which is passed &MockReader{}; either update the comment
to reflect that the plugin is initialized with a reader (change the By text to
"init plugin with reader") or, if the test intended no reader, change the
NewGPUClaimPlugin call to pass nil instead of &MockReader{}; locate this in the
test around the plugin variable initialization and the gpu.NewGPUClaimPlugin /
MockReader usage and make the comment and call consistent.

@hardikdr hardikdr added the area/iaas Issues related to IronCore IaaS development. label Jan 24, 2026
@hardikdr hardikdr added this to Roadmap Jan 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/iaas Issues related to IronCore IaaS development. size/XXL

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Implement abstraction for resources (CPU, RAM, GPU, ... )

4 participants