Skip to content

Conversation

@onmete
Copy link
Contributor

@onmete onmete commented Aug 19, 2025

Description

Collect config on startup.
Similar to openshift/lightspeed-service#2582

Example data:

{
  "metadata": {
    "timestamp": "2025-08-19T08:32:31.230917+00:00",
    "service_version": "0.1.3",
    "config_file_path": "/home/ometelka/projects/lightspeed-core/lightspeed-stack/dev/lightspeed-stack-conf.yaml"
  },
  "configuration": "name: foo bar baz\nservice..."
}

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up service version
  • Bump-up dependent library
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change
  • Unit tests improvement
  • Integration tests improvement
  • End to end tests improvement

Related Tickets & Documents

  • Related Issue #
  • Closes #

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

  • Please provide detailed steps to perform tests related to this code change.
  • How were the fix/results from this change verified? Please provide relevant screenshots or results.

Summary by CodeRabbit

  • New Features

    • Optional collection and persistence of service configuration at startup, with enable/disable toggle and configurable storage location.
  • Documentation

    • Examples and guides updated to show the new configuration options and added fields in API response examples.
  • Tests

    • Added tests covering configuration persistence, metadata, storage directory creation, content preservation, and unique output filenames.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 19, 2025

Walkthrough

Adds config collection: new UserDataCollection fields config_enabled and config_storage; validation requires config_storage when enabled; examples and docs updated; /config response sample extended; new store_config(cfg_file) persists original YAML + metadata to configured storage at startup when enabled; tests added/updated.

Changes

Cohort / File(s) Summary
Example configs & templates
examples/lightspeed-stack-lls-external.yaml, examples/lightspeed-stack-lls-library.yaml, lightspeed-stack.yaml, tests/configuration/lightspeed-stack.yaml
Added user_data_collection keys: config_enabled and config_storage (path).
Documentation
README.md, docs/deployment_guide.md, docs/getting_started.md
Documented config_enabled and config_storage options and updated YAML snippets.
Model updates
src/models/config.py
Added UserDataCollection.config_enabled: bool = False and config_storage: Optional[str] = None; added validation to require config_storage when config_enabled is true.
Startup persistence
src/lightspeed_stack.py
Added store_config(cfg_file: str) to write JSON snapshot (metadata + raw YAML) into config_storage; main() calls it when config_enabled is true.
API sample update
src/app/endpoints/config.py
Extended 200 response example to include config_enabled and config_storage under user_data_collection.
Model tests
tests/unit/models/test_config.py
Updated expected serialized Configuration to include config_enabled and config_storage.
Store-config tests
tests/unit/test_lightspeed_stack.py
Added tests for store_config: JSON structure and metadata (timestamp, service_version, config_file_path), exact YAML preservation, directory creation, and unique filenames.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Starter as Service Starter
  participant App as lightspeed_stack.main()
  participant Model as UserDataCollection
  participant Store as store_config()
  participant FS as File System

  Starter->>App: start --config-file <path>
  App->>Model: load configuration (user_data_collection)
  alt config_enabled == true
    App->>Store: store_config(config_file)
    Store->>FS: mkdir -p config_storage (if missing)
    Store->>FS: write JSON {metadata, configuration: raw YAML}
    Store-->>App: return stored file path
  else config_enabled == false
    App-->>Starter: log "config collection disabled"
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • tisnik
  • umago

Poem

A rabbit taps the config drum,
New keys hop in—then safely come.
I bundle YAML, timestamp bright,
And tuck it in a burrowed site.
Hooray—configs stored, and all is snug. 🐇📦

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@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: 2

🧹 Nitpick comments (11)
examples/lightspeed-stack-lls-external.yaml (1)

18-19: Config collection enabled in example — confirm intent and add a security warning comment.

Enabling config collection by default in examples may persist raw YAML (including secrets like API keys) to disk. If intentional for demonstration, add an explicit warning to guide users on securing storage and permissions.

   transcripts_storage: "/tmp/data/transcripts"
-  config_enabled: true
+  # WARNING: Enabling config collection stores the original YAML (may include secrets).
+  # Ensure config_storage is on a secure volume and files are written with restrictive permissions (e.g., 0600).
+  config_enabled: true
   config_storage: "/tmp/data/config"
examples/lightspeed-stack-lls-library.yaml (1)

17-18: Same as external example: enabled by default — add a security warning comment.

Mirror the caution in this example too so users understand the implications.

   transcripts_storage: "/tmp/data/transcripts"
-  config_enabled: true
+  # WARNING: Enabling config collection stores the original YAML (may include secrets).
+  # Ensure config_storage is on a secure volume and files are written with restrictive permissions (e.g., 0600).
+  config_enabled: true
   config_storage: "/tmp/data/config"
src/models/config.py (1)

220-223: Tighten validation: reject empty strings (and optionally non-absolute paths).

Current check allows empty strings. This can lead to runtime errors or writing to unintended locations. At minimum, guard against blanks.

-        if self.config_enabled and self.config_storage is None:
+        if self.config_enabled and (
+            self.config_storage is None or self.config_storage.strip() == ""
+        ):
             raise ValueError(
                 "config_storage is required when config collection is enabled"
             )

Optional follow-ups (no blocking):

  • Consider typing config_storage as Optional[Path] and validating directory semantics (absolute path, writable) similar to other path validations in this module.
  • Docstring nit: rename “storage_location” to “storage fields” for consistency with field names.
src/app/endpoints/config.py (1)

43-44: Docs updated to include config fields — also consider redacting/guarding secrets in /config.

The example now reflects config_enabled and config_storage. Independently of this change, please verify that /config is not publicly exposed when auth is disabled and that sensitive values (e.g., api_key) are redacted in responses where appropriate.

tests/unit/models/test_config.py (1)

601-603: LGTM on serialization expectations; add tests for the new validation paths.

The dump assertion for config_enabled/config_storage looks good. Please add unit tests to cover:

  • config_enabled=True with missing/blank config_storage should raise.
  • config_enabled=True with a non-empty storage should pass.

Example tests to add:

def test_user_data_collection_config_enabled_requires_storage() -> None:
    with pytest.raises(ValueError, match="config_storage is required when config collection is enabled"):
        UserDataCollection(config_enabled=True, config_storage=None)

    with pytest.raises(ValueError, match="config_storage is required when config collection is enabled"):
        UserDataCollection(config_enabled=True, config_storage="  ")  # blank

def test_user_data_collection_config_enabled_with_storage_ok() -> None:
    cfg = UserDataCollection(config_enabled=True, config_storage="/tmp/data/config")
    assert cfg.config_enabled is True
    assert cfg.config_storage == "/tmp/data/config"

If helpful, I can open a follow-up PR adding these tests.

src/lightspeed_stack.py (4)

43-50: Consider redacting secrets before persisting raw YAML

Storing the original YAML verbatim can leak credentials or tokens. If configs may contain secrets, add an opt-in redaction step or at least a warning in docs. A simple first step is masking common keys (token, api_key, password, secret, client_secret, private_key).

I can propose a small YAML-aware redaction helper if desired.


52-59: Ensure storage directory has restrictive permissions

Create the directory (or tighten if it exists) with 0700 to avoid other users reading stored configs.

     config_storage = configuration.user_data_collection_configuration.config_storage
     if config_storage is None:
         raise ValueError("config_storage must be set when config collection is enabled")
     storage_path = Path(config_storage)
-    storage_path.mkdir(parents=True, exist_ok=True)
+    storage_path.mkdir(parents=True, exist_ok=True)
+    try:
+        os.chmod(storage_path, 0o700)
+    except Exception:
+        logger.debug("Could not set 0700 permissions on config storage dir: %s", storage_path)
     config_file_path = storage_path / f"{suid.get_suid()}.json"

108-113: Don’t fail service startup if config persistence fails

Make the collection best-effort. If I/O or permissions fail, log a warning and continue.

-    # store service configuration if enabled
-    if configuration.user_data_collection_configuration.config_enabled:
-        store_config(args.config_file)
-    else:
-        logger.debug("Config collection is disabled in configuration")
+    # store service configuration if enabled (best-effort)
+    if configuration.user_data_collection_configuration.config_enabled:
+        try:
+            store_config(args.config_file)
+        except Exception as e:
+            logger.warning("Config collection failed: %s", e)
+    else:
+        logger.debug("Config collection is disabled in configuration")

30-50: Return the stored file path (or ID) to enable downstream correlation

Returning the path or a content hash/SUID would let other components reference the stored config (e.g., attach ID to transcripts/feedback) without duplicating data.

Apply:

-def store_config(cfg_file: str) -> None:
+def store_config(cfg_file: str) -> Path:
@@
-    logger.info("Service configuration stored in '%s'", config_file_path)
+    logger.info("Service configuration stored in '%s'", config_file_path)
+    return config_file_path

And in main, ignore the return value or propagate the ID as needed.

tests/unit/test_lightspeed_stack.py (2)

205-238: Add a negative test for missing storage path (config_storage=None)

Covers the error path explicitly and protects against accidental regressions if validation changes.

You can append:

@patch("lightspeed_stack.configuration")
def test_store_config_raises_when_storage_missing(mock_configuration_module, sample_config_file):
    mock_config = MagicMock()
    mock_config.user_data_collection_configuration.config_storage = None
    mock_configuration_module.user_data_collection_configuration = (
        mock_config.user_data_collection_configuration
    )
    with pytest.raises(ValueError):
        store_config(sample_config_file)

205-238: Optionally validate timestamp format includes UTC offset

If you want to assert the UTC semantic, check it contains a timezone offset like +00:00.

Example:

assert stored_data["metadata"]["timestamp"].endswith("+00:00")
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b51226d and 476e064.

📒 Files selected for processing (7)
  • examples/lightspeed-stack-lls-external.yaml (1 hunks)
  • examples/lightspeed-stack-lls-library.yaml (1 hunks)
  • src/app/endpoints/config.py (1 hunks)
  • src/lightspeed_stack.py (3 hunks)
  • src/models/config.py (2 hunks)
  • tests/unit/models/test_config.py (1 hunks)
  • tests/unit/test_lightspeed_stack.py (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
src/lightspeed_stack.py (2)
src/configuration.py (2)
  • configuration (55-60)
  • user_data_collection_configuration (79-84)
src/utils/suid.py (1)
  • get_suid (6-12)
tests/unit/test_lightspeed_stack.py (2)
src/lightspeed_stack.py (2)
  • create_argument_parser (66-93)
  • store_config (30-63)
src/configuration.py (1)
  • user_data_collection_configuration (79-84)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build-pr
  • GitHub Check: e2e_tests
🔇 Additional comments (6)
src/models/config.py (1)

208-209: LGTM: New fields align with examples and endpoint docs.

The addition of config_enabled and config_storage is consistent across the codebase.

src/lightspeed_stack.py (1)

30-39: Good addition: clear scope and rationale for storing config once

Docstring explains immutability and dedup rationale well.

tests/unit/test_lightspeed_stack.py (4)

3-10: Solid, focused test scaffolding

Imports, fixtures, and patching strategy correctly isolate filesystem and module state. Good use of tmpdir and MagicMock.


67-96: End-to-end happy path coverage looks good

This validates file creation and essential JSON structure, including path echo and content presence.


100-129: Nice check for directory auto-creation

Covers a common deployment case where the storage path doesn’t pre-exist.


157-177: Good version propagation assertion

Patching version ensures the metadata wiring is exercised.

Copy link
Contributor

@tisnik tisnik left a comment

Choose a reason for hiding this comment

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

what it is good for? Do we need to support this feature? I mean - the timestamp etc., the service can be (re)started any time. And why the config file is copied (ok, transformed), if it does not have to contain all the config options?

@onmete
Copy link
Contributor Author

onmete commented Aug 19, 2025

@tisnik

what it is good for? Do we need to support this feature?

Yes, we need to know how users are configuring the service. It contains also service version, which is another important piece of information. It is useful on its own, but also to handle breaking changes in config (if that is the case for postprocessing).

timestamp

It is there to be able to correlate to the rest of the data, e.g. what config was used to generate concrete transcript.

service can be (re)started any time

And the config will be collected with each restart. It might produce duplicate data, but that is expected. Would you prefer to check the stored data first, to avoid storing the same config after the restart? I think this belongs more on the data processing side, but I'm fine doing it here.

And why the config file is copied

This is how the data collection works. It stores what needs to be collected in defined place. The data source here is the file, so yes, it is basically copied, but how else would you do that? Note that the collection dir is purged after collection so we can't do symlink (it is forbidden in exporter too).

if it does not have to contain all the config options

What do you mean? That the config file doesn't contain all the configuration that is later added in the runtime config object? We are interested in the user/input configuration - not what it is transformed to internally.

Is llamastack config something users can change? Or is it shipped with the lightspeed-stack? If users can change it, then we also need to collect that - I would add some other metadata to distinguish these two configs.

Copy link
Contributor

@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

🧹 Nitpick comments (7)
README.md (7)

128-130: Add an inline caution in the example about secrets in stored configs.

When enabled, the service persists the raw YAML config. Add a brief YAML comment to warn users not to include secrets or to use sanitized configs.

Apply this diff:

   transcripts_enabled: true
   transcripts_storage: "/tmp/data/transcripts"
+  # Caution: when enabled, the raw YAML config is stored with metadata.
+  # Avoid secrets in this file or use a sanitized copy.
   config_enabled: true
   config_storage: "/tmp/data/config"

246-248: Repeat the secrets caution in the library-client example block.

Same rationale as above—store-only sanitized configs or ensure no secrets are present.

Apply this diff:

   transcripts_enabled: true
   transcripts_storage: "/tmp/data/transcripts"
+  # Caution: when enabled, the raw YAML config is stored with metadata.
+  # Avoid secrets in this file or use a sanitized copy.
   config_enabled: true
   config_storage: "/tmp/data/config"

262-264: Add inline caution to the generic user_data_collection example.

Make the risk explicit at each example site for clarity.

Apply this diff:

   transcripts_enabled: true
   transcripts_storage: "/tmp/data/transcripts"
+  # Caution: when enabled, the raw YAML config is stored with metadata.
+  # Avoid secrets in this file or use a sanitized copy.
   config_enabled: true
   config_storage: "/tmp/data/config"

272-274: Fix markdownlint and minor grammar in the list.

The linter expects asterisks (MD004). Also ensure “JSON” is fully spelled out.

Apply this diff:

-* `config_enabled`: Enable/disable collection of service configuration at startup
-* `config_storage`: Directory path where configuration JSON files are stored
+* `config_enabled`: Enable/disable collection of service configuration at startup
+* `config_storage`: Directory path where configuration JSON files are stored

If your repo enforces MD004 globally, consider updating earlier list items as well for consistency (can be a follow-up).


661-663: Add inline caution in Quick Integration snippet.

Mirrors the above examples; helps avoid accidental storage of sensitive config.

Apply this diff:

     transcripts_enabled: true
     transcripts_storage: "/shared/data/transcripts"
+    # Caution: when enabled, the raw YAML config is stored with metadata.
+    # Avoid secrets in this file or use a sanitized copy.
     config_enabled: true
     config_storage: "/shared/data/config"

128-130: Consider documenting the /config endpoint and stored payload format.

We mention the fields, but a short subsection under “Endpoints” describing the collected JSON schema (metadata + configuration) would help users and integrators.

I can draft a minimal “Configuration Collection” endpoint/doc blurb with example response and caveats. Want me to add it?


262-264: Review storage safeguards for config persistence

  • Sanitization
    The store_config function reads and dumps the raw YAML under "configuration" (src/lightspeed_stack.py:40–43). There is no redaction or sanitization of secrets. If storing raw configs (including credentials) is acceptable, please document this decision clearly in the README. Otherwise, introduce a sanitization step or redact sensitive fields before persisting.

  • Symlink protection
    We currently pass config_storage straight to Path.mkdir and file writes (src/lightspeed_stack.py:56–61) without checking for symlinks. To prevent link traversal or accidental writes outside the intended directory, consider:
    • Verifying that config_storage and all parent directories are not symlinks (e.g., via os.lstat().is_symlink())
    • Resolving the path with Path.resolve(strict=True) and confirming it remains under an approved base directory

  • Directory creation
    Using storage_path.mkdir(parents=True, exist_ok=True) correctly handles nested paths and race conditions.

  • Filename uniqueness
    Filenames are generated with uuid4() (src/lightspeed_stack.py:58) and tested by test_store_config_unique_filenames. A timestamp is included in the metadata (not in the filename). If you need chronological ordering of the files, consider adding a timestamp prefix to the filename.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 476e064 and c063a63.

📒 Files selected for processing (5)
  • README.md (5 hunks)
  • docs/deployment_guide.md (2 hunks)
  • docs/getting_started.md (1 hunks)
  • lightspeed-stack.yaml (1 hunks)
  • tests/configuration/lightspeed-stack.yaml (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • docs/getting_started.md
  • docs/deployment_guide.md
🧰 Additional context used
🪛 LanguageTool
README.md

[grammar] ~272-~272: There might be a mistake here.
Context: ...tion of service configuration at startup - config_storage: Directory path where configuration JSO...

(QB_NEW_EN)

🪛 markdownlint-cli2 (0.17.2)
README.md

272-272: Unordered list style
Expected: asterisk; Actual: dash

(MD004, ul-style)


273-273: Unordered list style
Expected: asterisk; Actual: dash

(MD004, ul-style)

🔇 Additional comments (1)
tests/configuration/lightspeed-stack.yaml (1)

35-38: Configuration Tests Updated Successfully
The new transcripts_enabled, transcripts_storage, config_enabled, and config_storage entries in tests/configuration/lightspeed-stack.yaml correctly mirror the model changes and defaults. At runtime, both the transcript and config storage paths are created via

storage_path.mkdir(parents=True, exist_ok=True)

in the application code, so no manual fixture or teardown is needed in the tests. CI jobs run in isolated /tmp environments, preventing residue or permission issues between runs.

Comment on lines +23 to +24
config_enabled: true
config_storage: "/tmp/data/config"
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 19, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Do not enable config collection by default in shipped config (privacy/security risk).

Having config_enabled: true in the default config may persist raw configuration on first run, potentially capturing secrets, API keys, or internal file paths without explicit user consent. Recommend defaulting to false here and letting docs/examples instruct how to enable.

Apply this diff:

-  config_enabled: true
+  config_enabled: false
   config_storage: "/tmp/data/config"
🤖 Prompt for AI Agents
In lightspeed-stack.yaml around lines 23 to 24, the default config_enabled is
set to true; change it to false to avoid enabling config collection by default,
leaving config_storage as-is; also update any docs/examples to show how to
explicitly enable config collection (and mention user consent and any migration
steps for existing installs) so users opt in rather than having it enabled by
default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, this is actually a good comment. In OLS, the config doesn't contain any secrets (if it does, it is user's fault and we are fine collecting it in this case), but the lightspeed-stack.yaml contains api_key. So I'll revisit the config and do some extra redaction.
cc @tisnik

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

"service_version": version.__version__,
"config_file_path": cfg_file,
},
"configuration": config_content,
Copy link
Contributor

Choose a reason for hiding this comment

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

Another concern I have reading this, is that we do not redact anything from the configuration file. These files may include passwords [0][1], API keys [2], etc...

I'm not sure clients will want to send that anywhere. And even those storing this kind of content might have to deal with legal issues/procedures in order to do so.

[0]

tls_key_password: Optional[FilePath] = None

[1]
[2]
api_key: Optional[str] = None

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup - see previous comment: #417 (comment)
I'll have a look at the redaction of these fields.

@onmete onmete marked this pull request as draft August 20, 2025 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants