Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76b661326e |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/reference/configuration-reference/
|
||||
version: 2.1
|
||||
|
||||
# Define a job to be invoked later in a workflow.
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
|
||||
jobs:
|
||||
say-hello:
|
||||
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
|
||||
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
|
||||
docker:
|
||||
# Specify the version you desire here
|
||||
# See: https://circleci.com/developer/images/image/cimg/base
|
||||
- image: cimg/base:current
|
||||
|
||||
# Add steps to the job
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
|
||||
steps:
|
||||
# Checkout the code as the first step.
|
||||
- checkout
|
||||
- run:
|
||||
name: "Say hello"
|
||||
command: "echo Hello, World!"
|
||||
|
||||
# Orchestrate jobs using workflows
|
||||
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
|
||||
workflows:
|
||||
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
|
||||
# Inside the workflow, you define the jobs you want to run.
|
||||
jobs:
|
||||
- say-hello
|
||||
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
.github
|
||||
.circleci
|
||||
.env
|
||||
docs
|
||||
qqq-all-bom
|
||||
qqq-all-bom-test
|
||||
qqq-all-app/src
|
||||
qqq-all-app/target/*
|
||||
!qqq-all-app/target/qqq-all-app.jar
|
||||
@@ -1,29 +0,0 @@
|
||||
# Local demonstration values only. Copy to .env and override before exposing ports.
|
||||
QQQ_ALL_PORT=8080
|
||||
KEYCLOAK_PORT=8081
|
||||
POSTGRES_USER=qqq
|
||||
POSTGRES_PASSWORD=demo-postgres-password
|
||||
POSTGRES_DATABASE=qqq
|
||||
MYSQL_USER=qqq
|
||||
MYSQL_PASSWORD=demo-mysql-password
|
||||
MYSQL_ROOT_PASSWORD=demo-mysql-root-password
|
||||
MYSQL_DATABASE=qqq
|
||||
MONGO_USER=qqq
|
||||
MONGO_PASSWORD=demo-mongo-password
|
||||
MONGO_DATABASE=qqq
|
||||
MONGO_REPLICA_SET=rs0
|
||||
MONGO_KEYFILE=demo-mongo-internal-replica-key-change-this-before-sharing
|
||||
MINIO_ROOT_USER=qqq
|
||||
MINIO_ROOT_PASSWORD=demo-minio-password
|
||||
MINIO_BUCKET=qqq-demo
|
||||
SFTP_USER=qqq
|
||||
SFTP_PASSWORD=demo-sftp-password
|
||||
RABBITMQ_USER=qqq
|
||||
RABBITMQ_PASSWORD=demo-rabbitmq-password
|
||||
ARTEMIS_USER=qqq
|
||||
ARTEMIS_PASSWORD=demo-artemis-password
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=demo-keycloak-admin-password
|
||||
OIDC_CLIENT_SECRET=demo-qqq-all-client-secret
|
||||
DEMO_ADMIN_PASSWORD=demo-admin-password
|
||||
DEMO_USER_PASSWORD=demo-user-password
|
||||
@@ -1,33 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
container:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/container.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
# Same gates as the old orb build-and-test + core smoke: script unit tests,
|
||||
# the full reactor (BOM tests, packaged integration tests), then the packaged
|
||||
# core jar smoked over HTTP. The Dockerfile copies that verified jar.
|
||||
prebuild: |
|
||||
set -euo pipefail
|
||||
# The smoke scripts need Python 3 (stdlib only); the job image has none and no root for apt.
|
||||
mkdir -p /tmp/python
|
||||
curl -fsSLo /tmp/python.tgz https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.12.15%2B20261013-x86_64-unknown-linux-gnu-install_only.tar.gz
|
||||
echo "5d546fc004fc2435fa142eb8f42ed8de5eb4725963dc22f7e6671014a80eb560 /tmp/python.tgz" | sha256sum -c -
|
||||
tar -xzf /tmp/python.tgz -C /tmp/python --strip-components=1 && rm /tmp/python.tgz
|
||||
# The release-bundle and full-smoke config tests render compose.yaml with `docker compose config`
|
||||
# (no daemon). Provide the Compose binary behind a `docker compose`-only shim.
|
||||
curl -fsSLo /tmp/python/bin/docker-compose https://github.com/docker/compose/releases/download/v5.6.1/docker-compose-linux-x86_64
|
||||
echo "47737f4236e6d3ccea7291995203f8f018058783b4c3a9469119811a0b5db8c0 /tmp/python/bin/docker-compose" | sha256sum -c -
|
||||
printf '%s\n' '#!/bin/sh' '[ "$1" = compose ] || { echo "only docker compose is installed" >&2; exit 1; }' 'shift' 'exec docker-compose "$@"' > /tmp/python/bin/docker
|
||||
chmod +x /tmp/python/bin/docker-compose /tmp/python/bin/docker
|
||||
export PATH=/tmp/python/bin:$PATH
|
||||
python3 -m unittest discover -s scripts -p 'test_*.py' -v
|
||||
mvn verify
|
||||
python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar
|
||||
images: |
|
||||
qqq-all|Dockerfile|.
|
||||
# develop pushes git.kof22.com/qrun-io/qqq-all:ci-<sha>-run-<n> plus the moving develop tag.
|
||||
publish-branch: develop
|
||||
extra-tags: develop
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report something in qqq-all that does not work
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
|
||||
**Clear and concise description of what the bug is.**
|
||||
|
||||
## Expected vs Actual Behavior
|
||||
|
||||
**What you expected to happen:**
|
||||
- Expected behavior description
|
||||
|
||||
**What actually happened:**
|
||||
- Actual behavior description
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1. Start the stack with '...'
|
||||
2. Go to '...'
|
||||
3. Click on '...'
|
||||
4. See error
|
||||
|
||||
## Environment
|
||||
|
||||
**qqq-all Version:** [e.g., 4.1.0-SNAPSHOT, 4.1.0]
|
||||
**Profile:** [core or full]
|
||||
**Java Version:** [e.g., 21]
|
||||
**Docker Version:** [if using the full profile]
|
||||
**OS:** [e.g., macOS 15, Ubuntu 24.04, Windows 11 with WSL]
|
||||
|
||||
## Additional Context
|
||||
|
||||
**Add any other context about the problem here:**
|
||||
|
||||
- Screenshots (if applicable)
|
||||
- Error logs or stack traces
|
||||
- Related issues or pull requests
|
||||
|
||||
## Before Submitting
|
||||
|
||||
- [ ] Searched [existing issues](https://github.com/QRun-IO/qqq-all/issues) for duplicates
|
||||
- [ ] Checked whether this is a framework bug that belongs in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)
|
||||
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: QQQ Framework Issues
|
||||
url: https://github.com/QRun-IO/qqq/issues
|
||||
about: Bugs and feature requests for the QQQ framework itself
|
||||
- name: QQQ Documentation Wiki
|
||||
url: https://github.com/QRun-IO/qqq/wiki
|
||||
about: Complete documentation, guides, and tutorials for QQQ
|
||||
- name: GitHub Discussions
|
||||
url: https://github.com/QRun-IO/qqq/discussions
|
||||
about: Ask questions and discuss QQQ with the community
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: Documentation Issue
|
||||
about: Report documentation problems or suggest improvements
|
||||
title: "[DOCS] "
|
||||
labels: documentation
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Documentation Issue
|
||||
|
||||
**What documentation issue are you reporting?**
|
||||
|
||||
- [ ] **Missing Documentation** - Something is not documented
|
||||
- [ ] **Outdated Information** - Documentation is incorrect or outdated
|
||||
- [ ] **Unclear Content** - Documentation is confusing or hard to understand
|
||||
- [ ] **Missing Examples** - Need more examples or code samples
|
||||
- [ ] **Broken Links** - Links don't work or go to wrong places
|
||||
- [ ] **Other** - Please describe below
|
||||
|
||||
## Description
|
||||
|
||||
**Clear and concise description of the documentation issue:**
|
||||
|
||||
## Location
|
||||
|
||||
**Where is this documentation issue located?**
|
||||
- **File/Page**: [e.g., README.md, CONTRIBUTING.md]
|
||||
- **Section**: [e.g., "Quick Start"]
|
||||
- **URL**: [if applicable]
|
||||
|
||||
## Suggested Improvement
|
||||
|
||||
**How would you improve this documentation?**
|
||||
- What information should be added?
|
||||
- How could it be made clearer?
|
||||
- What examples would be helpful?
|
||||
|
||||
## Before Submitting
|
||||
|
||||
- [ ] Searched [existing issues](https://github.com/QRun-IO/qqq-all/issues) for similar documentation requests
|
||||
- [ ] Checked whether this is about framework docs that belong in the [QQQ Wiki](https://github.com/QRun-IO/qqq/wiki)
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest something qqq-all should configure or demonstrate
|
||||
title: "[FEATURE] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like:**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered:**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Use Case
|
||||
|
||||
**What is the use case for this feature?**
|
||||
- Which QQQ feature, backend, or qBit does it involve?
|
||||
- Which profile does it affect (`core`, `full`, or both)?
|
||||
- Who would benefit from it?
|
||||
|
||||
## Implementation Ideas
|
||||
|
||||
**Do you have any ideas about how this could be implemented?**
|
||||
- Technical approach suggestions
|
||||
- Any constraints or considerations
|
||||
|
||||
## Before Submitting
|
||||
|
||||
- [ ] Searched [existing issues](https://github.com/QRun-IO/qqq-all/issues) for similar requests
|
||||
- [ ] Checked whether this is a framework feature that belongs in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)
|
||||
@@ -1,52 +0,0 @@
|
||||
# Pull Request
|
||||
|
||||
## Description
|
||||
|
||||
**What does this PR do?**
|
||||
A clear and concise description of what this pull request accomplishes.
|
||||
|
||||
**Related Issue:**
|
||||
Closes #[issue_number]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] **Bug Fix** - Fixes an existing issue
|
||||
- [ ] **New Feature** - Adds new functionality
|
||||
- [ ] **Breaking Change** - Fix or feature that would cause existing functionality to not work as expected
|
||||
- [ ] **Documentation Update** - Updates documentation
|
||||
- [ ] **Refactoring** - Code change that neither fixes a bug nor adds a feature
|
||||
- [ ] **Build / CI** - Changes to the Maven build, CircleCI, compose, or image
|
||||
- [ ] **Test Addition** - Adding missing tests or correcting existing tests
|
||||
|
||||
## Testing
|
||||
|
||||
**How has this been tested?**
|
||||
- [ ] **Unit Tests**: All unit tests pass
|
||||
- [ ] **Integration Tests**: Integration tests pass
|
||||
- [ ] **Profiles**: Checked the affected profile(s) (`core`, `full`)
|
||||
- [ ] **Manual Testing**: Tested manually in a development environment
|
||||
|
||||
**Test Commands:**
|
||||
```bash
|
||||
# Commands used for testing, and their results
|
||||
mvn -B verify
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
**Before submitting this PR, please ensure:**
|
||||
|
||||
- [ ] **Code Style**: Follows QQQ's [Code Review Standards](https://github.com/QRun-IO/qqq/wiki/Code-Review-Standards)
|
||||
- [ ] **Tests**: All tests pass
|
||||
- [ ] **Documentation**: Updated the README or docs where behavior changed
|
||||
- [ ] **Secrets**: No secrets committed; any demo credentials are marked demo-only and overridable by env
|
||||
- [ ] **Breaking Changes**: Documented any breaking changes
|
||||
- [ ] **Commit Messages**: Follow conventional commit format
|
||||
- [ ] **Self Review**: Code has been reviewed by the author
|
||||
|
||||
## Additional Information
|
||||
|
||||
**Any additional information that reviewers should know:**
|
||||
- Screenshots (if applicable)
|
||||
- Dependencies added/removed
|
||||
- Migration notes
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
target/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.iml
|
||||
.env
|
||||
/.env.local
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
#####################################
|
||||
## Eclipse / NVIM Modules for Java ##
|
||||
#####################################
|
||||
**/.settings/
|
||||
**/.classpath
|
||||
**/.factorypath
|
||||
**/.project
|
||||
|
||||
#############################################
|
||||
## Original contents from github template: ##
|
||||
#############################################
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
.flattened-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/.cache/
|
||||
@@ -1,78 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- **Demonstrating empathy and kindness** toward other people
|
||||
- **Being respectful** of differing opinions, viewpoints, and experiences
|
||||
- **Giving and gracefully accepting** constructive feedback
|
||||
- **Accepting responsibility** and apologizing to those affected by our mistakes
|
||||
- **Focusing on what is best** for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- **The use of sexualized language or imagery**, and sexual attention or advances of any kind
|
||||
- **Trolling, insulting or derogatory comments**, and personal or political attacks
|
||||
- **Public or private harassment**
|
||||
- **Publishing others' private information**, such as a physical or email address, without their explicit permission
|
||||
- **Other conduct which could reasonably be considered inappropriate** in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **contact@qrun.io**. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).
|
||||
|
||||
## Contact Information
|
||||
|
||||
- **Community Leaders**: contact@qrun.io
|
||||
- **Security Issues**: security@qrun.io (see [SECURITY.md](SECURITY.md))
|
||||
- **Company**: QRunIO, LLC
|
||||
- **Website**: https://qrun.io
|
||||
@@ -1,46 +0,0 @@
|
||||
# Contributing to qqq-all
|
||||
|
||||
qqq-all is open source and welcomes contributions. It is the reference build of [QQQ](https://github.com/QRun-IO/qqq) with every feature configured, so most framework changes belong in QQQ or the relevant qBit; changes here wire those pieces together, add demo data, or fix the build. For framework guides, see the [QQQ Wiki](https://github.com/QRun-IO/qqq/wiki).
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Report issues.** Found a bug or want a feature in the demo stack? [Open an issue](https://github.com/QRun-IO/qqq-all/issues/new/choose). Framework bugs go to [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues).
|
||||
2. **Contribute code.** Branch from `develop` as `feature/<issue>-<short-description>` and open the pull request against `develop`.
|
||||
3. **Improve docs.** Documentation fixes are welcome in the same way as code.
|
||||
|
||||
**Key requirements:**
|
||||
- Java 21 and Maven 3.8+
|
||||
- Follow QQQ's [Code Review Standards](https://github.com/QRun-IO/qqq/wiki/Code-Review-Standards)
|
||||
- Add or update tests for any behavior you change
|
||||
- Use [conventional commit](https://www.conventionalcommits.org/) messages
|
||||
- Never commit secrets; demo credentials must be marked demo-only and overridable by environment variables
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone git@github.com:QRun-IO/qqq-all.git
|
||||
cd qqq-all
|
||||
mvn -B verify
|
||||
```
|
||||
|
||||
qqq-all tracks the QQQ version. Until QQQ 4.1 ships, the build uses `4.1.0-SNAPSHOT` artifacts from the [Sonatype Central snapshot repository](https://central.sonatype.com/repository/maven-snapshots/), which the parent `pom.xml` already declares.
|
||||
|
||||
## Contribution Checklist
|
||||
|
||||
- [ ] `mvn -B verify` passes locally
|
||||
- [ ] Tests cover the change
|
||||
- [ ] Code follows QQQ's code style
|
||||
- [ ] Commit messages follow the conventional commit format
|
||||
- [ ] Docs updated where behavior changed
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **[QQQ Wiki](https://github.com/QRun-IO/qqq/wiki)**: framework guides
|
||||
- **[Issues](https://github.com/QRun-IO/qqq-all/issues)**: search existing issues or open a new one
|
||||
- **[Discussions](https://github.com/QRun-IO/qqq/discussions)**: ask questions
|
||||
|
||||
## Legal Notice
|
||||
|
||||
When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content, and that the content you contribute may be provided under the project license ([Apache-2.0](LICENSE)).
|
||||
|
||||
Please follow our [Code of Conduct](CODE_OF_CONDUCT.md). Report security issues privately as described in [SECURITY.md](SECURITY.md).
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
FROM eclipse-temurin:21-jre-jammy
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 qqq \
|
||||
&& useradd --system --uid 10001 --gid qqq --home-dir /app qqq \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R qqq:qqq /app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=qqq:qqq qqq-all-app/target/qqq-all-app.jar /app/qqq-all.jar
|
||||
COPY --chown=qqq:qqq LICENSE NOTICE /app/
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
ENV QQQ_ALL_DATA_DIR=/app/data
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=45s --retries=6 \
|
||||
CMD curl --fail --silent http://127.0.0.1:8080/health >/dev/null || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "/app/qqq-all.jar"]
|
||||
@@ -1,30 +0,0 @@
|
||||
QQQ All
|
||||
Copyright 2026 QRunIO, LLC
|
||||
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
================================================================================
|
||||
|
||||
This software contains contributions from:
|
||||
|
||||
- James Maes
|
||||
|
||||
================================================================================
|
||||
|
||||
Third-Party Licenses:
|
||||
|
||||
This software depends on and may redistribute third-party open source software
|
||||
components, including the QQQ framework, qBits, JDBC drivers, and message broker
|
||||
clients. Each component has its own license. Please see the individual component
|
||||
documentation for license details.
|
||||
|
||||
================================================================================
|
||||
|
||||
Trademarks:
|
||||
|
||||
QQQ, qBits, qRun, qStudio, and the QRunIO logo are trademarks of QRunIO, LLC.
|
||||
Use of these trademarks must comply with QRunIO's Trademark Guidelines.
|
||||
|
||||
================================================================================
|
||||
@@ -1,55 +0,0 @@
|
||||
# qqq-all
|
||||
|
||||
A runnable reference app for QQQ 4.1 with two profiles: `core` uses local storage and embedded Artemis; `full` adds external storage, RabbitMQ, OpenSearch quick search, Keycloak, Mailpit, and the included qbits. It is a local demo under construction while QQQ 4.1 finishes. See the [design](docs/design.md), [plan](docs/plan.md), and [epic #1](https://github.com/QRun-IO/qqq-all/issues/1).
|
||||
|
||||
The stack pins public Next **1.0.0-RC.8** for application testing. The owner approved an RC carrying its documented Firefox/WebKit/tablet failures, report/query and input parity gaps, sub-path hosting, real Google Drive verification, and visual-approval limitations. These remain open; passing qqq-all HTTP smoke checks do not establish browser parity or GA acceptance. See the [RC8 scope and approval](docs/release.md#next-rc8-integration).
|
||||
|
||||
## Quick start
|
||||
|
||||
Install Java 21 and Maven, then build the app and its BOM from the repository root:
|
||||
|
||||
```bash
|
||||
mvn -B verify
|
||||
```
|
||||
|
||||
Run core from the packaged jar:
|
||||
|
||||
```bash
|
||||
java -jar qqq-all-app/target/qqq-all-app.jar
|
||||
```
|
||||
|
||||
Or copy the local demo settings and run core with Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --profile core up --build --wait
|
||||
```
|
||||
|
||||
To run full instead, stop core first, then use the same `.env` (or copy `.env.example` if starting with full):
|
||||
|
||||
```bash
|
||||
docker compose --profile full up --build --wait
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8080/` for the dashboard and `/health` for status. The full profile starts PostgreSQL, MySQL, MongoDB, MinIO, SFTP, OpenSearch, Artemis, RabbitMQ, Mailpit, and Keycloak. Its demo logins are `demo-admin` and `demo-user`; their sample passwords and the OIDC client secret come from your local `.env` (copied from [.env.example](.env.example)). The browser issuer is `http://keycloak.localhost:8081`; make that name resolve to loopback if your host does not already. Compose publishes demo ports on host loopback. The [app guide](qqq-all-app/README.md) lists the environment contract and broker-management check.
|
||||
|
||||
## Explore the app
|
||||
|
||||
Core seeds customers and orders in H2, order lines in SQLite, and product files under `./data`. Insert an order as Admin to publish an Artemis `orderEvents` message and trigger `syncOrder`; inspect its process trace. Run `SendWebhookEvent` as Admin, then inspect the demo webhook receipt. Core's mock Demo User can read but cannot edit webhook destinations or invoke delivery. Sample customer and order rows are merged on every start, so edits to those rows can be reset.
|
||||
|
||||
Full adds sample tables on PostgreSQL, MySQL, MongoDB, MinIO S3, and SFTP. It includes OpenSearch customer quick search, table views, process tracing, workflows, geo addresses, SFTP import, and webhook administration with Keycloak role permissions. The `syncOrder` completion event publishes to RabbitMQ through QQQ ESB metadata. The full smoke check exercises this round trip with the public QQQ snapshot dependencies; final release acceptance must repeat it with released dependencies.
|
||||
|
||||
To stop the full stack, run `docker compose --profile full down`. Add `--volumes` only when you intend to erase demo data. For a fresh Artemis volume, the configured login is required; old volumes created for anonymous access should be recreated. This reference stack uses demo credentials and is not a production deployment.
|
||||
|
||||
CI runs [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) through the shared `KofTwentyTwo/ci-workflows` container workflow: script unit tests, `mvn verify`, and the packaged core HTTP smoke check on every push, then the image build and Trivy scan. Pushes to `develop` publish `git.kof22.com/qrun-io/qqq-all` (`ci-<sha>-run-<n>` and `develop`). The full Compose smoke check runs locally with `python3 scripts/run_full_smoke.py`.
|
||||
|
||||
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path. The GitHub release workflow that called the retired qqq-orb was removed; a versioned release path on Gitea is not defined yet.
|
||||
|
||||
## Contributing and license
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md), the [Code of Conduct](CODE_OF_CONDUCT.md), and the [security policy](SECURITY.md). Licensed under Apache-2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
The application JAR includes exact copies of these project documents under
|
||||
`META-INF/qqq-all/`, separate from dependency notices. The container also includes
|
||||
them at `/app/LICENSE` and `/app/NOTICE`. `PackagedNoticesIT` checks the shaded JAR's
|
||||
project documents against the root files during the existing `mvn verify` lifecycle.
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
qqq-all versions track QQQ versions.
|
||||
|
||||
| Version | Supported | Notes |
|
||||
| ------- | --------- | ----- |
|
||||
| 4.1.x | Yes | In development on `develop` (`4.1.0-SNAPSHOT`) until QQQ 4.1 ships |
|
||||
|
||||
## Scope
|
||||
|
||||
qqq-all is a local demo and reference build. It is not a production deployment: production still needs DNS, TLS, backups, monitoring, and highly available brokers. Any demo credentials in this repository are for local use only and must be overridden before exposing a stack to a network.
|
||||
|
||||
Vulnerabilities in the QQQ framework or a qBit should be reported to that project. If you are unsure where an issue belongs, report it here and it will be routed.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Security vulnerabilities should NEVER be reported publicly.**
|
||||
|
||||
### Private Reporting
|
||||
|
||||
Use GitHub's private vulnerability reporting:
|
||||
**[Report a vulnerability](https://github.com/QRun-IO/qqq-all/security/advisories/new)**
|
||||
|
||||
Or email: **security@qrun.io**
|
||||
|
||||
### What to Include
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- qqq-all version and profile (`core` or `full`), Java version, OS, and Docker version if relevant
|
||||
|
||||
### Response Timeline
|
||||
|
||||
| Stage | Timeline |
|
||||
|-------|----------|
|
||||
| Initial response | 24 hours |
|
||||
| Assessment | 3 business days |
|
||||
| Resolution | Based on severity |
|
||||
|
||||
## For Contributors
|
||||
|
||||
- Dependencies must not introduce HIGH or CRITICAL vulnerabilities
|
||||
- Secrets must never be committed
|
||||
- Follow QQQ's secure coding practices ([CODE_STYLE.md](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md))
|
||||
|
||||
## Contact
|
||||
|
||||
- **Security issues**: security@qrun.io
|
||||
- **General contact**: contact@qrun.io
|
||||
- **Organization**: [QRun-IO](https://github.com/QRun-IO)
|
||||
-292
@@ -1,292 +0,0 @@
|
||||
name: qqq-all
|
||||
|
||||
x-app-image: &app-image
|
||||
build:
|
||||
context: .
|
||||
image: qqq-all:local
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
ports:
|
||||
- "127.0.0.1:${QQQ_ALL_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 45s
|
||||
|
||||
services:
|
||||
app-core:
|
||||
<<: *app-image
|
||||
profiles: [core]
|
||||
environment:
|
||||
QQQ_ALL_PROFILE: core
|
||||
QQQ_ALL_PORT: "8080"
|
||||
QQQ_ALL_BIND_HOST: 0.0.0.0
|
||||
|
||||
app-full:
|
||||
<<: *app-image
|
||||
profiles: [full]
|
||||
environment:
|
||||
QQQ_ALL_PROFILE: full
|
||||
QQQ_ALL_PORT: "8080"
|
||||
QQQ_ALL_BIND_HOST: 0.0.0.0
|
||||
QQQ_ALL_POSTGRES_HOST: postgres
|
||||
QQQ_ALL_POSTGRES_DATABASE: ${POSTGRES_DATABASE:-qqq}
|
||||
QQQ_ALL_POSTGRES_USER: ${POSTGRES_USER:-qqq}
|
||||
QQQ_ALL_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
QQQ_ALL_MYSQL_HOST: mysql
|
||||
QQQ_ALL_MYSQL_DATABASE: ${MYSQL_DATABASE:-qqq}
|
||||
QQQ_ALL_MYSQL_USER: ${MYSQL_USER:-qqq}
|
||||
QQQ_ALL_MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD}
|
||||
QQQ_ALL_MONGO_HOST: mongo
|
||||
QQQ_ALL_MONGO_DATABASE: ${MONGO_DATABASE:-qqq}
|
||||
QQQ_ALL_MONGO_USER: ${MONGO_USER:-qqq}
|
||||
QQQ_ALL_MONGO_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD}
|
||||
QQQ_ALL_MONGO_REPLICA_SET: ${MONGO_REPLICA_SET:-rs0}
|
||||
QQQ_ALL_S3_ENDPOINT: http://minio:9000
|
||||
QQQ_ALL_S3_BUCKET: ${MINIO_BUCKET:-qqq-demo}
|
||||
QQQ_ALL_S3_ACCESS_KEY: ${MINIO_ROOT_USER:-qqq}
|
||||
QQQ_ALL_S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
|
||||
QQQ_ALL_SFTP_HOST: sftp
|
||||
QQQ_ALL_SFTP_USER: ${SFTP_USER:-qqq}
|
||||
QQQ_ALL_SFTP_PASSWORD: ${SFTP_PASSWORD:?set SFTP_PASSWORD}
|
||||
QQQ_ALL_ARTEMIS_URL: tcp://artemis:61616
|
||||
QQQ_ALL_ARTEMIS_USER: ${ARTEMIS_USER:-qqq}
|
||||
QQQ_ALL_ARTEMIS_PASSWORD: ${ARTEMIS_PASSWORD:?set ARTEMIS_PASSWORD}
|
||||
QQQ_ALL_ARTEMIS_MANAGEMENT_URL: http://artemis:8161
|
||||
QQQ_ALL_RABBITMQ_URL: amqp://rabbitmq:5672/%2F
|
||||
QQQ_ALL_RABBITMQ_USER: ${RABBITMQ_USER:-qqq}
|
||||
QQQ_ALL_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD:?set RABBITMQ_PASSWORD}
|
||||
QQQ_ALL_RABBITMQ_MANAGEMENT_URL: http://rabbitmq:15672
|
||||
QQQ_ALL_OPENSEARCH_HOST: opensearch
|
||||
QQQ_ALL_SMTP_HOST: mailpit
|
||||
QQQ_ALL_OIDC_BASE_URL: http://keycloak.localhost:${KEYCLOAK_PORT:-8081}/realms/qqq-all
|
||||
QQQ_ALL_OIDC_CLIENT_ID: qqq-all
|
||||
QQQ_ALL_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
mysql: {condition: service_healthy}
|
||||
mongo-init: {condition: service_completed_successfully}
|
||||
minio-init: {condition: service_completed_successfully}
|
||||
sftp: {condition: service_healthy}
|
||||
opensearch: {condition: service_healthy}
|
||||
artemis: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
mailpit: {condition: service_healthy}
|
||||
keycloak: {condition: service_healthy}
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
profiles: [full]
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-qqq}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-qqq}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./infra/postgres:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
profiles: [full]
|
||||
environment:
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-qqq}
|
||||
MYSQL_USER: ${MYSQL_USER:-qqq}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./infra/mysql:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
profiles: [full]
|
||||
entrypoint: ["bash", "/start.sh"]
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-qqq}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD}
|
||||
MONGO_REPLICA_SET: ${MONGO_REPLICA_SET:-rs0}
|
||||
MONGO_KEYFILE: ${MONGO_KEYFILE:?set MONGO_KEYFILE}
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- mongo-config:/data/configdb
|
||||
- ./infra/mongo/start.sh:/start.sh:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --quiet --username=\"$$MONGO_INITDB_ROOT_USERNAME\" --password=\"$$MONGO_INITDB_ROOT_PASSWORD\" --authenticationDatabase admin --eval 'db.adminCommand({ping:1}).ok' | grep -q 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
mongo-init:
|
||||
image: mongo:7
|
||||
profiles: [full]
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
environment:
|
||||
MONGO_REPLICA_SET: ${MONGO_REPLICA_SET:-rs0}
|
||||
MONGO_USER: ${MONGO_USER:-qqq}
|
||||
MONGO_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD}
|
||||
volumes:
|
||||
- ./infra/mongo/init.sh:/init.sh:ro
|
||||
entrypoint: ["bash", "/init.sh"]
|
||||
restart: "no"
|
||||
|
||||
minio:
|
||||
build:
|
||||
context: ./infra/minio
|
||||
image: qqq-all-minio:local
|
||||
profiles: [full]
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-qqq}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
ports:
|
||||
- "127.0.0.1:9001:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
minio-init:
|
||||
image: public.ecr.aws/aws-cli/aws-cli:2.27.41
|
||||
profiles: [full]
|
||||
depends_on:
|
||||
minio: {condition: service_healthy}
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-qqq}
|
||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:-qqq-demo}
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command: ["aws --endpoint-url http://minio:9000 s3api head-bucket --bucket \"$$MINIO_BUCKET\" >/dev/null 2>&1 || aws --endpoint-url http://minio:9000 s3api create-bucket --bucket \"$$MINIO_BUCKET\""]
|
||||
restart: "no"
|
||||
|
||||
sftp:
|
||||
image: atmoz/sftp:alpine
|
||||
profiles: [full]
|
||||
command: "${SFTP_USER:-qqq}:${SFTP_PASSWORD:?set SFTP_PASSWORD}:1001:1001:upload"
|
||||
volumes:
|
||||
- type: volume
|
||||
source: sftp-data
|
||||
target: /home/${SFTP_USER:-qqq}/upload
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep sshd >/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.19.1
|
||||
profiles: [full]
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
DISABLE_SECURITY_PLUGIN: "true"
|
||||
OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl --fail --silent http://127.0.0.1:9200/_cluster/health >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
|
||||
artemis:
|
||||
image: apache/activemq-artemis:2.42.0-alpine
|
||||
profiles: [full]
|
||||
environment:
|
||||
ARTEMIS_USER: ${ARTEMIS_USER:-qqq}
|
||||
ARTEMIS_PASSWORD: ${ARTEMIS_PASSWORD:?set ARTEMIS_PASSWORD}
|
||||
ANONYMOUS_LOGIN: "false"
|
||||
volumes:
|
||||
- artemis-data:/var/lib/artemis-instance
|
||||
ports:
|
||||
- "127.0.0.1:8161:8161"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8161/console/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4-management-alpine
|
||||
profiles: [full]
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-qqq}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?set RABBITMQ_PASSWORD}
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
ports:
|
||||
- "127.0.0.1:15672:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.22.3
|
||||
profiles: [full]
|
||||
ports:
|
||||
- "127.0.0.1:8025:8025"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8025/api/v1/info"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.2.5
|
||||
profiles: [full]
|
||||
networks:
|
||||
default:
|
||||
aliases: [keycloak.localhost]
|
||||
command: ["start-dev", "--import-realm"]
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD}
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HOSTNAME: http://keycloak.localhost:${KEYCLOAK_PORT:-8081}
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HTTP_PORT: ${KEYCLOAK_PORT:-8081}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||
DEMO_ADMIN_PASSWORD: ${DEMO_ADMIN_PASSWORD:?set DEMO_ADMIN_PASSWORD}
|
||||
DEMO_USER_PASSWORD: ${DEMO_USER_PASSWORD:?set DEMO_USER_PASSWORD}
|
||||
QQQ_ALL_PORT: ${QQQ_ALL_PORT:-8080}
|
||||
volumes:
|
||||
- ./infra/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
|
||||
ports:
|
||||
- "127.0.0.1:${KEYCLOAK_PORT:-8081}:${KEYCLOAK_PORT:-8081}"
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/127.0.0.1/9000"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
postgres-data:
|
||||
mysql-data:
|
||||
mongo-data:
|
||||
mongo-config:
|
||||
minio-data:
|
||||
sftp-data:
|
||||
opensearch-data:
|
||||
artemis-data:
|
||||
rabbitmq-data:
|
||||
+5
-5
@@ -8,7 +8,7 @@
|
||||
|
||||
**Depends on:** ESB epic QRun-IO/qqq#739 and Next-as-default QRun-IO/qqq#649, both in qqq 4.1.
|
||||
|
||||
**Licensing:** the owner selected Apache-2.0 for all first-party code under [QQQ #923](https://github.com/QRun-IO/qqq/issues/923). Complete the reviewed header/license alignment and verify published metadata before release. Preserve third-party licenses and attribution.
|
||||
**Licensing:** do not change LICENSE files or source headers in any repo. Header/license alignment (several repos still carry AGPL headers and LICENSE files) is a separate owner decision.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- Maven: never `mvn install` while other agents build; use `-pl … -am verify`.
|
||||
- QQQ code style and checkstyle of the target repo. New files in `qqq-all` use the Apache-2.0 header; in other repos copy a neighboring file's header.
|
||||
- No secrets in the repo. Demo credentials live only in `compose.yaml` and the Keycloak realm file, are marked demo-only, and are overridable by env.
|
||||
- The owner authorized signed commits, pushes, reviewed merges and publication after the release gates pass. Follow [the release procedure](release.md) and [#10](https://github.com/QRun-IO/qqq-all/issues/10); this authorization does not bypass required reviews or public-artifact checks.
|
||||
- Releases, tags, and Maven Central or GHCR publishing of release versions: ask James first.
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
Each:
|
||||
1. If `main` has commits not in `develop`, open a back-merge PR (`main` → `develop`) first. Resolve conflicts keeping develop's features and main's fixes; no force-push.
|
||||
2. Parent → `com.kingsrook:qbit-build-parent:2.0.0` (imports `qqq-bom-pom` 4.0.0). Remove child `qqq-bom-pom` imports that shadow the parent. Java 21. Apply the separately reviewed Apache-2.0 alignment under QQQ #923 while preserving third-party terms.
|
||||
2. Parent → `com.kingsrook:qbit-build-parent:2.0.0` (imports `qqq-bom-pom` 4.0.0). Remove child `qqq-bom-pom` imports that shadow the parent. Java 21. Leave LICENSE files and headers as they are.
|
||||
3. Build and test green on the parent's qqq 4.0.0. Also add an opt-in Maven profile `qqq-snapshot` that imports `qqq-bom-pom:${qqq.snapshot.version}` (default `4.1.0-SNAPSHOT`) ahead of the parent's BOM and adds the Central snapshots repository (`https://central.sonatype.com/repository/maven-snapshots/`); `-Pqqq-snapshot` must also build and test green. (The parent has no qqq-version property to override.) The move to a 4.1 parent happens at 4.1 GA (task C9).
|
||||
4. Integration tests that need Docker fail, not skip, when `CI=true`.
|
||||
5. Remove explicit qqq module `<version>`s in the child pom so the BOM (or the `qqq-snapshot` profile) controls them — `qbit-workflows` pins them explicitly on develop.
|
||||
@@ -95,7 +95,7 @@ Each:
|
||||
- README quick start (one command per profile) and a feature tour mapping each QQQ feature to where the demo shows it.
|
||||
- Limits: local demo and reference only; production needs DNS, TLS, backups, monitoring, HA brokers.
|
||||
|
||||
**C9. Publish (authorized after qqq 4.1 GA with ESB, accepted Next default and all release gates).**
|
||||
**C9. Publish (after qqq 4.1 GA with ESB and Next default; ask James first).**
|
||||
- Release a `qbit-build-parent` that imports the 4.1 BOM, re-pin the 8 qbits to it, and release them.
|
||||
- Pin qqq 4.1.x.
|
||||
- Tag `4.1.0`.
|
||||
@@ -133,4 +133,4 @@ Most qbit repos have issues disabled, so their tasks are tracked in QRun-IO/qqq.
|
||||
| Q6 | C5 | C4 |
|
||||
| Q7 | C6, C8 | C5 |
|
||||
| Q8 | C7 | C6; ESB epic complete |
|
||||
| Q9 | C9 | qqq 4.1 GA (ESB + accepted Next default); all #10 release gates |
|
||||
| Q9 | C9 | qqq 4.1 GA (ESB + Next default); James's go-ahead |
|
||||
@@ -1,46 +0,0 @@
|
||||
# 4.1.0 release preparation
|
||||
|
||||
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The GitHub manual release workflow (GHCR image plus Compose bundle, via the retired qqq-orb) was removed with the move to Gitea CI; `develop` images now publish to `git.kof22.com/qrun-io/qqq-all`. A versioned release path on Gitea is not defined yet.
|
||||
|
||||
## Exact coordinated RC under QQQ #921
|
||||
|
||||
The same workflow accepts only `4.1.0` (default GA path) or exact `4.1.0-RC.1`. For the [coordinated RC chain](https://github.com/QRun-IO/qqq/issues/921), first publish and verify the accepted Next candidate, QQQ `v4.1.0-RC.1`, and the corresponding build parent/eight QBits. Then review actual source pins: root `revision` and BOM `qqq.version` both `4.1.0-RC.1`, explicit accepted Next version, and immutable published QBit versions. No command-line version overrides substitute for source edits. The current RC8/snapshot pins are intentionally unchanged and cannot pass release validation. Acceptance limitations belong to the exact accepted pair; the RC8 notes below are historical evidence, not automatic approval for a newer pair.
|
||||
|
||||
Use the existing `release-central` profile and the parent/BOM-only command below to stage the exact RC POMs for **manual** Central review (`autoPublish=false`). Its enforcers accept only GA or exact RC1, require the QQQ pin to equal the source revision, and reject snapshot/range/LATEST dependency pins. After both POMs are public and the reviewed source is merged, require a successful CircleCI `verify-and-smoke` workflow with `build-and-test`, `core` and `full` on that exact develop SHA and an annotated signed tag named `4.1.0-RC.1` at it. The workflow requires GitHub's tag verification to report `verified=true`, `reason=valid`, and checks the exact local/remote tag object and commit. Public QQQ/Next release tags must match their pins and prerelease state; first-party BOM inputs (including recursive public parents) must resolve anonymously, with qbit-build-parent importing this QQQ version. The full reactor also rejects transitive snapshot dependencies. Public POM comparison includes the explicit Next pin. Existing GHCR visibility, protected environment, license/enable controls, destination-absence checks and live ref rechecks remain required.
|
||||
|
||||
When those gates and owner authorization are satisfied, dispatch **Release 4.1.0** on develop with `version=4.1.0-RC.1`, the full `release_sha`, `smoke_evidence=circle`, and its successful `circle_workflow_id`. RC publication uses `--prerelease --latest=false`; it never becomes the latest GA release. The default `version=4.1.0` still requires upstream non-prerelease GA and follows the separate GA requirements below. Do not dispatch while any pin or external prerequisite is pending.
|
||||
|
||||
## Image Compose bundle
|
||||
|
||||
The workflow attaches `qqq-all-<version>.tar.gz` and its rendered `compose.yaml`. Download/extract the **whole bundle**, retain the `infra` directory beside Compose, and follow its README. Both app profiles use the exact pushed GHCR digest and have no app build context. The bundle includes the tracked PostgreSQL/MySQL/Mongo initialization, Keycloak realm, MinIO Dockerfile, `.env.example`, LICENSE/NOTICE and `release.json` source/image identity. It excludes local `.env`, application source and Maven outputs. Full-profile MinIO still builds locally and needs network access; other service images are unchanged. The standalone Compose asset alone is insufficient for full-profile operation. Configure a unique Compose project and demo credentials, build only MinIO for full, then start with `--no-build`. Verify anonymous image pull and real core/full startup from the extracted bundle after publication; config-rendering tests are not runtime acceptance.
|
||||
|
||||
## Next RC8 integration
|
||||
|
||||
The stack BOM explicitly pins public `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.8`, independently of the framework snapshot's dashboard pin. Its [release source](https://github.com/QRun-IO/qqq-frontend-next/commit/cd3d4731d1910e68e994da9c74a8c28a2f1b7c1c) is `cd3d4731d1910e68e994da9c74a8c28a2f1b7c1c`; the public JAR SHA-256 is `238c9ae80beea336717be759219f0d65a1d614e8e7d6a145a16374cc392a73a1`. The owner [approved a QQQ RC with these documented limitations](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5930629521): Firefox/WebKit/tablet failures, incomplete report/query and input parity, sub-path hosting, real Google Drive verification, and visual approval. See the [RC8 release notes](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.8) for the retained failures and incomplete runs.
|
||||
|
||||
This approval applies to exact RC8 and does not turn those limitations into passing acceptance. The successor contracts `core.widget.data_bag_viewer`, `core.widget.pivot_table_setup`, `core.widget.filter_and_columns_setup`, `core.widget.row_builder`, `core.widget.script_viewer`, `core.widget.blocks`, and `dashboard.acceptance` remain pending and excluded from verified counts, with Next 1.0 final / QQQ 4.1 GA as the follow-up target. qqq-all's package and HTTP smoke checks verify integration and asset delivery, not browser parity. This repository remains a snapshot integration build; the separate GA procedure below and other release/security/public-consumer gates remain in force.
|
||||
|
||||
## Prerequisites and Maven artifacts
|
||||
|
||||
1. QQQ must publish a non-draft, non-prerelease `v4.1.0` GitHub release with ESB and the Next dashboard included. Release `qbit-build-parent` against the 4.1 BOM, then publish all eight qbits against that parent. This work occurs in their own repositories.
|
||||
2. In a separate reviewed change to this repository, set root `revision` and the BOM's `qqq.version` to `4.1.0`; replace the explicit Next RC8 pin with the accepted Next 1.0 final version and pin each qbit to a published non-snapshot version. Run `mvn -B -ntp verify`, the core and full smoke checks, and review the resolved dependencies and effective POMs. Do not use `-Drevision=4.1.0` against a snapshot source tree as a substitute for reviewed pins.
|
||||
3. Stage **both** `com.kingsrook.qqq:qqq-all-parent:4.1.0` and `com.kingsrook.qqq:qqq-all-bom:4.1.0` using the opt-in `release-central` Maven profile. The profile runs the release-version/dependency checks, signs the POMs with GPG, and uses the Central Publishing Maven plugin with `autoPublish=false`. It is only for the parent and BOM reactor; the app and BOM test JAR are excluded. POM packaging needs no source or Javadoc JARs. Configure the owner's `central` Portal user token in a private Maven `settings.xml` and a trusted GPG key outside the repository. Do not place credentials in this project or CI.
|
||||
|
||||
```bash
|
||||
mvn -B -ntp -pl qqq-all-bom -am -Prelease-central deploy
|
||||
```
|
||||
|
||||
The command uploads a deployment for **manual** Central Portal review; it does not auto-publish. Check the staged parent and BOM POM coordinates, signatures, license/SCM metadata, 4.1 QQQ import, and all eight qbit pins. Publish in the Portal only after the required reviews and release gates pass; the owner has already authorized this gated publication. The profile fails validation while any current snapshot version remains. `mvn deploy` without the profile is not a release path.
|
||||
4. Wait until both release POMs resolve anonymously from Maven Central. The release workflow fetches them from `repo.maven.apache.org` and compares their coordinates and BOM pins with its exact source commit. If either artifact is absent or differs, it stops before image publication.
|
||||
|
||||
The owner selected Apache-2.0 for first-party code in [QQQ #923](https://github.com/QRun-IO/qqq/issues/923). Complete its reviewed alignment and verify the published artifacts before enabling release, preserving third-party terms. Existing Apache-2.0 metadata alone does not prove that all first-party headers and artifacts are aligned. The owner must also bootstrap the `qqq-all` GHCR package and make it **Public** in package settings; first publication defaults private. The workflow checks public visibility and refuses to push a private release image.
|
||||
|
||||
## Exact commit and manual dispatch
|
||||
|
||||
After the release POMs are public, merge the version-pin change to `develop`. Require a successful webhook-triggered CircleCI `verify-and-smoke` workflow on the exact intended commit; `build-and-test`, `core` and `full` must all pass. Record its full SHA and workflow UUID. The shared verifier checks public Circle records against the latest trusted GitHub CircleCI check; it needs no exported Circle token for this public repository. Create an **annotated**, signed `4.1.0` tag at that exact `develop` commit. The workflow checks GitHub's valid signature verification, the tag object and its exact commit, including live remote refs. Do not move the tag or advance `develop` before dispatch.
|
||||
|
||||
Configure a protected `qqq-all-release` GitHub environment with no required human reviewers and branch restriction to `develop`. After Apache alignment and the publication preconditions pass, set environment variables `QQQ_ALL_RELEASE_LICENSE_APPROVED=true` and `QQQ_ALL_RELEASE_ENABLED=true`. Unset them to close the gate. These variables are automated release controls; enable them only after the documented artifact and license prerequisites are established.
|
||||
|
||||
Dispatch **Release 4.1.0** on `develop` with `version=4.1.0` (default), `release_sha` (the full 40-character commit SHA) `smoke_evidence=circle`, and `circle_workflow_id` (the successful Circle workflow UUID). The job requires that checkout, `develop`, the annotated signed tag, and the smoke run all refer to that SHA. It checks upstream GA, both public Central POMs and immutable upstream inputs, public GHCR visibility, and destination absence; verifies the reactor with a fresh public-only cache and rejects transitive snapshots; then rechecks the live `develop` and remote tag refs before publication. Only then does it build and push `ghcr.io/qrun-io/qqq-all:4.1.0` and create the GitHub `4.1.0` release with the Compose bundle and rendered `compose.yaml` attached.
|
||||
|
||||
The image push and GitHub release creation are separate API operations, not an atomic transaction. If the release step fails after the image push, inspect the image digest and workflow logs before recovery; never reuse or overwrite the version tag blindly. Verify the public image pull, release asset, and both Maven POMs from an unauthenticated environment after publication. No workflow in this preparation branch publishes or tags anything by itself.
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"realm": "qqq-all",
|
||||
"enabled": true,
|
||||
"displayName": "QQQ All Demo",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{"name": "admin", "description": "Full demo permissions"},
|
||||
{"name": "viewer", "description": "Read-only demo permissions"}
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "qqq-all",
|
||||
"name": "QQQ All Demo",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "${OIDC_CLIENT_SECRET}",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"redirectUris": ["http://localhost:${QQQ_ALL_PORT}/*", "http://127.0.0.1:${QQQ_ALL_PORT}/*"],
|
||||
"webOrigins": ["http://localhost:${QQQ_ALL_PORT}", "http://127.0.0.1:${QQQ_ALL_PORT}"],
|
||||
"defaultClientScopes": ["web-origins", "acr", "profile", "roles", "email"]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "demo-admin",
|
||||
"email": "admin@example.test",
|
||||
"firstName": "Demo",
|
||||
"lastName": "Admin",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"realmRoles": ["admin"],
|
||||
"credentials": [{"type": "password", "value": "${DEMO_ADMIN_PASSWORD}", "temporary": false}]
|
||||
},
|
||||
{
|
||||
"username": "demo-user",
|
||||
"email": "user@example.test",
|
||||
"firstName": "Demo",
|
||||
"lastName": "User",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"realmRoles": ["viewer"],
|
||||
"credentials": [{"type": "password", "value": "${DEMO_USER_PASSWORD}", "temporary": false}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
RUN git clone --depth 1 --branch RELEASE.2025-09-06T17-38-46Z https://github.com/minio/minio.git /src
|
||||
WORKDIR /src
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /minio .
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S -g 10001 minio \
|
||||
&& adduser -S -u 10001 -G minio minio \
|
||||
&& mkdir /data \
|
||||
&& chown minio:minio /data
|
||||
COPY --from=build /minio /usr/local/bin/minio
|
||||
USER 10001:10001
|
||||
EXPOSE 9000 9001
|
||||
ENTRYPOINT ["minio"]
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
for attempt in {1..30}; do
|
||||
if mongosh --host mongo --quiet --username="${MONGO_USER}" --password="${MONGO_PASSWORD}" --authenticationDatabase admin \
|
||||
--eval 'try { rs.status().ok } catch (e) { if (e.codeName === "NotYetInitialized") { rs.initiate({_id: process.env.MONGO_REPLICA_SET, members: [{_id: 0, host: "mongo:27017"}]}).ok } else { throw e } }' | grep -q 1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'Mongo replica set did not initialize' >&2
|
||||
exit 1
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s' "${MONGO_KEYFILE}" | base64 > /data/configdb/keyfile
|
||||
chmod 400 /data/configdb/keyfile
|
||||
chown mongodb:mongodb /data/configdb/keyfile
|
||||
exec docker-entrypoint.sh mongod --replSet "${MONGO_REPLICA_SET}" --bind_ip_all --auth --keyFile /data/configdb/keyfile
|
||||
@@ -1,6 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS supplier_order (
|
||||
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
reference VARCHAR(120) NOT NULL
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO supplier_order (id, reference) VALUES (1, 'DEMO-SUPPLIER-001');
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS warehouse_customer (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO warehouse_customer (id, name) VALUES (1, 'Demo Warehouse Customer')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,90 +0,0 @@
|
||||
-- C4's OAuth2 session tables and qbit-user-role-permissions tables.
|
||||
-- Names follow QInstanceEnricher's camelCase-to-snake_case backend mapping.
|
||||
CREATE TABLE IF NOT EXISTS user_session (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
uuid VARCHAR(40) UNIQUE,
|
||||
access_token TEXT,
|
||||
user_id VARCHAR(100)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redirect_state (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
state VARCHAR(45) UNIQUE,
|
||||
redirect_uri VARCHAR(4096),
|
||||
create_date TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
email VARCHAR(250),
|
||||
full_name VARCHAR(250)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description VARCHAR(500)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
name VARCHAR(250) NOT NULL UNIQUE,
|
||||
description VARCHAR(500),
|
||||
object_type VARCHAR(50),
|
||||
object_label VARCHAR(250)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_permission_int (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
role_id INTEGER NOT NULL REFERENCES role(id),
|
||||
permission_id INTEGER NOT NULL REFERENCES permission(id),
|
||||
UNIQUE (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_role_int (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
user_id INTEGER NOT NULL REFERENCES "user"(id),
|
||||
role_id INTEGER NOT NULL REFERENCES role(id),
|
||||
UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permission_int (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
user_id INTEGER NOT NULL REFERENCES "user"(id),
|
||||
permission_id INTEGER NOT NULL REFERENCES permission(id),
|
||||
UNIQUE (user_id, permission_id)
|
||||
);
|
||||
|
||||
INSERT INTO role (name, description) VALUES
|
||||
('admin', 'All QQQ All demo tables'),
|
||||
('viewer', 'Read-only QQQ All demo tables')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
SELECT table_name || '.' || action, 'table', table_name
|
||||
FROM (VALUES
|
||||
('customer'), ('order'), ('orderLine'), ('product'),
|
||||
('warehouseCustomer'), ('supplierOrder'), ('shipment'), ('document'), ('importFile')
|
||||
) AS tables(table_name)
|
||||
CROSS JOIN (VALUES ('read'), ('insert'), ('edit'), ('delete')) AS actions(action)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE role.name = 'admin' OR (role.name = 'viewer' AND permission.name LIKE '%.read')
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Add the webhook qbit's protected resources to the demo OIDC roles.
|
||||
-- Runs after C6's 02-auth-and-roles.sql during PostgreSQL initialization.
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
SELECT table_name || '.' || action, 'table', table_name
|
||||
FROM (VALUES
|
||||
('webhook'), ('webhookSubscription'), ('webhookEvent'),
|
||||
('webhookEventContent'), ('webhookEventSendLog'),
|
||||
('scheduledJob'), ('scheduledJobParameter')
|
||||
) AS tables(table_name)
|
||||
CROSS JOIN (VALUES ('read'), ('insert'), ('edit'), ('delete')) AS actions(action)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
SELECT process_name || '.hasAccess', 'process', process_name
|
||||
FROM (VALUES
|
||||
('SendWebhookEvent'), ('SendTestEventToWebhook'), ('ManageWebhookHealth')
|
||||
) AS processes(process_name)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE (role.name = 'admin' OR (role.name = 'viewer' AND permission.name LIKE '%.read'))
|
||||
AND (permission.name LIKE 'webhook.%'
|
||||
OR permission.name LIKE 'webhookSubscription.%'
|
||||
OR permission.name LIKE 'webhookEvent.%'
|
||||
OR permission.name LIKE 'webhookEventContent.%'
|
||||
OR permission.name LIKE 'webhookEventSendLog.%'
|
||||
OR permission.name LIKE 'scheduledJob.%'
|
||||
OR permission.name LIKE 'scheduledJobParameter.%'
|
||||
OR permission.name IN ('SendWebhookEvent.hasAccess',
|
||||
'SendTestEventToWebhook.hasAccess', 'ManageWebhookHealth.hasAccess'))
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Give the full-profile demo admin service-level ESB visibility and controls.
|
||||
The viewer role keeps table READ without broker-wide message access. */
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
VALUES
|
||||
('esbView.hasAccess', 'app', 'ESB'),
|
||||
('esbOperate.hasAccess', 'process', 'ESB operations'),
|
||||
('esbDelete.hasAccess', 'process', 'ESB message deletion')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE role.name = 'admin'
|
||||
AND permission.name IN ('esbView.hasAccess', 'esbOperate.hasAccess', 'esbDelete.hasAccess')
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Read-only API catalog, transient notes, and the fixed JavaScript calculation.
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
VALUES
|
||||
('apiCatalog.read', 'table', 'API Catalog'),
|
||||
('demoNote.read', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.insert', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.edit', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.delete', 'table', 'Demo Note (Transient)'),
|
||||
('calculateOrderTotal.hasAccess', 'process', 'Calculate Order Total (JavaScript)')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE permission.name IN ('apiCatalog.read', 'demoNote.read', 'demoNote.insert',
|
||||
'demoNote.edit', 'demoNote.delete', 'calculateOrderTotal.hasAccess')
|
||||
AND (role.name = 'admin' OR (role.name = 'viewer' AND permission.name IN ('apiCatalog.read', 'demoNote.read')))
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Quick Search 1.x protects its admin processes (quickSearchFullReindex and friends) and its
|
||||
-- admin app with the quick_search_admin permission; the full-profile admin runs the reindex.
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
VALUES
|
||||
('quick_search_admin.hasAccess', 'process', 'Quick Search Admin')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE permission.name = 'quick_search_admin.hasAccess'
|
||||
AND role.name = 'admin'
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -1,180 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>QQQ All</name>
|
||||
<description>QQQ with every feature configured and working out of the box: a tested stack BOM and a reference application.</description>
|
||||
<url>https://github.com/QRun-IO/qqq-all</url>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
<scm>
|
||||
<connection>scm:git:https://github.com/QRun-IO/qqq-all.git</connection>
|
||||
<developerConnection>scm:git:ssh://git@github.com/QRun-IO/qqq-all.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qqq-all</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook, LLC</name>
|
||||
<email>pub@qrun.io</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://qrun.io</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<modules>
|
||||
<module>qqq-all-bom</module>
|
||||
<module>qqq-all-bom-test</module>
|
||||
<module>qqq-all-app</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<!-- qqq-all versions track QQQ versions. -->
|
||||
<revision>4.1.0-SNAPSHOT</revision>
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<!-- Select only the parent and BOM reactor: mvn -pl qqq-all-bom -am -Prelease-central deploy. -->
|
||||
<profile>
|
||||
<id>release-central</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>require-release-revision</id>
|
||||
<phase>validate</phase>
|
||||
<goals><goal>enforce</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireProperty>
|
||||
<property>revision</property>
|
||||
<regex>^4[.]1[.]0(-RC[.]1)?$</regex>
|
||||
<message>Central release requires reviewed 4.1.0 or exact 4.1.0-RC.1 source pins.</message>
|
||||
</requireProperty>
|
||||
<requireReleaseDeps/>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.2.8</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-release-poms</id>
|
||||
<phase>verify</phase>
|
||||
<goals><goal>sign</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.10.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>false</autoPublish>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<repositories>
|
||||
<!-- QQQ 4.1.0-SNAPSHOT and other QRun-IO snapshots are published here until 4.1 ships. -->
|
||||
<repository>
|
||||
<id>central-portal-snapshots</id>
|
||||
<name>Sonatype Central Portal Snapshots</name>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
<updatePolicy>always</updatePolicy>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<!-- Pinned because CI's system Maven (3.6.3) defaults to plugins that predate maven.compiler.release and JUnit 5. -->
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
<plugins>
|
||||
<!-- Resolve ${revision} in installed and deployed POMs (CI sets it per branch). -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>flatten-maven-plugin</artifactId>
|
||||
<version>1.7.3</version>
|
||||
<configuration>
|
||||
<updatePomFile>true</updatePomFile>
|
||||
<flattenMode>resolveCiFriendliesOnly</flattenMode>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>flatten</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>flatten</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>flatten.clean</id>
|
||||
<phase>clean</phase>
|
||||
<goals>
|
||||
<goal>clean</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -1,134 +0,0 @@
|
||||
# Core reference app
|
||||
|
||||
Build with Java 21 and Maven, then start the standalone core profile:
|
||||
|
||||
```sh
|
||||
mvn -pl qqq-all-app -am verify
|
||||
java -jar qqq-all-app/target/qqq-all-app.jar
|
||||
```
|
||||
|
||||
The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`.
|
||||
|
||||
`QQQ_ALL_BIND_HOST` can select another interface when intentionally running behind a container port mapping. The Compose core service sets it to `0.0.0.0` inside the container while publishing only on host loopback.
|
||||
|
||||
Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. Admin can read and write the customer, order, order-line, product, and address tables and open the ESB overview at `/qqq/v1/esb/overview`; Demo User can read those tables but cannot write them or open the ESB overview. These identities are for local demonstrations. Quick search is disabled in core because it requires OpenSearch; the customer list remains available through the normal table query API.
|
||||
|
||||
The core demo seeds a customer table view, a shipping address linked to the geo country/state/city tables, and an order review workflow. The `RunRecordWorkflow` process can run that workflow against an order; `syncOrder` writes a process trace after an order event. It also seeds an active `orderStored` webhook subscription to its receiver at `/demo/order-webhook`. Insert an order as Admin, run `SendWebhookEvent` as Admin, and inspect `/demo/order-webhook-receipts` to see the delivered payload. Demo User can read webhook records but cannot edit their configuration or invoke the delivery process. The receiver is available only in the core profile, which Compose publishes on host loopback.
|
||||
|
||||
## Enterprise profile: the assistant
|
||||
|
||||
`QQQ_ALL_PROFILE=enterprise` (the terminal-client test server, started by qqq-cli's
|
||||
`scripts/enterprise-server.sh`) installs qbit-assistant with two model provider rows, reconciled at every
|
||||
start: **Anthropic (Claude)** (key variable `ANTHROPIC_API_KEY`; only the name is stored) and **Local
|
||||
Ollama** (OpenAI-compatible, kind OLLAMA, `QQQ_ASSISTANT_OLLAMA_URL` default `http://127.0.0.1:11434/v1`,
|
||||
model `QQQ_ASSISTANT_OLLAMA_MODEL` default `llama3.3:70b-instruct-q4_K_M`, no key). The default is
|
||||
Anthropic when `ANTHROPIC_API_KEY` is set, else Ollama when it answers and lists the model, else neither
|
||||
(the assistant reports `NOT_CONFIGURED` with what to configure). Every enterprise session holds
|
||||
`assistant.hasAccess` and `assistant.admin`. A turn may take 900 s and one Ollama call 600 s, for a local
|
||||
70B model.
|
||||
|
||||
## Full profile
|
||||
|
||||
Set `QQQ_ALL_PROFILE=full` to use external services. The full profile requires an explicit `QQQ_ALL_BIND_HOST`; use `127.0.0.1` for local-only access or deliberately set another interface when a reverse proxy needs it. `QQQ_ALL_PORT` defaults to `8080`. The full profile replaces core's mock authentication with Keycloak OIDC. It does not start an embedded broker.
|
||||
|
||||
Configure the following environment variables before starting the same jar:
|
||||
|
||||
| Service | Required variables | Optional variables (default) |
|
||||
| --- | --- | --- |
|
||||
| PostgreSQL | `QQQ_ALL_POSTGRES_HOST`, `QQQ_ALL_POSTGRES_DATABASE`, `QQQ_ALL_POSTGRES_USER`, `QQQ_ALL_POSTGRES_PASSWORD` | `QQQ_ALL_POSTGRES_PORT` (5432) |
|
||||
| MySQL | `QQQ_ALL_MYSQL_HOST`, `QQQ_ALL_MYSQL_DATABASE`, `QQQ_ALL_MYSQL_USER`, `QQQ_ALL_MYSQL_PASSWORD` | `QQQ_ALL_MYSQL_PORT` (3306) |
|
||||
| MongoDB replica set | `QQQ_ALL_MONGO_HOST`, `QQQ_ALL_MONGO_DATABASE`, `QQQ_ALL_MONGO_USER`, `QQQ_ALL_MONGO_PASSWORD`, `QQQ_ALL_MONGO_REPLICA_SET` | `QQQ_ALL_MONGO_PORT` (27017), `QQQ_ALL_MONGO_AUTH_DATABASE` (admin) |
|
||||
| MinIO S3 | `QQQ_ALL_S3_ENDPOINT`, `QQQ_ALL_S3_BUCKET`, `QQQ_ALL_S3_ACCESS_KEY`, `QQQ_ALL_S3_SECRET_KEY` | `QQQ_ALL_S3_REGION` (us-east-1) |
|
||||
| SFTP | `QQQ_ALL_SFTP_HOST`, `QQQ_ALL_SFTP_USER`, `QQQ_ALL_SFTP_PASSWORD` | `QQQ_ALL_SFTP_PORT` (22), `QQQ_ALL_SFTP_BASE_PATH` (/upload) |
|
||||
| ESB | `QQQ_ALL_ARTEMIS_URL`, `QQQ_ALL_ARTEMIS_USER`, `QQQ_ALL_ARTEMIS_PASSWORD`, `QQQ_ALL_ARTEMIS_MANAGEMENT_URL`, `QQQ_ALL_RABBITMQ_URL`, `QQQ_ALL_RABBITMQ_USER`, `QQQ_ALL_RABBITMQ_PASSWORD`, `QQQ_ALL_RABBITMQ_MANAGEMENT_URL` | — |
|
||||
| Mailpit SMTP | `QQQ_ALL_SMTP_HOST` | `QQQ_ALL_SMTP_PORT` (1025) |
|
||||
| Keycloak OIDC | `QQQ_ALL_OIDC_BASE_URL`, `QQQ_ALL_OIDC_CLIENT_ID`, `QQQ_ALL_OIDC_CLIENT_SECRET` | `QQQ_ALL_OIDC_EXTERNAL_BASE_URL` (base URL), `QQQ_ALL_OIDC_SCOPES` (`openid email profile`) |
|
||||
| OpenSearch quick search | `QQQ_ALL_OPENSEARCH_HOST` | `QQQ_ALL_OPENSEARCH_PORT` (9200), `QQQ_ALL_OPENSEARCH_INDEX` (`qqq-all-customers`), `QQQ_ALL_OPENSEARCH_USER`, `QQQ_ALL_OPENSEARCH_PASSWORD`, `QQQ_ALL_OPENSEARCH_SSL` (false) |
|
||||
|
||||
The full app adds sample tables on each external backend, `orderEvents` on Artemis, and `orderSyncEvents` on RabbitMQ. Quick search is configured for customer `name` and `email`; run `quickSearchFullReindex` after OpenSearch and the customer data are ready. The SFTP integration adds its import source and staging tables plus `SFTPImportFileSyncProcess`; `externalImportFile` remains the direct SFTP file sample. Keycloak realm roles resolve by name against the PostgreSQL `role` table from `qbit-user-role-permissions`, then become QQQ session permissions. Unknown roles receive no permissions. Webhook tables and delivery processes require explicit permissions: the viewer role receives read access, while the admin role can edit destinations and run delivery. The Compose profile provisions demo SQL tables, OAuth session and redirect-state tables, qbit permission tables and roles, and the MinIO bucket on first startup; `infra/postgres/03-webhook-permissions.sql` adds webhook grants after the role seed.
|
||||
|
||||
The full-profile admin receives ESB service visibility, queue controls, and message deletion through `infra/postgres/04-esb-permissions.sql`; the viewer role does not. The full smoke checks the RabbitMQ process-completion event through QQQ's authenticated ESB message endpoint, which decodes the broker's JMS message. Fresh PostgreSQL volumes apply this SQL automatically. For a running full-profile stack with an existing volume, apply the idempotent grant file once:
|
||||
|
||||
```bash
|
||||
COMPOSE_PROFILES=full docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < infra/postgres/04-esb-permissions.sql
|
||||
```
|
||||
|
||||
Then sign in as Admin and open the ESB overview. The Viewer role must remain unable to open it.
|
||||
|
||||
On startup, the full demo creates its remaining PostgreSQL qbit tables from the registered metadata and checks every expected column and type. The hand-maintained domain, OAuth, and role tables stay under `infra/postgres`. If a persisted qbit table is missing a required column or has a different type, startup fails with a migration/reset message instead of silently accepting it. The full-profile RabbitMQ completion-event smoke requires QQQ's process publishing and permission-scoped ESB message endpoint; the broker management smoke below can run independently.
|
||||
|
||||
After starting the full Compose profile, run the opt-in broker management smoke test with the same broker credentials. For the supplied demo environment:
|
||||
|
||||
```sh
|
||||
set -a
|
||||
. ./.env.example
|
||||
set +a
|
||||
QQQ_ALL_COMPOSE_BROKERS=true mvn -pl qqq-all-app -am -Dtest=FullProfileBrokerIntegrationTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
The test uses the loopback management ports to read and pause/resume the Artemis subscription queue, then creates, reads, purges, and deletes a temporary RabbitMQ queue. For a customized `.env`, source that file instead. It is skipped during ordinary `mvn verify`.
|
||||
|
||||
## Local runtime demonstrations (#21)
|
||||
|
||||
Both profiles include the BOM-managed API backend and JavaScript module. The shaded
|
||||
JAR retains Nashorn's `ScriptEngineFactory` service registration. No extra service,
|
||||
API key, script editor, or user-supplied code is needed.
|
||||
|
||||
| Demo | Behavior | Access |
|
||||
| --- | --- | --- |
|
||||
| `apiCatalog` | QQQ API-backed table reads three synthetic products from this app's `/demo/catalog` HTTP endpoint; native queries page through the endpoint and get-by-ID uses `/demo/catalog/{id}`. | Admin and Demo/Viewer can read; neither receives write grants. |
|
||||
| `calculateOrderTotal` | Executes fixed JavaScript through `ExecuteCodeAction`: quantity × unit price in cents → `totalCents`. It changes no records. | Admin only. |
|
||||
| `demoNote` | Memory-backed table, seeded with `Transient demo note` on every start and cleared on shutdown. | Admin can edit; Demo/Viewer can read. |
|
||||
|
||||
The internal catalog URL follows `QQQ_ALL_BIND_HOST` and the configured HTTP port.
|
||||
IPv4/IPv6 wildcard listeners use their corresponding loopback address; literal
|
||||
IPv6 hosts are bracketed correctly in the URL.
|
||||
|
||||
The catalog endpoint exposes **only synthetic public demo data**. It is not an
|
||||
outbound proxy and does not forward session credentials. The adapter supports
|
||||
ascending ID order, limit/skip pagination, and one exact ID filter; unsupported
|
||||
filters and sorts fail explicitly. Native get of an absent ID returns no record.
|
||||
The endpoint rejects invalid or excessive page sizes (maximum 1000).
|
||||
|
||||
With the core JAR running on port 8080, exercise the real QQQ routes:
|
||||
|
||||
```sh
|
||||
curl -fsS -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:8080/qqq/v1/table/apiCatalog/query
|
||||
curl -fsS http://127.0.0.1:8080/data/apiCatalog/103
|
||||
curl -fsS -H 'Cookie: sessionId=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -H 'Content-Type: application/json' -d '{}' 'http://127.0.0.1:8080/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250'
|
||||
```
|
||||
|
||||
The last response contains `values.totalCents: 3750`. Quantity must be an integer
|
||||
in 1..1000; unit price must be an integer in 0..1000000 cents. The process does not
|
||||
accept code from the caller. This demonstration does not alter stored-script or
|
||||
Test-mode behavior.
|
||||
|
||||
Memory notes are deliberately **not persisted** with the data directory or a
|
||||
Compose volume. Restarting discards edits and restores the seed. Like the existing
|
||||
QQQ memory and ESB runtime singletons, this demo assumes one application per JVM.
|
||||
The memory lifecycle touches only `demoNote`; it never resets the global store.
|
||||
|
||||
Full-profile PostgreSQL now uses QQQ's first-party `PostgreSQLBackendMetaData` and
|
||||
strategy, including driver/URL selection, quoted identifiers, default-value
|
||||
inserts, generated IDs, null binding, and UTC timestamp handling. Existing qbit
|
||||
schema provisioning and compatibility checks remain in place.
|
||||
|
||||
Fresh full-profile volumes apply `infra/postgres/05-runtime-demo-permissions.sql`.
|
||||
For an existing volume, apply the idempotent grants and then sign in again:
|
||||
|
||||
```sh
|
||||
COMPOSE_PROFILES=full docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < infra/postgres/05-runtime-demo-permissions.sql
|
||||
```
|
||||
|
||||
Quick Search 1.x protects `quickSearchFullReindex` and its other admin processes with
|
||||
`quick_search_admin.hasAccess`; fresh volumes grant it to the admin role through
|
||||
`infra/postgres/06-quick-search-permissions.sql`. Apply that file the same way on an existing volume.
|
||||
|
||||
`mvn -pl qqq-all-app -am verify` includes native local API, calculation/permission,
|
||||
memory restart, and packaged-JAR checks. `scripts/run_core_smoke.py` and
|
||||
`scripts/run_full_smoke.py` verify these routes with actual Admin and restricted
|
||||
sessions; full smoke also retains the OIDC, qbit, and broker checks. The opt-in
|
||||
`PostgresCompatibilityTest` uses `QQQ_ALL_TEST_POSTGRES_PORT` and
|
||||
`QQQ_ALL_TEST_POSTGRES_PASSWORD` against a local `qqq` database/user; it creates and
|
||||
drops only a uniquely named test table. It is skipped during ordinary verify.
|
||||
All eight included qbits, their explicit deferrals, and release gates are unchanged.
|
||||
@@ -1,146 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qqq-all-app</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QQQ All - Reference Application</name>
|
||||
<description>Reference QQQ application with every backend, qBit, and the ESB wired, plus seeded demo data.</description>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-bom</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-api</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-language-support-javascript</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-postgres</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-mongodb</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-sqlite</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-javalin</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-health</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-esb</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-user-role-permissions</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-quick-search</artifactId><version>1.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-customizable-table-views</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-standard-process-trace</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-webhooks</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-workflows</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-geo-data</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-sftp-data-integration</artifactId></dependency>
|
||||
<!-- Enterprise profile (QQQ_ALL_PROFILE=enterprise): every locally runnable qbit, for terminal-client testing. -->
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-alerts</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant-provider-anthropic</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-assistant-provider-openai</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-backups</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-cdn</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-cluster</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-collaboration</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-data-analysis</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-db-performance</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-email</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-file-gateway</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-help</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-jobs</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-object-storage</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-qa-capture</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-redis</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-release-notes</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-secured-data</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-session-store</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-slack</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-sql-workbench</artifactId><version>1.0.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-worm-audit</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId></dependency>
|
||||
<dependency><groupId>org.xerial</groupId><artifactId>sqlite-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.apache.artemis</groupId><artifactId>artemis-jakarta-server</artifactId><version>2.57.0</version></dependency>
|
||||
<dependency><groupId>com.rabbitmq.jms</groupId><artifactId>rabbitmq-jms</artifactId></dependency>
|
||||
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.2</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>qqq-all-app</finalName>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>${project.basedir}/..</directory>
|
||||
<targetPath>META-INF/qqq-all</targetPath>
|
||||
<filtering>false</filtering>
|
||||
<includes>
|
||||
<include>LICENSE</include>
|
||||
<include>NOTICE</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.kingsrook.qqq.all.ProfileLauncher</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes><exclude>META-INF/*.SF</exclude><exclude>META-INF/*.DSA</exclude><exclude>META-INF/*.RSA</exclude></excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<qqqAllJar>${project.build.directory}/${project.build.finalName}.jar</qqqAllJar>
|
||||
<qqqAllRoot>${project.basedir}/..</qqqAllRoot>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals><goal>integration-test</goal><goal>verify</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.scripts.ExecuteCodeAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
|
||||
|
||||
/** Execute a fixed, side-effect-free JavaScript calculation through QQQ. */
|
||||
public class CalculateOrderTotalStep implements BackendStep
|
||||
{
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer quantity = input.getValueInteger("quantity");
|
||||
Integer unitPriceCents = input.getValueInteger("unitPriceCents");
|
||||
if(quantity == null || quantity < 1 || quantity > 1000
|
||||
|| unitPriceCents == null || unitPriceCents < 0 || unitPriceCents > 1_000_000)
|
||||
{
|
||||
throw new QException("Supply quantity 1..1000 and unitPriceCents 0..1000000");
|
||||
}
|
||||
ExecuteCodeOutput result = new ExecuteCodeOutput();
|
||||
new ExecuteCodeAction().run(new ExecuteCodeInput()
|
||||
.withCodeReference(new QCodeReference().withCodeType(QCodeType.JAVA_SCRIPT)
|
||||
.withInlineCode("return quantity * unitPriceCents;"))
|
||||
.withInput(Map.of("quantity", quantity, "unitPriceCents", unitPriceCents)), result);
|
||||
output.addValue("totalCents", ((Number) result.getOutput()).intValue());
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/** Creates the small, repeatable core demo data set before the web server starts. */
|
||||
final class CoreData
|
||||
{
|
||||
private CoreData()
|
||||
{
|
||||
}
|
||||
|
||||
static String h2DatabaseName(Path dataDirectory)
|
||||
{
|
||||
return dataDirectory.resolve("h2/qqq-all").toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
static Path sqlitePath(Path dataDirectory)
|
||||
{
|
||||
return dataDirectory.resolve("sqlite/qqq-all.db");
|
||||
}
|
||||
|
||||
static void seed(Path dataDirectory) throws IOException, SQLException
|
||||
{
|
||||
Files.createDirectories(dataDirectory.resolve("h2"));
|
||||
Files.createDirectories(dataDirectory.resolve("sqlite"));
|
||||
Files.createDirectories(dataDirectory.resolve("files/products"));
|
||||
|
||||
try(Connection connection = DriverManager.getConnection(
|
||||
"jdbc:h2:file:" + h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS customer (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(120), email VARCHAR(200))");
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, order_no VARCHAR(40), customer_id INTEGER, status VARCHAR(40))");
|
||||
statement.execute("MERGE INTO customer (id, name, email) KEY (id) VALUES (1, 'Ada Lovelace', 'ada@example.test'), (2, 'Grace Hopper', 'grace@example.test')");
|
||||
statement.execute("MERGE INTO orders (id, order_no, customer_id, status) KEY (id) VALUES (1, 'ORD-1001', 1, 'NEW')");
|
||||
}
|
||||
|
||||
try(Connection connection = DriverManager.getConnection("jdbc:sqlite:" + sqlitePath(dataDirectory));
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS order_line (id INTEGER PRIMARY KEY, order_id INTEGER, product_id INTEGER, quantity INTEGER)");
|
||||
statement.execute("INSERT OR IGNORE INTO order_line (id, order_id, product_id, quantity) VALUES (1, 1, 1, 2)");
|
||||
}
|
||||
|
||||
Path product = dataDirectory.resolve("files/products/1.json");
|
||||
if(Files.notExists(product))
|
||||
{
|
||||
Files.writeString(product, "{\"id\":1,\"name\":\"QRun Starter Kit\",\"price\":29.00}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.esb.runtime.QEsbRuntime;
|
||||
|
||||
/** Launches the ESB subscriber through the common QQQ application launcher. */
|
||||
public class CoreEsbRuntimeService implements QRuntimeServiceInterface
|
||||
{
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "coreEsb";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(QInstance instance)
|
||||
{
|
||||
QEsbRuntime.getInstance().start(instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
QEsbRuntime.getInstance().stop();
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookSubscriptionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import org.apache.activemq.artemis.core.config.Configuration;
|
||||
import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl;
|
||||
import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ;
|
||||
|
||||
/** Owns the embedded broker and launcher as one closeable core profile. */
|
||||
final class CoreRuntime implements AutoCloseable
|
||||
{
|
||||
private final EmbeddedActiveMQ broker;
|
||||
private final QApplicationLauncher launcher;
|
||||
private final Thread shutdownHook;
|
||||
|
||||
private CoreRuntime(EmbeddedActiveMQ broker, QApplicationLauncher launcher)
|
||||
{
|
||||
this.broker = broker;
|
||||
this.launcher = launcher;
|
||||
this.shutdownHook = new Thread(this::close, "qqq-all-core-shutdown");
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
|
||||
static CoreRuntime launch(Path dataDirectory, Integer port) throws Exception
|
||||
{
|
||||
return launch(dataDirectory, port, "127.0.0.1");
|
||||
}
|
||||
|
||||
static CoreRuntime launch(Path dataDirectory, Integer port, String bindHost) throws Exception
|
||||
{
|
||||
CoreData.seed(dataDirectory);
|
||||
Integer brokerPort = findFreePort();
|
||||
Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath();
|
||||
String brokerUrl = "tcp://127.0.0.1:" + brokerPort;
|
||||
DemoOrderWebhookReceiver.clear();
|
||||
DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost).defineQInstance(), port);
|
||||
Configuration configuration = new ConfigurationImpl()
|
||||
.setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false)
|
||||
.setBindingsDirectory(brokerDirectory.resolve("bindings").toString())
|
||||
.setJournalDirectory(brokerDirectory.resolve("journal").toString())
|
||||
.setPagingDirectory(brokerDirectory.resolve("paging").toString())
|
||||
.setLargeMessagesDirectory(brokerDirectory.resolve("large-messages").toString())
|
||||
.addAcceptorConfiguration("tcp", brokerUrl);
|
||||
EmbeddedActiveMQ broker = new EmbeddedActiveMQ().setConfiguration(configuration);
|
||||
broker.start();
|
||||
try
|
||||
{
|
||||
QApplicationLauncher launcher = QApplicationLauncher.run(
|
||||
new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost),
|
||||
new QApplicationLauncherConfig().withRegisterShutdownHook(false)
|
||||
.withServerCustomizer(server -> server.withPort(port)
|
||||
.withJavalinConfigCustomizer(config -> config.jetty.host = bindHost)));
|
||||
QContext.init(launcher.getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
WebhookSubscriptionsHelper.clearMemoizations();
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
EnterpriseAssistant.reconcileAtStartup(launcher.getQInstance());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
return new CoreRuntime(broker, launcher);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
broker.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer findFreePort() throws IOException
|
||||
{
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
QApplicationLauncher getLauncher()
|
||||
{
|
||||
return launcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
launcher.stop();
|
||||
try
|
||||
{
|
||||
broker.stop();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalStateException("Could not stop embedded Artemis", e);
|
||||
}
|
||||
if(Thread.currentThread() != shutdownHook)
|
||||
{
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
|
||||
/** The local catalog supports ascending ID pages and optional exact ID selection. */
|
||||
public class DemoCatalogApi extends BaseAPIActionUtil
|
||||
{
|
||||
@Override
|
||||
public String buildUrlSuffixForSingleRecordGet(Serializable primaryKey) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return "/" + Integer.parseInt(String.valueOf(primaryKey));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw new QException("Invalid catalog ID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Integer getApiStandardLimit()
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildQueryStringForGet(QQueryFilter filter, Integer limit, Integer skip, Map<String, QFieldMetaData> fields) throws QException
|
||||
{
|
||||
String query = "?limit=" + (limit == null ? 2 : limit) + "&skip=" + (skip == null ? 0 : skip);
|
||||
if(filter != null)
|
||||
{
|
||||
if(!filter.getSubFilters().isEmpty() || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND
|
||||
|| filter.getCriteria().size() > 1 || filter.getOrderBys().stream().anyMatch(order -> !"id".equals(order.getFieldName()) || !order.getIsAscending()))
|
||||
{
|
||||
throw new QException("Demo catalog supports ascending ID order and one exact ID filter only");
|
||||
}
|
||||
if(!filter.getCriteria().isEmpty())
|
||||
{
|
||||
var criterion = filter.getCriteria().getFirst();
|
||||
if(!"id".equals(criterion.getFieldName()) || criterion.getOperator() != QCriteriaOperator.EQUALS || criterion.getValues().size() != 1)
|
||||
{
|
||||
throw new QException("Demo catalog supports one exact ID filter only");
|
||||
}
|
||||
try
|
||||
{
|
||||
query += "&id=" + Integer.parseInt(String.valueOf(criterion.getValues().getFirst()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw new QException("Invalid catalog ID", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CountOutput doCount(QTableMetaData table, CountInput input) throws QException
|
||||
{
|
||||
QQueryFilter filter = input.getFilter() == null ? new QQueryFilter() : input.getFilter().clone();
|
||||
filter.setLimit(null);
|
||||
filter.setSkip(null);
|
||||
return new CountOutput().withCount(doQuery(table, new QueryInput(table.getName()).withFilter(filter)).getRecords().size());
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import static io.javalin.apibuilder.ApiBuilder.get;
|
||||
|
||||
/** Synthetic public supplier data, owned by the same server as the reference app. */
|
||||
public class DemoCatalogRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final List<Map<String, Object>> CATALOG = List.of(
|
||||
Map.of("id", 101, "name", "Starter Kit", "unitPriceCents", 2500),
|
||||
Map.of("id", 102, "name", "Refill Pack", "unitPriceCents", 1250),
|
||||
Map.of("id", 103, "name", "Shipping Box", "unitPriceCents", 300));
|
||||
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.apiBuilder(() ->
|
||||
{
|
||||
get("/demo/catalog/{id}", context ->
|
||||
{
|
||||
try
|
||||
{
|
||||
int id = Integer.parseInt(context.pathParam("id"));
|
||||
var row = CATALOG.stream().filter(item -> Integer.valueOf(id).equals(item.get("id"))).findFirst();
|
||||
if(row.isPresent())
|
||||
{
|
||||
context.json(row.get());
|
||||
}
|
||||
else
|
||||
{
|
||||
context.status(404);
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
context.status(400);
|
||||
}
|
||||
});
|
||||
get("/demo/catalog", context ->
|
||||
{
|
||||
try
|
||||
{
|
||||
int skip = Integer.parseInt(context.queryParamAsClass("skip", String.class).getOrDefault("0"));
|
||||
int limit = Integer.parseInt(context.queryParamAsClass("limit", String.class).getOrDefault("2"));
|
||||
Integer id = context.queryParam("id") == null ? null : Integer.valueOf(context.queryParam("id"));
|
||||
if(skip < 0 || limit < 1 || limit > 1000)
|
||||
{
|
||||
context.status(400);
|
||||
return;
|
||||
}
|
||||
context.json(CATALOG.stream().filter(row -> id == null || id.equals(row.get("id")))
|
||||
.skip(skip).limit(limit).toList());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
context.status(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookEventTypeCustomizerInterface;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
/** Builds a self-contained demo payload without requiring a separate public API definition. */
|
||||
public class DemoOrderWebhookContent implements WebhookEventTypeCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public WebhookEventContent buildEventContent(QRecord record, String eventType, String apiName, String apiVersion)
|
||||
{
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("eventType", eventType);
|
||||
body.put("orderNo", record.getValueString("orderNo"));
|
||||
body.put("orderId", record.getValueInteger("id"));
|
||||
return new WebhookEventContent().withPostBody(JsonUtils.toJson(body));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import static io.javalin.apibuilder.ApiBuilder.get;
|
||||
import static io.javalin.apibuilder.ApiBuilder.post;
|
||||
|
||||
/** Local receiver that makes successful order webhook delivery visible in the core demo. */
|
||||
public class DemoOrderWebhookReceiver implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final List<String> RECEIPTS = new CopyOnWriteArrayList<>();
|
||||
|
||||
static void clear()
|
||||
{
|
||||
RECEIPTS.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.apiBuilder(() ->
|
||||
{
|
||||
post("/demo/order-webhook", context ->
|
||||
{
|
||||
String body = context.body();
|
||||
if(body.length() > 8192)
|
||||
{
|
||||
context.status(413);
|
||||
return;
|
||||
}
|
||||
RECEIPTS.add(body);
|
||||
if(RECEIPTS.size() > 20)
|
||||
{
|
||||
RECEIPTS.removeFirst();
|
||||
}
|
||||
context.status(200).result("ok");
|
||||
});
|
||||
get("/demo/order-webhook-receipts", context -> context.json(List.copyOf(RECEIPTS)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
/** Creates repeatable local tables for the qbit metadata exposed by core. */
|
||||
final class DemoQBitData
|
||||
{
|
||||
/** These tables have hand-maintained PostgreSQL DDL in infra/postgres/01 and 02. */
|
||||
private static final Set<String> POSTGRES_MANAGED_TABLES = Set.of("warehouse_customer", "user_session",
|
||||
"redirect_state", "user", "role", "permission", "role_permission_int", "user_role_int",
|
||||
"user_permission_int");
|
||||
|
||||
/** H2 (MySQL mode) keywords that qbit tables use as column or table names. */
|
||||
private static final Set<String> H2_RESERVED_WORDS = Set.of("VALUE", "KEY", "VALUES", "USER", "ORDER", "GROUP",
|
||||
"LIMIT", "OFFSET", "YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND", "ROW", "ROWS", "END", "FROM", "TO",
|
||||
"ALL", "AND", "OR", "NOT", "NULL", "TRUE", "FALSE", "IF", "IN", "IS", "AS", "ON", "BY", "SELECT", "WHERE",
|
||||
"TABLE", "INTERVAL", "DEFAULT", "CHECK", "CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "INDEX", "JOIN",
|
||||
"LEFT", "RIGHT", "INNER", "OUTER", "CROSS", "NATURAL", "UNION", "EXCEPT", "INTERSECT", "MINUS", "DISTINCT",
|
||||
"HAVING", "QUALIFY", "WINDOW", "CASE", "WHEN", "THEN", "ELSE", "FETCH", "FOR", "WITH", "CURRENT_DATE",
|
||||
"CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "LOCALTIME", "LOCALTIMESTAMP", "ARRAY", "BETWEEN",
|
||||
"EXISTS", "LIKE", "ILIKE", "REGEXP", "SET", "UNKNOWN", "USING", "TOP", "SYSDATE", "SYSTIME", "TODAY",
|
||||
"_ROWID_", "ROWNUM", "LISTAGG", "INTERSECTS", "ANY", "SOME", "ASYMMETRIC", "SYMMETRIC", "AUTHORIZATION",
|
||||
"CURRENT_CATALOG", "CURRENT_SCHEMA", "CURRENT_PATH", "CURRENT_ROLE", "LATERAL", "UESCAPE");
|
||||
|
||||
private DemoQBitData()
|
||||
{
|
||||
}
|
||||
|
||||
static void ensureSchema(Path dataDirectory, QInstance instance, Integer port) throws SQLException
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(
|
||||
"jdbc:h2:file:" + CoreData.h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(!"h2".equals(table.getBackendName()) || "customer".equals(table.getName()) || "order".equals(table.getName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details
|
||||
? details.getTableName() : table.getName();
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String name = field.getBackendName() == null ? field.getName() : field.getBackendName();
|
||||
String type = sqlType(field.getType());
|
||||
String identity = field.getName().equals(table.getPrimaryKeyField())
|
||||
&& (field.getType() == QFieldType.INTEGER || field.getType() == QFieldType.LONG)
|
||||
? " GENERATED BY DEFAULT AS IDENTITY" : "";
|
||||
String primaryKey = field.getName().equals(table.getPrimaryKeyField()) ? " PRIMARY KEY" : "";
|
||||
columns.add(identifier(name) + " " + type + identity + primaryKey);
|
||||
}
|
||||
if(!columns.isEmpty())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS " + identifier(tableName)
|
||||
+ " (" + String.join(", ", columns) + ")");
|
||||
}
|
||||
}
|
||||
statement.execute("MERGE INTO shipping_country (id, name, alpha2_code) KEY (id) VALUES (1, 'United States', 'US')");
|
||||
statement.execute("MERGE INTO shipping_stateProvince (id, name, country_id) KEY (id) VALUES (1, 'Illinois', 1)");
|
||||
statement.execute("MERGE INTO shipping_city (id, name, state_province_id, latitude, longitude) KEY (id) "
|
||||
+ "VALUES (1, 'Chicago', 1, 41.8781, -87.6298)");
|
||||
statement.execute("MERGE INTO address (id, customer_id, line1, city_id) KEY (id) "
|
||||
+ "VALUES (1, 1, '1 Demo Street', 1)");
|
||||
statement.execute("MERGE INTO customizable_table (id, table_name, is_active, default_table_view_id) KEY (id) "
|
||||
+ "VALUES (1, 'customer', true, 1)");
|
||||
statement.execute("MERGE INTO table_view (id, customizable_table_id, name) KEY (id) "
|
||||
+ "VALUES (1, 1, 'Customer Directory')");
|
||||
statement.execute("MERGE INTO table_view_field (id, table_view_id, field_name, access_level) KEY (id) VALUES "
|
||||
+ "(1, 1, 'customer.id', 'READ_ONLY'), "
|
||||
+ "(2, 1, 'customer.name', 'EDITABLE_OPTIONAL'), "
|
||||
+ "(3, 1, 'customer.email', 'EDITABLE_OPTIONAL')");
|
||||
statement.execute("MERGE INTO workflow (id, name, workflow_type_name, table_name, current_workflow_revision_id) "
|
||||
+ "KEY (id) VALUES (1, 'Order Status Review', 'RecordWorkflow', 'order', 1)");
|
||||
statement.execute("MERGE INTO workflow_revision (id, workflow_id, version_no, start_step_no, commit_message, author) "
|
||||
+ "KEY (id) VALUES (1, 1, 1, 1, 'Demo order review', 'qqq-all')");
|
||||
statement.execute("MERGE INTO workflow_step (id, workflow_revision_id, step_no, workflow_step_type_name, summary, input_values_json) "
|
||||
+ "KEY (id) VALUES (1, 1, 1, 'updateInputRecordField', 'Mark order reviewed', "
|
||||
+ "'{\"fieldName\":\"status\",\"value\":\"REVIEWED\"}')");
|
||||
statement.execute("MERGE INTO webhook (id, name, url, active_status_id, health_status_id) KEY (id) "
|
||||
+ "VALUES (1, 'Local Order Receiver', 'http://127.0.0.1:" + port + "/demo/order-webhook', 1, 1)");
|
||||
statement.execute("MERGE INTO webhook_subscription (id, webhook_id, webhook_event_type_name, "
|
||||
+ "active_status_id, api_name, api_version, create_date) KEY (id) "
|
||||
+ "VALUES (1, 1, 'orderStored', 1, 'none', 'none', CURRENT_TIMESTAMP)");
|
||||
}
|
||||
}
|
||||
|
||||
static void ensurePostgresSchema(QInstance instance) throws SQLException
|
||||
{
|
||||
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) instance.getBackend("postgres");
|
||||
try(Connection connection = DriverManager.getConnection(ConnectionManager.getJdbcUrl(backend),
|
||||
backend.getUsername(), backend.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : postgresSchemaStatements(instance))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
for(QTableMetaData table : postgresQBitTables(instance))
|
||||
{
|
||||
assertPostgresColumns(connection, table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> postgresSchemaStatements(QInstance instance)
|
||||
{
|
||||
List<String> statements = new ArrayList<>();
|
||||
for(QTableMetaData table : postgresQBitTables(instance))
|
||||
{
|
||||
String tableName = postgresTableName(table);
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String name = field.getBackendName() == null ? field.getName() : field.getBackendName();
|
||||
String identity = field.getName().equals(table.getPrimaryKeyField())
|
||||
&& (field.getType() == QFieldType.INTEGER || field.getType() == QFieldType.LONG)
|
||||
? " GENERATED BY DEFAULT AS IDENTITY" : "";
|
||||
String primaryKey = field.getName().equals(table.getPrimaryKeyField()) ? " PRIMARY KEY" : "";
|
||||
columns.add(postgresIdentifier(name) + " " + postgresSqlType(field.getType()) + identity + primaryKey);
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + postgresIdentifier(tableName)
|
||||
+ " (" + String.join(", ", columns) + ")");
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static List<QTableMetaData> postgresQBitTables(QInstance instance)
|
||||
{
|
||||
return instance.getTables().values().stream()
|
||||
.filter(table -> "postgres".equals(table.getBackendName()) && !table.getFields().isEmpty())
|
||||
.filter(table -> !POSTGRES_MANAGED_TABLES.contains(postgresTableName(table)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String postgresTableName(QTableMetaData table)
|
||||
{
|
||||
return table.getBackendDetails() instanceof RDBMSTableBackendDetails details
|
||||
? details.getTableName() : table.getName();
|
||||
}
|
||||
|
||||
private static void assertPostgresColumns(Connection connection, QTableMetaData table) throws SQLException
|
||||
{
|
||||
String tableName = postgresTableName(table);
|
||||
Map<String, String> actual = new HashMap<>();
|
||||
try(PreparedStatement query = connection.prepareStatement("SELECT column_name, data_type "
|
||||
+ "FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?"))
|
||||
{
|
||||
query.setString(1, tableName);
|
||||
try(ResultSet rows = query.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
actual.put(rows.getString(1), rows.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String columnName = field.getBackendName() == null ? field.getName() : field.getBackendName();
|
||||
String expectedType = postgresInformationSchemaType(field.getType());
|
||||
String actualType = actual.get(columnName);
|
||||
if(!expectedType.equals(actualType))
|
||||
{
|
||||
throw new SQLException("Full-profile table " + tableName + " column " + columnName
|
||||
+ " must be " + expectedType + "; found " + (actualType == null ? "missing" : actualType)
|
||||
+ ". Recreate the demo volume or migrate the persisted schema.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String postgresInformationSchemaType(QFieldType type)
|
||||
{
|
||||
return switch(type)
|
||||
{
|
||||
case INTEGER -> "integer";
|
||||
case LONG -> "bigint";
|
||||
case DECIMAL -> "numeric";
|
||||
case BOOLEAN -> "boolean";
|
||||
case DATE -> "date";
|
||||
case TIME -> "time without time zone";
|
||||
case DATE_TIME -> "timestamp without time zone";
|
||||
case BLOB -> "bytea";
|
||||
case STRING, PASSWORD -> "character varying";
|
||||
case TEXT, HTML -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
private static String postgresIdentifier(String value)
|
||||
{
|
||||
if(value == null || !value.matches("[A-Za-z][A-Za-z0-9_]*"))
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported demo schema identifier");
|
||||
}
|
||||
return "\"" + value + "\"";
|
||||
}
|
||||
|
||||
private static String postgresSqlType(QFieldType type)
|
||||
{
|
||||
return switch(type)
|
||||
{
|
||||
case INTEGER -> "INTEGER";
|
||||
case LONG -> "BIGINT";
|
||||
case DECIMAL -> "DECIMAL(20,6)";
|
||||
case BOOLEAN -> "BOOLEAN";
|
||||
case DATE -> "DATE";
|
||||
case TIME -> "TIME";
|
||||
case DATE_TIME -> "TIMESTAMP";
|
||||
case BLOB -> "BYTEA";
|
||||
case STRING, PASSWORD -> "VARCHAR(4000)";
|
||||
case TEXT, HTML -> "TEXT";
|
||||
};
|
||||
}
|
||||
|
||||
private static String identifier(String value)
|
||||
{
|
||||
if(value == null || !value.matches("[A-Za-z][A-Za-z0-9_]*"))
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported demo schema identifier");
|
||||
}
|
||||
if(H2_RESERVED_WORDS.contains(value.toUpperCase()))
|
||||
{
|
||||
return "`" + value + "`";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String sqlType(QFieldType type)
|
||||
{
|
||||
return switch(type)
|
||||
{
|
||||
case INTEGER -> "INTEGER";
|
||||
case LONG -> "BIGINT";
|
||||
case DECIMAL -> "DECIMAL(20,6)";
|
||||
case BOOLEAN -> "BOOLEAN";
|
||||
case DATE -> "DATE";
|
||||
case TIME -> "TIME";
|
||||
case DATE_TIME -> "TIMESTAMP";
|
||||
case BLOB -> "BLOB";
|
||||
case STRING, PASSWORD -> "VARCHAR(4000)";
|
||||
case TEXT, HTML -> "CLOB";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitConfig;
|
||||
import com.kingsrook.qbits.customizabletableviews.CustomizableTableViewsQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchStartupMode;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
|
||||
import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitConfig;
|
||||
import com.kingsrook.qbits.sftpdataintegration.SFTPDataIntegrationQBitProducer;
|
||||
import com.kingsrook.qbits.geodata.GeoDataQBitConfig;
|
||||
import com.kingsrook.qbits.geodata.GeoDataQBitProducer;
|
||||
import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitConfig;
|
||||
import com.kingsrook.qbits.standardprocesstrace.StandardProcessTraceQBitProducer;
|
||||
import com.kingsrook.qbits.standardprocesstrace.utils.StandardProcessTracer;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitProducer;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.workflows.WorkflowsQBitConfig;
|
||||
import com.kingsrook.qbits.workflows.WorkflowsQBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.ProvidedOrSuppliedTableConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.processes.QQQProcessesMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.tables.QQQTablesMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
/** Registers the locally usable qbits against the demo's relational backend. */
|
||||
final class DemoQBits
|
||||
{
|
||||
private DemoQBits()
|
||||
{
|
||||
}
|
||||
|
||||
static void addCore(QInstance instance, String backendName) throws QException
|
||||
{
|
||||
new QQQTablesMetaDataProvider().defineAll(instance, backendName, backendName, null);
|
||||
new QQQProcessesMetaDataProvider().defineAll(instance, backendName, backendName, null);
|
||||
instance.addScheduler(new SimpleSchedulerMetaData().withName("demoScheduler"));
|
||||
new ScheduledJobsMetaDataProvider().defineAll(instance, backendName, null);
|
||||
instance.addPossibleValueSource(new TimeZonePossibleValueSourceMetaDataProvider().produce());
|
||||
instance.addPossibleValueSource(TablesPossibleValueSourceMetaDataProvider.defineTablesPossibleValueSource(instance));
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("apiName")
|
||||
.withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("none", "No API in this demo"))));
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("apiVersion")
|
||||
.withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("none", "No API version"))));
|
||||
if(instance.getPossibleValueSource("role") == null)
|
||||
{
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("role")
|
||||
.withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER)
|
||||
.withEnumValues(List.of(new QPossibleValue<>(0, "No role"))));
|
||||
}
|
||||
|
||||
MetaDataCustomizerInterface<QTableMetaData> tableCustomizer =
|
||||
(i, table) ->
|
||||
{
|
||||
table.setBackendName(backendName);
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails()
|
||||
.withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
return table;
|
||||
};
|
||||
|
||||
new CustomizableTableViewsQBitProducer()
|
||||
.withQBitConfig(new CustomizableTableViewsQBitConfig()
|
||||
.withTableMetaDataCustomizer(tableCustomizer))
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
CustomizableTableViewsQBitProducer.activateTableMetaDataPersonalizerInterface(instance);
|
||||
|
||||
StandardProcessTraceQBitConfig traceConfig = new StandardProcessTraceQBitConfig()
|
||||
.withTableMetaDataCustomizer(tableCustomizer);
|
||||
StandardProcessTracer.setStandardProcessTraceQBitConfig(traceConfig);
|
||||
new StandardProcessTraceQBitProducer()
|
||||
.withStandardProcessTraceQBitConfig(traceConfig)
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
instance.getProcess("syncOrder").setProcessTracerCodeReference(new QCodeReference(StandardProcessTracer.class));
|
||||
|
||||
new WebhooksQBitProducer()
|
||||
.withQBitConfig(new WebhooksQBitConfig().withDefaultBackendNameForTables(backendName)
|
||||
.withSchedulerName("demoScheduler")
|
||||
.withTableMetaDataCustomizer(tableCustomizer))
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
WebhooksRegistry.ofOrWithNew(instance).registerWebhookEventType(new WebhookEventType()
|
||||
.withName("orderStored").withLabel("Order Stored")
|
||||
.withCategory(WebhookEventCategory.STORE).withTableName("order")
|
||||
.withCustomizer(new QCodeReference(DemoOrderWebhookContent.class)));
|
||||
|
||||
moveTestEventFormAfterPreview(instance);
|
||||
|
||||
new WorkflowsQBitProducer()
|
||||
.withQBitConfig(new WorkflowsQBitConfig().withTableMetaDataCustomizer(tableCustomizer))
|
||||
.produce(instance).addSelfToInstance(instance);
|
||||
|
||||
new GeoDataQBitProducer()
|
||||
.withConfig(new GeoDataQBitConfig().withBackendName(backendName).withTableNamePrefix("shipping"))
|
||||
.produce(instance, "shipping-geo");
|
||||
instance.addTable(new QTableMetaData().withName("shipping_country").withLabel("Country")
|
||||
.withBackendName(backendName).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("alpha2Code", QFieldType.STRING).withBackendName("alpha2_code")));
|
||||
instance.addTable(new QTableMetaData().withName("shipping_stateProvince").withLabel("State Province")
|
||||
.withBackendName(backendName).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("countryId", QFieldType.INTEGER).withBackendName("country_id")
|
||||
.withPossibleValueSourceName("shipping_country")));
|
||||
instance.addTable(new QTableMetaData().withName("shipping_city").withLabel("City")
|
||||
.withBackendName(backendName).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("stateProvinceId", QFieldType.INTEGER).withBackendName("state_province_id")
|
||||
.withPossibleValueSourceName("shipping_stateProvince"))
|
||||
.withField(new QFieldMetaData("latitude", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("longitude", QFieldType.DECIMAL)));
|
||||
instance.addTable(new QTableMetaData().withName("address").withLabel("Address")
|
||||
.withBackendName(backendName).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id"))
|
||||
.withField(new QFieldMetaData("line1", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("cityId", QFieldType.INTEGER).withBackendName("city_id")
|
||||
.withPossibleValueSourceName("shipping_city")));
|
||||
}
|
||||
|
||||
static void addFull(QInstance instance, FullProfileSettings settings) throws QException
|
||||
{
|
||||
QuickSearchQBitConfig searchConfig = new QuickSearchQBitConfig()
|
||||
.withBackendName("postgres")
|
||||
.withOpensearchHost(settings.get("OPENSEARCH_HOST"))
|
||||
.withOpensearchPort(settings.port("OPENSEARCH_PORT", "9200"))
|
||||
.withOpensearchIndexName(settings.getOrDefault("OPENSEARCH_INDEX", "qqq-all-customers"))
|
||||
.withOpensearchUsername(settings.get("OPENSEARCH_USER"))
|
||||
.withOpensearchPassword(settings.get("OPENSEARCH_PASSWORD"))
|
||||
.withUseSsl(Boolean.parseBoolean(settings.getOrDefault("OPENSEARCH_SSL", "false")))
|
||||
// quick search 1.x connects at startup unless degraded; 0.2.x did not (the enterprise profile bumped it)
|
||||
.withStartupMode(QuickSearchStartupMode.DEGRADED)
|
||||
.withSearchableTable("customer", List.of(new SearchableFieldConfig("name"),
|
||||
new SearchableFieldConfig("email")));
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// quick search 1.x registers its own QBitMetaData when produced //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
new QuickSearchQBitProducer().withConfig(searchConfig).produce(instance);
|
||||
|
||||
new SavedBulkLoadProfileMetaDataProvider().defineAll(instance, "postgres", null);
|
||||
new SavedReportsMetaDataProvider().defineAll(instance, "postgres", "sftp", null);
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// rendered reports are files, one per record, on the SFTP server: core //
|
||||
// now refuses a filesystem table without backend details (qqq#1364) //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
instance.getTable("reportStorage").setBackendDetails(new SFTPTableBackendDetails().withBasePath("reportStorage")
|
||||
.withCardinality(Cardinality.ONE).withFileNameFieldName("reference").withContentsFieldName("contents"));
|
||||
new SFTPDataIntegrationQBitProducer().withSftpDataIntegrationQBitConfig(new SFTPDataIntegrationQBitConfig()
|
||||
.withSourceFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
|
||||
.withDestinationFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
|
||||
.withStagingFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("files"))
|
||||
.withSchedulerName("demoScheduler")
|
||||
.withTableMetaDataCustomizer((i, table) ->
|
||||
{
|
||||
if(table.getBackendName() == null)
|
||||
{
|
||||
table.setBackendName("postgres");
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails()
|
||||
.withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
}
|
||||
return table;
|
||||
})).produce(instance, "demo-import");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* qbit-webhooks 0.x puts SendTestEventToWebhook's input form before the
|
||||
* preview step, so the records chosen at init are lost and the run fails
|
||||
* with "No records were selected" (QRun-IO/qqq#1361; fixed on qbit-webhooks
|
||||
* fix/test-event-records). Until a release carries it, move the form after
|
||||
* the first backend step here.
|
||||
*/
|
||||
static void moveTestEventFormAfterPreview(QInstance instance)
|
||||
{
|
||||
QProcessMetaData process = instance.getProcess("SendTestEventToWebhook");
|
||||
if(process == null || process.getStepList() == null || process.getStepList().size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<QStepMetaData> steps = process.getStepList();
|
||||
if(steps.get(0) instanceof QFrontendStepMetaData form && "input".equals(form.getName()) && steps.get(1) instanceof QBackendStepMetaData)
|
||||
{
|
||||
steps.remove(0);
|
||||
steps.add(1, form);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionOutputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
/** Shared, local-only demonstrations included in both profiles. */
|
||||
final class DemoRuntimeFeatures
|
||||
{
|
||||
private DemoRuntimeFeatures()
|
||||
{
|
||||
}
|
||||
|
||||
static void add(QInstance instance, int port, String bindHost) throws QException
|
||||
{
|
||||
instance.addBackend(new APIBackendMetaData().withName("demoApi")
|
||||
.withBaseUrl(catalogBaseUrl(bindHost, port))
|
||||
.withAuthorizationType(AuthorizationType.NONE).withContentType("application/json")
|
||||
.withActionUtil(new QCodeReference(DemoCatalogApi.class)));
|
||||
instance.addTable(new QTableMetaData().withName("apiCatalog").withLabel("API Catalog")
|
||||
.withBackendName("demoApi").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new APITableBackendDetails().withTablePath("/catalog"))
|
||||
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("unitPriceCents", QFieldType.INTEGER)));
|
||||
instance.addBackend(new QBackendMetaData().withName("transientMemory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName("demoNote").withLabel("Demo Note (Transient)")
|
||||
.withBackendName("transientMemory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("text", QFieldType.STRING)));
|
||||
instance.withRuntimeService(new QCodeReference(TransientNotesService.class));
|
||||
instance.addProcess(new QProcessMetaData().withName("calculateOrderTotal").withLabel("Calculate Order Total (JavaScript)")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
|
||||
// the inputs as a form, so interactive clients ask for them (QRun-IO/qqq-all#45)
|
||||
.withStep(new QFrontendStepMetaData().withName("input").withLabel("Order line")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("quantity", QFieldType.INTEGER).withLabel("Quantity").withIsRequired(true)
|
||||
.withHelpContent(new QHelpContent("1 to 1000")))
|
||||
.withFormField(new QFieldMetaData("unitPriceCents", QFieldType.INTEGER).withLabel("Unit price (cents)").withIsRequired(true)
|
||||
.withHelpContent(new QHelpContent("0 to 1000000"))))
|
||||
.withStep(new QBackendStepMetaData().withName("calculate")
|
||||
.withCode(new QCodeReference(CalculateOrderTotalStep.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("quantity", QFieldType.INTEGER),
|
||||
new QFieldMetaData("unitPriceCents", QFieldType.INTEGER))))
|
||||
.withOutputMetaData(new QFunctionOutputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("totalCents", QFieldType.INTEGER)))))
|
||||
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Total")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData("quantity", QFieldType.INTEGER).withLabel("Quantity"))
|
||||
.withViewField(new QFieldMetaData("unitPriceCents", QFieldType.INTEGER).withLabel("Unit price (cents)"))
|
||||
.withViewField(new QFieldMetaData("totalCents", QFieldType.INTEGER).withLabel("Total (cents)"))));
|
||||
QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(new QCodeReference(DemoCatalogRoutes.class));
|
||||
}
|
||||
|
||||
private static String catalogBaseUrl(String bindHost, int port) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
InetAddress address = InetAddress.getByName(bindHost);
|
||||
String host = address.isAnyLocalAddress()
|
||||
? (address instanceof Inet6Address ? "::1" : "127.0.0.1") : bindHost;
|
||||
return new URI("http", null, host, port, "/demo", null, null).toASCIIString();
|
||||
}
|
||||
catch(UnknownHostException | URISyntaxException e)
|
||||
{
|
||||
throw new QException("Could not derive demo catalog URL from HTTP bind host", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
|
||||
/** Fixed identities for local mock authentication; the session cookie selects one. */
|
||||
public class DemoUsers implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
private static final List<String> DEMO_TABLES = List.of("customer", "order", "orderLine", "product", "address");
|
||||
private static final List<String> WEBHOOK_TABLES = List.of("webhook", "webhookSubscription",
|
||||
"webhookEvent", "webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter");
|
||||
private static final List<String> WEBHOOK_PROCESSES = List.of("SendWebhookEvent",
|
||||
"SendTestEventToWebhook", "ManageWebhookHealth");
|
||||
|
||||
static void protectDemoTables(QInstance instance)
|
||||
{
|
||||
for(String tableName : DEMO_TABLES)
|
||||
{
|
||||
instance.getTable(tableName).setPermissionRules(new QPermissionRules()
|
||||
.withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
}
|
||||
protectWebhookResources(instance);
|
||||
}
|
||||
|
||||
static void protectWebhookResources(QInstance instance)
|
||||
{
|
||||
for(String tableName : WEBHOOK_TABLES)
|
||||
{
|
||||
instance.getTable(tableName).setPermissionRules(new QPermissionRules()
|
||||
.withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
}
|
||||
for(String processName : WEBHOOK_PROCESSES)
|
||||
{
|
||||
instance.getProcess(processName).setPermissionRules(new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
session.withPermissions("apiCatalog.read", "demoNote.read");
|
||||
Boolean admin = ADMIN_SESSION.equals(session.getUuid()) || EnterpriseFeatures.isEnabled();
|
||||
String name = admin ? "Admin" : "Demo User";
|
||||
String userId = admin ? "core:admin" : "core:demo";
|
||||
session.getUser().setIdReference(userId);
|
||||
session.getUser().setFullName(name);
|
||||
for(String tableName : DEMO_TABLES)
|
||||
{
|
||||
session.withPermission(tableName + ".read");
|
||||
if(admin)
|
||||
{
|
||||
session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete");
|
||||
}
|
||||
}
|
||||
for(String tableName : WEBHOOK_TABLES)
|
||||
{
|
||||
session.withPermission(tableName + ".read");
|
||||
if(admin)
|
||||
{
|
||||
session.withPermissions(tableName + ".insert", tableName + ".edit", tableName + ".delete");
|
||||
}
|
||||
}
|
||||
if(admin)
|
||||
{
|
||||
session.withPermissions("calculateOrderTotal.hasAccess", "demoNote.insert", "demoNote.edit", "demoNote.delete");
|
||||
session.withPermissions("esbView.hasAccess", "esbOperate.hasAccess", "esbDelete.hasAccess");
|
||||
for(String processName : WEBHOOK_PROCESSES)
|
||||
{
|
||||
session.withPermission(processName + ".hasAccess");
|
||||
}
|
||||
}
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
grantEnterpriseAccess(instance, session, userId);
|
||||
}
|
||||
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
EnterpriseSessions.record(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The enterprise test server: every session is an all-access admin, so the
|
||||
* terminal clients can reach every qbit surface.
|
||||
*/
|
||||
static void grantEnterpriseAccess(QInstance instance, QSession session, String userId)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// enterprise test server: every session is an all-access admin, so //
|
||||
// the terminal clients can reach every qbit surface //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// permissions some modules check by literal name, outside the //
|
||||
// table/process permission rules (QRun-IO/qqq#1362) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// qbit-collaboration's custom checkers (Custom Fields, tags, the //
|
||||
// admin tables) test these names; without them the Custom Fields //
|
||||
// table answered 403 "This needs collaboration.view." (#47) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
session.withPermissions("collaboration.view", "collaboration.admin", "collaboration.fields.manage", "collaboration.tags.manage");
|
||||
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
|
||||
{
|
||||
if(keyType.getAllAccessKeyName() != null)
|
||||
{
|
||||
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
|
||||
}
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// per-user keys (owner locks of qbit tables) take the user id //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
session.withSecurityKeyValue(keyType.getName(), userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The assistant on the enterprise test server: two provider rows, kept in
|
||||
** step with the environment at every start.
|
||||
**
|
||||
** - "Anthropic (Claude)": type anthropic, key variable ANTHROPIC_API_KEY
|
||||
** (only the NAME is stored; enterprise-server.sh passes the value from its
|
||||
** private 0600 secrets file). Enabled when the variable is set.
|
||||
** - "Local Ollama": type openai-compatible, kind OLLAMA, base URL
|
||||
** http://127.0.0.1:11434/v1, model QQQ_ASSISTANT_OLLAMA_MODEL (default
|
||||
** llama3.3:70b-instruct-q4_K_M), no key. Enabled when Ollama answers and
|
||||
** lists that model.
|
||||
**
|
||||
** The default is Anthropic when its key is set, else Ollama when reachable;
|
||||
** with neither, both rows are disabled and GET /assistant/config answers
|
||||
** NOT_CONFIGURED with a message that says what to configure.
|
||||
*******************************************************************************/
|
||||
final class EnterpriseAssistant
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(EnterpriseAssistant.class);
|
||||
|
||||
static final String ANTHROPIC_ROW = "Anthropic (Claude)";
|
||||
static final String OLLAMA_ROW = "Local Ollama";
|
||||
static final String ANTHROPIC_KEY_ENV = "ANTHROPIC_API_KEY";
|
||||
static final String OLLAMA_MODEL_ENV = "QQQ_ASSISTANT_OLLAMA_MODEL";
|
||||
static final String OLLAMA_URL_ENV = "QQQ_ASSISTANT_OLLAMA_URL";
|
||||
static final String OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
|
||||
static final String OLLAMA_MODEL = "llama3.3:70b-instruct-q4_K_M";
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// a local 70B model reads the tool catalog slowly: give a turn and one //
|
||||
// model call room (the qbit defaults are 300 s and 120 s) //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
static final Integer MAX_TURN_SECONDS = 900;
|
||||
static final Integer OLLAMA_REQUEST_SECONDS = 600;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Which provider answers, from the environment at startup.
|
||||
*******************************************************************************/
|
||||
enum Choice
|
||||
{
|
||||
ANTHROPIC,
|
||||
OLLAMA,
|
||||
NONE
|
||||
}
|
||||
|
||||
|
||||
|
||||
private EnterpriseAssistant()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QBit config: both provider types, the Ollama URL allowed as a base
|
||||
** URL override, and the code-registered Anthropic fallback.
|
||||
*******************************************************************************/
|
||||
static AssistantQBitConfig config(String backendName, String uiApiBaseUrl, Function<String, String> env)
|
||||
{
|
||||
return new AssistantQBitConfig()
|
||||
.withBackendName(backendName).withUiApiBaseUrl(uiApiBaseUrl)
|
||||
.withProviderType(new AnthropicProviderType())
|
||||
.withProviderType(new OpenAiCompatibleProviderType())
|
||||
.withAllowedBaseUrls(Set.of(ollamaBaseUrl(env)))
|
||||
.withMaxTurnSeconds(MAX_TURN_SECONDS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Anthropic when its key is set, else Ollama when reachable, else none.
|
||||
*******************************************************************************/
|
||||
static Choice choose(Function<String, String> env, Predicate<String> ollamaHasModel)
|
||||
{
|
||||
String key = env.apply(ANTHROPIC_KEY_ENV);
|
||||
if(key != null && !key.isBlank())
|
||||
{
|
||||
return Choice.ANTHROPIC;
|
||||
}
|
||||
return ollamaHasModel.test(ollamaModel(env)) ? Choice.OLLAMA : Choice.NONE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Ollama model (QQQ_ASSISTANT_OLLAMA_MODEL, default llama3.3 70B).
|
||||
*******************************************************************************/
|
||||
static String ollamaModel(Function<String, String> env)
|
||||
{
|
||||
String model = env.apply(OLLAMA_MODEL_ENV);
|
||||
return model == null || model.isBlank() ? OLLAMA_MODEL : model.trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Ollama base URL (QQQ_ASSISTANT_OLLAMA_URL, default 127.0.0.1:11434/v1).
|
||||
*******************************************************************************/
|
||||
static String ollamaBaseUrl(Function<String, String> env)
|
||||
{
|
||||
String url = env.apply(OLLAMA_URL_ENV);
|
||||
return url == null || url.isBlank() ? OLLAMA_BASE_URL : url.trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The two provider rows for a choice (id left out).
|
||||
*******************************************************************************/
|
||||
static List<QRecord> rows(Choice choice, Function<String, String> env)
|
||||
{
|
||||
QRecord anthropic = new QRecord()
|
||||
.withValue("name", ANTHROPIC_ROW)
|
||||
.withValue("providerType", AnthropicModelProvider.NAME)
|
||||
.withValue("apiKeyEnvName", ANTHROPIC_KEY_ENV)
|
||||
.withValue("enabled", choice == Choice.ANTHROPIC)
|
||||
.withValue("isDefault", choice == Choice.ANTHROPIC);
|
||||
QRecord ollama = new QRecord()
|
||||
.withValue("name", OLLAMA_ROW)
|
||||
.withValue("providerType", new OpenAiCompatibleProviderType().getType())
|
||||
.withValue("kind", "OLLAMA")
|
||||
.withValue("location", "LOCAL")
|
||||
.withValue("baseUrl", ollamaBaseUrl(env))
|
||||
.withValue("model", ollamaModel(env))
|
||||
.withValue("requestTimeoutSeconds", OLLAMA_REQUEST_SECONDS)
|
||||
.withValue("enabled", choice == Choice.OLLAMA)
|
||||
.withValue("isDefault", choice == Choice.OLLAMA);
|
||||
return List.of(anthropic, ollama);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert or update the two rows (matched by name) for this start. Runs
|
||||
** with the caller's QContext (the system session at startup).
|
||||
*******************************************************************************/
|
||||
static Choice reconcile(QInstance instance, Function<String, String> env, Predicate<String> ollamaHasModel) throws QException
|
||||
{
|
||||
if(instance.getTable(AssistantProvider.TABLE_NAME) == null)
|
||||
{
|
||||
return Choice.NONE;
|
||||
}
|
||||
Choice choice = choose(env, ollamaHasModel);
|
||||
List<QRecord> wanted = rows(choice, env);
|
||||
|
||||
QueryInput queryInput = new QueryInput(AssistantProvider.TABLE_NAME);
|
||||
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.IN, List.of(ANTHROPIC_ROW, OLLAMA_ROW))));
|
||||
Map<Serializable, QRecord> existing = new java.util.HashMap<>();
|
||||
for(QRecord record : new QueryAction().execute(queryInput).getRecords())
|
||||
{
|
||||
existing.putIfAbsent(record.getValueString("name"), record);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the non-default row first, so marking the default one last leaves it //
|
||||
// as the only default (the qbit clears other defaults on save) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> ordered = wanted.stream().sorted((a, b) -> Boolean.compare(Boolean.TRUE.equals(a.getValueBoolean("isDefault")), Boolean.TRUE.equals(b.getValueBoolean("isDefault")))).toList();
|
||||
for(QRecord row : ordered)
|
||||
{
|
||||
QRecord current = existing.get(row.getValueString("name"));
|
||||
if(current == null)
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(AssistantProvider.TABLE_NAME).withRecord(row));
|
||||
}
|
||||
else
|
||||
{
|
||||
row.setValue("id", current.getValue("id"));
|
||||
new UpdateAction().execute(new UpdateInput(AssistantProvider.TABLE_NAME).withRecord(row));
|
||||
}
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconcile from the process environment; a failure is logged and leaves
|
||||
** the server running (the assistant then reports NOT_CONFIGURED).
|
||||
*******************************************************************************/
|
||||
static void reconcileAtStartup(QInstance instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
Choice choice = reconcile(instance, System::getenv, model -> ollamaHasModel(ollamaBaseUrl(System::getenv), model));
|
||||
LOG.info("Enterprise assistant model provider: " + choice);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not set up the enterprise assistant provider rows", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when Ollama answers GET {base}/models within 3 s and lists the model.
|
||||
*******************************************************************************/
|
||||
static boolean ollamaHasModel(String baseUrl, String model)
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/models")).timeout(Duration.ofSeconds(3)).GET().build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return response.statusCode() == 200 && response.body().contains("\"" + model + "\"");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
if(e instanceof InterruptedException)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitProducer;
|
||||
import com.kingsrook.qbits.backups.storage.InMemoryBackupStorage;
|
||||
import com.kingsrook.qbits.cdn.CdnQBitConfig;
|
||||
import com.kingsrook.qbits.cdn.CdnQBitProducer;
|
||||
import com.kingsrook.qbits.cluster.ClusterQBitConfig;
|
||||
import com.kingsrook.qbits.cluster.ClusterQBitProducer;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
|
||||
import com.kingsrook.qbits.dbperformance.DbPerfQBitConfig;
|
||||
import com.kingsrook.qbits.dbperformance.DbPerfQBitProducer;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitProducer;
|
||||
import com.kingsrook.qbits.help.HelpQBitConfig;
|
||||
import com.kingsrook.qbits.help.HelpQBitProducer;
|
||||
import com.kingsrook.qbits.jobs.JobsQBitConfig;
|
||||
import com.kingsrook.qbits.jobs.JobsQBitProducer;
|
||||
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
|
||||
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer;
|
||||
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
|
||||
import com.kingsrook.qbits.objectstorage.config.BucketRole;
|
||||
import com.kingsrook.qbits.objectstorage.config.ProviderType;
|
||||
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
|
||||
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableTableConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.releasenotes.ReleaseNotesQBitConfig;
|
||||
import com.kingsrook.qbits.releasenotes.ReleaseNotesQBitProducer;
|
||||
import com.kingsrook.qbits.releasenotes.sources.ComponentVersionsSource;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataQBitConfig;
|
||||
import com.kingsrook.qbits.secureddata.SecuredDataQBitProducer;
|
||||
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
|
||||
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
|
||||
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.SlackQBitProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitProducer;
|
||||
import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig;
|
||||
import com.kingsrook.qbits.wormaudit.WormAuditQBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.querystats.QueryStatMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.savedviews.SavedViewsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.scripts.ScriptsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesKeyValueStoreMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.sharing.SharingMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.AbstractFilesystemTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "enterprise" profile (QQQ_ALL_PROFILE=enterprise): the core profile plus
|
||||
** every qbit and core module an enterprise rollout runs, all on the local H2
|
||||
** backend, for exercising the terminal clients against a large instance.
|
||||
**
|
||||
** Each feature is named; QQQ_ALL_ENTERPRISE_EXCLUDE (comma separated names)
|
||||
** leaves features out. Quick search needs QQQ_ALL_OPENSEARCH_HOST and redis
|
||||
** needs QQQ_ALL_REDIS_HOST; without them those two are left out.
|
||||
*******************************************************************************/
|
||||
final class EnterpriseFeatures
|
||||
{
|
||||
static final String PROFILE = "enterprise";
|
||||
|
||||
private static final String SAMPLE_PROVIDER = "com.kingsrook.sampleapp.metadata.SampleMetaDataProvider";
|
||||
|
||||
private interface Feature
|
||||
{
|
||||
void add(QInstance instance) throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private EnterpriseFeatures()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the process runs the enterprise profile.
|
||||
*******************************************************************************/
|
||||
static boolean isEnabled()
|
||||
{
|
||||
return PROFILE.equals(System.getenv("QQQ_ALL_PROFILE"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names the operator excluded.
|
||||
*******************************************************************************/
|
||||
static Set<String> excluded()
|
||||
{
|
||||
String value = System.getenv().getOrDefault("QQQ_ALL_ENTERPRISE_EXCLUDE", "");
|
||||
return Arrays.stream(value.split(",")).map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add every enterprise feature that is not excluded.
|
||||
*******************************************************************************/
|
||||
static void add(QInstance instance, String backendName, int port) throws QException
|
||||
{
|
||||
String schedulerName = "demoScheduler";
|
||||
String baseUrl = "http://127.0.0.1:" + port;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// qbits schedule with cron expressions, which the simple scheduler of //
|
||||
// the core profile cannot run: swap it for an in-memory Quartz //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Properties quartzProperties = new Properties();
|
||||
quartzProperties.put("org.quartz.scheduler.instanceName", "qqqAllEnterprise");
|
||||
quartzProperties.put("org.quartz.threadPool.threadCount", "4");
|
||||
quartzProperties.put("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore");
|
||||
instance.getSchedulers().put(schedulerName, new QuartzSchedulerMetaData().withProperties(quartzProperties).withName(schedulerName));
|
||||
MetaDataCustomizerInterface<QTableMetaData> tableCustomizer = (i, table) ->
|
||||
{
|
||||
if(table.getBackendName() == null || backendName.equals(table.getBackendName()))
|
||||
{
|
||||
table.setBackendName(backendName);
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails()
|
||||
.withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
}
|
||||
return table;
|
||||
};
|
||||
Consumer<QTableMetaData> enricher = table ->
|
||||
{
|
||||
try
|
||||
{
|
||||
tableCustomizer.customizeMetaData(instance, table);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
};
|
||||
|
||||
Map<String, Feature> features = new LinkedHashMap<>();
|
||||
|
||||
/////////////////////////
|
||||
// core qqq modules //
|
||||
/////////////////////////
|
||||
features.put("audits", i ->
|
||||
{
|
||||
new AuditsMetaDataProvider().defineAll(i, backendName, enricher);
|
||||
for(String tableName : List.of("customer", "order", "address"))
|
||||
{
|
||||
i.getTable(tableName).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
}
|
||||
});
|
||||
features.put("scripts", i ->
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the scripts provider defines its own "tables" possible values //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
i.getPossibleValueSources().remove("tables");
|
||||
new ScriptsMetaDataProvider().defineAll(i, backendName, enricher);
|
||||
});
|
||||
features.put("savedViews", i -> new SavedViewsMetaDataProvider().defineAll(i, backendName, enricher));
|
||||
features.put("savedReports", i ->
|
||||
{
|
||||
new SavedReportsMetaDataProvider().defineAll(i, backendName, "files", enricher);
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// rendered reports are files, one per record, under the files //
|
||||
// backend (the provider leaves the storage details to the host) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
QTableMetaData reportStorage = i.getTable("reportStorage");
|
||||
if(reportStorage != null && reportStorage.getBackendDetails() == null)
|
||||
{
|
||||
reportStorage.setBackendDetails(new FilesystemTableBackendDetails().withBasePath("reportStorage").withCardinality(Cardinality.ONE).withFileNameFieldName("reference").withContentsFieldName("contents"));
|
||||
}
|
||||
ensureFilesystemDirectories(i);
|
||||
});
|
||||
features.put("bulkLoadProfiles", i -> new SavedBulkLoadProfileMetaDataProvider().defineAll(i, backendName, enricher));
|
||||
features.put("sharing", i -> new SharingMetaDataProvider().defineAll(i, null));
|
||||
features.put("queryStats", i -> new QueryStatMetaDataProvider().defineAll(i, backendName, enricher));
|
||||
features.put("userPreferences", i -> new UserPreferencesKeyValueStoreMetaDataProvider().defineAll(i, backendName, null));
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// central health: its tables turn on the health UI API extension, so //
|
||||
// /version lists it (QRun-IO/qqq-all#44). Enterprise only for now: in //
|
||||
// the core profile its backend checks share the cached sqlite //
|
||||
// connection across the test suite's runtimes //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
features.put("health", i -> new HealthMetaDataProvider().defineAll(i, backendName, enricher));
|
||||
features.put("durableJobs", i ->
|
||||
{
|
||||
new DurableJobsMetaDataProvider().defineAll(i, backendName, enricher);
|
||||
i.withSupplementalMetaData(new DurableJobsMetaData().withWorkerThreads(2));
|
||||
});
|
||||
|
||||
/////////////////////////
|
||||
// qbits //
|
||||
/////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// object storage on a local MinIO (enterprise-server.sh starts it): //
|
||||
// collaboration attachments live in its private "collaboration" //
|
||||
// bucket. Credentials are references to the server's env only. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String s3Endpoint = System.getenv("S3_ENDPOINT");
|
||||
if(s3Endpoint != null && !s3Endpoint.isBlank())
|
||||
{
|
||||
features.put("objectStorage", i -> new ObjectStorageQBitProducer().withQBitConfig(new ObjectStorageQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withAdminApp(true)
|
||||
.withConnection(new StorageConnectionConfig("local").withProviderType(ProviderType.MINIO)
|
||||
.withEndpoint("${env.S3_ENDPOINT}").withRegion("us-east-1").withAddressingStyle(AddressingStyle.PATH)
|
||||
.withAccessKeyRef("${env.S3_ACCESS_KEY}").withSecretKeyRef("${env.S3_SECRET_KEY}"))
|
||||
.withBucket(new StorageBucketConfig("collaboration").withConnection("local")
|
||||
.withBucketName("${env.S3_ATTACHMENTS_BUCKET}").withRole(BucketRole.PRIVATE).withIndexed(false)))
|
||||
.produce(i).addSelfToInstance(i));
|
||||
}
|
||||
|
||||
features.put("jobs", i -> new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
|
||||
.withSchedulerName(schedulerName)).produce(i).addSelfToInstance(i));
|
||||
features.put("collaboration", i -> new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withUiApi(true).withAdminApp(true)
|
||||
.withDefaultFeatures(RecordFeature.values())
|
||||
.withFrontendBaseUrl(baseUrl)).produce(i).addSelfToInstance(i));
|
||||
features.put("alerts", i -> new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)
|
||||
.withPublicBaseUrl(baseUrl)).produce(i).addSelfToInstance(i));
|
||||
features.put("dataAnalysis", i -> new DataAnalysisQBitProducer().withQBitConfig(new DataAnalysisQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)).produce(i).addSelfToInstance(i));
|
||||
features.put("dbPerformance", i -> new DbPerfQBitProducer().withQBitConfig(new DbPerfQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)).produce(i).addSelfToInstance(i));
|
||||
features.put("sqlWorkbench", i -> new SqlWorkbenchQBitProducer().withQBitConfig(new SqlWorkbenchQBitConfig()
|
||||
.withBackendName(backendName).withTableMetaDataCustomizer(tableCustomizer)
|
||||
.withAllowedBackendNames(List.of(backendName))).produce(i).addSelfToInstance(i));
|
||||
features.put("help", i -> new HelpQBitProducer().withQBitConfig(new HelpQBitConfig()
|
||||
.withBackendName(backendName).withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
features.put("releaseNotes", i -> new ReleaseNotesQBitProducer().withQBitConfig(new ReleaseNotesQBitConfig()
|
||||
.withBackendName(backendName).withApplicationName("qqqAll").withApplicationLabel("QQQ All")
|
||||
.withAppVersion("4.1.0").withAllowNonReleaseVersions(true)
|
||||
.withSource(new ComponentVersionsSource())).produce(i).addSelfToInstance(i));
|
||||
features.put("qaCapture", i -> new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig()
|
||||
.withBackendName(backendName).withEnvironment("local").withBackendVersion("4.1.0-SNAPSHOT"))
|
||||
.produce(i).addSelfToInstance(i));
|
||||
features.put("securedData", i -> new SecuredDataQBitProducer().withQBitConfig(new SecuredDataQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
features.put("email", i -> new EmailQBitProducer().withQBitConfig(new EmailQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withPublicBaseUrl(baseUrl)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
features.put("slack", i -> new SlackQBitProducer().withQBitConfig(new SlackQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withPublicBaseUrl(baseUrl)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
features.put("cluster", i -> new ClusterQBitProducer().withQBitConfig(new ClusterQBitConfig()
|
||||
.withBackendName(backendName)).produce(i).addSelfToInstance(i));
|
||||
features.put("cdn", i -> new CdnQBitProducer().withQBitConfig(new CdnQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)
|
||||
.withPurgeableTables(List.of("customer")).withTableMetaDataCustomizer(tableCustomizer))
|
||||
.produce(i).addSelfToInstance(i));
|
||||
features.put("backups", i -> new BackupsQBitProducer().withQBitConfig(new BackupsQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName)
|
||||
.withStorageResolver(name -> new InMemoryBackupStorage())).produce(i).addSelfToInstance(i));
|
||||
features.put("fileGateway", i -> new FileGatewayQBitProducer().withQBitConfig(new FileGatewayQBitConfig()
|
||||
.withBackendName(backendName).withSftpEnabled(false).withSchedulerName(schedulerName)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
features.put("sessionStore", i ->
|
||||
{
|
||||
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
|
||||
.withProviderType(QSessionStoreProviderType.TABLE_BASED).withBackendName(backendName)
|
||||
.withDefaultTtl(Duration.ofHours(8)).withEnableSlidingExpiration(true)).produce(i, "session-store");
|
||||
if(i.getProcess("cleanExpiredSessions") != null && i.getProcess("cleanExpiredSessions").getSchedule() != null)
|
||||
{
|
||||
i.getProcess("cleanExpiredSessions").getSchedule().setSchedulerName(schedulerName);
|
||||
}
|
||||
});
|
||||
features.put("wormAudit", i ->
|
||||
{
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519");
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
new WormAuditQBitProducer().withQBitConfig(new WormAuditQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withAppRole("app")
|
||||
.withSigningKey(() -> new WormAuditQBitConfig.SigningKey("local", keyPair.getPrivate()))
|
||||
.withTrustedPublicKeys(Map.of("local", keyPair.getPublic()))
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i);
|
||||
});
|
||||
features.put("assistant", i -> new AssistantQBitProducer().withQBitConfig(EnterpriseAssistant.config(backendName, baseUrl, System::getenv)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)).produce(i).addSelfToInstance(i));
|
||||
|
||||
if(isSampleAppOnClasspath())
|
||||
{
|
||||
features.put("sampleApp", EnterpriseFeatures::mergeSampleApp);
|
||||
}
|
||||
|
||||
String opensearchHost = System.getenv("QQQ_ALL_OPENSEARCH_HOST");
|
||||
if(opensearchHost != null && !opensearchHost.isBlank())
|
||||
{
|
||||
features.put("quickSearch", i ->
|
||||
{
|
||||
QuickSearchQBitConfig searchConfig = new QuickSearchQBitConfig()
|
||||
.withBackendName(backendName)
|
||||
.withOpensearchHost(opensearchHost)
|
||||
.withOpensearchPort(Integer.valueOf(System.getenv().getOrDefault("QQQ_ALL_OPENSEARCH_PORT", "9200")))
|
||||
.withOpensearchIndexName(System.getenv().getOrDefault("QQQ_ALL_OPENSEARCH_INDEX", "qqq-all-enterprise"))
|
||||
.withUseSsl(false)
|
||||
.withAllowPlaintextCredentials(true)
|
||||
.withSchedulerName(schedulerName)
|
||||
.withTableMetaDataCustomizer(tableCustomizer)
|
||||
// new tables are queued for their first build, which the basepull runs: keep it quick
|
||||
.withBasepullRepeatSeconds(60);
|
||||
for(SearchableTableConfig table : searchableTables(i))
|
||||
{
|
||||
searchConfig.withSearchableTable(table);
|
||||
}
|
||||
new QuickSearchQBitProducer().withConfig(searchConfig).produce(i).addSelfToInstance(i);
|
||||
});
|
||||
}
|
||||
|
||||
String redisHost = System.getenv("QQQ_ALL_REDIS_HOST");
|
||||
if(redisHost != null && !redisHost.isBlank())
|
||||
{
|
||||
features.put("redis", i -> new RedisQBitProducer().withQBitConfig(new RedisQBitConfig()
|
||||
.withConnection("realtime", new RedisConnectionConfig().withHost(redisHost)
|
||||
.withPort(Integer.valueOf(System.getenv().getOrDefault("QQQ_ALL_REDIS_PORT", "6379")))
|
||||
.withTls(false))
|
||||
.withRealtime("realtime", "local", "qqq-all")).produce(i).addSelfToInstance(i));
|
||||
}
|
||||
|
||||
Set<String> excluded = excluded();
|
||||
for(Map.Entry<String, Feature> entry : features.entrySet())
|
||||
{
|
||||
if(excluded.contains(entry.getKey()))
|
||||
{
|
||||
System.out.println("[enterprise] skipped " + entry.getKey());
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
entry.getValue().add(instance);
|
||||
System.out.println("[enterprise] added " + entry.getKey());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
if("true".equals(System.getenv("QQQ_ALL_ENTERPRISE_LENIENT")))
|
||||
{
|
||||
System.out.println("[enterprise] FAILED " + entry.getKey() + ": " + e);
|
||||
Arrays.stream(e.getStackTrace()).limit(6).forEach(frame -> System.out.println("[enterprise] at " + frame));
|
||||
continue;
|
||||
}
|
||||
throw new QException("Enterprise feature " + entry.getKey() + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
enableColumnStatistics(instance);
|
||||
auditDataTables(instance);
|
||||
addNavigationApps(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ turns QUERY_STATS (column statistics: counts, distinct values, min/max
|
||||
** and averages per field) off on every backend by default. Turn it on for the
|
||||
** backends whose module can aggregate, RDBMS (H2, SQLite, the sample's
|
||||
** in-memory H2) and the memory backend, so their tables offer statistics as
|
||||
** the web UI and the terminal clients expect; filesystem and API backends
|
||||
** cannot aggregate and stay without it. Tables of those backends that list
|
||||
** QUERY_STATS as disabled keep it off. The columnStats process that serves
|
||||
** the statistics is added when the instance lacks it; core guards it by field
|
||||
** access (QRun-IO/qqq#1367: only fully readable fields, results tagged).
|
||||
*******************************************************************************/
|
||||
static void enableColumnStatistics(QInstance instance)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the UI API serves column statistics through this process //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(instance.getProcess(ColumnStatsStep.getProcessMetaData().getName()) == null)
|
||||
{
|
||||
instance.addProcess(ColumnStatsStep.getProcessMetaData());
|
||||
}
|
||||
|
||||
for(QBackendMetaData backend : instance.getBackends().values())
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData || new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
|
||||
{
|
||||
backend.withCapability(Capability.QUERY_STATS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Field-level audits on the demo and sample data tables (customer, order and
|
||||
** address get them with the audits feature), when audits are installed.
|
||||
*******************************************************************************/
|
||||
static void auditDataTables(QInstance instance)
|
||||
{
|
||||
if(instance.getTable("audit") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String tableName : List.of("customer", "order", "orderLine", "address", "person", "pet", "petNote", "carrier"))
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
if(table != null && table.getAuditRules() == null)
|
||||
{
|
||||
table.setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Text fields worth searching besides a table's record label fields. */
|
||||
private static final Set<String> SEARCHABLE_FIELD_NAMES = Set.of("name", "label", "title", "description", "email",
|
||||
"firstName", "lastName", "city", "state", "country", "code", "orderNo", "status");
|
||||
|
||||
/** The backends of the demo and sample data (QBit admin tables live on h2 too, and are left out). */
|
||||
private static final Set<String> SAMPLE_BACKENDS = Set.of("rdbms", "memory");
|
||||
private static final List<String> DEMO_TABLES = List.of("customer", "order", "address", "shipping_country",
|
||||
"shipping_stateProvince", "shipping_city");
|
||||
|
||||
/*******************************************************************************
|
||||
** Quick Search registrations for the demo tables and every visible sample-app
|
||||
** table with a record label: the label fields plus the obvious text fields,
|
||||
** STRING fields only (OpenSearch indexes them as searchable text; dates,
|
||||
** numbers and booleans stay out). Self-priming creates their index rows and
|
||||
** queues each for its first build.
|
||||
*******************************************************************************/
|
||||
static List<SearchableTableConfig> searchableTables(QInstance instance)
|
||||
{
|
||||
List<SearchableTableConfig> tables = new ArrayList<>();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
boolean demo = DEMO_TABLES.contains(table.getName());
|
||||
boolean sample = SAMPLE_BACKENDS.contains(table.getBackendName()) && !table.getIsHidden()
|
||||
&& table.getRecordLabelFields() != null && !table.getRecordLabelFields().isEmpty();
|
||||
if(!demo && !sample)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for(String labelField : Objects.requireNonNullElse(table.getRecordLabelFields(), List.<String>of()))
|
||||
{
|
||||
names.add(labelField);
|
||||
}
|
||||
names.addAll(SEARCHABLE_FIELD_NAMES);
|
||||
List<SearchableFieldConfig> fields = new ArrayList<>();
|
||||
for(String name : names)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
if(field != null && field.getType() == QFieldType.STRING)
|
||||
{
|
||||
fields.add(new SearchableFieldConfig(name).withWeight(Objects.requireNonNullElse(table.getRecordLabelFields(), List.<String>of()).contains(name) ? 3 : 1));
|
||||
}
|
||||
}
|
||||
if(fields.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SearchableTableConfig config = new SearchableTableConfig(table.getName(), fields);
|
||||
if((table.getRecordLabelFields() == null || table.getRecordLabelFields().isEmpty()) && fields.stream().anyMatch(f -> "name".equals(f.getFieldName())))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// label hits by name where the table itself has no label //
|
||||
///////////////////////////////////////////////////////////////
|
||||
config.withRecordLabelFormat("%s", "name");
|
||||
}
|
||||
if(!table.getFields().containsKey("modifyDate"))
|
||||
{
|
||||
config.withBasepullTimestampField(null);
|
||||
}
|
||||
tables.add(config);
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The core profile has no apps and several qbits only add tables. Put the
|
||||
** demo tables in a "QQQ All" app and every visible table that no app reaches
|
||||
** into an "Enterprise Admin" app, one section per table-name prefix, so the
|
||||
** clients' app tree reaches everything.
|
||||
*******************************************************************************/
|
||||
private static void addNavigationApps(QInstance instance)
|
||||
{
|
||||
List<String> demoTables = List.of("customer", "order", "orderLine", "product", "address", "apiCatalog", "demoNote",
|
||||
"shipping_country", "shipping_stateProvince", "shipping_city");
|
||||
List<QAppChildMetaData> demoChildren = new ArrayList<>();
|
||||
demoTables.stream().map(instance::getTable).filter(Objects::nonNull).forEach(demoChildren::add);
|
||||
List.of("syncOrder", "calculateOrderTotal").stream().map(instance::getProcess).filter(Objects::nonNull).forEach(demoChildren::add);
|
||||
instance.addApp(new QAppMetaData().withName("qqqAll").withLabel("QQQ All").withIcon(new QIcon("storefront"))
|
||||
.withSectionOfChildren(new QAppSection().withName("demoData").withLabel("Demo Data").withIcon(new QIcon("table_chart")), demoChildren));
|
||||
|
||||
Set<String> reached = new HashSet<>();
|
||||
for(QAppMetaData app : instance.getApps().values())
|
||||
{
|
||||
if(app.getChildren() != null)
|
||||
{
|
||||
app.getChildren().stream().filter(c -> c instanceof QTableMetaData).forEach(c -> reached.add(c.getName()));
|
||||
}
|
||||
if(app.getSections() != null)
|
||||
{
|
||||
app.getSections().stream().filter(section -> section.getTables() != null).forEach(section -> reached.addAll(section.getTables()));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<QAppChildMetaData>> byPrefix = new TreeMap<>();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(reached.contains(table.getName()) || table.getIsHidden())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String backendName = QInstanceEnricher.inferBackendName(table.getName());
|
||||
String prefix = backendName.contains("_") ? backendName.substring(0, backendName.indexOf('_')) : backendName;
|
||||
byPrefix.computeIfAbsent(prefix, k -> new ArrayList<>()).add(table);
|
||||
}
|
||||
QAppMetaData admin = new QAppMetaData().withName("enterpriseAdmin").withLabel("Enterprise Admin").withIcon(new QIcon("admin_panel_settings"));
|
||||
byPrefix.forEach((prefix, tables) -> admin.withSectionOfChildren(new QAppSection().withName("admin_" + prefix)
|
||||
.withLabel(QInstanceEnricher.nameToLabel(prefix)).withIcon(new QIcon("folder")), tables));
|
||||
instance.addApp(admin);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Filesystem tables (rendered report storage) answer 500 when their directory
|
||||
** does not exist yet: create each one under the files backend.
|
||||
*******************************************************************************/
|
||||
private static void ensureFilesystemDirectories(QInstance instance) throws Exception
|
||||
{
|
||||
if(!(instance.getBackend("files") instanceof FilesystemBackendMetaData files))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if("files".equals(table.getBackendName()) && table.getBackendDetails() instanceof AbstractFilesystemTableBackendDetails details && details.getBasePath() != null)
|
||||
{
|
||||
Files.createDirectories(Path.of(files.getBasePath(), details.getBasePath()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the qqq sample project's classes are on the classpath (the start
|
||||
** script adds qqq-sample-project/target/classes).
|
||||
*******************************************************************************/
|
||||
private static boolean isSampleAppOnClasspath()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(SAMPLE_PROVIDER);
|
||||
return true;
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge the standard qqq sample app (person, pet, carrier, city files, the
|
||||
** field and adornment labs, its processes, widgets and apps) into this
|
||||
** instance. Its ESB wiring is dropped (this instance has its own broker) and
|
||||
** names this instance already defines win. Reflection keeps the sample
|
||||
** project out of the build; it is a classpath add-on.
|
||||
*******************************************************************************/
|
||||
private static void mergeSampleApp(QInstance instance) throws Exception
|
||||
{
|
||||
Class<?> provider = Class.forName(SAMPLE_PROVIDER);
|
||||
QInstance sample = (QInstance) provider.getMethod("defineInstance").invoke(null);
|
||||
provider.getMethod("primeTestDatabase", String.class).invoke(null, "prime-test-database.sql");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the sample's sales module defines its own "customer"; this instance //
|
||||
// already has one, so the sales module stays out //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Set<String> skip = Set.of("sales", "customer", "sku", "tag", "sales_order", "sales_order_line", "sales_order_tag",
|
||||
"salesOrderJoinLine", "salesOrderJoinTag", "salesOrderLines", "salesOrderTags");
|
||||
sample.getTables().keySet().removeAll(skip);
|
||||
sample.getPossibleValueSources().keySet().removeAll(skip);
|
||||
sample.getJoins().keySet().removeAll(skip);
|
||||
sample.getWidgets().keySet().removeAll(skip);
|
||||
sample.getApps().keySet().removeAll(skip);
|
||||
sample.getApps().values().forEach(app ->
|
||||
{
|
||||
if(app.getChildren() != null)
|
||||
{
|
||||
app.getChildren().removeIf(child -> skip.contains(child.getName()));
|
||||
}
|
||||
if(app.getSections() != null)
|
||||
{
|
||||
app.getSections().forEach(section ->
|
||||
{
|
||||
if(section.getTables() != null)
|
||||
{
|
||||
section.getTables().removeIf(skip::contains);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sample.getBackends().forEach((name, value) -> instance.getBackends().putIfAbsent(name, value));
|
||||
sample.getPossibleValueSources().forEach((name, value) -> instance.getPossibleValueSources().putIfAbsent(name, value));
|
||||
sample.getSecurityKeyTypes().forEach((name, value) -> instance.getSecurityKeyTypes().putIfAbsent(name, value));
|
||||
sample.getTables().forEach((name, table) ->
|
||||
{
|
||||
if(table.getSupplementalMetaData() != null)
|
||||
{
|
||||
table.getSupplementalMetaData().values().removeIf(EnterpriseFeatures::isEsb);
|
||||
}
|
||||
instance.getTables().putIfAbsent(name, table);
|
||||
});
|
||||
sample.getProcesses().forEach((name, process) ->
|
||||
{
|
||||
if(process.getSupplementalMetaData() != null)
|
||||
{
|
||||
process.getSupplementalMetaData().values().removeIf(EnterpriseFeatures::isEsb);
|
||||
}
|
||||
instance.getProcesses().putIfAbsent(name, process);
|
||||
});
|
||||
sample.getJoins().forEach((name, value) -> instance.getJoins().putIfAbsent(name, value));
|
||||
sample.getReports().forEach((name, value) -> instance.getReports().putIfAbsent(name, value));
|
||||
sample.getWidgets().forEach((name, value) -> instance.getWidgets().putIfAbsent(name, value));
|
||||
sample.getApps().forEach((name, value) -> instance.getApps().putIfAbsent(name, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ESB supplemental meta data of the sample points at its own destinations.
|
||||
*******************************************************************************/
|
||||
private static boolean isEsb(Object supplemental)
|
||||
{
|
||||
return supplemental != null && supplemental.getClass().getName().startsWith("com.kingsrook.qqq.esb.");
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
|
||||
/**
|
||||
* Records mock sign-ins in the session store (qbit-session-store) for the
|
||||
* enterprise profile.
|
||||
*
|
||||
* Only the OAuth2 module writes to the session store; MOCK authentication
|
||||
* builds a fresh session on every request and stores nothing, so the
|
||||
* storedSession table stayed empty and "who is online" could not count
|
||||
* terminals. This is the smallest hook: the mock module's session customizer
|
||||
* calls {@link #record(QSession)}, which stores one row per session uuid
|
||||
* (user id, a lean copy of the session, expiresAt) the first time this JVM
|
||||
* sees it and touches it (sliding expiry) at most once a minute after that.
|
||||
*/
|
||||
final class EnterpriseSessions
|
||||
{
|
||||
static final Duration TOUCH_INTERVAL = Duration.ofMinutes(1);
|
||||
|
||||
private static final Map<String, Long> LAST_SEEN = new ConcurrentHashMap<>();
|
||||
|
||||
private EnterpriseSessions()
|
||||
{
|
||||
}
|
||||
|
||||
static void record(QSession session)
|
||||
{
|
||||
String uuid = session == null ? null : session.getUuid();
|
||||
if(uuid == null || !QSessionStoreHelper.isSessionStoreAvailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = LAST_SEEN.get(uuid);
|
||||
if(last != null && now - last < TOUCH_INTERVAL.toMillis())
|
||||
{
|
||||
return;
|
||||
}
|
||||
LAST_SEEN.put(uuid, now);
|
||||
if(last == null && QSessionStoreHelper.loadSession(uuid).isEmpty())
|
||||
{
|
||||
QSessionStoreHelper.storeSession(uuid, lean(session), QSessionStoreHelper.getDefaultTtl());
|
||||
}
|
||||
else
|
||||
{
|
||||
QSessionStoreHelper.touchSession(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/** The identity only: the admin session's thousand permissions do not belong in a row. */
|
||||
static QSession lean(QSession session)
|
||||
{
|
||||
QSession lean = new QSession();
|
||||
lean.setUuid(session.getUuid());
|
||||
lean.setIdReference(session.getUuid());
|
||||
if(session.getUser() != null)
|
||||
{
|
||||
QUser user = new QUser();
|
||||
user.setIdReference(session.getUser().getIdReference());
|
||||
user.setFullName(session.getUser().getFullName());
|
||||
lean.setUser(user);
|
||||
}
|
||||
return (lean);
|
||||
}
|
||||
|
||||
/** Tests start from a clean slate. */
|
||||
static void forget()
|
||||
{
|
||||
LAST_SEEN.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.userrolepermissions.model.Role;
|
||||
import com.kingsrook.qbits.userrolepermissions.utils.PermissionManager;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Resolves Keycloak realm roles through qbit-user-role-permissions. */
|
||||
public class FullOidcSessionCustomizer implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
Set<String> roles = realmRoles(context.get("idToken") instanceof JSONObject idToken ? idToken : null);
|
||||
if(roles.isEmpty() && context.get("jwtPayloadJsonObject") instanceof JSONObject accessToken)
|
||||
{
|
||||
roles = realmRoles(accessToken);
|
||||
}
|
||||
try
|
||||
{
|
||||
session.setPermissions(permissionsForRoles(roles));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not resolve OIDC role permissions", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> realmRoles(JSONObject claims)
|
||||
{
|
||||
if(claims == null)
|
||||
{
|
||||
return Set.of();
|
||||
}
|
||||
JSONObject realmAccess = claims.optJSONObject("realm_access");
|
||||
JSONArray values = realmAccess == null ? null : realmAccess.optJSONArray("roles");
|
||||
if(values == null)
|
||||
{
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> roles = new HashSet<>();
|
||||
for(Integer i = 0; i < values.length(); i++)
|
||||
{
|
||||
if(values.opt(i) instanceof String role && !role.isBlank())
|
||||
{
|
||||
roles.add(role);
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
protected Set<String> permissionsForRoles(Set<String> roles) throws QException
|
||||
{
|
||||
if(roles.isEmpty())
|
||||
{
|
||||
return Set.of();
|
||||
}
|
||||
QSession previousSession = QContext.getQSession();
|
||||
try
|
||||
{
|
||||
QContext.setQSession(new QSystemUserSession());
|
||||
Set<Integer> roleIds = new QueryAction().execute(new QueryInput(Role.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter().withCriteria("name", QCriteriaOperator.IN, roles)))
|
||||
.getRecords().stream().map(record -> record.getValueInteger("id")).collect(Collectors.toSet());
|
||||
return PermissionManager.getInstance().getEffectivePermissionsForRoles(roleIds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setQSession(previousSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitConfig;
|
||||
import com.kingsrook.qbits.userrolepermissions.UserRolePermissionsQBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.email.EmailMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.ProvidedOrSuppliedTableConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.RedirectStateMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.RecordFormat;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.esb.model.EsbDestinationType;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessEvent;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessPublication;
|
||||
import com.kingsrook.qqq.esb.model.EsbProviderType;
|
||||
import com.kingsrook.qqq.esb.model.QEsbDestinationMetaData;
|
||||
import com.kingsrook.qqq.esb.model.QEsbProviderMetaData;
|
||||
|
||||
/** Extends the core demo with external services selected by the full profile. */
|
||||
final class FullProfileApplication extends AbstractQQQApplication
|
||||
{
|
||||
private final Path dataDirectory;
|
||||
private final FullProfileSettings settings;
|
||||
private final boolean provisionSchema;
|
||||
|
||||
FullProfileApplication(Path dataDirectory, FullProfileSettings settings)
|
||||
{
|
||||
this(dataDirectory, settings, false);
|
||||
}
|
||||
|
||||
FullProfileApplication(Path dataDirectory, FullProfileSettings settings, boolean provisionSchema)
|
||||
{
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.settings = settings;
|
||||
this.provisionSchema = provisionSchema;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false, settings.port("PORT", "8080"), settings.get("BIND_HOST")).defineQInstance();
|
||||
addBackends(instance);
|
||||
addTables(instance);
|
||||
addAuthentication(instance);
|
||||
addEsb(instance);
|
||||
DemoQBits.addCore(instance, "postgres");
|
||||
DemoQBits.addFull(instance, settings);
|
||||
// OIDC and ESB sessions use string user IDs, unlike the qbit's default integer user ID.
|
||||
instance.getTable("processTrace").getField("userId").setType(QFieldType.STRING);
|
||||
DemoUsers.protectWebhookResources(instance);
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(!"postgres".equals(table.getBackendName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(table.getBackendDetails() == null)
|
||||
{
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails()
|
||||
.withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
}
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
}
|
||||
|
||||
EmailMessagingProviderMetaData mail = new EmailMessagingProviderMetaData()
|
||||
.withSmtpServer(settings.get("SMTP_HOST"))
|
||||
.withSmtpPort(settings.getOrDefault("SMTP_PORT", "1025"));
|
||||
mail.setName("mailpit");
|
||||
instance.addMessagingProvider(mail);
|
||||
if(provisionSchema)
|
||||
{
|
||||
try
|
||||
{
|
||||
DemoQBitData.ensurePostgresSchema(instance);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw new QException("Could not provision full-profile qbit tables", e);
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void addBackends(QInstance instance)
|
||||
{
|
||||
instance.addBackend(new PostgreSQLBackendMetaData().withName("postgres")
|
||||
.withHostName(settings.get("POSTGRES_HOST")).withPort(settings.port("POSTGRES_PORT", "5432"))
|
||||
.withDatabaseName(settings.get("POSTGRES_DATABASE"))
|
||||
.withUsername(settings.get("POSTGRES_USER")).withPassword(settings.get("POSTGRES_PASSWORD")));
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("mysql").withVendor("mysql")
|
||||
.withHostName(settings.get("MYSQL_HOST")).withPort(settings.port("MYSQL_PORT", "3306"))
|
||||
.withDatabaseName(settings.get("MYSQL_DATABASE"))
|
||||
.withUsername(settings.get("MYSQL_USER")).withPassword(settings.get("MYSQL_PASSWORD")));
|
||||
instance.addBackend(new MongoDBBackendMetaData().withName("mongo")
|
||||
.withHost(settings.get("MONGO_HOST")).withPort(settings.port("MONGO_PORT", "27017"))
|
||||
.withDatabaseName(settings.get("MONGO_DATABASE"))
|
||||
.withUsername(settings.get("MONGO_USER")).withPassword(settings.get("MONGO_PASSWORD"))
|
||||
.withAuthSourceDatabase(settings.getOrDefault("MONGO_AUTH_DATABASE", "admin"))
|
||||
.withUrlSuffix("replicaSet=" + settings.get("MONGO_REPLICA_SET"))
|
||||
.withTransactionsSupported(true));
|
||||
instance.addBackend(new MinioBackendMetaData(settings.get("S3_ENDPOINT")).withName("s3")
|
||||
.withBucketName(settings.get("S3_BUCKET"))
|
||||
.withAccessKey(settings.get("S3_ACCESS_KEY")).withSecretKey(settings.get("S3_SECRET_KEY"))
|
||||
.withRegion(settings.getOrDefault("S3_REGION", "us-east-1")));
|
||||
instance.addBackend(new SFTPBackendMetaData().withName("sftp")
|
||||
.withHostName(settings.get("SFTP_HOST")).withPort(settings.port("SFTP_PORT", "22"))
|
||||
.withUsername(settings.get("SFTP_USER")).withPassword(settings.get("SFTP_PASSWORD"))
|
||||
.withBasePath(settings.getOrDefault("SFTP_BASE_PATH", "/upload")));
|
||||
}
|
||||
|
||||
private void addTables(QInstance instance)
|
||||
{
|
||||
instance.addTable(new QTableMetaData().withName("warehouseCustomer").withLabel("Warehouse Customer")
|
||||
.withBackendName("postgres").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("warehouse_customer"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("supplierOrder").withLabel("Supplier Order")
|
||||
.withBackendName("mysql").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("supplier_order"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("reference", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("shipment").withLabel("Shipment")
|
||||
.withBackendName("mongo").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new MongoDBTableBackendDetails().withTableName("shipment"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING).withBackendName("_id"))
|
||||
.withField(new QFieldMetaData("tracking", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("document").withLabel("Document")
|
||||
.withBackendName("s3").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new S3TableBackendDetails().withBasePath("documents")
|
||||
.withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("externalImportFile").withLabel("External Import File")
|
||||
.withBackendName("sftp").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new SFTPTableBackendDetails().withBasePath("imports")
|
||||
.withRecordFormat(RecordFormat.JSON).withCardinality(Cardinality.MANY).withGlob("*.json"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
}
|
||||
|
||||
private void addAuthentication(QInstance instance) throws QException
|
||||
{
|
||||
instance.addTable(new UserSessionMetaDataProducer("postgres").produce(instance));
|
||||
instance.addTable(new RedirectStateMetaDataProducer("postgres").produce(instance));
|
||||
|
||||
UserRolePermissionsQBitConfig qbitConfig = new UserRolePermissionsQBitConfig()
|
||||
.withUserTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("postgres"))
|
||||
.withTableMetaDataCustomizer((i, table) ->
|
||||
{
|
||||
table.setBackendName("postgres");
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails()
|
||||
.withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
return table;
|
||||
});
|
||||
new UserRolePermissionsQBitProducer().withUserRolePermissionsQBitConfig(qbitConfig).produce(instance);
|
||||
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new OAuth2AuthenticationMetaData()
|
||||
.withBaseUrl(settings.get("OIDC_BASE_URL"))
|
||||
.withExternalBaseUrl(settings.getOrDefault("OIDC_EXTERNAL_BASE_URL", settings.get("OIDC_BASE_URL")))
|
||||
.withClientId(settings.get("OIDC_CLIENT_ID"))
|
||||
.withClientSecret(settings.get("OIDC_CLIENT_SECRET"))
|
||||
.withScopes(settings.getOrDefault("OIDC_SCOPES", "openid email profile"))
|
||||
.withUserSessionTableName(UserSession.TABLE_NAME)
|
||||
.withRedirectStateTableName(RedirectStateMetaDataProducer.TABLE_NAME)
|
||||
.withCustomizer(new QCodeReference(FullOidcSessionCustomizer.class))
|
||||
.withName("keycloak"));
|
||||
}
|
||||
|
||||
private void addEsb(QInstance instance)
|
||||
{
|
||||
EsbInstanceMetaData.of(instance)
|
||||
.getProvider("artemis")
|
||||
.withUsername(settings.get("ARTEMIS_USER"))
|
||||
.withPassword(settings.get("ARTEMIS_PASSWORD"))
|
||||
.withManagementUrl(settings.get("ARTEMIS_MANAGEMENT_URL"))
|
||||
.withManagementUsername(settings.get("ARTEMIS_USER"))
|
||||
.withManagementPassword(settings.get("ARTEMIS_PASSWORD"));
|
||||
EsbInstanceMetaData.of(instance)
|
||||
.withProvider(new QEsbProviderMetaData().withName("rabbitmq")
|
||||
.withType(EsbProviderType.RABBITMQ).withUrl(settings.get("RABBITMQ_URL"))
|
||||
.withUsername(settings.get("RABBITMQ_USER")).withPassword(settings.get("RABBITMQ_PASSWORD"))
|
||||
.withManagementUrl(settings.get("RABBITMQ_MANAGEMENT_URL"))
|
||||
.withManagementUsername(settings.get("RABBITMQ_USER"))
|
||||
.withManagementPassword(settings.get("RABBITMQ_PASSWORD")))
|
||||
.withDestination(new QEsbDestinationMetaData().withName("orderSyncEvents")
|
||||
.withType(EsbDestinationType.QUEUE).withProviderName("rabbitmq")
|
||||
.withDestinationName("qqq.all.orderSyncEvents"));
|
||||
EsbProcessMetaData.ofOrWithNew(instance.getProcess("syncOrder"))
|
||||
.withPublication(new EsbProcessPublication().withDestinationName("orderSyncEvents")
|
||||
.withEvents(List.of(EsbProcessEvent.COMPLETED)));
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Validated environment settings for the external services in the full profile. */
|
||||
final class FullProfileSettings
|
||||
{
|
||||
private static final List<String> REQUIRED = List.of(
|
||||
"BIND_HOST",
|
||||
"POSTGRES_HOST", "POSTGRES_DATABASE", "POSTGRES_USER", "POSTGRES_PASSWORD",
|
||||
"MYSQL_HOST", "MYSQL_DATABASE", "MYSQL_USER", "MYSQL_PASSWORD",
|
||||
"MONGO_HOST", "MONGO_DATABASE", "MONGO_USER", "MONGO_PASSWORD", "MONGO_REPLICA_SET",
|
||||
"S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
|
||||
"SFTP_HOST", "SFTP_USER", "SFTP_PASSWORD",
|
||||
"ARTEMIS_URL", "ARTEMIS_USER", "ARTEMIS_PASSWORD", "ARTEMIS_MANAGEMENT_URL",
|
||||
"RABBITMQ_URL", "RABBITMQ_USER", "RABBITMQ_PASSWORD", "RABBITMQ_MANAGEMENT_URL",
|
||||
"SMTP_HOST", "OIDC_BASE_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET",
|
||||
"OPENSEARCH_HOST");
|
||||
|
||||
private final Map<String, String> values;
|
||||
|
||||
private FullProfileSettings(Map<String, String> values)
|
||||
{
|
||||
this.values = Map.copyOf(values);
|
||||
}
|
||||
|
||||
static FullProfileSettings from(Map<String, String> environment)
|
||||
{
|
||||
for(String name : REQUIRED)
|
||||
{
|
||||
if(environment.get("QQQ_ALL_" + name) == null || environment.get("QQQ_ALL_" + name).isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Missing required environment variable QQQ_ALL_" + name);
|
||||
}
|
||||
}
|
||||
return new FullProfileSettings(environment);
|
||||
}
|
||||
|
||||
String get(String name)
|
||||
{
|
||||
return values.get("QQQ_ALL_" + name);
|
||||
}
|
||||
|
||||
String getOrDefault(String name, String defaultValue)
|
||||
{
|
||||
String value = get(name);
|
||||
return value == null || value.isBlank() ? defaultValue : value;
|
||||
}
|
||||
|
||||
Integer port(String name, String defaultValue)
|
||||
{
|
||||
Integer port = Integer.valueOf(getOrDefault(name, defaultValue));
|
||||
if(port < 1 || port > 65535)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid port in QQQ_ALL_" + name);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
|
||||
|
||||
/** S3 metadata with an explicit endpoint for MinIO. */
|
||||
public final class MinioBackendMetaData extends S3BackendMetaData
|
||||
{
|
||||
public MinioBackendMetaData(String endpoint)
|
||||
{
|
||||
setEndpoint(endpoint);
|
||||
setPathStyleAccess(true);
|
||||
setBackendType(MinioBackendModule.class);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.S3BackendModule;
|
||||
|
||||
/** Uses the shared S3 actions with MinIO endpoint metadata. */
|
||||
public final class MinioBackendModule extends S3BackendModule
|
||||
{
|
||||
static
|
||||
{
|
||||
QBackendModuleDispatcher.registerBackendModule(new MinioBackendModule());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBackendType()
|
||||
{
|
||||
return "minio";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends QBackendMetaData> getBackendMetaDataClass()
|
||||
{
|
||||
return MinioBackendMetaData.class;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
|
||||
/** Selects the standalone or externally backed reference application. */
|
||||
public final class ProfileLauncher
|
||||
{
|
||||
private ProfileLauncher()
|
||||
{
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
launch(System.getenv());
|
||||
}
|
||||
|
||||
static String profile(Map<String, String> environment)
|
||||
{
|
||||
String selected = environment.getOrDefault("QQQ_ALL_PROFILE", "core");
|
||||
if(!"core".equals(selected) && !"full".equals(selected) && !EnterpriseFeatures.PROFILE.equals(selected))
|
||||
{
|
||||
throw new IllegalArgumentException("QQQ_ALL_PROFILE must be core, full or enterprise");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
static void launch(Map<String, String> environment) throws Exception
|
||||
{
|
||||
String selected = profile(environment);
|
||||
Path dataDirectory = Path.of(environment.getOrDefault("QQQ_ALL_DATA_DIR", "./data"));
|
||||
Integer port = Integer.valueOf(environment.getOrDefault("QQQ_ALL_PORT", "8080"));
|
||||
if("core".equals(selected) || EnterpriseFeatures.PROFILE.equals(selected))
|
||||
{
|
||||
CoreRuntime.launch(dataDirectory, port, environment.getOrDefault("QQQ_ALL_BIND_HOST", "127.0.0.1"));
|
||||
return;
|
||||
}
|
||||
|
||||
FullProfileSettings settings = FullProfileSettings.from(environment);
|
||||
CoreData.seed(dataDirectory);
|
||||
QApplicationLauncher.run(new FullProfileApplication(dataDirectory, settings, true),
|
||||
new QApplicationLauncherConfig().withRegisterShutdownHook(true)
|
||||
.withServerCustomizer(server -> server.withPort(port)
|
||||
.withJavalinConfigCustomizer(config -> config.jetty.host = settings.get("BIND_HOST"))));
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.tracing.ProcessTracerKeyRecordMessage;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.RecordFormat;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.sqlite.model.metadata.SQLiteBackendMetaData;
|
||||
import com.kingsrook.qqq.esb.envelope.EsbEvent;
|
||||
import com.kingsrook.qqq.esb.api.EsbJavalinMetaDataProducer;
|
||||
import com.kingsrook.qqq.esb.metadata.EsbAppMetaDataProducer;
|
||||
import com.kingsrook.qqq.esb.model.EsbDestinationType;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProviderType;
|
||||
import com.kingsrook.qqq.esb.model.EsbTableEvent;
|
||||
import com.kingsrook.qqq.esb.model.EsbTableMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbTablePublication;
|
||||
import com.kingsrook.qqq.esb.model.EsbTrigger;
|
||||
import com.kingsrook.qqq.esb.model.QEsbDestinationMetaData;
|
||||
import com.kingsrook.qqq.esb.model.QEsbProviderMetaData;
|
||||
import com.kingsrook.qqq.esb.runtime.EsbTriggerHandler;
|
||||
import com.kingsrook.qqq.middleware.health.JavalinHealthRouteProvider;
|
||||
import com.kingsrook.qqq.middleware.health.indicators.MemoryHealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
/** The self-contained core QQQ reference application. */
|
||||
public final class QqqAllApplication extends AbstractQQQApplication
|
||||
{
|
||||
private final Path dataDirectory;
|
||||
private final String brokerUrl;
|
||||
private final boolean includeDemoQbits;
|
||||
private final int httpPort;
|
||||
private final String bindHost;
|
||||
|
||||
public QqqAllApplication(Path dataDirectory, String brokerUrl)
|
||||
{
|
||||
this(dataDirectory, brokerUrl, true);
|
||||
}
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits)
|
||||
{
|
||||
this(dataDirectory, brokerUrl, includeDemoQbits, 8080);
|
||||
}
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort)
|
||||
{
|
||||
this(dataDirectory, brokerUrl, includeDemoQbits, httpPort, "127.0.0.1");
|
||||
}
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort, String bindHost)
|
||||
{
|
||||
this.bindHost = bindHost;
|
||||
this.httpPort = httpPort;
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.brokerUrl = brokerUrl;
|
||||
this.includeDemoQbits = includeDemoQbits;
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path dataDirectory = Path.of(System.getenv().getOrDefault("QQQ_ALL_DATA_DIR", "./data"));
|
||||
Integer port = Integer.valueOf(System.getenv().getOrDefault("QQQ_ALL_PORT", "8080"));
|
||||
CoreRuntime.launch(dataDirectory, port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QInstance().withFailOnMetaDataProducerError(true);
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData()
|
||||
.withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoUsers.class)));
|
||||
|
||||
instance.addBackend(new RDBMSBackendMetaData()
|
||||
.withName("h2").withVendor("h2").withHostName("file")
|
||||
.withDatabaseName(CoreData.h2DatabaseName(dataDirectory)).withUsername("sa"));
|
||||
instance.addBackend(new SQLiteBackendMetaData().withName("sqlite")
|
||||
.withPath(CoreData.sqlitePath(dataDirectory).toAbsolutePath().toString()));
|
||||
instance.addBackend(new FilesystemBackendMetaData().withName("files")
|
||||
.withBasePath(dataDirectory.resolve("files").toAbsolutePath().toString()));
|
||||
|
||||
instance.addTable(new QTableMetaData().withName("customer").withLabel("Customer")
|
||||
.withBackendName("h2").withPrimaryKeyField("id")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("email", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("order").withLabel("Order")
|
||||
.withBackendName("h2").withBackendDetails(new RDBMSTableBackendDetails().withTableName("orders"))
|
||||
.withPrimaryKeyField("id").withRecordLabelFormat("%s").withRecordLabelFields("orderNo")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("orderNo", QFieldType.STRING).withBackendName("order_no"))
|
||||
.withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id"))
|
||||
.withField(new QFieldMetaData("status", QFieldType.STRING))
|
||||
.withSupplementalMetaData(new EsbTableMetaData().withPublication(new EsbTablePublication()
|
||||
.withDestinationName("orderEvents").withEvents(List.of(EsbTableEvent.INSERT, EsbTableEvent.UPDATE)))));
|
||||
instance.addTable(new QTableMetaData().withName("orderLine").withLabel("Order Line")
|
||||
.withBackendName("sqlite").withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line"))
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("orderId", QFieldType.INTEGER).withBackendName("order_id"))
|
||||
.withField(new QFieldMetaData("productId", QFieldType.INTEGER).withBackendName("product_id"))
|
||||
.withField(new QFieldMetaData("quantity", QFieldType.INTEGER)));
|
||||
instance.addTable(new QTableMetaData().withName("product").withLabel("Product")
|
||||
.withBackendName("files").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new FilesystemTableBackendDetails()
|
||||
.withBasePath("products").withRecordFormat(RecordFormat.JSON)
|
||||
.withCardinality(Cardinality.MANY).withGlob("*.json"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("price", QFieldType.DECIMAL)));
|
||||
|
||||
instance.addProcess(new QProcessMetaData().withName("syncOrder").withLabel("Sync Order")
|
||||
.withTableName("order")
|
||||
.withStep(new QBackendStepMetaData().withName("sync")
|
||||
.withCode(new QCodeReference(SyncOrderStep.class)))
|
||||
.withSupplementalMetaData(new EsbProcessMetaData()
|
||||
.withTrigger(new EsbTrigger().withDestinationName("orderEvents"))));
|
||||
|
||||
EsbInstanceMetaData.of(instance).withInstanceName("qqqAll")
|
||||
.withProvider(new QEsbProviderMetaData().withName("artemis")
|
||||
.withType(EsbProviderType.ACTIVEMQ_ARTEMIS).withUrl(brokerUrl))
|
||||
.withDestination(new QEsbDestinationMetaData().withName("orderEvents")
|
||||
.withType(EsbDestinationType.TOPIC).withProviderName("artemis")
|
||||
.withDestinationName("qqq.all.orderEvents"));
|
||||
instance.withRuntimeService(new QCodeReference(CoreEsbRuntimeService.class));
|
||||
MetaDataProducerHelper.processAllMetaDataProducersInPackage(instance, EsbAppMetaDataProducer.class.getPackageName());
|
||||
new EsbJavalinMetaDataProducer().produce(instance);
|
||||
|
||||
instance.withSupplementalMetaData(new HealthCheckMetaData().withEnabled(true)
|
||||
.withEndpointPath("/health").withIndicators(new ArrayList<>(List.of(new MemoryHealthIndicator().withThreshold(99)))));
|
||||
QJavalinMetaData.ofOrWithNew(instance)
|
||||
.withAdditionalRouteProviderReference(new QCodeReference(JavalinHealthRouteProvider.class));
|
||||
if(includeDemoQbits)
|
||||
{
|
||||
DemoQBits.addCore(instance, "h2");
|
||||
if(EnterpriseFeatures.isEnabled())
|
||||
{
|
||||
EnterpriseFeatures.add(instance, "h2", httpPort);
|
||||
}
|
||||
DemoUsers.protectDemoTables(instance);
|
||||
QJavalinMetaData.ofOrWithNew(instance)
|
||||
.withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class));
|
||||
}
|
||||
DemoRuntimeFeatures.add(instance, httpPort, bindHost);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** A visible effect for the demo and for the ESB integration test. */
|
||||
public static final class SyncOrderStep implements BackendStep
|
||||
{
|
||||
private static final AtomicInteger RUN_COUNT = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getValue(EsbTriggerHandler.VALUE_ESB_MESSAGES) instanceof List<?> messages)
|
||||
{
|
||||
for(Object message : messages)
|
||||
{
|
||||
if(message instanceof EsbEvent event
|
||||
&& event.getSource().endsWith("/table/order") && event.getSubject() != null)
|
||||
{
|
||||
Integer orderId = Integer.valueOf(event.getSubject());
|
||||
input.getProcessTracer().ifPresent(tracer -> tracer.handleMessage(input,
|
||||
new ProcessTracerKeyRecordMessage("order", orderId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
RUN_COUNT.incrementAndGet();
|
||||
}
|
||||
|
||||
public static Integer getRunCount()
|
||||
{
|
||||
return RUN_COUNT.get();
|
||||
}
|
||||
|
||||
public static void reset()
|
||||
{
|
||||
RUN_COUNT.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/** Seed and clear only the application's transient memory table. */
|
||||
public class TransientNotesService implements QRuntimeServiceInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(TransientNotesService.class);
|
||||
private QInstance instance;
|
||||
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "transientDemoNotes";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(QInstance instance) throws QException
|
||||
{
|
||||
this.instance = instance;
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
clearNotes();
|
||||
new InsertAction().execute(new InsertInput("demoNote").withRecords(List.of(
|
||||
new QRecord().withValue("id", 1).withValue("text", "Transient demo note"))));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
clearNotes();
|
||||
}
|
||||
catch(Exception cleanup)
|
||||
{
|
||||
failure.addSuppressed(cleanup);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
clearNotes();
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
LOG.warn("Could not clear transient demo notes", failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearNotes() throws QException
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput("demoNote").withQueryFilter(new QQueryFilter()));
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
|
||||
import com.kingsrook.qqq.esb.runtime.EsbTriggerState;
|
||||
import com.kingsrook.qqq.esb.runtime.QEsbRuntime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Exercises the assembled core application, including its real broker and backends. */
|
||||
class CoreApplicationTest
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void bootsQueriesEveryTableAndProcessesOrderEvents() throws Exception
|
||||
{
|
||||
Integer port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
|
||||
QqqAllApplication.SyncOrderStep.reset();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(dataDirectory, port))
|
||||
{
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpResponse<String> health = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + port + "/health")).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getApp("esb"));
|
||||
HttpResponse<String> adminEsb = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/esb/overview"))
|
||||
.header("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, adminEsb.statusCode(), adminEsb.body());
|
||||
assertTrue(adminEsb.body().contains("\"destinations\""));
|
||||
HttpResponse<String> viewerEsb = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/esb/overview"))
|
||||
.header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(403, viewerEsb.statusCode());
|
||||
|
||||
HttpResponse<String> dashboard = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + port + "/")).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, dashboard.statusCode());
|
||||
assertTrue(dashboard.body().contains("html"));
|
||||
|
||||
HttpResponse<String> demoRead = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/order/query"))
|
||||
.header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, demoRead.statusCode(), demoRead.body());
|
||||
HttpResponse<String> demoInsert = insertOrder(client, port, DemoUsers.DEMO_SESSION, "ORD-DENIED");
|
||||
assertEquals(403, demoInsert.statusCode());
|
||||
assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION,
|
||||
"webhook", "{\"url\":\"http://127.0.0.1:1/\"}").statusCode());
|
||||
assertEquals(403, updateWebhook(client, port, DemoUsers.DEMO_SESSION,
|
||||
"webhookSubscription", "{\"activeStatusId\":3}").statusCode());
|
||||
assertEquals(403, runWebhookProcess(client, port, DemoUsers.DEMO_SESSION).statusCode());
|
||||
assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION,
|
||||
"webhook", "{\"url\":\"http://127.0.0.1:" + port + "/demo/order-webhook\"}").statusCode());
|
||||
assertEquals(200, updateWebhook(client, port, DemoUsers.ADMIN_SESSION,
|
||||
"webhookSubscription", "{\"activeStatusId\":1}").statusCode());
|
||||
HttpResponse<String> adminInsert = insertOrder(client, port, DemoUsers.ADMIN_SESSION, "ORD-ADMIN");
|
||||
assertEquals(200, adminInsert.statusCode(), adminInsert.body());
|
||||
assertEquals(200, runWebhookProcess(client, port, DemoUsers.ADMIN_SESSION).statusCode());
|
||||
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
MockAuthenticationModule mockAuth = new MockAuthenticationModule();
|
||||
assertEquals("core:admin", mockAuth.createSession(runtime.getLauncher().getQInstance(),
|
||||
Map.of("sessionId", DemoUsers.ADMIN_SESSION)).getUser().getIdReference());
|
||||
assertEquals("core:demo", mockAuth.createSession(runtime.getLauncher().getQInstance(),
|
||||
Map.of("sessionId", DemoUsers.DEMO_SESSION)).getUser().getIdReference());
|
||||
|
||||
for(String table : List.of("customer", "order", "orderLine", "product", "address", "shipping_city"))
|
||||
{
|
||||
assertFalse(new QueryAction().execute(new QueryInput(table)).getRecords().isEmpty(), table);
|
||||
}
|
||||
assertEquals("Customer Directory", new QueryAction().execute(new QueryInput("TableView"))
|
||||
.getRecords().getFirst().getValueString("name"));
|
||||
assertEquals("Order Status Review", new QueryAction().execute(new QueryInput("workflow"))
|
||||
.getRecords().getFirst().getValueString("name"));
|
||||
|
||||
waitFor(() -> QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents") != null
|
||||
&& QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents").getState() == EsbTriggerState.RUNNING);
|
||||
Integer insertedOrderId = new InsertAction().execute(new InsertInput("order").withRecords(List.of(
|
||||
new QRecord().withValue("orderNo", "ORD-1002")
|
||||
.withValue("customerId", 2).withValue("status", "NEW"))))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0);
|
||||
waitFor(() -> new QueryAction().execute(new QueryInput("processTrace")).getRecords().stream()
|
||||
.anyMatch(trace -> insertedOrderId.equals(trace.getValueInteger("keyRecordId"))));
|
||||
waitFor(() -> !new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().isEmpty());
|
||||
|
||||
HttpResponse<String> receipts = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts"))
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, receipts.statusCode(), receipts.body());
|
||||
waitFor(() -> client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/demo/order-webhook-receipts"))
|
||||
.build(), HttpResponse.BodyHandlers.ofString()).body().contains("ORD-ADMIN"));
|
||||
waitFor(() -> new QueryAction().execute(new QueryInput("webhookEvent")).getRecords().stream()
|
||||
.anyMatch(event -> WebhookEventStatus.DELIVERED.getId().equals(event.getValueInteger("eventStatusId"))));
|
||||
|
||||
RunProcessInput workflow = new RunProcessInput();
|
||||
workflow.setProcessName("RunRecordWorkflow");
|
||||
workflow.setCallback(QProcessCallbackFactory.forPrimaryKey("id", 1));
|
||||
workflow.addValue("tableName", "order");
|
||||
workflow.addValue("workflowId", 1);
|
||||
workflow.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(workflow);
|
||||
assertFalse(new QueryAction().execute(new QueryInput("workflowRunLog")).getRecords().isEmpty());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> updateWebhook(HttpClient client, Integer port, String sessionId,
|
||||
String tableName, String body) throws Exception
|
||||
{
|
||||
return client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/data/" + tableName + "/1"))
|
||||
.header("Cookie", "sessionId=" + sessionId)
|
||||
.header("Content-Type", "application/json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(body))
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static HttpResponse<String> runWebhookProcess(HttpClient client, Integer port, String sessionId)
|
||||
throws Exception
|
||||
{
|
||||
return client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/processes/SendWebhookEvent/run?webhookId=1"))
|
||||
.header("Cookie", "sessionId=" + sessionId)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static HttpResponse<String> insertOrder(HttpClient client, Integer port, String sessionId, String orderNo)
|
||||
throws Exception
|
||||
{
|
||||
return client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/data/order/"))
|
||||
.header("Cookie", "sessionId=" + sessionId)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{\"orderNo\":\"" + orderNo
|
||||
+ "\",\"customerId\":1,\"status\":\"NEW\"}"))
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static void waitFor(java.util.concurrent.Callable<Boolean> condition) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
|
||||
while(!condition.call() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertTrue(condition.call());
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Exercises the shaded entry point and its HTTP API after packaging. */
|
||||
class CoreJarIT
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void packagedApplicationServesSeededData() throws Exception
|
||||
{
|
||||
Integer port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
|
||||
Path jar = Path.of(System.getProperty("qqqAllJar"));
|
||||
Path log = dataDirectory.resolve("app.log");
|
||||
ProcessBuilder builder = new ProcessBuilder(
|
||||
Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-jar", jar.toString());
|
||||
builder.environment().put("QQQ_ALL_PROFILE", "core");
|
||||
builder.environment().put("QQQ_ALL_PORT", port.toString());
|
||||
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("data").toString());
|
||||
builder.redirectErrorStream(true).redirectOutput(log.toFile());
|
||||
Process process = builder.start();
|
||||
try
|
||||
{
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
URI healthUri = URI.create("http://127.0.0.1:" + port + "/health");
|
||||
HttpResponse<String> health = awaitHealth(client, healthUri, process, log);
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"),
|
||||
"Core profile must bind only to loopback");
|
||||
|
||||
HttpRequest query = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build();
|
||||
HttpResponse<String> customers = client.send(query, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, customers.statusCode(), customers.body());
|
||||
assertTrue(customers.body().contains("Ada Lovelace"));
|
||||
for(String table : java.util.List.of("apiCatalog", "demoNote"))
|
||||
{
|
||||
var result = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/qqq/v1/table/" + table + "/query")).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, result.statusCode(), result.body());
|
||||
var records = new org.json.JSONObject(result.body()).getJSONArray("records");
|
||||
assertEquals(table.equals("apiCatalog") ? 3 : 1, records.length());
|
||||
}
|
||||
var calculation = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"))
|
||||
.header("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, calculation.statusCode(), calculation.body());
|
||||
assertEquals(3750, new org.json.JSONObject(calculation.body()).getJSONObject("values").getInt("totalCents"));
|
||||
}
|
||||
try(var packaged = new java.util.jar.JarFile(jar.toFile()))
|
||||
{
|
||||
var service = packaged.getJarEntry("META-INF/services/javax.script.ScriptEngineFactory");
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(service);
|
||||
try(var input = packaged.getInputStream(service))
|
||||
{
|
||||
assertTrue(new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)
|
||||
.contains("org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory"));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.destroy();
|
||||
if(!process.waitFor(5, TimeUnit.SECONDS))
|
||||
{
|
||||
process.destroyForcibly();
|
||||
process.waitFor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"::1", "::", "0:0:0:0:0:0:0:0", "0.0.0.0"})
|
||||
void packagedCatalogUsesConfiguredListener(String bindHost) throws Exception
|
||||
{
|
||||
String clientHost = bindHost.contains(":") ? "::1" : "127.0.0.1";
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0, 50, java.net.InetAddress.getByName(clientHost)))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
URI base = new URI("http", null, clientHost, port, "/", null, null);
|
||||
Path log = dataDirectory.resolve("bound-app.log");
|
||||
ProcessBuilder builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
|
||||
"-jar", System.getProperty("qqqAllJar"));
|
||||
builder.environment().put("QQQ_ALL_PROFILE", "core");
|
||||
builder.environment().put("QQQ_ALL_BIND_HOST", bindHost);
|
||||
builder.environment().put("QQQ_ALL_PORT", Integer.toString(port));
|
||||
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("bound-data").toString());
|
||||
builder.redirectErrorStream(true).redirectOutput(log.toFile());
|
||||
Process process = builder.start();
|
||||
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build())
|
||||
{
|
||||
assertEquals(200, awaitHealth(client, base.resolve("health"), process, log).statusCode());
|
||||
var direct = client.send(HttpRequest.newBuilder(base.resolve("demo/catalog"))
|
||||
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, direct.statusCode(), "Configured listener must serve its catalog before testing the backend");
|
||||
assertEquals(2, new org.json.JSONArray(direct.body()).length());
|
||||
|
||||
var all = catalogQuery(client, base, "{}");
|
||||
assertEquals(java.util.List.of(101, 102, 103), all.toList().stream()
|
||||
.map(row -> ((java.util.Map<?, ?>) ((java.util.Map<?, ?>) row).get("values")).get("id")).toList());
|
||||
for(int skip = 0; skip < 3; skip++)
|
||||
{
|
||||
var page = catalogQuery(client, base, "{\"filter\":{\"skip\":" + skip + ",\"limit\":1}}");
|
||||
assertEquals(1, page.length());
|
||||
assertEquals(java.util.List.of(101, 102, 103).get(skip), page.getJSONObject(0).getJSONObject("values").getInt("id"));
|
||||
}
|
||||
assertEquals(0, catalogQuery(client, base, "{\"filter\":{\"skip\":3,\"limit\":1}}").length());
|
||||
var get = client.send(HttpRequest.newBuilder(base.resolve("data/apiCatalog/103"))
|
||||
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, get.statusCode(), get.body());
|
||||
assertEquals("Shipping Box", new org.json.JSONObject(get.body()).getJSONObject("values").getString("name"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.destroy();
|
||||
if(!process.waitFor(5, TimeUnit.SECONDS))
|
||||
{
|
||||
process.destroyForcibly();
|
||||
assertTrue(process.waitFor(5, TimeUnit.SECONDS), "Owned packaged process must stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static org.json.JSONArray catalogQuery(HttpClient client, URI base, String body) throws Exception
|
||||
{
|
||||
var response = client.send(HttpRequest.newBuilder(base.resolve("qqq/v1/table/apiCatalog/query"))
|
||||
.timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, response.statusCode(), response.body());
|
||||
return new org.json.JSONObject(response.body()).getJSONArray("records");
|
||||
}
|
||||
|
||||
private static HttpResponse<String> awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
|
||||
while(System.nanoTime() < deadline && process.isAlive())
|
||||
{
|
||||
try
|
||||
{
|
||||
return client.send(HttpRequest.newBuilder().uri(uri).build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
catch(java.net.ConnectException e)
|
||||
{
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Packaged app did not become healthy. Output:\n" + Files.readString(log));
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.workflows.definition.WorkflowsRegistry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CoreQBitsTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void coreExposesLocalQbitsAndOrderTrace() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
new QInstanceValidator().validate(instance);
|
||||
Set<String> artifacts = instance.getQBits().values().stream()
|
||||
.map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet());
|
||||
assertTrue(artifacts.containsAll(Set.of("customizable-table-views", "standard-process-trace",
|
||||
"webhooks", "workflows", "qbit-geo-data")));
|
||||
assertNotNull(instance.getTable("TableView"));
|
||||
assertNotNull(instance.getTable("processTrace"));
|
||||
assertNotNull(instance.getTable("webhook"));
|
||||
assertNotNull(instance.getTable("workflow"));
|
||||
assertNotNull(instance.getProcess("syncOrder").getProcessTracerCodeReference());
|
||||
assertNotNull(WebhooksRegistry.of(instance).getWebhookEventType("orderStored"));
|
||||
assertNotNull(WorkflowsRegistry.of(instance).getWorkflowType("RecordWorkflow"));
|
||||
assertNotNull(instance.getTable("address").getField("cityId").getPossibleValueSourceName());
|
||||
assertTrue(artifacts.stream().noneMatch("quick-search"::equals));
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.customfields.FieldSecurity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The enterprise admin passes qbit-collaboration's Custom Fields checker (qqq-all#47). */
|
||||
class EnterpriseAccessTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCanReadAndManageCustomFields() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
QSession plain = new QSession();
|
||||
QContext.init(instance, plain);
|
||||
assertThrows(QPermissionDeniedException.class,
|
||||
() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
|
||||
|
||||
QSession admin = new QSession();
|
||||
DemoUsers.grantEnterpriseAccess(instance, admin, "core:admin");
|
||||
QContext.init(instance, admin);
|
||||
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
|
||||
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new InsertInput("collaboration_field_definition"), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customizedSessionsStillCarryTheUserForTheFrontend() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
QSession session = new QSession();
|
||||
session.setUuid(DemoUsers.ADMIN_SESSION);
|
||||
session.setUser(new QUser());
|
||||
new DemoUsers().customizeSession(instance, session, Map.of());
|
||||
assertEquals("Admin", ((Map<?, ?>) session.getValuesForFrontend().get("user")).get("name"));
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The enterprise assistant's provider rows follow the environment: Anthropic with a key, else Ollama, else none. */
|
||||
class EnterpriseAssistantTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
MemoryRecordStore.fullReset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void choiceFollowsKeyThenOllama()
|
||||
{
|
||||
assertEquals(EnterpriseAssistant.Choice.ANTHROPIC, EnterpriseAssistant.choose(env(Map.of("ANTHROPIC_API_KEY", "sk-test")), model -> true));
|
||||
assertEquals(EnterpriseAssistant.Choice.OLLAMA, EnterpriseAssistant.choose(env(Map.of("ANTHROPIC_API_KEY", " ")), model -> true));
|
||||
assertEquals(EnterpriseAssistant.Choice.NONE, EnterpriseAssistant.choose(env(Map.of()), model -> false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ollamaModelAndUrlFromEnvironment()
|
||||
{
|
||||
List<String> asked = new java.util.ArrayList<>();
|
||||
EnterpriseAssistant.choose(env(Map.of("QQQ_ASSISTANT_OLLAMA_MODEL", "qwen3-coder:30b")), model -> asked.add(model));
|
||||
assertEquals(List.of("qwen3-coder:30b"), asked);
|
||||
assertEquals("llama3.3:70b-instruct-q4_K_M", EnterpriseAssistant.ollamaModel(env(Map.of())));
|
||||
assertEquals("http://127.0.0.1:11434/v1", EnterpriseAssistant.ollamaBaseUrl(env(Map.of())));
|
||||
|
||||
AssistantQBitConfig config = EnterpriseAssistant.config("h2", "http://127.0.0.1:8078", env(Map.of("QQQ_ASSISTANT_OLLAMA_URL", "http://127.0.0.1:9999/v1")));
|
||||
assertTrue(config.getAllowedBaseUrls().contains("http://127.0.0.1:9999/v1"));
|
||||
assertTrue(config.getProviderTypes().containsKey("anthropic"));
|
||||
assertTrue(config.getProviderTypes().containsKey("openai-compatible"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rowsNeverHoldAKey()
|
||||
{
|
||||
List<QRecord> rows = EnterpriseAssistant.rows(EnterpriseAssistant.Choice.ANTHROPIC, env(Map.of("ANTHROPIC_API_KEY", "sk-secret")));
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
assertFalse(row.getValues().values().stream().anyMatch(v -> String.valueOf(v).contains("sk-secret")));
|
||||
}
|
||||
assertEquals("ANTHROPIC_API_KEY", rows.get(0).getValueString("apiKeyEnvName"));
|
||||
assertNull(rows.get(1).getValue("apiKeyEnvName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcileInsertsThenSwitchesTheDefault() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
new AssistantQBitProducer().withQBitConfig(EnterpriseAssistant.config("transientMemory", "http://127.0.0.1:1", env(Map.of()))).produce(instance);
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
|
||||
assertEquals(EnterpriseAssistant.Choice.OLLAMA, EnterpriseAssistant.reconcile(instance, env(Map.of()), model -> true));
|
||||
Map<String, QRecord> rows = providerRows();
|
||||
assertEquals(2, rows.size());
|
||||
assertTrue(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("isDefault"));
|
||||
assertTrue(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("enabled"));
|
||||
assertEquals("OLLAMA", rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueString("kind"));
|
||||
assertEquals("llama3.3:70b-instruct-q4_K_M", rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueString("model"));
|
||||
assertFalse(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("enabled"));
|
||||
|
||||
assertEquals(EnterpriseAssistant.Choice.ANTHROPIC, EnterpriseAssistant.reconcile(instance, env(Map.of("ANTHROPIC_API_KEY", "sk-test")), model -> true));
|
||||
rows = providerRows();
|
||||
assertEquals(2, rows.size());
|
||||
assertTrue(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("isDefault"));
|
||||
assertTrue(rows.get(EnterpriseAssistant.ANTHROPIC_ROW).getValueBoolean("enabled"));
|
||||
assertFalse(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("isDefault"));
|
||||
assertFalse(rows.get(EnterpriseAssistant.OLLAMA_ROW).getValueBoolean("enabled"));
|
||||
|
||||
assertEquals(EnterpriseAssistant.Choice.NONE, EnterpriseAssistant.reconcile(instance, env(Map.of()), model -> false));
|
||||
assertTrue(providerRows().values().stream().noneMatch(row -> row.getValueBoolean("enabled")));
|
||||
}
|
||||
|
||||
private static Map<String, QRecord> providerRows() throws Exception
|
||||
{
|
||||
return new QueryAction().execute(new QueryInput(AssistantProvider.TABLE_NAME)).getRecords().stream()
|
||||
.collect(Collectors.toMap(row -> row.getValueString("name"), row -> row));
|
||||
}
|
||||
|
||||
private static Function<String, String> env(Map<String, String> values)
|
||||
{
|
||||
return new HashMap<>(values)::get;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The enterprise profile's columnStats process (core's step, QRun-IO/qqq#1367)
|
||||
* refuses statistics for a field the caller cannot read in full and tags its
|
||||
* results with the analysed table.
|
||||
*/
|
||||
class EnterpriseColumnStatsAccessTest
|
||||
{
|
||||
private static final String KEY = "salaryViewer";
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
MemoryRecordStore.fullReset();
|
||||
instance = new QInstance();
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName(KEY));
|
||||
instance.addTable(new QTableMetaData().withName("employee").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("salary", QFieldType.DECIMAL).withLabel("Salary")
|
||||
.withFieldSecurityLock(new FieldSecurityLock().withSecurityKeyType(KEY)
|
||||
.withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withOverrideValues(List.of(true)))));
|
||||
EnterpriseFeatures.enableColumnStatistics(instance);
|
||||
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
new InsertAction().execute(new InsertInput("employee").withRecords(List.of(
|
||||
new QRecord().withValue("name", "Ada").withValue("salary", new BigDecimal("100")),
|
||||
new QRecord().withValue("name", "Grace").withValue("salary", new BigDecimal("200")))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
MemoryRecordStore.fullReset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedFieldIsRefusedWithoutTheTier() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
QPermissionDeniedException denied = assertThrows(QPermissionDeniedException.class, () -> run("salary"));
|
||||
assertTrue(denied.getMessage() != null && !denied.getMessage().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedFieldIsAllowedWithTheTier() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession().withSecurityKeyValue(KEY, true));
|
||||
RunProcessOutput output = run("salary");
|
||||
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
|
||||
QRecord stats = (QRecord) output.getValues().get("statsRecord");
|
||||
assertEquals(2, stats.getValueInteger("count"));
|
||||
assertEquals("employee", stats.getTableName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrestrictedFieldIsAllowed() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
RunProcessOutput output = run("name");
|
||||
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
|
||||
}
|
||||
|
||||
private RunProcessOutput run(String fieldName) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("columnStats");
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
input.addValue("tableName", "employee");
|
||||
input.addValue("fieldName", fieldName);
|
||||
return (new RunProcessAction().execute(input));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The enterprise profile offers column statistics wherever the backend can aggregate. */
|
||||
class EnterpriseColumnStatsTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void queryStatsOnAggregatingBackendsOnly() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
|
||||
{
|
||||
assertFalse(hasQueryStats(instance, tableName), tableName + " before");
|
||||
}
|
||||
|
||||
EnterpriseFeatures.enableColumnStatistics(instance);
|
||||
assertNotNull(instance.getProcess("columnStats"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// H2 (customer, order), SQLite (orderLine) and memory (demoNote) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
|
||||
{
|
||||
assertTrue(hasQueryStats(instance, tableName), tableName);
|
||||
assertTrue(hasCapability(instance, tableName, Capability.TABLE_COUNT), tableName);
|
||||
assertTrue(hasCapability(instance, tableName, Capability.TABLE_EXPORT), tableName);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// the filesystem (product) and API (apiCatalog) backends do //
|
||||
// not aggregate, so they stay without statistics //
|
||||
////////////////////////////////////////////////////////////////
|
||||
assertFalse(hasQueryStats(instance, "product"));
|
||||
assertFalse(hasQueryStats(instance, "apiCatalog"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataTablesAreAuditedWhenAuditsAreInstalled() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
new AuditsMetaDataProvider().defineAll(instance, "h2", null);
|
||||
EnterpriseFeatures.auditDataTables(instance);
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "address"})
|
||||
{
|
||||
assertEquals(AuditLevel.FIELD, instance.getTable(tableName).getAuditRules().getAuditLevel(), tableName);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasQueryStats(QInstance instance, String tableName)
|
||||
{
|
||||
return hasCapability(instance, tableName, Capability.QUERY_STATS);
|
||||
}
|
||||
|
||||
private static boolean hasCapability(QInstance instance, String tableName, Capability capability)
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
QBackendMetaData backend = instance.getBackendForTable(tableName);
|
||||
return table.isCapabilityEnabled(backend, capability);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
|
||||
import com.kingsrook.qbits.quicksearch.SearchableTableConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Quick Search covers the demo and sample tables, with their text fields only. */
|
||||
class EnterpriseQuickSearchTablesTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void demoAndSampleTablesWithTheirTextFields() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// a stand-in for a sample-app table: labelled, on the sample's //
|
||||
// backend, with fields OpenSearch must not index as text //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
instance.addBackend(new QBackendMetaData().withName("rdbms").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName("countryState").withBackendName("rdbms").withPrimaryKeyField("id")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("description", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("code", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME)));
|
||||
|
||||
Map<String, List<String>> tables = EnterpriseFeatures.searchableTables(instance).stream()
|
||||
.collect(Collectors.toMap(SearchableTableConfig::getTableName,
|
||||
table -> table.getFields().stream().map(SearchableFieldConfig::getFieldName).toList()));
|
||||
|
||||
assertEquals(List.of("name", "email"), tables.get("customer").stream().sorted((a, b) -> a.equals("name") ? -1 : 1).toList());
|
||||
assertTrue(tables.get("shipping_stateProvince").contains("name"));
|
||||
assertEquals(List.of("name", "description"), tables.get("countryState"));
|
||||
assertFalse(tables.containsKey("webhook"), "QBit admin tables on h2 stay out");
|
||||
assertFalse(tables.containsKey("product"), "the filesystem product table is not a demo search table");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/** Mock sign-ins become one stored session per uuid, touched rather than re-stored. */
|
||||
class EnterpriseSessionsTest
|
||||
{
|
||||
private final RecordingStore store = new RecordingStore();
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
EnterpriseSessions.forget();
|
||||
QSessionStoreRegistry.getInstance().clear();
|
||||
QSessionStoreRegistry.getInstance().register(store);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QSessionStoreRegistry.getInstance().clear();
|
||||
EnterpriseSessions.forget();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneRowPerSessionUuid()
|
||||
{
|
||||
EnterpriseSessions.record(session("aaaa", "core:admin"));
|
||||
EnterpriseSessions.record(session("aaaa", "core:admin"));
|
||||
EnterpriseSessions.record(session("bbbb", "core:admin"));
|
||||
|
||||
assertEquals(List.of("aaaa", "bbbb"), new ArrayList<>(store.sessions.keySet()));
|
||||
assertEquals("core:admin", store.sessions.get("bbbb").getUser().getIdReference());
|
||||
assertNull(store.sessions.get("aaaa").getPermissions(), "only the identity is stored");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownSessionIsTouchedNotStoredAgain()
|
||||
{
|
||||
store.sessions.put("cccc", session("cccc", "core:admin"));
|
||||
EnterpriseSessions.record(session("cccc", "core:admin"));
|
||||
assertEquals(0, store.stores);
|
||||
assertEquals(1, store.touches);
|
||||
}
|
||||
|
||||
private static QSession session(String uuid, String userId)
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setUuid(uuid);
|
||||
QUser user = new QUser();
|
||||
user.setIdReference(userId);
|
||||
session.setUser(user);
|
||||
session.withPermissions("customer.read", "customer.edit");
|
||||
return (session);
|
||||
}
|
||||
|
||||
/** A session store that keeps what it is given and counts calls. */
|
||||
private static class RecordingStore implements QSessionStoreProviderInterface
|
||||
{
|
||||
private final Map<String, QSession> sessions = new LinkedHashMap<>();
|
||||
private int stores;
|
||||
private int touches;
|
||||
|
||||
@Override
|
||||
public void store(String sessionUuid, QSession session, Duration ttl)
|
||||
{
|
||||
stores++;
|
||||
sessions.put(sessionUuid, session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<QSession> load(String sessionUuid)
|
||||
{
|
||||
return (Optional.ofNullable(sessions.get(sessionUuid)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String sessionUuid)
|
||||
{
|
||||
sessions.remove(sessionUuid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touch(String sessionUuid)
|
||||
{
|
||||
touches++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getDefaultTtl()
|
||||
{
|
||||
return (Duration.ofHours(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.userrolepermissions.model.Role;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/** Keycloak realm roles must flow through the role permission resolver on login and resume. */
|
||||
class FullOidcSessionCustomizerTest
|
||||
{
|
||||
@Test
|
||||
void resolvesRolesBeforeAnAuthenticationSessionExists()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(),
|
||||
new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory")
|
||||
.withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName(Role.TABLE_NAME).withBackendName("memory")
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
|
||||
QContext.init(instance, null);
|
||||
try
|
||||
{
|
||||
QSession session = new QSession();
|
||||
new FullOidcSessionCustomizer().customizeSession(instance, session,
|
||||
Map.of("idToken", new JSONObject("{\"realm_access\":{\"roles\":[\"viewer\"]}}")));
|
||||
assertEquals(Set.of(), session.getPermissions());
|
||||
assertNull(QContext.getQSession());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsIdTokenRolesToSessionPermissions()
|
||||
{
|
||||
RecordingCustomizer customizer = new RecordingCustomizer();
|
||||
QSession session = new QSession();
|
||||
customizer.customizeSession(null, session, Map.of("idToken", new JSONObject("{\"realm_access\":{\"roles\":[\"admin\",\"viewer\"]}}")));
|
||||
assertEquals(Set.of("admin", "viewer"), customizer.requestedRoles);
|
||||
assertEquals(Set.of("customer.read"), session.getPermissions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesAccessTokenRolesOnSessionResumeAndDoesNotGrantUnknownRoles()
|
||||
{
|
||||
RecordingCustomizer customizer = new RecordingCustomizer();
|
||||
QSession session = new QSession();
|
||||
customizer.customizeSession(null, session, Map.of("jwtPayloadJsonObject", new JSONObject("{\"realm_access\":{\"roles\":[\"viewer\"]}}")));
|
||||
assertEquals(Set.of("viewer"), customizer.requestedRoles);
|
||||
|
||||
QSession noRoles = new QSession();
|
||||
customizer.customizeSession(null, noRoles, Map.of("jwtPayloadJsonObject", new JSONObject("{}")));
|
||||
assertEquals(Set.of(), noRoles.getPermissions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesAccessTokenRolesWhenIdTokenHasNoRealmRoles()
|
||||
{
|
||||
RecordingCustomizer customizer = new RecordingCustomizer();
|
||||
QSession session = new QSession();
|
||||
customizer.customizeSession(null, session, Map.of(
|
||||
"idToken", new JSONObject("{\"email\":\"demo@example.test\"}"),
|
||||
"jwtPayloadJsonObject", new JSONObject("{\"realm_access\":{\"roles\":[\"viewer\"]}}")));
|
||||
assertEquals(Set.of("viewer"), customizer.requestedRoles);
|
||||
}
|
||||
|
||||
private static class RecordingCustomizer extends FullOidcSessionCustomizer
|
||||
{
|
||||
private Set<String> requestedRoles;
|
||||
|
||||
@Override
|
||||
protected Set<String> permissionsForRoles(Set<String> roles)
|
||||
{
|
||||
requestedRoles = roles;
|
||||
return roles.isEmpty() ? Set.of() : Set.of("customer.read");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.esb.management.ArtemisJolokiaAdapter;
|
||||
import com.kingsrook.qqq.esb.management.EsbBrokerNames;
|
||||
import com.kingsrook.qqq.esb.management.RabbitManagementAdapter;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProviderType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Opt-in smoke test against the local full Compose brokers and their management APIs. */
|
||||
@EnabledIfEnvironmentVariable(named = "QQQ_ALL_COMPOSE_BROKERS", matches = "true")
|
||||
class FullProfileBrokerIntegrationTest
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void managesQueuesWithFullProfileCredentials() throws Exception
|
||||
{
|
||||
Map<String, String> environment = FullProfileTest.environment();
|
||||
environment.put("QQQ_ALL_ARTEMIS_USER", System.getenv("ARTEMIS_USER"));
|
||||
environment.put("QQQ_ALL_ARTEMIS_PASSWORD", System.getenv("ARTEMIS_PASSWORD"));
|
||||
environment.put("QQQ_ALL_ARTEMIS_MANAGEMENT_URL", "http://127.0.0.1:8161");
|
||||
environment.put("QQQ_ALL_RABBITMQ_USER", System.getenv("RABBITMQ_USER"));
|
||||
environment.put("QQQ_ALL_RABBITMQ_PASSWORD", System.getenv("RABBITMQ_PASSWORD"));
|
||||
environment.put("QQQ_ALL_RABBITMQ_MANAGEMENT_URL", "http://127.0.0.1:15672");
|
||||
QInstance instance = new FullProfileApplication(dataDirectory, FullProfileSettings.from(environment)).defineQInstance();
|
||||
EsbInstanceMetaData esb = EsbInstanceMetaData.of(instance);
|
||||
|
||||
ArtemisJolokiaAdapter artemis = new ArtemisJolokiaAdapter(esb.getProvider("artemis"));
|
||||
String subscription = EsbBrokerNames.subscriptionQueue(EsbProviderType.ACTIVEMQ_ARTEMIS,
|
||||
"qqq.all.orderEvents", "syncOrder.qqq.all.orderEvents");
|
||||
assertTrue(artemis.getQueueInfo(subscription).isPresent());
|
||||
try
|
||||
{
|
||||
artemis.pauseQueue(subscription);
|
||||
assertTrue(artemis.getQueueInfo(subscription).orElseThrow().paused());
|
||||
}
|
||||
finally
|
||||
{
|
||||
artemis.resumeQueue(subscription);
|
||||
}
|
||||
assertFalse(artemis.getQueueInfo(subscription).orElseThrow().paused());
|
||||
|
||||
RabbitManagementAdapter rabbit = new RabbitManagementAdapter(esb.getProvider("rabbitmq"));
|
||||
String queueName = "qqq.all.c6.managementProbe." + UUID.randomUUID();
|
||||
String queueUrl = "http://127.0.0.1:15672/api/queues/%2F/" + queueName;
|
||||
String credentials = environment.get("QQQ_ALL_RABBITMQ_USER") + ":" + environment.get("QQQ_ALL_RABBITMQ_PASSWORD");
|
||||
String authorization = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
|
||||
try(HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build())
|
||||
{
|
||||
HttpRequest create = HttpRequest.newBuilder(URI.create(queueUrl))
|
||||
.header("Authorization", authorization).header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString("{\"durable\":true,\"auto_delete\":false,\"arguments\":{}}"))
|
||||
.build();
|
||||
assertEquals(201, client.send(create, HttpResponse.BodyHandlers.discarding()).statusCode());
|
||||
try
|
||||
{
|
||||
assertTrue(rabbit.getQueueInfo(queueName).isPresent());
|
||||
assertEquals(0L, rabbit.purgeQueue(queueName));
|
||||
}
|
||||
finally
|
||||
{
|
||||
HttpRequest delete = HttpRequest.newBuilder(URI.create(queueUrl))
|
||||
.header("Authorization", authorization).DELETE().build();
|
||||
assertEquals(204, client.send(delete, HttpResponse.BodyHandlers.discarding()).statusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.email.EmailMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Full profile wiring tests that require no external services. */
|
||||
class FullProfileTest
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void rejectsMissingFullProfileCredentialsBeforeStartup()
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> FullProfileSettings.from(Map.of("QQQ_ALL_PROFILE", "full")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wiresEveryFullBackendBrokerMailAndOidcFromEnvironment() throws Exception
|
||||
{
|
||||
FullProfileSettings settings = FullProfileSettings.from(environment());
|
||||
QInstance instance = new FullProfileApplication(dataDirectory, settings).defineQInstance();
|
||||
new QInstanceValidator().validate(instance);
|
||||
|
||||
RDBMSBackendMetaData postgres = (RDBMSBackendMetaData) instance.getBackend("postgres");
|
||||
assertEquals("com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData", postgres.getClass().getName());
|
||||
assertEquals("postgres.example.test", postgres.getHostName());
|
||||
assertEquals("jdbc:postgresql://postgres.example.test:5432/qqq", ConnectionManager.getJdbcUrl(postgres));
|
||||
assertEquals("org.postgresql.Driver", ConnectionManager.getJdbcDriverClassName(postgres));
|
||||
assertEquals("\"", postgres.getActionStrategy().getIdentifierQuoteString());
|
||||
assertEquals("DEFAULT VALUES", postgres.getActionStrategy().getInsertDefaultValuesClause());
|
||||
assertEquals("mysql.example.test", ((RDBMSBackendMetaData) instance.getBackend("mysql")).getHostName());
|
||||
assertEquals("replicaSet=rs0", ((MongoDBBackendMetaData) instance.getBackend("mongo")).getUrlSuffix());
|
||||
assertEquals("postgres", instance.getTable("warehouseCustomer").getBackendName());
|
||||
assertEquals("mysql", instance.getTable("supplierOrder").getBackendName());
|
||||
assertEquals("mongo", instance.getTable("shipment").getBackendName());
|
||||
assertEquals("http://minio.example.test:9000",
|
||||
((MinioBackendMetaData) instance.getBackend("s3")).getEndpoint());
|
||||
assertEquals("s3", instance.getTable("document").getBackendName());
|
||||
assertEquals("sftp", instance.getTable("externalImportFile").getBackendName());
|
||||
|
||||
EsbInstanceMetaData esb = EsbInstanceMetaData.of(instance);
|
||||
assertEquals("tcp://artemis.example.test:61616", esb.getProvider("artemis").getUrl());
|
||||
assertEquals("fixture", esb.getProvider("artemis").getUsername());
|
||||
assertEquals("fixture", esb.getProvider("artemis").getPassword());
|
||||
assertEquals("http://artemis.example.test:8161", esb.getProvider("artemis").getManagementUrl());
|
||||
assertEquals("fixture", esb.getProvider("artemis").getManagementUsername());
|
||||
assertEquals("fixture", esb.getProvider("artemis").getManagementPassword());
|
||||
assertEquals("amqp://rabbit.example.test:5672/%2F", esb.getProvider("rabbitmq").getUrl());
|
||||
assertEquals("http://rabbit.example.test:15672", esb.getProvider("rabbitmq").getManagementUrl());
|
||||
assertEquals("fixture", esb.getProvider("rabbitmq").getManagementUsername());
|
||||
assertEquals("fixture", esb.getProvider("rabbitmq").getManagementPassword());
|
||||
assertEquals("artemis", esb.getDestination("orderEvents").getProviderName());
|
||||
assertEquals("rabbitmq", esb.getDestination("orderSyncEvents").getProviderName());
|
||||
|
||||
OAuth2AuthenticationMetaData auth = (OAuth2AuthenticationMetaData) instance.getAuthentication();
|
||||
assertEquals(QAuthenticationType.OAUTH2, auth.getType());
|
||||
assertEquals("https://keycloak.example.test/realms/demo", auth.getBaseUrl());
|
||||
assertNotNull(instance.getTable(auth.getUserSessionTableName()));
|
||||
assertNotNull(instance.getTable(auth.getRedirectStateTableName()));
|
||||
assertEquals("mailpit.example.test", ((EmailMessagingProviderMetaData) instance.getMessagingProvider("mailpit")).getSmtpServer());
|
||||
assertTrue(instance.getQBits().values().stream().anyMatch(qbit -> "user-role-permissions".equals(qbit.getArtifactId())));
|
||||
}
|
||||
|
||||
static Map<String, String> environment()
|
||||
{
|
||||
Map<String, String> values = new HashMap<>();
|
||||
values.put("QQQ_ALL_BIND_HOST", "127.0.0.1");
|
||||
values.put("QQQ_ALL_POSTGRES_HOST", "postgres.example.test");
|
||||
values.put("QQQ_ALL_POSTGRES_DATABASE", "qqq");
|
||||
values.put("QQQ_ALL_POSTGRES_USER", "fixture");
|
||||
values.put("QQQ_ALL_POSTGRES_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_MYSQL_HOST", "mysql.example.test");
|
||||
values.put("QQQ_ALL_MYSQL_DATABASE", "qqq");
|
||||
values.put("QQQ_ALL_MYSQL_USER", "fixture");
|
||||
values.put("QQQ_ALL_MYSQL_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_MONGO_HOST", "mongo.example.test");
|
||||
values.put("QQQ_ALL_MONGO_DATABASE", "qqq");
|
||||
values.put("QQQ_ALL_MONGO_USER", "fixture");
|
||||
values.put("QQQ_ALL_MONGO_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_MONGO_REPLICA_SET", "rs0");
|
||||
values.put("QQQ_ALL_S3_BUCKET", "qqq-demo");
|
||||
values.put("QQQ_ALL_S3_ENDPOINT", "http://minio.example.test:9000");
|
||||
values.put("QQQ_ALL_S3_ACCESS_KEY", "fixture");
|
||||
values.put("QQQ_ALL_S3_SECRET_KEY", "fixture");
|
||||
values.put("QQQ_ALL_SFTP_HOST", "sftp.example.test");
|
||||
values.put("QQQ_ALL_SFTP_USER", "fixture");
|
||||
values.put("QQQ_ALL_SFTP_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_ARTEMIS_URL", "tcp://artemis.example.test:61616");
|
||||
values.put("QQQ_ALL_ARTEMIS_USER", "fixture");
|
||||
values.put("QQQ_ALL_ARTEMIS_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_ARTEMIS_MANAGEMENT_URL", "http://artemis.example.test:8161");
|
||||
values.put("QQQ_ALL_RABBITMQ_URL", "amqp://rabbit.example.test:5672/%2F");
|
||||
values.put("QQQ_ALL_RABBITMQ_MANAGEMENT_URL", "http://rabbit.example.test:15672");
|
||||
values.put("QQQ_ALL_RABBITMQ_USER", "fixture");
|
||||
values.put("QQQ_ALL_RABBITMQ_PASSWORD", "fixture");
|
||||
values.put("QQQ_ALL_SMTP_HOST", "mailpit.example.test");
|
||||
values.put("QQQ_ALL_OIDC_BASE_URL", "https://keycloak.example.test/realms/demo");
|
||||
values.put("QQQ_ALL_OIDC_CLIENT_ID", "qqq-all");
|
||||
values.put("QQQ_ALL_OIDC_CLIENT_SECRET", "fixture");
|
||||
values.put("QQQ_ALL_OPENSEARCH_HOST", "127.0.0.1");
|
||||
values.put("QQQ_ALL_OPENSEARCH_PORT", "1");
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FullQBitsTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void fullAddsCustomerSearchAndSftpImportToTheDemo() throws Exception
|
||||
{
|
||||
FullProfileSettings settings = FullProfileSettings.from(FullProfileTest.environment());
|
||||
QInstance instance = new FullProfileApplication(dataDirectory, settings).defineQInstance();
|
||||
new QInstanceValidator().validate(instance);
|
||||
|
||||
Set<String> artifacts = instance.getQBits().values().stream()
|
||||
.map(qbit -> qbit.getArtifactId()).collect(Collectors.toSet());
|
||||
assertTrue(artifacts.containsAll(Set.of("quick-search", "user-role-permissions",
|
||||
"customizable-table-views", "standard-process-trace", "webhooks", "workflows",
|
||||
"qbit-geo-data", "sftp-data-integration")));
|
||||
assertNotNull(instance.getApp("quickSearchAdmin"));
|
||||
assertNotNull(instance.getProcess("quickSearchFullReindex"));
|
||||
assertNotNull(instance.getProcess("quickSearchReconcileIndex"));
|
||||
assertNotNull(instance.getProcess("SFTPImportFileSyncProcess"));
|
||||
assertNotNull(instance.getTable("SFTPImportSourceFileTable"));
|
||||
assertEquals("sftp", instance.getTable("SFTPImportSourceFileTable").getBackendName());
|
||||
assertEquals("scheduled_job", ((RDBMSTableBackendDetails) instance.getTable("scheduledJob")
|
||||
.getBackendDetails()).getTableName());
|
||||
assertEquals("create_date", instance.getTable("scheduledJob").getField("createDate").getBackendName());
|
||||
assertEquals(QFieldType.STRING, instance.getTable("processTrace").getField("userId").getType());
|
||||
assertTrue(DemoQBitData.postgresSchemaStatements(instance).stream()
|
||||
.anyMatch(sql -> sql.startsWith("CREATE TABLE IF NOT EXISTS \"scheduled_job\"")
|
||||
&& sql.contains("\"create_date\" TIMESTAMP")));
|
||||
assertTrue(DemoQBitData.postgresSchemaStatements(instance).stream()
|
||||
.anyMatch(sql -> sql.startsWith("CREATE TABLE IF NOT EXISTS \"webhook_subscription\"")
|
||||
&& sql.contains("\"webhook_event_type_name\" VARCHAR(4000)")));
|
||||
assertFalse(DemoQBitData.postgresSchemaStatements(instance).stream()
|
||||
.anyMatch(sql -> sql.contains("\"user_session\"") || sql.contains("\"warehouse_customer\"")));
|
||||
for(String tableName : Set.of("webhook", "webhookSubscription", "webhookEvent",
|
||||
"webhookEventContent", "webhookEventSendLog", "scheduledJob", "scheduledJobParameter"))
|
||||
{
|
||||
assertEquals(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS,
|
||||
instance.getTable(tableName).getPermissionRules().getLevel(), tableName);
|
||||
}
|
||||
for(String processName : Set.of("SendWebhookEvent", "SendTestEventToWebhook", "ManageWebhookHealth"))
|
||||
{
|
||||
assertEquals(PermissionLevel.HAS_ACCESS_PERMISSION,
|
||||
instance.getProcess(processName).getPermissionRules().getLevel(), processName);
|
||||
}
|
||||
QuickSearchQBitConfig search = (QuickSearchQBitConfig) instance.getQBits().values().stream()
|
||||
.filter(qbit -> "quick-search".equals(qbit.getArtifactId())).findFirst().orElseThrow().getConfig();
|
||||
assertEquals("customer", search.getSearchableTables().getFirst().getTableName());
|
||||
assertEquals(Set.of("name", "email"), search.getSearchableTables().getFirst().getFields().stream()
|
||||
.map(field -> field.getFieldName()).collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerPermissionsCannotChangeWebhookDestinationOrSendEvent() throws Exception
|
||||
{
|
||||
QInstance instance = new FullProfileApplication(dataDirectory,
|
||||
FullProfileSettings.from(FullProfileTest.environment())).defineQInstance();
|
||||
QSession viewer = new QSession().withPermissions("webhook.read", "webhookSubscription.read");
|
||||
QContext.init(instance, viewer);
|
||||
try
|
||||
{
|
||||
assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhook"),
|
||||
"webhook", TablePermissionSubType.EDIT));
|
||||
assertFalse(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"),
|
||||
"webhookSubscription", TablePermissionSubType.EDIT));
|
||||
assertThrows(QPermissionDeniedException.class,
|
||||
() -> PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminPermissionsCanChangeWebhookDestinationAndSendEvent() throws Exception
|
||||
{
|
||||
QInstance instance = new FullProfileApplication(dataDirectory,
|
||||
FullProfileSettings.from(FullProfileTest.environment())).defineQInstance();
|
||||
QSession admin = new QSession().withPermissions("webhook.edit", "webhookSubscription.edit",
|
||||
"SendWebhookEvent.hasAccess");
|
||||
QContext.init(instance, admin);
|
||||
try
|
||||
{
|
||||
assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhook"),
|
||||
"webhook", TablePermissionSubType.EDIT));
|
||||
assertTrue(PermissionsHelper.hasTablePermission(new QueryInput("webhookSubscription"),
|
||||
"webhookSubscription", TablePermissionSubType.EDIT));
|
||||
PermissionsHelper.checkProcessPermissionThrowing(new RunProcessInput(), "SendWebhookEvent");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.actions.AbstractS3Action;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3Utils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MinioBackendTest
|
||||
{
|
||||
@Test
|
||||
void usesConfiguredEndpointForAllFilesystemActions() throws Exception
|
||||
{
|
||||
MinioBackendMetaData backend = new MinioBackendMetaData("http://minio.example.test:9000");
|
||||
backend.setName("s3");
|
||||
backend.setBucketName("qqq-demo");
|
||||
backend.setAccessKey("fixture");
|
||||
backend.setSecretKey("fixture");
|
||||
backend.setRegion("us-east-1");
|
||||
MinioBackendModule module = assertInstanceOf(MinioBackendModule.class,
|
||||
new QBackendModuleDispatcher().getQBackendModule(backend));
|
||||
assertUrl((AbstractS3Action) module.getActionBase(), backend);
|
||||
assertUrl((AbstractS3Action) module.getQueryInterface(), backend);
|
||||
assertUrl((AbstractS3Action) module.getInsertInterface(), backend);
|
||||
assertUrl((AbstractS3Action) module.getCountInterface(), backend);
|
||||
assertUrl((AbstractS3Action) module.getDeleteInterface(), backend);
|
||||
assertUrl((AbstractS3Action) module.getStorageInterface(), backend);
|
||||
}
|
||||
|
||||
private static void assertUrl(AbstractS3Action action, MinioBackendMetaData backend) throws Exception
|
||||
{
|
||||
action.preAction(backend);
|
||||
S3Utils utils = utils(action);
|
||||
assertEquals("http://minio.example.test:9000/qqq-demo/example.json",
|
||||
utils.getS3Client().utilities().getUrl(request -> request.bucket("qqq-demo").key("example.json")).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAndWriteUseLocalS3Endpoint() throws Exception
|
||||
{
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
AtomicReference<String> written = new AtomicReference<>();
|
||||
server.createContext("/", exchange ->
|
||||
{
|
||||
byte[] response;
|
||||
if("PUT".equals(exchange.getRequestMethod()))
|
||||
{
|
||||
written.set(exchange.getRequestURI().getPath() + ":" +
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
exchange.getResponseHeaders().add("ETag", "\"fixture-etag\"");
|
||||
response = new byte[0];
|
||||
}
|
||||
else
|
||||
{
|
||||
String xml = "<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">"
|
||||
+ "<Name>qqq-demo</Name><IsTruncated>false</IsTruncated>"
|
||||
+ "<Contents><Key>documents/example.json</Key><Size>2</Size>"
|
||||
+ "<LastModified>2026-01-01T00:00:00.000Z</LastModified></Contents></ListBucketResult>";
|
||||
response = xml.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/xml");
|
||||
}
|
||||
exchange.sendResponseHeaders(200, response.length);
|
||||
exchange.getResponseBody().write(response);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try
|
||||
{
|
||||
MinioBackendMetaData backend = new MinioBackendMetaData("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
backend.setName("s3");
|
||||
backend.setBucketName("qqq-demo");
|
||||
backend.setAccessKey("fixture");
|
||||
backend.setSecretKey("fixture");
|
||||
backend.setRegion("us-east-1");
|
||||
MinioBackendModule module = new MinioBackendModule();
|
||||
|
||||
AbstractS3Action query = (AbstractS3Action) module.getQueryInterface();
|
||||
query.preAction(backend);
|
||||
assertEquals("documents/example.json", utils(query).getS3Client()
|
||||
.listObjectsV2(request -> request.bucket("qqq-demo")).contents().getFirst().key());
|
||||
|
||||
AbstractS3Action insert = (AbstractS3Action) module.getInsertInterface();
|
||||
insert.preAction(backend);
|
||||
utils(insert).writeFile("qqq-demo", "documents/new.json", "{}".getBytes(StandardCharsets.UTF_8), "application/json");
|
||||
assertTrue(written.get().startsWith("/qqq-demo/documents/new.json:"));
|
||||
assertTrue(written.get().contains("\r\n{}\r\n"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Utils utils(AbstractS3Action action) throws Exception
|
||||
{
|
||||
var accessor = AbstractS3Action.class.getDeclaredMethod("getS3Utils");
|
||||
accessor.setAccessible(true);
|
||||
return (S3Utils) accessor.invoke(action);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/** Verifies this project's notices in the actual shaded application archive. */
|
||||
class PackagedNoticesIT
|
||||
{
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"LICENSE", "NOTICE"})
|
||||
void packagedProjectDocumentMatchesRoot(String document) throws Exception
|
||||
{
|
||||
Path rootDocument = Path.of(System.getProperty("qqqAllRoot"), document);
|
||||
try(JarFile jar = new JarFile(System.getProperty("qqqAllJar")))
|
||||
{
|
||||
String name = "META-INF/qqq-all/" + document;
|
||||
var entry = jar.getJarEntry(name);
|
||||
assertNotNull(entry, "Missing project document: " + name);
|
||||
assertEquals(1L, jar.stream().filter(candidate -> candidate.getName().equals(name)).count(),
|
||||
"Project document must occur exactly once: " + name);
|
||||
try(var input = jar.getInputStream(entry))
|
||||
{
|
||||
assertArrayEquals(Files.readAllBytes(rootDocument), input.readAllBytes(),
|
||||
"Packaged document must preserve root bytes: " + document);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Opt-in native PostgreSQL oracle; owns only its uniquely named table. */
|
||||
@EnabledIfEnvironmentVariable(named = "QQQ_ALL_TEST_POSTGRES_PORT", matches = "[0-9]+")
|
||||
@Timeout(30)
|
||||
class PostgresCompatibilityTest
|
||||
{
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void nativeStrategyPreservesGeneratedIdsNullsAndUtcTimestamps() throws Exception
|
||||
{
|
||||
var environment = FullProfileTest.environment();
|
||||
environment.put("QQQ_ALL_POSTGRES_HOST", "127.0.0.1");
|
||||
environment.put("QQQ_ALL_POSTGRES_PORT", System.getenv("QQQ_ALL_TEST_POSTGRES_PORT"));
|
||||
environment.put("QQQ_ALL_POSTGRES_DATABASE", "qqq");
|
||||
environment.put("QQQ_ALL_POSTGRES_USER", "qqq");
|
||||
environment.put("QQQ_ALL_POSTGRES_PASSWORD", System.getenv("QQQ_ALL_TEST_POSTGRES_PASSWORD"));
|
||||
var instance = new FullProfileApplication(directory, FullProfileSettings.from(environment)).defineQInstance();
|
||||
var backend = (RDBMSBackendMetaData) instance.getBackend("postgres");
|
||||
String table = "compat_" + UUID.randomUUID().toString().replace("-", "");
|
||||
instance.addTable(new QTableMetaData().withName("compatibility").withBackendName("postgres")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("value", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("created", QFieldType.DATE_TIME)));
|
||||
instance.addTable(new QTableMetaData().withName("compatibilityDefault").withBackendName("postgres")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
var previous = QContext.capture();
|
||||
try(var connection = DriverManager.getConnection(ConnectionManager.getJdbcUrl(backend), backend.getUsername(), backend.getPassword());
|
||||
var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE " + table + " (id SERIAL PRIMARY KEY, value TEXT, created TIMESTAMP)");
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
var empty = new InsertAction().execute(new InsertInput("compatibilityDefault").withRecords(List.of(new QRecord()))).getRecords().getFirst();
|
||||
assertNotNull(empty.getValueInteger("id"));
|
||||
Instant timestamp = Instant.parse("2026-09-25T12:34:56Z");
|
||||
var row = new InsertAction().execute(new InsertInput("compatibility").withRecords(List.of(
|
||||
new QRecord().withValue("value", "demo").withValue("created", timestamp)))).getRecords().getFirst();
|
||||
assertTrue(row.getValueInteger("id") > empty.getValueInteger("id"));
|
||||
new UpdateAction().execute(new UpdateInput("compatibility").withRecords(List.of(
|
||||
new QRecord().withValue("id", row.getValueInteger("id")).withValue("value", null))));
|
||||
assertNull(GetAction.execute("compatibility", row.getValueInteger("id")).getValue("value"));
|
||||
try(var result = statement.executeQuery("SELECT value, created FROM " + table + " WHERE id = " + row.getValueInteger("id")))
|
||||
{
|
||||
assertTrue(result.next());
|
||||
assertNull(result.getString(1));
|
||||
assertEquals(timestamp, result.getTimestamp(2, java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))).toInstant());
|
||||
assertFalse(result.next());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
statement.execute("DROP TABLE " + table);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The executable profile selector must choose the right startup path. */
|
||||
class ProfileLauncherTest
|
||||
{
|
||||
@Test
|
||||
void selectsCoreByDefaultAndFullWhenRequested()
|
||||
{
|
||||
assertEquals("core", ProfileLauncher.profile(Map.of()));
|
||||
assertEquals("full", ProfileLauncher.profile(Map.of("QQQ_ALL_PROFILE", "full")));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ProfileLauncher.profile(Map.of("QQQ_ALL_PROFILE", "typo")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullFailsForMissingConfigurationBeforeOpeningServices()
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ProfileLauncher.launch(Map.of("QQQ_ALL_PROFILE", "full")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullRequiresAnExplicitHttpBindAddress()
|
||||
{
|
||||
Map<String, String> environment = new HashMap<>(FullProfileTest.environment());
|
||||
environment.remove("QQQ_ALL_BIND_HOST");
|
||||
assertEquals("Missing required environment variable QQQ_ALL_BIND_HOST",
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> FullProfileSettings.from(environment)).getMessage());
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import javax.script.ScriptEngineManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/** The reference application must ship its advertised backend and language. */
|
||||
class RuntimeAssemblyTest
|
||||
{
|
||||
@Test
|
||||
void includesApiBackendAndJavaScriptEngine()
|
||||
{
|
||||
assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.backend.module.api.APIBackendModule"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.languages.javascript.QJavaScriptExecutor"));
|
||||
assertNotNull(new ScriptEngineManager().getEngineByName("JavaScript"));
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.esb.connection.EsbConnectionManager;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Exercise the assembled demo against its real local HTTP backend. */
|
||||
@Timeout(45)
|
||||
class RuntimeFeaturesTest
|
||||
{
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@AfterEach
|
||||
void closeOwnedBrokerConnections()
|
||||
{
|
||||
EsbConnectionManager.getInstance().closeAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiCatalogQueriesGetsAndPaginates() throws Exception
|
||||
{
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getTable("apiCatalog"));
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals(List.of(101, 102, 103), new QueryAction().execute(new QueryInput("apiCatalog")).getRecords()
|
||||
.stream().map(record -> record.getValueInteger("id")).toList());
|
||||
assertEquals(102, new QueryAction().execute(new QueryInput("apiCatalog")
|
||||
.withFilter(new QQueryFilter().withSkip(1).withLimit(1))).getRecords().getFirst().getValueInteger("id"));
|
||||
assertEquals("Shipping Box", GetAction.execute("apiCatalog", 103).getValueString("name"));
|
||||
assertNull(GetAction.execute("apiCatalog", 999));
|
||||
assertEquals(3, new CountAction().execute(new CountInput("apiCatalog")).getCount());
|
||||
var exactId = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 103));
|
||||
assertEquals(1, new CountAction().execute(new CountInput("apiCatalog").withFilter(exactId)).getCount());
|
||||
assertEquals(List.of(103), new QueryAction().execute(new QueryInput("apiCatalog").withFilter(exactId)).getRecords()
|
||||
.stream().map(record -> record.getValueInteger("id")).toList());
|
||||
assertThrows(QException.class, () -> new QueryAction().execute(new QueryInput("apiCatalog")
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Starter Kit")))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculationUsesRealJavaScriptAndRequiresPermission() throws Exception
|
||||
{
|
||||
int port = freePort();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, port); HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getProcess("calculateOrderTotal"));
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"))
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}"));
|
||||
assertEquals(403, client.send(request.header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION).build(), HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
var allowed = client.send(request.setHeader("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, allowed.statusCode(), allowed.body());
|
||||
assertEquals(3750, new org.json.JSONObject(allowed.body()).getJSONObject("values").getInt("totalCents"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculationRejectsMissingNegativeAndExcessiveInputs()
|
||||
{
|
||||
for(Integer quantity : java.util.Arrays.asList(null, 0, -1, 1001))
|
||||
{
|
||||
var input = new RunBackendStepInput().addValue("quantity", quantity).addValue("unitPriceCents", 1250);
|
||||
assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput()));
|
||||
}
|
||||
for(Integer price : java.util.Arrays.asList(null, -1, 1_000_001))
|
||||
{
|
||||
var input = new RunBackendStepInput().addValue("quantity", 3).addValue("unitPriceCents", price);
|
||||
assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void memoryNotesResetAcrossRuntimeRestart() throws Exception
|
||||
{
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getTable("demoNote"));
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
new UpdateAction().execute(new UpdateInput("demoNote").withRecords(List.of(new QRecord().withValue("id", 1).withValue("text", "Only this run"))));
|
||||
assertEquals("Only this run", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
closeOwnedBrokerConnections();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int freePort() throws Exception
|
||||
{
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 QRunIO, LLC
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qqq-all-bom-test</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QQQ All - Stack BOM Test</name>
|
||||
<description>Resolves every artifact the stack BOM manages. Build-time check only; not published.</description>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-bom</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<!-- One versionless dependency per artifact the BOM manages, so resolving this -->
|
||||
<!-- module's test classpath resolves each of them. StackBomTest fails if one is missing. -->
|
||||
<dependencies>
|
||||
<!-- QQQ modules, qqq-esb, and qqq-frontend-next (from qqq-bom-pom) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-mongodb</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-filesystem</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-sqlite</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-slack</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-picocli</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-lambda</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-language-support-javascript</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-utility-lambdas</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- qBits -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-quick-search</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-user-role-permissions</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-customizable-table-views</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-standard-process-trace</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-webhooks</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-workflows</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-geo-data</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-sftp-data-integration</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JDBC drivers -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Broker clients -->
|
||||
<dependency>
|
||||
<groupId>org.apache.artemis</groupId>
|
||||
<artifactId>artemis-jakarta-client</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.rabbitmq.jms</groupId>
|
||||
<artifactId>rabbitmq-jms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test framework (not part of the stack) -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>6.0.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- Writes the effective POM (BOM imports expanded) for StackBomTest to read. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-help-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>effective-pom</id>
|
||||
<phase>generate-test-resources</phase>
|
||||
<goals>
|
||||
<goal>effective-pom</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<output>${project.build.directory}/effective-pom.xml</output>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -1,226 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRunIO, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.all.bom;
|
||||
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checks qqq-all-bom through this module's effective POM, which Maven writes
|
||||
** with the BOM imports expanded: the BOM manages the whole stack, and this
|
||||
** module depends on every stack artifact the BOM manages, so the build
|
||||
** resolves each one.
|
||||
*******************************************************************************/
|
||||
class StackBomTest
|
||||
{
|
||||
private static final Path EFFECTIVE_POM = Path.of("target", "effective-pom.xml");
|
||||
private static final Path BOM_POM = Path.of("..", "qqq-all-bom", "pom.xml");
|
||||
|
||||
private static List<Coordinate> managed;
|
||||
private static List<Coordinate> dependencies;
|
||||
private static List<Coordinate> bomDeclared;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the managed and declared dependencies from the effective POM.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll() throws Exception
|
||||
{
|
||||
Element effectivePom = parse(EFFECTIVE_POM);
|
||||
managed = readDependencies(child(effectivePom, "dependencyManagement"));
|
||||
dependencies = readDependencies(effectivePom);
|
||||
|
||||
bomDeclared = readDependencies(child(parse(BOM_POM), "dependencyManagement")).stream()
|
||||
.filter(coordinate -> !"import".equals(coordinate.scope()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BOM manages every artifact the qqq-all stack is built from.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testManagesTheStack()
|
||||
{
|
||||
Map<String, String> managedVersions = managed.stream().collect(Collectors.toMap(Coordinate::key, Coordinate::version));
|
||||
|
||||
List<String> required = List.of(
|
||||
"com.kingsrook.qqq:qqq-backend-core",
|
||||
"com.kingsrook.qqq:qqq-esb",
|
||||
"com.kingsrook.qqq:qqq-frontend-next",
|
||||
"com.kingsrook.qbits:qbit-quick-search",
|
||||
"com.kingsrook.qbits:qbit-user-role-permissions",
|
||||
"com.kingsrook.qbits:qbit-customizable-table-views",
|
||||
"com.kingsrook.qbits:qbit-standard-process-trace",
|
||||
"com.kingsrook.qbits:qbit-webhooks",
|
||||
"com.kingsrook.qbits:qbit-workflows",
|
||||
"com.kingsrook.qbits:qbit-geo-data",
|
||||
"com.kingsrook.qbits:qbit-sftp-data-integration",
|
||||
"org.postgresql:postgresql",
|
||||
"com.mysql:mysql-connector-j",
|
||||
"org.xerial:sqlite-jdbc",
|
||||
"com.h2database:h2",
|
||||
"org.apache.artemis:artemis-jakarta-client",
|
||||
"com.rabbitmq.jms:rabbitmq-jms");
|
||||
|
||||
List<String> missing = required.stream().filter(key -> !managedVersions.containsKey(key)).toList();
|
||||
assertEquals(List.of(), missing, "Artifacts the stack BOM does not manage");
|
||||
assertEquals("3.9.0", managedVersions.get("com.rabbitmq.jms:rabbitmq-jms"));
|
||||
assertEquals("1.0.0-RC.8", managedVersions.get("com.kingsrook.qqq:qqq-frontend-next"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This module depends on every stack artifact the BOM manages, so resolving
|
||||
** its test classpath resolves each one from the configured repositories.
|
||||
**
|
||||
** Stack artifacts are the managed com.kingsrook artifacts (qqq modules,
|
||||
** qqq-esb, qqq-frontend-next, qBits) plus everything qqq-all-bom declares
|
||||
** itself. The third-party entries qqq-bom-pom inherits from its parent
|
||||
** (Jackson, Netty, Jetty, and others) only align versions, so they are
|
||||
** resolved when something depends on them rather than here.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDependsOnEveryStackArtifact()
|
||||
{
|
||||
Set<String> stack = managed.stream()
|
||||
.filter(coordinate -> coordinate.groupId().startsWith("com.kingsrook."))
|
||||
.map(Coordinate::key)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
bomDeclared.forEach(coordinate -> stack.add(coordinate.key()));
|
||||
|
||||
Set<String> declared = dependencies.stream().map(Coordinate::key).collect(Collectors.toSet());
|
||||
|
||||
List<String> notResolved = stack.stream().filter(key -> !declared.contains(key)).toList();
|
||||
assertEquals(List.of(), notResolved, "Stack artifacts missing from qqq-all-bom-test's dependencies, so the build does not resolve them");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a POM file and return its project element.
|
||||
*******************************************************************************/
|
||||
private static Element parse(Path pom) throws Exception
|
||||
{
|
||||
Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(pom.toFile());
|
||||
return (document.getDocumentElement());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the dependency elements directly under parent's dependencies element.
|
||||
*******************************************************************************/
|
||||
private static List<Coordinate> readDependencies(Element parent)
|
||||
{
|
||||
List<Coordinate> coordinates = new ArrayList<>();
|
||||
for(Element dependency : children(child(parent, "dependencies"), "dependency"))
|
||||
{
|
||||
coordinates.add(new Coordinate(
|
||||
text(dependency, "groupId", null),
|
||||
text(dependency, "artifactId", null),
|
||||
text(dependency, "version", null),
|
||||
text(dependency, "type", "jar"),
|
||||
text(dependency, "classifier", ""),
|
||||
text(dependency, "scope", "compile")));
|
||||
}
|
||||
return (coordinates);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The single child element with the given name.
|
||||
*******************************************************************************/
|
||||
private static Element child(Element parent, String name)
|
||||
{
|
||||
List<Element> matches = children(parent, name);
|
||||
assertEquals(1, matches.size(), "Expected one <" + name + "> under <" + parent.getTagName() + ">");
|
||||
return (matches.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The child elements with the given name (direct children only, so plugin
|
||||
** dependencies are not mistaken for project dependencies).
|
||||
*******************************************************************************/
|
||||
private static List<Element> children(Element parent, String name)
|
||||
{
|
||||
List<Element> matches = new ArrayList<>();
|
||||
for(Node node = parent.getFirstChild(); node != null; node = node.getNextSibling())
|
||||
{
|
||||
if(node instanceof Element element && element.getTagName().equals(name))
|
||||
{
|
||||
matches.add(element);
|
||||
}
|
||||
}
|
||||
return (matches);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Text of the named child element, or defaultValue when it is absent.
|
||||
*******************************************************************************/
|
||||
private static String text(Element parent, String name, String defaultValue)
|
||||
{
|
||||
List<Element> matches = children(parent, name);
|
||||
return (matches.isEmpty() ? defaultValue : matches.get(0).getTextContent().trim());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A dependency coordinate; key() is how Maven matches a dependency to its
|
||||
** managed entry.
|
||||
*******************************************************************************/
|
||||
private record Coordinate(String groupId, String artifactId, String version, String type, String classifier, String scope)
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
String key()
|
||||
{
|
||||
String key = groupId + ":" + artifactId;
|
||||
if(!type.equals("jar") || !classifier.isEmpty())
|
||||
{
|
||||
key += ":" + type + (classifier.isEmpty() ? "" : ":" + classifier);
|
||||
}
|
||||
return (key);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qqq-all-bom</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>QQQ All - Stack BOM</name>
|
||||
<description>One tested version set for QQQ, the ESB, the Next dashboard, the included qBits, JDBC drivers, and broker clients.</description>
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-SNAPSHOT</qqq.version>
|
||||
<qqq-frontend-next.version>1.0.0-RC.8</qqq-frontend-next.version>
|
||||
|
||||
<!-- qBits: develop builds on qbit-build-parent 2.0.0; none has a release on that parent yet. -->
|
||||
<qbit-quick-search.version>0.2.1-SNAPSHOT</qbit-quick-search.version>
|
||||
<qbit-user-role-permissions.version>0.33.0-SNAPSHOT</qbit-user-role-permissions.version>
|
||||
<qbit-customizable-table-views.version>0.5.0-SNAPSHOT</qbit-customizable-table-views.version>
|
||||
<qbit-standard-process-trace.version>0.3.0-SNAPSHOT</qbit-standard-process-trace.version>
|
||||
<qbit-webhooks.version>0.4.0-SNAPSHOT</qbit-webhooks.version>
|
||||
<qbit-workflows.version>0.114.0-SNAPSHOT</qbit-workflows.version>
|
||||
<qbit-geo-data.version>0.2.0-SNAPSHOT</qbit-geo-data.version>
|
||||
<qbit-sftp-data-integration.version>0.4.0-SNAPSHOT</qbit-sftp-data-integration.version>
|
||||
|
||||
<!-- The versions qqq 4.1 builds and tests its backend modules and qqq-esb with. -->
|
||||
<postgresql.version>42.7.12</postgresql.version>
|
||||
<mysql-connector-j.version>9.5.0</mysql-connector-j.version>
|
||||
<sqlite-jdbc.version>3.51.1.0</sqlite-jdbc.version>
|
||||
<h2.version>2.4.240</h2.version>
|
||||
<artemis.version>2.57.0</artemis.version>
|
||||
<rabbitmq-jms.version>3.9.0</rabbitmq-jms.version>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release-central</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>require-release-bom-pins</id>
|
||||
<phase>validate</phase>
|
||||
<goals><goal>enforce</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireProperty>
|
||||
<property>qqq.version</property>
|
||||
<regex>^\Q${revision}\E$</regex>
|
||||
</requireProperty>
|
||||
<requireProperty><property>qqq-frontend-next.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-quick-search.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-user-role-permissions.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-customizable-table-views.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-standard-process-trace.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-webhooks.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-workflows.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-geo-data.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
<requireProperty><property>qbit-sftp-data-integration.version</property><regex>^[0-9]+[.][0-9]+[.][0-9]+(-RC[.][1-9][0-9]*)?$</regex></requireProperty>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- QQQ modules and qqq-esb. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Keep the accepted dashboard candidate independent of framework snapshots. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>${qqq-frontend-next.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- qBits -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-quick-search</artifactId>
|
||||
<version>${qbit-quick-search.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-user-role-permissions</artifactId>
|
||||
<version>${qbit-user-role-permissions.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-customizable-table-views</artifactId>
|
||||
<version>${qbit-customizable-table-views.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-standard-process-trace</artifactId>
|
||||
<version>${qbit-standard-process-trace.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-webhooks</artifactId>
|
||||
<version>${qbit-webhooks.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-workflows</artifactId>
|
||||
<version>${qbit-workflows.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-geo-data</artifactId>
|
||||
<version>${qbit-geo-data.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-sftp-data-integration</artifactId>
|
||||
<version>${qbit-sftp-data-integration.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JDBC drivers -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>${postgresql.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>${mysql-connector-j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>${sqlite-jdbc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>${h2.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Broker clients for qqq-esb -->
|
||||
<dependency>
|
||||
<groupId>org.apache.artemis</groupId>
|
||||
<artifactId>artemis-jakarta-client</artifactId>
|
||||
<version>${artemis.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.rabbitmq.jms</groupId>
|
||||
<artifactId>rabbitmq-jms</artifactId>
|
||||
<version>${rabbitmq-jms.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
</project>
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
app-full:
|
||||
environment:
|
||||
QQQ_ALL_OPENSEARCH_HOST: opensearch
|
||||
rabbitmq:
|
||||
ports: !override
|
||||
- "127.0.0.1::15672"
|
||||
artemis:
|
||||
ports: !override
|
||||
- "127.0.0.1::8161"
|
||||
mailpit:
|
||||
ports: !override
|
||||
- "127.0.0.1::8025"
|
||||
minio:
|
||||
ports: !override
|
||||
- "127.0.0.1::9001"
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package digest-pinned Compose with the tracked full-profile infrastructure."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from release_gate import validate_version
|
||||
|
||||
|
||||
def create_bundle(root, version, sha, image, output):
|
||||
validate_version(version)
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
||||
raise ValueError("Source SHA must be a full commit ID")
|
||||
if not re.fullmatch(r"ghcr\.io/qrun-io/qqq-all@sha256:[0-9a-f]{64}", image):
|
||||
raise ValueError("Release image must be the qqq-all GHCR digest")
|
||||
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
|
||||
rendered = subprocess.run(
|
||||
["docker", "compose", "--env-file", ".env.example", "--profile", "*", "-f", "compose.yaml",
|
||||
"config", "--no-interpolate", "--no-env-resolution", "--no-path-resolution", "--format", "json"],
|
||||
cwd=root, env=environment, capture_output=True, text=True, check=True,
|
||||
)
|
||||
compose = json.loads(rendered.stdout)
|
||||
compose.pop("x-app-image", None)
|
||||
for name in ("app-core", "app-full"):
|
||||
app = compose["services"][name]
|
||||
app.pop("build", None)
|
||||
app["image"] = image
|
||||
app["pull_policy"] = "always"
|
||||
compose["services"]["minio"]["image"] = "qqq-all-minio:" + version
|
||||
name = "qqq-all-" + version
|
||||
bundle = output / name
|
||||
bundle.mkdir(parents=True, exist_ok=False)
|
||||
(bundle / "compose.yaml").write_text(json.dumps(compose, indent=2) + "\n")
|
||||
tracked = subprocess.check_output(["git", "-C", str(root), "ls-files", "--", "infra"], text=True).splitlines()
|
||||
for filename in (".env.example", "LICENSE", "NOTICE", *tracked):
|
||||
source = root / filename
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise ValueError(f"Bundle input must be a regular file: {filename}")
|
||||
target = bundle / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
(bundle / "release.json").write_text(json.dumps(
|
||||
{"version": version, "source_sha": sha, "image": image}, indent=2) + "\n")
|
||||
(bundle / "README.md").write_text(f"""# qqq-all {version}
|
||||
|
||||
Extract this complete bundle and run commands from this directory. The application uses
|
||||
`{image}` (source `{sha}`). No application source, Maven or app build is required.
|
||||
Docker with Compose v2 is required. Copy `.env.example` to `.env` and replace
|
||||
the demonstration passwords before sharing the environment. All fixture values remain
|
||||
demo-only; retain the infra directory next to compose.yaml.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -p qqq-all-demo --profile core up -d --wait --no-build
|
||||
```
|
||||
|
||||
For the full profile, stop core first. The included MinIO Dockerfile still builds locally
|
||||
and needs network access; PostgreSQL/MySQL/Mongo initialization and the Keycloak realm
|
||||
use the bundled infra files. External service images also need network access.
|
||||
|
||||
```bash
|
||||
docker compose -p qqq-all-demo --profile core down
|
||||
docker compose -p qqq-all-demo --profile full build minio
|
||||
docker compose -p qqq-all-demo --profile full up -d --wait --no-build
|
||||
```
|
||||
|
||||
Use a distinct Compose project name for each installation. The application listens on
|
||||
http://localhost:8080 by default. The full-profile identity provider uses
|
||||
http://keycloak.localhost:8081; that hostname must resolve to loopback on the client.
|
||||
Ports and fixture settings are in `.env.example`. `docker compose ... down` retains
|
||||
named data volumes; remove volumes only when intentionally resetting that installation.
|
||||
The standalone compose.yaml asset needs this bundle's infra and environment template
|
||||
for full-profile operation. See release.json for the exact application identity.
|
||||
""")
|
||||
archive = output / (name + ".tar.gz")
|
||||
with tarfile.open(archive, "x:gz") as handle:
|
||||
handle.add(bundle, arcname=name)
|
||||
return archive
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--sha", required=True)
|
||||
parser.add_argument("--image", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(create_bundle(args.root.resolve(), args.version, args.sha, args.image, args.output.resolve()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,296 +0,0 @@
|
||||
"""Fail-closed checks for the manual 4.1.0 GA / exact RC.1 release workflow."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
QBITS = (
|
||||
"quick-search", "user-role-permissions", "customizable-table-views",
|
||||
"standard-process-trace", "webhooks", "workflows", "geo-data",
|
||||
"sftp-data-integration",
|
||||
)
|
||||
RELEASE_VERSIONS = ("4.1.0", "4.1.0-RC.1")
|
||||
|
||||
|
||||
def immutable_version(value):
|
||||
return re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}(?:-RC\.[1-9][0-9]*)?", value) is not None
|
||||
|
||||
|
||||
def validate_version(version):
|
||||
if version not in RELEASE_VERSIONS:
|
||||
raise GateError("This release workflow is limited to 4.1.0 and 4.1.0-RC.1")
|
||||
|
||||
|
||||
class GateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def properties(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
values = root.find(f"{namespace}properties")
|
||||
if values is None:
|
||||
raise GateError(f"Missing Maven properties in {path}")
|
||||
return {child.tag.removeprefix(namespace): (child.text or "").strip() for child in values}
|
||||
|
||||
|
||||
def xml_namespace(root):
|
||||
return root.tag.partition("}")[0] + "}" if root.tag.startswith("{") else ""
|
||||
|
||||
|
||||
def coordinates(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
parent = root.find(f"{namespace}parent")
|
||||
|
||||
def value(name):
|
||||
direct = root.findtext(f"{namespace}{name}")
|
||||
inherited = parent.findtext(f"{namespace}{name}") if parent is not None else None
|
||||
return direct or inherited
|
||||
|
||||
return value("groupId"), value("artifactId"), value("version")
|
||||
|
||||
|
||||
def managed_dependencies(path):
|
||||
root = ElementTree.parse(path).getroot()
|
||||
namespace = xml_namespace(root)
|
||||
values = properties(path)
|
||||
if root.findall(f"{namespace}profiles/{namespace}profile/{namespace}dependencyManagement"):
|
||||
raise GateError(f"Profile-managed dependency override in {path}")
|
||||
dependencies = root.find(f"{namespace}dependencyManagement/{namespace}dependencies")
|
||||
if dependencies is None:
|
||||
raise GateError(f"Missing dependencyManagement in {path}")
|
||||
|
||||
def field(dependency, name, default=""):
|
||||
return (dependency.findtext(f"{namespace}{name}") or default).strip()
|
||||
|
||||
managed = []
|
||||
for dependency in dependencies.findall(f"{namespace}dependency"):
|
||||
version = field(dependency, "version")
|
||||
reference = re.fullmatch(r"\$\{([^}]+)\}", version)
|
||||
if reference:
|
||||
version = values.get(reference.group(1), "")
|
||||
if not immutable_version(version):
|
||||
raise GateError(f"Unreleased managed dependency in {path}: "
|
||||
f"{field(dependency, 'artifactId')}")
|
||||
managed.append((field(dependency, "groupId"), field(dependency, "artifactId"),
|
||||
version, field(dependency, "type", "jar"),
|
||||
field(dependency, "scope", "compile"),
|
||||
field(dependency, "classifier")))
|
||||
if not managed:
|
||||
raise GateError(f"No managed dependencies in {path}")
|
||||
return managed
|
||||
|
||||
|
||||
def validate_managed_bom(path, version):
|
||||
managed = managed_dependencies(path)
|
||||
entries = {(group, artifact): (pin, kind, scope) for group, artifact, pin, kind, scope, _ in managed}
|
||||
if len(entries) != len(managed):
|
||||
raise GateError(f"Duplicate managed dependency in {path}")
|
||||
if entries.get(("com.kingsrook.qqq", "qqq-bom-pom")) != (version, "pom", "import"):
|
||||
raise GateError(f"QQQ BOM import differs from {version} in {path}")
|
||||
values = properties(path)
|
||||
if entries.get(("com.kingsrook.qqq", "qqq-frontend-next")) != (
|
||||
values.get("qqq-frontend-next.version"), "jar", "compile"):
|
||||
raise GateError(f"Managed Next differs from its release pin in {path}")
|
||||
for name in QBITS:
|
||||
artifact = f"qbit-{name}"
|
||||
if entries.get(("com.kingsrook.qbits", artifact)) != (
|
||||
values.get(f"{artifact}.version"), "jar", "compile"):
|
||||
raise GateError(f"Managed {artifact} differs from its release pin in {path}")
|
||||
return managed
|
||||
|
||||
|
||||
def validate_published_poms(parent_path, bom_path, version, local_bom):
|
||||
expected = (
|
||||
(parent_path, "qqq-all-parent"),
|
||||
(bom_path, "qqq-all-bom"),
|
||||
)
|
||||
for path, artifact in expected:
|
||||
if coordinates(path) != ("com.kingsrook.qqq", artifact, version):
|
||||
raise GateError(f"Published {artifact} POM is not the {version} release")
|
||||
published = properties(bom_path)
|
||||
if published.get("qqq.version") != version:
|
||||
raise GateError(f"Published BOM does not import QQQ {version}")
|
||||
for name in QBITS:
|
||||
key = f"qbit-{name}.version"
|
||||
value = published.get(key, "")
|
||||
if not immutable_version(value):
|
||||
raise GateError(f"Published BOM {key} is not a release")
|
||||
local = properties(local_bom)
|
||||
for key in ("qqq.version", "qqq-frontend-next.version", *(f"qbit-{name}.version" for name in QBITS)):
|
||||
if published.get(key) != local.get(key):
|
||||
raise GateError(f"Published BOM {key} differs from release source")
|
||||
if validate_managed_bom(bom_path, version) != validate_managed_bom(local_bom, version):
|
||||
raise GateError("Published BOM dependencyManagement differs from release source")
|
||||
|
||||
|
||||
def validate_poms(root, version):
|
||||
validate_version(version)
|
||||
parent = properties(root / "pom.xml")
|
||||
bom = properties(root / "qqq-all-bom/pom.xml")
|
||||
if parent.get("revision") != version or bom.get("qqq.version") != version:
|
||||
raise GateError(f"Parent revision and QQQ BOM pin must both equal {version}")
|
||||
for name in QBITS:
|
||||
key = f"qbit-{name}.version"
|
||||
value = bom.get(key, "")
|
||||
if not immutable_version(value):
|
||||
raise GateError(f"{key} must be a published non-snapshot version")
|
||||
for key, value in bom.items():
|
||||
if key.endswith(".version") and not immutable_version(value):
|
||||
raise GateError(f"{key} must be an immutable release version")
|
||||
validate_managed_bom(root / "qqq-all-bom/pom.xml", version)
|
||||
|
||||
|
||||
def validate_smoke(run, jobs, sha):
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
||||
raise GateError("Release SHA must be a full lowercase Git commit ID")
|
||||
expected = {
|
||||
"name": "Smoke", "path": ".github/workflows/smoke.yml",
|
||||
"head_sha": sha, "head_branch": "develop", "event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if run.get(key) != value:
|
||||
raise GateError(f"Smoke run {key} must equal {value}")
|
||||
observed = {"core": False, "full": False}
|
||||
for job in jobs.get("jobs", []):
|
||||
name = job.get("name")
|
||||
if name in observed:
|
||||
if job.get("conclusion") != "success":
|
||||
raise GateError(f"Smoke job {name} did not pass")
|
||||
observed[name] = True
|
||||
if not all(observed.values()):
|
||||
raise GateError("Smoke run must include successful core and full jobs")
|
||||
|
||||
|
||||
def validate_upstream_release(release, version):
|
||||
if (not immutable_version(version) or release.get("tag_name") != "v" + version
|
||||
or release.get("draft") is not False
|
||||
or release.get("prerelease") is not ("-RC." in version)):
|
||||
raise GateError(f"Upstream release must be public v{version} with matching prerelease state")
|
||||
|
||||
|
||||
def validate_tag(tag, version, sha, tag_sha):
|
||||
validate_version(version)
|
||||
if not all(re.fullmatch(r"[0-9a-f]{40}", value) for value in (sha, tag_sha)):
|
||||
raise GateError("Tag and commit IDs must be full Git SHAs")
|
||||
if (tag.get("sha") != tag_sha or tag.get("tag") != version
|
||||
or tag.get("object", {}).get("type") != "commit"
|
||||
or tag.get("object", {}).get("sha") != sha
|
||||
or tag.get("verification", {}).get("verified") is not True
|
||||
or tag.get("verification", {}).get("reason") != "valid"):
|
||||
raise GateError("Release tag must be signed, verified, annotated and point to the exact release commit")
|
||||
|
||||
|
||||
def download_public_inputs(bom, version, destination, central="https://repo.maven.apache.org/maven2"):
|
||||
"""Fetch the declared first-party inputs and their parents without credentials."""
|
||||
validate_version(version)
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
receipts = []
|
||||
visited = set()
|
||||
|
||||
def fetch(group, artifact, pin, kind):
|
||||
coordinate = (group, artifact, pin, kind)
|
||||
if coordinate in visited:
|
||||
return
|
||||
if (not re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*", group)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_-]+", artifact) or not immutable_version(pin)):
|
||||
raise GateError(f"Invalid or mutable public coordinate: {coordinate}")
|
||||
visited.add(coordinate)
|
||||
relative = f"{group.replace('.', '/')}/{artifact}/{pin}/{artifact}-{pin}.{kind}"
|
||||
url = central + "/" + relative
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
data = response.read()
|
||||
except (urllib.error.URLError, OSError) as error:
|
||||
if isinstance(error, urllib.error.HTTPError):
|
||||
error.close()
|
||||
raise GateError(f"Public input unavailable: {relative}") from error
|
||||
if not data:
|
||||
raise GateError(f"Empty public input: {relative}")
|
||||
path = destination / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
if kind == "pom":
|
||||
if coordinates(path) != (group, artifact, pin):
|
||||
raise GateError(f"Public POM coordinates differ: {relative}")
|
||||
root = ElementTree.fromstring(data)
|
||||
ns = xml_namespace(root)
|
||||
parent = root.find(f"{ns}parent")
|
||||
if parent is not None:
|
||||
parent_group = parent.findtext(f"{ns}groupId", "")
|
||||
parent_pin = parent.findtext(f"{ns}version", "")
|
||||
if not immutable_version(parent_pin):
|
||||
raise GateError(f"Mutable parent in {relative}")
|
||||
if parent_group.startswith("com.kingsrook"):
|
||||
fetch(parent_group, parent.findtext(f"{ns}artifactId", ""), parent_pin, "pom")
|
||||
if artifact == "qbit-build-parent":
|
||||
imports = root.findall(f"{ns}dependencyManagement/{ns}dependencies/{ns}dependency")
|
||||
props = root.find(f"{ns}properties")
|
||||
values = {child.tag.removeprefix(ns): child.text for child in props} if props is not None else {}
|
||||
qqq_imports = [dep for dep in imports if dep.findtext(f"{ns}groupId") == "com.kingsrook.qqq"
|
||||
and dep.findtext(f"{ns}artifactId") == "qqq-bom-pom"]
|
||||
if len(qqq_imports) != 1:
|
||||
raise GateError("Public qbit-build-parent must import the candidate QQQ BOM")
|
||||
dep = qqq_imports[0]
|
||||
actual = dep.findtext(f"{ns}version", "")
|
||||
actual = values.get(actual[2:-1], "") if actual.startswith("${") else actual
|
||||
if (actual != version or dep.findtext(f"{ns}type") != "pom"
|
||||
or dep.findtext(f"{ns}scope") != "import"):
|
||||
raise GateError("Public qbit-build-parent QQQ import differs from the candidate")
|
||||
receipts.append({"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
|
||||
|
||||
for group, artifact, pin, kind, _, classifier in validate_managed_bom(bom, version):
|
||||
if group.startswith("com.kingsrook"):
|
||||
if classifier or kind not in ("jar", "pom"):
|
||||
raise GateError(f"Unsupported first-party release input: {artifact}")
|
||||
fetch(group, artifact, pin, "pom")
|
||||
if kind == "jar":
|
||||
fetch(group, artifact, pin, "jar")
|
||||
(destination / "receipts.json").write_text(json.dumps(receipts, indent=2) + "\n")
|
||||
return receipts
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--sha", required=True)
|
||||
parser.add_argument("--smoke-run", type=Path, required=True)
|
||||
parser.add_argument("--smoke-jobs", type=Path, required=True)
|
||||
parser.add_argument("--published-parent", type=Path, required=True)
|
||||
parser.add_argument("--published-bom", type=Path, required=True)
|
||||
parser.add_argument("--upstream-release", type=Path, required=True)
|
||||
parser.add_argument("--next-release", type=Path, required=True)
|
||||
parser.add_argument("--tag-object", type=Path, required=True)
|
||||
parser.add_argument("--tag-sha", required=True)
|
||||
parser.add_argument("--public-inputs", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
validate_poms(args.root, args.version)
|
||||
validate_upstream_release(json.loads(args.upstream_release.read_text()), args.version)
|
||||
validate_upstream_release(json.loads(args.next_release.read_text()),
|
||||
properties(args.root / "qqq-all-bom/pom.xml")["qqq-frontend-next.version"])
|
||||
validate_tag(json.loads(args.tag_object.read_text()), args.version, args.sha, args.tag_sha)
|
||||
validate_smoke(json.loads(args.smoke_run.read_text()),
|
||||
json.loads(args.smoke_jobs.read_text()), args.sha)
|
||||
validate_published_poms(args.published_parent, args.published_bom, args.version,
|
||||
args.root / "qqq-all-bom/pom.xml")
|
||||
download_public_inputs(args.root / "qqq-all-bom/pom.xml", args.version, args.public_inputs)
|
||||
except (GateError, OSError, ElementTree.ParseError, json.JSONDecodeError) as error:
|
||||
print(f"Release gate failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print("Local POM and exact-commit smoke gates passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start the packaged core jar, smoke its HTTP API, and always stop it."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from smoke import CORE_ADMIN_SESSION, SmokeClient, check_core
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def await_health(client, process, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise AssertionError("packaged app exited before becoming healthy")
|
||||
try:
|
||||
if client.json("/health").get("status") == "UP":
|
||||
return
|
||||
except (AssertionError, OSError, ValueError):
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise AssertionError("timed out waiting for packaged app health")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("jar", type=Path)
|
||||
parser.add_argument("--timeout", type=int, default=90)
|
||||
args = parser.parse_args()
|
||||
if not args.jar.is_file():
|
||||
raise AssertionError("packaged jar was not found")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="qqq-all-core-smoke-") as directory:
|
||||
port = free_port()
|
||||
environment = os.environ.copy()
|
||||
environment.update({
|
||||
"QQQ_ALL_PROFILE": "core",
|
||||
"QQQ_ALL_PORT": str(port),
|
||||
"QQQ_ALL_DATA_DIR": str(Path(directory) / "data"),
|
||||
})
|
||||
with (Path(directory) / "app.log").open("wb") as output:
|
||||
process = subprocess.Popen(
|
||||
["java", "-jar", str(args.jar.resolve())],
|
||||
env=environment, stdout=output, stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{port}", CORE_ADMIN_SESSION)
|
||||
await_health(client, process, args.timeout)
|
||||
check_core(client, args.timeout)
|
||||
if process.poll() is not None:
|
||||
raise AssertionError("packaged app exited during smoke checks")
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (AssertionError, OSError, ValueError) as error:
|
||||
print(f"core smoke failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Boot a private full Compose stack, run HTTP smoke checks, and remove it."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from smoke import check_full
|
||||
from smoke_diagnostics import Diagnostics
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def free_port(exclude=()):
|
||||
for _ in range(30):
|
||||
port = 20000 + secrets.randbelow(10000)
|
||||
if port in exclude:
|
||||
continue
|
||||
try:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise AssertionError("could not reserve a full smoke host port")
|
||||
|
||||
|
||||
def demo_environment():
|
||||
values = {}
|
||||
for line in (ROOT / ".env.example").read_text().splitlines():
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if "PASSWORD" in key or "SECRET" in key or key == "MONGO_KEYFILE":
|
||||
value = secrets.token_urlsafe(24)
|
||||
values[key] = value
|
||||
app_port = free_port()
|
||||
values["QQQ_ALL_PORT"] = str(app_port)
|
||||
values["KEYCLOAK_PORT"] = str(free_port((app_port,)))
|
||||
return values
|
||||
|
||||
|
||||
def published_port(command, service, target, environment):
|
||||
result = subprocess.run(command + ["port", service, str(target)],
|
||||
cwd=ROOT, env=environment, capture_output=True, text=True,
|
||||
timeout=15, check=False)
|
||||
if result.returncode:
|
||||
raise AssertionError(f"could not discover {service} host port")
|
||||
address = result.stdout.strip()
|
||||
if not address.startswith("127.0.0.1:") or not address.rsplit(":", 1)[1].isdigit():
|
||||
raise AssertionError(f"unexpected {service} host port")
|
||||
return int(address.rsplit(":", 1)[1])
|
||||
|
||||
|
||||
def query_opensearch(command, environment, index_name, query):
|
||||
url = ("http://127.0.0.1:9200/" + urllib.parse.quote(index_name, safe="")
|
||||
+ "/_search")
|
||||
result = subprocess.run(
|
||||
command + ["exec", "-T", "opensearch", "curl", "--fail", "--silent",
|
||||
"--show-error", "--max-time", "5", "-H", "Content-Type: application/json",
|
||||
"--data-binary", json.dumps(query), url],
|
||||
cwd=ROOT, env=environment, capture_output=True, text=True, timeout=15, check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AssertionError("OpenSearch query failed inside Compose")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
raise AssertionError("OpenSearch returned invalid JSON") from None
|
||||
|
||||
|
||||
def prepare_sftp_import_directory(command, environment, username):
|
||||
path = f"/home/{username}/upload/imports"
|
||||
for args in (["mkdir", "-p", path], ["chown", "1001:1001", path]):
|
||||
result = subprocess.run(
|
||||
command + ["exec", "-T", "sftp"] + args,
|
||||
cwd=ROOT, env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=15, check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AssertionError("could not prepare SFTP import directory")
|
||||
|
||||
|
||||
def main():
|
||||
if not (ROOT / "compose.yaml").is_file():
|
||||
raise AssertionError("C6 Compose is not integrated into this branch")
|
||||
values = demo_environment()
|
||||
project = "qqqallsmoke" + secrets.token_hex(4)
|
||||
environment = os.environ.copy()
|
||||
environment.update(values)
|
||||
os.environ.update(values)
|
||||
diagnostics = Diagnostics(ROOT / "target/smoke-diagnostics", environment)
|
||||
with tempfile.TemporaryDirectory(prefix="qqq-all-full-smoke-") as directory:
|
||||
env_file = Path(directory) / "compose.env"
|
||||
env_file.write_text("".join(f"{key}={value}\n" for key, value in values.items()))
|
||||
env_file.chmod(0o600)
|
||||
command = ["docker", "compose", "-p", project, "--env-file", str(env_file),
|
||||
"-f", str(ROOT / "compose.yaml"), "-f", str(ROOT / "scripts/compose.smoke.yaml"),
|
||||
"--profile", "full"]
|
||||
try:
|
||||
diagnostics.jar_hashes(ROOT)
|
||||
diagnostics.pull(command, ROOT, environment)
|
||||
started = diagnostics.run(command + ["up", "-d", "--build", "--wait", "--wait-timeout", "360"],
|
||||
ROOT, environment, timeout=480)
|
||||
diagnostics.write("startup.json", started)
|
||||
if started["exit_code"] != 0:
|
||||
raise AssertionError("full Compose stack did not become healthy")
|
||||
prepare_sftp_import_directory(command, environment, values.get("SFTP_USER", "qqq"))
|
||||
check_full(
|
||||
f"http://127.0.0.1:{values['QQQ_ALL_PORT']}",
|
||||
f"http://keycloak.localhost:{values['KEYCLOAK_PORT']}/realms/qqq-all",
|
||||
lambda index, query: query_opensearch(command, environment, index, query),
|
||||
120,
|
||||
)
|
||||
except (AssertionError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
diagnostics.write("failure.json", {"type": type(error).__name__,
|
||||
"message": diagnostics.sanitize(str(error))})
|
||||
raise AssertionError(diagnostics.sanitize(str(error))) from None
|
||||
finally:
|
||||
try:
|
||||
diagnostics.collect(command, ROOT, environment)
|
||||
except Exception as error:
|
||||
# Evidence failure must not suppress the smoke error or private-stack cleanup.
|
||||
print(f"smoke diagnostics collection failed: {type(error).__name__}", file=sys.stderr)
|
||||
finally:
|
||||
cleanup = diagnostics.run(command + ["down", "--volumes", "--remove-orphans"],
|
||||
ROOT, environment, timeout=120)
|
||||
diagnostics.write("cleanup.json", cleanup)
|
||||
if cleanup["exit_code"] != 0 and sys.exc_info()[0] is None:
|
||||
raise AssertionError("private Compose cleanup failed; see sanitized diagnostics")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (AssertionError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
print(f"full smoke failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
@@ -1,384 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box HTTP smoke checks for the qqq-all reference application."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import html.parser
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
CORE_TABLES = ("customer", "order", "orderLine", "product")
|
||||
CORE_SEEDS = (
|
||||
("customer", "name", "Ada Lovelace"),
|
||||
("order", "orderNo", "ORD-1001"),
|
||||
("orderLine", "quantity", 2),
|
||||
("product", "name", "QRun Starter Kit"),
|
||||
)
|
||||
FULL_TABLES = CORE_TABLES + (
|
||||
"warehouseCustomer", "supplierOrder", "shipment", "document", "externalImportFile",
|
||||
"SFTPImportSourceFileTable",
|
||||
)
|
||||
CORE_ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
|
||||
|
||||
class LoginForm(html.parser.HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.action = None
|
||||
self.values = {}
|
||||
self.inside = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
values = dict(attrs)
|
||||
if tag == "form" and values.get("id") == "kc-form-login":
|
||||
self.action = values.get("action")
|
||||
self.inside = True
|
||||
elif tag == "input" and self.inside and values.get("name"):
|
||||
self.values[values["name"]] = values.get("value", "")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "form":
|
||||
self.inside = False
|
||||
|
||||
|
||||
class StopAtCallback(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
def redirect_request(self, request, fp, code, msg, headers, url):
|
||||
if url.startswith(self.callback + "?"):
|
||||
return None
|
||||
return super().redirect_request(request, fp, code, msg, headers, url)
|
||||
|
||||
|
||||
def wait_for(label, predicate, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if predicate():
|
||||
return
|
||||
except (OSError, TimeoutError, AssertionError, ValueError):
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
raise AssertionError(f"timed out waiting for {label}")
|
||||
|
||||
|
||||
class SmokeClient:
|
||||
def __init__(self, base_url, session_id=None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session_id = session_id
|
||||
self.cookies = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies))
|
||||
|
||||
def request(self, path, body=None, method=None, expected_status=200):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
if self.session_id:
|
||||
headers["Cookie"] = "sessionId=" + self.session_id
|
||||
request = urllib.request.Request(
|
||||
self.base_url + path, data=data, method=method,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=5) as response:
|
||||
if response.status != expected_status:
|
||||
raise AssertionError(f"{path}: HTTP {response.status}")
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
try:
|
||||
if status == expected_status:
|
||||
return error.read()
|
||||
finally:
|
||||
error.close()
|
||||
raise AssertionError(f"{path}: HTTP {status}") from None
|
||||
|
||||
def json(self, path, body=None):
|
||||
return json.loads(self.request(path, body))
|
||||
|
||||
def records(self, table):
|
||||
result = self.json(f"/qqq/v1/table/{table}/query", {})
|
||||
records = result.get("records")
|
||||
if not isinstance(records, list):
|
||||
raise AssertionError(f"{table}: query did not return records")
|
||||
return records
|
||||
|
||||
def require_record(self, table, field, value):
|
||||
if not any(record.get("values", {}).get(field) == value for record in self.records(table)):
|
||||
raise AssertionError(f"missing expected {table} {field}")
|
||||
|
||||
|
||||
def require_oidc_session(client, result):
|
||||
# Next uses an HttpOnly cookie; current QQQ intentionally omits its UUID from JSON.
|
||||
cookies = [cookie for cookie in client.cookies if cookie.name == "sessionUUID"]
|
||||
if (len(cookies) != 1 or not cookies[0].value
|
||||
or not cookies[0].has_nonstandard_attr("HttpOnly")):
|
||||
raise AssertionError("QQQ did not establish an HttpOnly OIDC session")
|
||||
if "uuid" in result:
|
||||
raise AssertionError("QQQ exposed the HttpOnly session UUID in JSON")
|
||||
|
||||
|
||||
def allow_loopback_http_session(client):
|
||||
parsed = urllib.parse.urlsplit(client.base_url)
|
||||
if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost"):
|
||||
raise AssertionError("OIDC HTTP cookie adjustment requires a loopback app")
|
||||
cookies = [cookie for cookie in client.cookies if cookie.name == "sessionUUID"]
|
||||
if len(cookies) != 1:
|
||||
raise AssertionError("OIDC did not set exactly one session cookie")
|
||||
if cookies[0].secure:
|
||||
cookies[0].secure = False
|
||||
|
||||
|
||||
def allow_loopback_http_cookies(cookies, url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
host = parsed.hostname or ""
|
||||
if parsed.scheme != "http" or not (host in ("127.0.0.1", "localhost")
|
||||
or host.endswith(".localhost")):
|
||||
raise AssertionError("OIDC HTTP cookie adjustment requires a loopback host")
|
||||
for cookie in cookies:
|
||||
if cookie.secure and cookie.domain.lstrip(".") == host:
|
||||
cookie.secure = False
|
||||
|
||||
|
||||
def check_health_and_dashboard(client, timeout):
|
||||
wait_for("health", lambda: client.json("/health").get("status") == "UP", timeout)
|
||||
html = client.request("/").decode()
|
||||
match = re.search(r'["\'](/_next/static/[^"\']+\.(?:js|css))["\']', html)
|
||||
if not match:
|
||||
raise AssertionError("dashboard did not reference a Next asset")
|
||||
if not client.request(match.group(1)):
|
||||
raise AssertionError("Next asset was empty")
|
||||
print("health and Next asset: OK")
|
||||
|
||||
|
||||
def check_tables(client, tables, require_seed=True):
|
||||
for table in tables:
|
||||
records = client.records(table)
|
||||
if require_seed and not records:
|
||||
raise AssertionError(f"{table}: no seeded records")
|
||||
if require_seed:
|
||||
for table, field, value in CORE_SEEDS:
|
||||
client.require_record(table, field, value)
|
||||
else:
|
||||
client.require_record("customer", "name", "Ada Lovelace")
|
||||
print("backend table queries: OK")
|
||||
|
||||
|
||||
def trigger_order(client):
|
||||
before = {record.get("values", {}).get("id") for record in client.records("processTrace")}
|
||||
marker = "SMOKE-" + uuid.uuid4().hex[:12]
|
||||
response = client.json("/data/order/", {
|
||||
"orderNo": marker, "customerId": 1, "status": "NEW"
|
||||
})
|
||||
inserted = response.get("records", [])
|
||||
order_id = inserted[0].get("values", {}).get("id") if inserted else None
|
||||
if not order_id:
|
||||
raise AssertionError("order insert did not return an ID")
|
||||
return before, order_id, marker
|
||||
|
||||
|
||||
def check_artemis_round_trip(client, timeout):
|
||||
before, order_id, marker = trigger_order(client)
|
||||
process_uuid = None
|
||||
|
||||
def new_sync_trace():
|
||||
nonlocal process_uuid
|
||||
for record in client.records("processTrace"):
|
||||
values = record.get("values", {})
|
||||
if (values.get("id") not in before
|
||||
and record.get("recordLabel", "").startswith("Sync Order -")
|
||||
and values.get("keyRecordId") == order_id
|
||||
and values.get("processUUID")):
|
||||
process_uuid = values["processUUID"]
|
||||
return True
|
||||
return False
|
||||
|
||||
wait_for("Artemis syncOrder trace",
|
||||
new_sync_trace, timeout)
|
||||
client.require_record("order", "orderNo", marker)
|
||||
print("Artemis order event round trip: OK")
|
||||
return process_uuid
|
||||
|
||||
|
||||
def check_runtime_features(client, can_calculate=True):
|
||||
records = client.records("apiCatalog")
|
||||
if [record.get("values", {}).get("id") for record in records] != [101, 102, 103]:
|
||||
raise AssertionError("API catalog did not traverse all pages")
|
||||
record = client.json("/data/apiCatalog/103")
|
||||
if record.get("values", {}).get("name") != "Shipping Box":
|
||||
raise AssertionError("API catalog get returned incorrect record")
|
||||
client.require_record("demoNote", "text", "Transient demo note")
|
||||
path = "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"
|
||||
if can_calculate:
|
||||
result = client.json(path, {})
|
||||
if result.get("values", {}).get("totalCents") != 3750:
|
||||
raise AssertionError("JavaScript calculation returned incorrect total")
|
||||
else:
|
||||
client.request(path, {}, expected_status=403)
|
||||
client.request("/data/demoNote/1", {"text": "forbidden"}, method="PATCH", expected_status=403)
|
||||
client.request("/data/apiCatalog/", {"id": 999, "name": "forbidden"}, expected_status=403)
|
||||
print("API catalog, transient memory, JavaScript permissions: OK")
|
||||
|
||||
|
||||
def check_core(client, timeout):
|
||||
check_health_and_dashboard(client, timeout)
|
||||
check_tables(client, CORE_TABLES)
|
||||
check_runtime_features(client)
|
||||
check_runtime_features(SmokeClient(client.base_url, "dddddddd-dddd-4ddd-8ddd-dddddddddddd"), False)
|
||||
check_artemis_round_trip(client, timeout)
|
||||
|
||||
|
||||
def oidc_login(client, keycloak_url, username, password):
|
||||
"""Complete the browser authorization-code flow and establish a QQQ cookie."""
|
||||
try:
|
||||
discovery_url = keycloak_url.rstrip("/") + "/.well-known/openid-configuration"
|
||||
with urllib.request.urlopen(discovery_url, timeout=5) as response:
|
||||
authorization_endpoint = json.load(response)["authorization_endpoint"]
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
state = secrets.token_urlsafe(24)
|
||||
callback = client.base_url + "/"
|
||||
query = urllib.parse.urlencode({
|
||||
"client_id": "qqq-all", "response_type": "code", "scope": "openid email profile",
|
||||
"redirect_uri": callback, "state": state, "code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
})
|
||||
browser_cookies = http.cookiejar.CookieJar()
|
||||
browser = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(browser_cookies), StopAtCallback(callback)
|
||||
)
|
||||
with browser.open(authorization_endpoint + "?" + query, timeout=10) as response:
|
||||
form = LoginForm()
|
||||
form.feed(response.read().decode())
|
||||
if not form.action:
|
||||
raise AssertionError("Keycloak login form missing")
|
||||
if urllib.parse.urlsplit(authorization_endpoint).scheme == "http":
|
||||
allow_loopback_http_cookies(browser_cookies, authorization_endpoint)
|
||||
form.values.update({"username": username, "password": password})
|
||||
request = urllib.request.Request(form.action, data=urllib.parse.urlencode(form.values).encode())
|
||||
try:
|
||||
with browser.open(request, timeout=10) as response:
|
||||
raise AssertionError(f"Keycloak did not redirect after login (HTTP {response.status})")
|
||||
except urllib.error.HTTPError as response:
|
||||
if response.code not in (302, 303):
|
||||
status = response.code
|
||||
response.close()
|
||||
raise AssertionError(f"Keycloak rejected the login (HTTP {status})") from None
|
||||
location = response.headers.get("Location", "")
|
||||
response.close()
|
||||
parameters = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query)
|
||||
if parameters.get("state") != [state] or not parameters.get("code"):
|
||||
raise AssertionError("OIDC callback did not contain a valid code and state")
|
||||
result = client.json("/manageSession", {
|
||||
"code": parameters["code"][0], "redirectUri": callback, "codeVerifier": verifier
|
||||
})
|
||||
require_oidc_session(client, result)
|
||||
if urllib.parse.urlsplit(client.base_url).scheme == "http":
|
||||
allow_loopback_http_session(client)
|
||||
except (OSError, KeyError, ValueError, urllib.error.HTTPError) as error:
|
||||
raise AssertionError("OIDC login did not complete") from None
|
||||
print(f"OIDC {username} login: OK")
|
||||
|
||||
|
||||
def http_query_index(search_url, index_name, query):
|
||||
request = urllib.request.Request(
|
||||
search_url.rstrip("/") + "/" + urllib.parse.quote(index_name, safe="") + "/_search",
|
||||
data=json.dumps(query).encode(), headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def check_quick_search(client, query_index, index_name, timeout):
|
||||
result = client.json("/processes/quickSearchFullReindex/run", {})
|
||||
if result.get("exception"):
|
||||
raise AssertionError("quick-search reindex failed")
|
||||
|
||||
def finds_customer():
|
||||
query = {"query": {"bool": {"must": {"match": {"searchableText": "Ada"}},
|
||||
"filter": {"term": {"sourceTable": "customer"}}}}}
|
||||
hits = query_index(index_name, query)["hits"]["hits"]
|
||||
return any("Ada Lovelace" in json.dumps(hit.get("_source", {})) for hit in hits)
|
||||
|
||||
wait_for("quick search seeded customer", finds_customer, timeout)
|
||||
print("OpenSearch customer quick search: OK")
|
||||
|
||||
|
||||
def check_rabbitmq(client, timeout, process_uuid):
|
||||
def message_arrived():
|
||||
offset = 0
|
||||
while True:
|
||||
suffix = "" if offset == 0 else f"?offset={offset}"
|
||||
page = client.json("/qqq/v1/esb/messages/orderSyncEvents" + suffix)
|
||||
messages = page.get("messages", [])
|
||||
for message in messages:
|
||||
event = message.get("event")
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
data = event.get("data")
|
||||
if (event.get("type") == "qqq.process.syncOrder.completed"
|
||||
and isinstance(data, dict)
|
||||
and data.get("processName") == "syncOrder"
|
||||
and data.get("processUUID") == process_uuid):
|
||||
return True
|
||||
if not page.get("hasMore"):
|
||||
return False
|
||||
if not messages:
|
||||
raise ValueError("ESB browse returned an empty page with more messages")
|
||||
offset += len(messages)
|
||||
|
||||
wait_for("RabbitMQ syncOrder completion", message_arrived, timeout)
|
||||
print("RabbitMQ completion publication: OK")
|
||||
|
||||
|
||||
def check_full(base_url, keycloak_url, query_index, timeout):
|
||||
viewer = SmokeClient(base_url)
|
||||
check_health_and_dashboard(viewer, timeout)
|
||||
oidc_login(viewer, keycloak_url, "demo-user", os.environ["DEMO_USER_PASSWORD"])
|
||||
viewer.require_record("customer", "name", "Ada Lovelace")
|
||||
check_runtime_features(viewer, False)
|
||||
admin = SmokeClient(base_url)
|
||||
oidc_login(admin, keycloak_url, "demo-admin", os.environ["DEMO_ADMIN_PASSWORD"])
|
||||
check_tables(admin, FULL_TABLES, require_seed=False)
|
||||
check_runtime_features(admin)
|
||||
check_quick_search(admin, query_index, os.environ.get("QQQ_ALL_OPENSEARCH_INDEX", "qqq-all-customers"), timeout)
|
||||
process_uuid = check_artemis_round_trip(admin, timeout)
|
||||
check_rabbitmq(admin, timeout, process_uuid)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("profile", choices=("core", "full"))
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--timeout", type=int, default=90)
|
||||
parser.add_argument("--keycloak-url", default="http://keycloak.localhost:8081/realms/qqq-all")
|
||||
parser.add_argument("--opensearch-url")
|
||||
args = parser.parse_args()
|
||||
client = SmokeClient(args.base_url, CORE_ADMIN_SESSION if args.profile == "core" else None)
|
||||
if args.profile == "core":
|
||||
check_core(client, args.timeout)
|
||||
else:
|
||||
if not args.opensearch_url:
|
||||
raise AssertionError("full profile needs --opensearch-url; use run_full_smoke.py for private Compose")
|
||||
check_full(args.base_url, args.keycloak_url,
|
||||
lambda index, query: http_query_index(args.opensearch_url, index, query),
|
||||
args.timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (AssertionError, OSError, ValueError, KeyError) as error:
|
||||
print(f"smoke failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small, sanitized receipts for the private full-smoke stack only."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LIMIT = 65536
|
||||
CONTAINER_FORMAT = ('{"id":{{json .Id}},"service":{{json (index .Config.Labels '
|
||||
'"com.docker.compose.service")}},"image_id":{{json .Image}},'
|
||||
'"status":{{json .State.Status}},"exit_code":{{json .State.ExitCode}},'
|
||||
'"oom_killed":{{json .State.OOMKilled}},"error":{{json .State.Error}},'
|
||||
'"health":{{if index .State "Health"}}{{json (index .State "Health")}}{{else}}null{{end}}}')
|
||||
IMAGE_FORMAT = ('{"id":{{json .Id}},"repo_digests":{{json .RepoDigests}},'
|
||||
'"architecture":{{json .Architecture}},"os":{{json .Os}}}')
|
||||
|
||||
|
||||
class Diagnostics:
|
||||
def __init__(self, directory, environment):
|
||||
self.directory = directory
|
||||
self.secrets = set()
|
||||
for key, value in environment.items():
|
||||
if value and re.search(r'PASSWORD|PASSWD|SECRET|TOKEN|KEYFILE|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTHORIZATION', key, re.I):
|
||||
self.secrets.update((value, urllib.parse.quote(value, safe=''),
|
||||
urllib.parse.quote_plus(value),
|
||||
base64.b64encode(value.encode()).decode()))
|
||||
|
||||
def sanitize(self, text):
|
||||
if isinstance(text, bytes):
|
||||
text = text.decode('utf-8', errors='replace')
|
||||
text = text or ''
|
||||
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
|
||||
for secret in sorted(self.secrets, key=len, reverse=True):
|
||||
text = text.replace(secret, '[REDACTED]')
|
||||
text = re.sub(r'(?i)(\b[\w.-]{0,80}(?:password|passwd|secret|token|authorization|credential|api[_-]?key|access[_-]?key)\b["\s]*[:=]\s*)("[^"]*"|\x27[^\x27]*\x27|[^\s,;]+)', r'\1[REDACTED]', text)
|
||||
text = re.sub(r'(?i)\b(Bearer|Basic)\s+[^\s,;]+', r'\1 [REDACTED]', text)
|
||||
text = re.sub(r'(?i)\b([a-z][a-z0-9+.-]*://)[^/\s@]+@', r'\1[REDACTED]@', text)
|
||||
if len(text) > LIMIT:
|
||||
text = '[...truncated...]\n' + text[-(LIMIT - 18):]
|
||||
return text
|
||||
|
||||
def write(self, name, data):
|
||||
# Redact strings before JSON encoding, so escaped secrets cannot escape filtering.
|
||||
def clean(value):
|
||||
if isinstance(value, str):
|
||||
return self.sanitize(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: clean(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [clean(item) for item in value]
|
||||
return value
|
||||
try:
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
path = self.directory / name
|
||||
path.write_text(json.dumps(clean(data), indent=2) + '\n')
|
||||
path.chmod(0o600)
|
||||
except OSError as error:
|
||||
print(f"smoke diagnostics write failed: {type(error).__name__}", file=sys.stderr)
|
||||
|
||||
def run(self, args, root, environment, timeout=15):
|
||||
try:
|
||||
result = subprocess.run(args, cwd=root, env=environment, capture_output=True,
|
||||
text=True, timeout=timeout, check=False)
|
||||
return {'exit_code': result.returncode,
|
||||
'output': self.sanitize((result.stdout or '') + (result.stderr or ''))}
|
||||
except subprocess.TimeoutExpired as error:
|
||||
return {'exit_code': None, 'error': 'TimeoutExpired',
|
||||
'output': self.sanitize(self.sanitize(error.stdout) + self.sanitize(error.stderr))}
|
||||
except OSError as error:
|
||||
return {'exit_code': None, 'error': type(error).__name__, 'output': ''}
|
||||
|
||||
def jar_hashes(self, root):
|
||||
data = {'dependencies': []}
|
||||
manifest = root / 'qqq-all-app/target/smoke-classpath.txt'
|
||||
if manifest.is_file():
|
||||
for name in sorted(set(manifest.read_text().strip().split(os.pathsep))):
|
||||
path = Path(name)
|
||||
match = re.search(r'/com/kingsrook/(qqq|qbits)/([^/]+)/([^/]+)/[^/]+\.jar$', path.as_posix())
|
||||
if not match:
|
||||
continue
|
||||
group, artifact, version = match.groups()
|
||||
entry = {'coordinate': f'com.kingsrook.{group}:{artifact}:{version}', 'file': path.name}
|
||||
if path.is_file():
|
||||
with path.open('rb') as stream:
|
||||
entry['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
else:
|
||||
entry['error'] = 'resolved JAR missing'
|
||||
data['dependencies'].append(entry)
|
||||
else:
|
||||
data['error'] = 'resolved classpath missing; run the full-smoke Maven command'
|
||||
app = root / 'qqq-all-app/target/qqq-all-app.jar'
|
||||
if app.is_file():
|
||||
with app.open('rb') as stream:
|
||||
data['application_sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
self.write('jars.json', data)
|
||||
|
||||
def pull(self, command, root, environment):
|
||||
receipt = {'pulls': []}
|
||||
try:
|
||||
# Keep the uninterpolated model in memory only; retain no service environment.
|
||||
model = subprocess.run(command + ['config', '--format', 'json', '--no-interpolate',
|
||||
'--no-env-resolution'], cwd=root, env=environment,
|
||||
capture_output=True, text=True, timeout=15, check=False)
|
||||
if model.returncode:
|
||||
raise ValueError('Compose image selection failed')
|
||||
services = json.loads(model.stdout)['services']
|
||||
if not isinstance(services, dict) or not 1 <= len(services) <= 32:
|
||||
raise ValueError('unexpected service inventory')
|
||||
selected = [(name, service['image']) for name, service in sorted(services.items())
|
||||
if 'build' not in service]
|
||||
if any(not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]*', name)
|
||||
or not isinstance(image, str) or '${' in image for name, image in selected):
|
||||
raise ValueError('unresolved service image')
|
||||
except (KeyError, TypeError, ValueError, OSError, subprocess.TimeoutExpired) as error:
|
||||
receipt['selection_error'] = type(error).__name__
|
||||
self.write('pulls.json', receipt)
|
||||
raise AssertionError('could not select Compose service images; see pulls.json') from None
|
||||
deadline = time.monotonic() + 480
|
||||
for service, image in selected:
|
||||
first = image.split('/')[0]
|
||||
registry = first if '/' in image and ('.' in first or ':' in first or first == 'localhost') else 'docker.io'
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
result = {'exit_code': None, 'error': 'pull-stage budget exhausted', 'output': ''}
|
||||
else:
|
||||
result = self.run(command + ['--parallel', '1', '--progress', 'plain', 'pull',
|
||||
'--ignore-buildable', '--policy', 'missing', service],
|
||||
root, environment, timeout=min(120, remaining))
|
||||
receipt['pulls'].append({'service': service, 'image': image,
|
||||
'registry': registry, 'result': result})
|
||||
self.write('pulls.json', receipt)
|
||||
if result['exit_code'] != 0:
|
||||
raise AssertionError(f'Compose pull failed for {service} ({image}); see pulls.json')
|
||||
|
||||
def collect(self, command, root, environment):
|
||||
deadline = time.monotonic() + 60
|
||||
def run(args):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return {'exit_code': None, 'error': 'collection budget exhausted', 'output': ''}
|
||||
return self.run(args, root, environment, timeout=min(15, remaining))
|
||||
inventory = run(command + ['ps', '-a', '-q'])
|
||||
ids = [line for line in inventory['output'].splitlines()
|
||||
if re.fullmatch(r'[a-f0-9]{12,64}', line)][:32]
|
||||
services, images = [], {}
|
||||
for container in ids:
|
||||
result = run(['docker', 'inspect', '--format', CONTAINER_FORMAT, container])
|
||||
try:
|
||||
raw = json.loads(result['output'])
|
||||
state = {key: raw.get(key) for key in ('id', 'service', 'image_id', 'status',
|
||||
'exit_code', 'oom_killed', 'error')}
|
||||
health = raw.get('health') or {}
|
||||
state['health'] = {'status': health.get('Status'),
|
||||
'checks': [{key: check.get(key) for key in ('Start', 'End', 'ExitCode', 'Output')}
|
||||
for check in health.get('Log', [])[-5:]]}
|
||||
services.append(state)
|
||||
image = state['image_id']
|
||||
if isinstance(image, str) and re.fullmatch(r'sha256:[a-f0-9]{64}', image) and image not in images:
|
||||
found = run(['docker', 'image', 'inspect', '--format', IMAGE_FORMAT, image])
|
||||
try:
|
||||
details = json.loads(found['output'])
|
||||
images[image] = {key: details.get(key) for key in ('id', 'repo_digests', 'architecture', 'os')}
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
images[image] = {'error': 'image identity unavailable', 'command': found}
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
services.append({'id': container, 'error': 'container state unavailable', 'command': result})
|
||||
self.write(f'container-{container[:12]}.json',
|
||||
run(['docker', 'logs', '--timestamps', '--tail', '100', container]))
|
||||
self.write('services.json', {'inventory': inventory, 'services': services, 'images': images})
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Exercise the delivered archive with Compose, without running any containers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
IMAGE = "ghcr.io/qrun-io/qqq-all@sha256:" + "a" * 64
|
||||
|
||||
|
||||
class ReleaseBundleTest(unittest.TestCase):
|
||||
def test_bundle_uses_digest_and_keeps_full_profile_infra_without_app_build(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = subprocess.run(["python3", str(ROOT / "scripts/release_bundle.py"),
|
||||
"--root", str(ROOT), "--version", "4.1.0-RC.1",
|
||||
"--sha", "b" * 40, "--image", IMAGE, "--output", directory],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
archive = Path(directory) / "qqq-all-4.1.0-RC.1.tar.gz"
|
||||
extracted = Path(directory) / "extracted"
|
||||
with tarfile.open(archive) as bundle:
|
||||
names = bundle.getnames()
|
||||
self.assertIn("qqq-all-4.1.0-RC.1/infra/minio/Dockerfile", names)
|
||||
self.assertIn("qqq-all-4.1.0-RC.1/infra/keycloak/realm.json", names)
|
||||
self.assertIn("qqq-all-4.1.0-RC.1/.env.example", names)
|
||||
self.assertNotIn("qqq-all-4.1.0-RC.1/.env", names)
|
||||
self.assertNotIn("qqq-all-4.1.0-RC.1/Dockerfile", names)
|
||||
bundle.extractall(extracted, filter="data")
|
||||
root = extracted / "qqq-all-4.1.0-RC.1"
|
||||
bundled = json.loads((root / "compose.yaml").read_text())
|
||||
self.assertEqual("/home/${SFTP_USER:-qqq}/upload",
|
||||
bundled["services"]["sftp"]["volumes"][0]["target"])
|
||||
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
|
||||
result = subprocess.run(["docker", "compose", "--env-file", ".env.example", "--profile", "*",
|
||||
"-f", "compose.yaml", "config", "--format", "json"],
|
||||
cwd=root, env=environment, capture_output=True, text=True)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
config = json.loads(result.stdout)
|
||||
original_result = subprocess.run(["docker", "compose", "--env-file", ".env.example", "--profile", "*",
|
||||
"-f", "compose.yaml", "config", "--format", "json"],
|
||||
cwd=ROOT, env=environment, capture_output=True, text=True, check=True)
|
||||
original = json.loads(original_result.stdout.replace(str(ROOT.resolve()), str(root.resolve())))
|
||||
for name, service in original["services"].items():
|
||||
if name in ("app-core", "app-full"):
|
||||
service.pop("build")
|
||||
service.update(image=IMAGE, pull_policy="always")
|
||||
if name == "minio":
|
||||
service["image"] = "qqq-all-minio:4.1.0-RC.1"
|
||||
self.assertEqual(service, config["services"][name], name)
|
||||
for service in ("app-core", "app-full"):
|
||||
self.assertEqual(IMAGE, config["services"][service]["image"])
|
||||
self.assertNotIn("build", config["services"][service])
|
||||
self.assertEqual((root / "infra/minio").resolve(), Path(config["services"]["minio"]["build"]["context"]).resolve())
|
||||
for service in config["services"].values():
|
||||
for volume in service.get("volumes", []):
|
||||
if volume["type"] == "bind":
|
||||
self.assertTrue(Path(volume["source"]).exists(), volume)
|
||||
manifest = json.loads((root / "release.json").read_text())
|
||||
self.assertEqual("4.1.0-RC.1", manifest["version"])
|
||||
self.assertEqual("b" * 40, manifest["source_sha"])
|
||||
self.assertEqual(IMAGE, manifest["image"])
|
||||
|
||||
def test_bundle_rejects_mutable_image_and_unapproved_version(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for version, image in (("4.1.0-RC.2", IMAGE), ("4.1.0-RC.1", "ghcr.io/qrun-io/qqq-all:develop"),
|
||||
("4.1.0", "ghcr.io/other/qqq-all@sha256:" + "a" * 64)):
|
||||
result = subprocess.run(["python3", str(ROOT / "scripts/release_bundle.py"),
|
||||
"--root", str(ROOT), "--version", version, "--sha", "b" * 40,
|
||||
"--image", image, "--output", directory], capture_output=True, text=True)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual([], list(Path(directory).iterdir()))
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Release gate checks use fixtures so the workflow can fail closed before publication."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from release_gate import GateError, validate_poms, validate_published_poms, validate_smoke
|
||||
|
||||
|
||||
RELEASE_SHA = "a" * 40
|
||||
QBITS = (
|
||||
"quick-search", "user-role-permissions", "customizable-table-views",
|
||||
"standard-process-trace", "webhooks", "workflows", "geo-data",
|
||||
"sftp-data-integration",
|
||||
)
|
||||
|
||||
|
||||
def management(qqq_version="${qqq.version}", qbit_version=None):
|
||||
qbit_version = qbit_version or (lambda name: f"${{qbit-{name}.version}}")
|
||||
dependencies = (
|
||||
"<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId>"
|
||||
f"<version>{qqq_version}</version><type>pom</type><scope>import</scope></dependency>"
|
||||
)
|
||||
dependencies += "".join(
|
||||
"<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
f"<artifactId>qbit-{name}</artifactId><version>{qbit_version(name)}</version></dependency>"
|
||||
for name in QBITS
|
||||
)
|
||||
dependencies += (
|
||||
"<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId>"
|
||||
"<version>1.0.0-RC.8</version></dependency>"
|
||||
)
|
||||
return f"<dependencyManagement><dependencies>{dependencies}</dependencies></dependencyManagement>"
|
||||
|
||||
|
||||
class ReleaseGateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
(self.root / "qqq-all-bom").mkdir()
|
||||
self.write_poms("4.1.0", "4.1.0", "1.2.3")
|
||||
|
||||
def write_poms(self, revision, qqq_version, qbit_version):
|
||||
(self.root / "pom.xml").write_text(
|
||||
f"<project><properties><revision>{revision}</revision></properties></project>"
|
||||
)
|
||||
(self.root / "qqq-all-bom/pom.xml").write_text(
|
||||
"<project><properties>"
|
||||
f"<qqq.version>{qqq_version}</qqq.version>"
|
||||
"<qqq-frontend-next.version>1.0.0-RC.8</qqq-frontend-next.version>"
|
||||
+ "".join(f"<qbit-{name}.version>{qbit_version}</qbit-{name}.version>" for name in QBITS)
|
||||
+ "</properties>" + management() + "</project>"
|
||||
)
|
||||
|
||||
def published_poms(self, resolved=False):
|
||||
parent = self.root / "published-parent.pom"
|
||||
bom = self.root / "published-bom.pom"
|
||||
parent.write_text("<project xmlns='http://maven.apache.org/POM/4.0.0'>"
|
||||
"<groupId>com.kingsrook.qqq</groupId>"
|
||||
"<artifactId>qqq-all-parent</artifactId><version>4.1.0</version></project>")
|
||||
managed = (management("4.1.0", lambda name: "1.2.3") if resolved else management())
|
||||
bom.write_text("<project xmlns='http://maven.apache.org/POM/4.0.0'><parent>"
|
||||
"<groupId>com.kingsrook.qqq</groupId>"
|
||||
"<artifactId>qqq-all-parent</artifactId><version>4.1.0</version></parent>"
|
||||
"<artifactId>qqq-all-bom</artifactId><properties><qqq.version>4.1.0</qqq.version>"
|
||||
"<qqq-frontend-next.version>1.0.0-RC.8</qqq-frontend-next.version>"
|
||||
+ "".join(f"<qbit-{name}.version>1.2.3</qbit-{name}.version>" for name in QBITS)
|
||||
+ "</properties>" + managed + "</project>")
|
||||
return parent, bom
|
||||
|
||||
def test_release_poms_require_ga_versions(self):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
for revision, qqq_version, qbit_version in (
|
||||
("4.1.0-SNAPSHOT", "4.1.0", "1.2.3"),
|
||||
("4.1.0", "4.1.0-SNAPSHOT", "1.2.3"),
|
||||
("4.1.0", "4.1.0", "1.2.3-SNAPSHOT"),
|
||||
):
|
||||
with self.subTest(revision=revision, qqq=qqq_version, qbit=qbit_version):
|
||||
self.write_poms(revision, qqq_version, qbit_version)
|
||||
with self.assertRaises(GateError):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
self.write_poms("4.1.0", "4.1.0", "1.2.3")
|
||||
source_bom = self.root / "qqq-all-bom/pom.xml"
|
||||
source_bom.write_text(source_bom.read_text().replace(
|
||||
"<artifactId>qbit-webhooks</artifactId>",
|
||||
"<artifactId>qbit-other</artifactId>"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_poms(self.root, "4.1.0")
|
||||
|
||||
def test_smoke_run_must_match_release_sha_and_both_jobs(self):
|
||||
run = {
|
||||
"name": "Smoke", "path": ".github/workflows/smoke.yml",
|
||||
"head_sha": RELEASE_SHA, "head_branch": "develop", "event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
jobs = {"jobs": [
|
||||
{"name": "core", "conclusion": "success"},
|
||||
{"name": "full", "conclusion": "success"},
|
||||
]}
|
||||
validate_smoke(run, jobs, RELEASE_SHA)
|
||||
for changed_run, changed_jobs in (
|
||||
({**run, "head_sha": "b" * 40}, jobs),
|
||||
({**run, "conclusion": "failure"}, jobs),
|
||||
({**run, "path": ".github/workflows/other.yml"}, jobs),
|
||||
({**run, "event": "workflow_dispatch"}, jobs),
|
||||
({**run, "head_branch": "main"}, jobs),
|
||||
(run, {"jobs": [jobs["jobs"][0]]}),
|
||||
(run, {"jobs": [jobs["jobs"][0], {"name": "full", "conclusion": "skipped"}]}),
|
||||
):
|
||||
with self.subTest(run=changed_run, jobs=changed_jobs):
|
||||
with self.assertRaises(GateError):
|
||||
validate_smoke(changed_run, changed_jobs, RELEASE_SHA)
|
||||
|
||||
def test_published_parent_and_bom_must_be_release_poms(self):
|
||||
parent, bom = self.published_poms()
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
self.published_poms(resolved=True)
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
parent.write_text(parent.read_text().replace("qqq-all-parent", "wrong-parent"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
self.published_poms(resolved=True)
|
||||
bom.write_text(bom.read_text().replace("4.1.0", "4.1.0-SNAPSHOT"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
|
||||
def test_published_bom_rejects_managed_dependency_overrides(self):
|
||||
parent, bom = self.published_poms(resolved=True)
|
||||
original = bom.read_text()
|
||||
mutations = (
|
||||
original.replace("<artifactId>qqq-bom-pom</artifactId>",
|
||||
"<artifactId>qqq-other-bom</artifactId>"),
|
||||
original.replace("<type>pom</type><scope>import</scope>",
|
||||
"<type>pom</type><scope>compile</scope>"),
|
||||
original.replace("<version>4.1.0</version><type>pom</type><scope>import</scope>",
|
||||
"<version>4.1.0-SNAPSHOT</version><type>pom</type><scope>import</scope>"),
|
||||
original.replace("<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>",
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.3-SNAPSHOT</version>"),
|
||||
original.replace("<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>",
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.4</version>"),
|
||||
original.replace("<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.3</version>"
|
||||
"</dependency>", ""),
|
||||
original.replace("</dependencies></dependencyManagement>",
|
||||
"<dependency><groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId><version>1.2.4</version>"
|
||||
"</dependency></dependencies></dependencyManagement>"),
|
||||
original.replace("</project>", "<profiles><profile><id>override</id>"
|
||||
"<activation><activeByDefault>true</activeByDefault></activation>"
|
||||
"<dependencyManagement><dependencies><dependency>"
|
||||
"<groupId>com.kingsrook.qbits</groupId>"
|
||||
"<artifactId>qbit-webhooks</artifactId>"
|
||||
"<version>1.2.3-SNAPSHOT</version>"
|
||||
"</dependency></dependencies></dependencyManagement>"
|
||||
"</profile></profiles></project>"),
|
||||
)
|
||||
for index, mutation in enumerate(mutations):
|
||||
with self.subTest(mutation=index):
|
||||
self.assertNotEqual(original, mutation)
|
||||
bom.write_text(mutation)
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
bom.write_text(original.replace(
|
||||
"<qbit-webhooks.version>1.2.3</qbit-webhooks.version>",
|
||||
"<qbit-webhooks.version>1.2.4</qbit-webhooks.version>"))
|
||||
with self.assertRaises(GateError):
|
||||
validate_published_poms(parent, bom, "4.1.0", self.root / "qqq-all-bom/pom.xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,151 +0,0 @@
|
||||
"""RC publication must retain the GA boundary and fail before any upload."""
|
||||
|
||||
import functools
|
||||
import http.server
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import release_gate
|
||||
from release_gate import GateError
|
||||
import test_release_gate
|
||||
|
||||
|
||||
RC = "4.1.0-RC.1"
|
||||
TAG_SHA = "b" * 40
|
||||
RELEASE_SHA = "a" * 40
|
||||
|
||||
|
||||
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseCandidateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = test_release_gate.ReleaseGateTest()
|
||||
self.fixture.setUp()
|
||||
self.addCleanup(self.fixture.doCleanups)
|
||||
self.root = self.fixture.root
|
||||
|
||||
def test_exact_rc_and_ga_are_allowed_but_other_candidates_are_not(self):
|
||||
for version in ("4.1.0", RC):
|
||||
self.fixture.write_poms(version, version, "1.2.3-RC.1")
|
||||
release_gate.validate_poms(self.root, version)
|
||||
for version in ("4.1.0-RC.2", "4.1.0-SNAPSHOT", "4.1.1", "LATEST", ""):
|
||||
with self.subTest(version=version), self.assertRaises(GateError):
|
||||
self.fixture.write_poms(version, version, "1.2.3")
|
||||
release_gate.validate_poms(self.root, version)
|
||||
|
||||
def test_rc_rejects_wrong_framework_pin_and_mutable_dependencies(self):
|
||||
for pin in ("4.1.0", "4.1.0-RC.2", "4.1.0-SNAPSHOT"):
|
||||
self.fixture.write_poms(RC, pin, "1.2.3")
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.validate_poms(self.root, RC)
|
||||
for pin in ("LATEST", "RELEASE", "[1.0,2.0)", "1.2.3-SNAPSHOT", "${unknown}"):
|
||||
self.fixture.write_poms(RC, RC, pin)
|
||||
with self.subTest(pin=pin), self.assertRaises(GateError):
|
||||
release_gate.validate_poms(self.root, RC)
|
||||
self.fixture.write_poms(RC, RC, "1.2.3")
|
||||
bom = self.root / "qqq-all-bom/pom.xml"
|
||||
bom.write_text(bom.read_text().replace("1.0.0-RC.8", "LATEST"))
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.validate_poms(self.root, RC)
|
||||
|
||||
def test_public_rc_distribution_poms_must_match_source_including_next(self):
|
||||
self.fixture.write_poms(RC, RC, "1.2.3")
|
||||
parent, bom = self.fixture.published_poms(resolved=True)
|
||||
for path in (parent, bom):
|
||||
path.write_text(path.read_text().replace("4.1.0", RC))
|
||||
release_gate.validate_published_poms(parent, bom, RC, self.root / "qqq-all-bom/pom.xml")
|
||||
original = bom.read_text()
|
||||
for changed in (original.replace("1.0.0-RC.8", "1.0.0-RC.9"),
|
||||
original.replace(RC, "4.1.0-RC.2"),
|
||||
original.replace("1.2.3", "[1,2)")):
|
||||
bom.write_text(changed)
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.validate_published_poms(parent, bom, RC, self.root / "qqq-all-bom/pom.xml")
|
||||
|
||||
def test_upstream_rc_requires_exact_public_prerelease_and_ga_requires_final(self):
|
||||
for version, prerelease in ((RC, True), ("4.1.0", False)):
|
||||
release = {"tag_name": "v" + version, "draft": False, "prerelease": prerelease}
|
||||
release_gate.validate_upstream_release(release, version)
|
||||
for changed in ({**release, "draft": True}, {**release, "prerelease": not prerelease},
|
||||
{**release, "tag_name": "v4.1.0-RC.2"}, {}):
|
||||
with self.subTest(changed=changed), self.assertRaises(GateError):
|
||||
release_gate.validate_upstream_release(changed, version)
|
||||
|
||||
def test_tag_requires_verified_signature_exact_name_object_and_commit(self):
|
||||
tag = {"sha": TAG_SHA, "tag": RC, "object": {"type": "commit", "sha": RELEASE_SHA},
|
||||
"verification": {"verified": True, "reason": "valid"}}
|
||||
release_gate.validate_tag(tag, RC, RELEASE_SHA, TAG_SHA)
|
||||
mutations = [
|
||||
{**tag, "tag": "4.1.0"}, {**tag, "sha": "c" * 40},
|
||||
{**tag, "object": {"type": "commit", "sha": "c" * 40}},
|
||||
{**tag, "object": {"type": "tag", "sha": RELEASE_SHA}},
|
||||
{**tag, "verification": {"verified": False, "reason": "unsigned"}},
|
||||
{**tag, "verification": {"verified": True, "reason": "unknown_key"}}, {},
|
||||
]
|
||||
for changed in mutations:
|
||||
with self.subTest(changed=changed), self.assertRaises(GateError):
|
||||
release_gate.validate_tag(changed, RC, RELEASE_SHA, TAG_SHA)
|
||||
|
||||
def test_public_download_fails_on_missing_or_wrong_coordinates_and_parent(self):
|
||||
self.fixture.write_poms(RC, RC, "1.2.3")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
central = Path(directory)
|
||||
dependencies = [("com.kingsrook.qqq", "qqq-bom-pom", RC, "pom"),
|
||||
("com.kingsrook.qqq", "qqq-frontend-next", "1.0.0-RC.8", "jar")]
|
||||
dependencies += [("com.kingsrook.qbits", "qbit-" + name, "1.2.3", "jar")
|
||||
for name in release_gate.QBITS]
|
||||
for group, artifact, version, kind in dependencies:
|
||||
folder = central / group.replace(".", "/") / artifact / version
|
||||
folder.mkdir(parents=True)
|
||||
stem = folder / (artifact + "-" + version)
|
||||
Path(str(stem) + ".pom").write_text(
|
||||
f"<project><groupId>{group}</groupId><artifactId>{artifact}</artifactId>"
|
||||
f"<version>{version}</version></project>")
|
||||
if kind == "jar":
|
||||
Path(str(stem) + ".jar").write_bytes(b"published fixture jar")
|
||||
handler = functools.partial(QuietHandler, directory=directory)
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.server_close)
|
||||
self.addCleanup(server.shutdown)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
dest = self.root / "public"
|
||||
bom = self.root / "qqq-all-bom/pom.xml"
|
||||
receipts = release_gate.download_public_inputs(bom, RC, dest, base_url)
|
||||
self.assertEqual(19, len(receipts))
|
||||
self.assertTrue(all(len(row["sha256"]) == 64 for row in receipts))
|
||||
public_pom = central / "com/kingsrook/qqq/qqq-frontend-next/1.0.0-RC.8/qqq-frontend-next-1.0.0-RC.8.pom"
|
||||
original = public_pom.read_text()
|
||||
public_pom.write_text(original.replace("1.0.0-RC.8", "1.0.0-RC.9"))
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.download_public_inputs(bom, RC, dest, base_url)
|
||||
public_pom.write_text(original.replace("</project>", "<parent><groupId>com.kingsrook</groupId>"
|
||||
"<artifactId>qbit-build-parent</artifactId><version>LATEST</version></parent></project>"))
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.download_public_inputs(bom, RC, dest, base_url)
|
||||
public_pom.write_text(original.replace("</project>", "<parent><groupId>com.kingsrook</groupId>"
|
||||
"<artifactId>qbit-build-parent</artifactId><version>2.1.0-RC.1</version></parent></project>"))
|
||||
parent = central / "com/kingsrook/qbit-build-parent/2.1.0-RC.1/qbit-build-parent-2.1.0-RC.1.pom"
|
||||
parent.parent.mkdir(parents=True)
|
||||
parent.write_text("<project><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId>"
|
||||
"<version>2.1.0-RC.1</version><dependencyManagement><dependencies><dependency>"
|
||||
"<groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId>"
|
||||
"<version>4.0.0</version><type>pom</type><scope>import</scope>"
|
||||
"</dependency></dependencies></dependencyManagement></project>")
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.download_public_inputs(bom, RC, dest, base_url)
|
||||
parent.write_text(parent.read_text().replace("4.0.0", RC))
|
||||
self.assertEqual(20, len(release_gate.download_public_inputs(bom, RC, dest, base_url)))
|
||||
public_pom.unlink()
|
||||
with self.assertRaises(GateError):
|
||||
release_gate.download_public_inputs(bom, RC, dest, base_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,535 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the HTTP smoke checks, without starting the application."""
|
||||
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from smoke import SmokeClient, check_artemis_round_trip, check_core, check_rabbitmq, check_tables, check_runtime_features, CORE_TABLES
|
||||
import run_full_smoke
|
||||
from run_full_smoke import ROOT
|
||||
|
||||
|
||||
class SmokeServer(ThreadingHTTPServer):
|
||||
def server_bind(self):
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
self.server_name = "localhost"
|
||||
self.server_port = self.server_address[1]
|
||||
|
||||
|
||||
class DemoHandler(BaseHTTPRequestHandler):
|
||||
orders = 0
|
||||
product_name = "QRun Starter Kit"
|
||||
trace_label = "Sync Order - demo"
|
||||
trace_order_id = None
|
||||
inserted_marker = None
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/data/apiCatalog/103":
|
||||
return self.send_json({"values": {"id": 103, "name": "Shipping Box"}})
|
||||
if self.path == "/health":
|
||||
return self.send_json({"status": "UP"})
|
||||
if self.path == "/":
|
||||
body = b'<html><script src="/_next/static/chunks/demo.js"></script></html>'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path == "/_next/static/chunks/demo.js":
|
||||
body = b"console.log('demo');"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
if self.path == "/data/apiCatalog/":
|
||||
return self.send_error(403)
|
||||
if self.path.startswith("/processes/calculateOrderTotal/run"):
|
||||
if "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" not in self.headers.get("Cookie", ""):
|
||||
return self.send_error(403)
|
||||
return self.send_json({"values": {"totalCents": 3750}})
|
||||
if self.path == "/data/order/":
|
||||
assert body["orderNo"].startswith("SMOKE-")
|
||||
type(self).orders += 1
|
||||
type(self).trace_order_id = 100 + self.orders
|
||||
type(self).inserted_marker = body["orderNo"]
|
||||
return self.send_json({"records": [{"values": {"id": 100 + self.orders}}]})
|
||||
if self.path.startswith("/qqq/v1/table/") and self.path.endswith("/query"):
|
||||
table = self.path.split("/")[4]
|
||||
if table == "apiCatalog":
|
||||
return self.send_json({"records": [{"values": {"id": i}} for i in (101, 102, 103)]})
|
||||
if table == "processTrace":
|
||||
records = [{"recordLabel": self.trace_label,
|
||||
"values": {"id": i, "processUUID": f"trace-{i}",
|
||||
"keyRecordId": self.trace_order_id}}
|
||||
for i in range(self.orders)]
|
||||
else:
|
||||
values = {
|
||||
"demoNote": {"id": 1, "text": "Transient demo note"},
|
||||
"customer": {"id": 1, "name": "Ada Lovelace"},
|
||||
"order": {"id": 1, "orderNo": "ORD-1001"},
|
||||
"orderLine": {"id": 1, "quantity": 2},
|
||||
"product": {"id": 1, "name": self.product_name},
|
||||
}
|
||||
records = [{"values": values.get(table, {"id": 1})}]
|
||||
if table == "order" and self.inserted_marker:
|
||||
records.append({"values": {"id": 100 + self.orders,
|
||||
"orderNo": self.inserted_marker}})
|
||||
return self.send_json({"records": records})
|
||||
self.send_error(404)
|
||||
|
||||
def do_PATCH(self):
|
||||
if self.path == "/data/demoNote/1":
|
||||
return self.send_error(403)
|
||||
self.send_error(404)
|
||||
|
||||
def send_json(self, value):
|
||||
data = json.dumps(value).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
class SmokeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
DemoHandler.orders = 0
|
||||
DemoHandler.trace_order_id = None
|
||||
DemoHandler.inserted_marker = None
|
||||
|
||||
def test_catalog_missing_final_page_fails(self):
|
||||
client = mock.Mock()
|
||||
client.records.return_value = [{"values": {"id": value}} for value in (101, 102)]
|
||||
with self.assertRaisesRegex(AssertionError, "all pages"):
|
||||
check_runtime_features(client)
|
||||
|
||||
def test_wrong_javascript_result_fails(self):
|
||||
client = mock.Mock()
|
||||
client.records.return_value = [{"values": {"id": value}} for value in (101, 102, 103)]
|
||||
client.json.side_effect = [{"values": {"name": "Shipping Box"}}, {"values": {"totalCents": 3749}}]
|
||||
with self.assertRaisesRegex(AssertionError, "incorrect total"):
|
||||
check_runtime_features(client)
|
||||
|
||||
def test_core_runner_fails_promptly_when_jar_exits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
jar = Path(directory) / "broken.jar"
|
||||
jar.write_bytes(b"not a jar")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(__file__).with_name("run_core_smoke.py")),
|
||||
str(jar), "--timeout", "1"], capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("packaged app exited", result.stderr)
|
||||
|
||||
def test_ci_core_smokes_maven_final_artifact(self):
|
||||
pom = ET.parse(ROOT / "qqq-all-app" / "pom.xml")
|
||||
final_name = pom.findtext(".//{http://maven.apache.org/POM/4.0.0}build/"
|
||||
"{http://maven.apache.org/POM/4.0.0}finalName")
|
||||
# CI smokes the exact jar Maven builds; keep the build output and the
|
||||
# CI smoke command on the same artifact path.
|
||||
self.assertEqual("qqq-all-app", final_name)
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "build.yaml").read_text()
|
||||
self.assertIn("python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar", workflow)
|
||||
|
||||
|
||||
def test_standalone_full_smoke_requires_explicit_search_endpoint(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(__file__).with_name("smoke.py")), "full",
|
||||
"--base-url", "http://127.0.0.1:1"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("run_full_smoke.py", result.stderr)
|
||||
|
||||
def test_full_runner_reserves_distinct_non_ephemeral_app_ports(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".env.example").write_text(
|
||||
"QQQ_ALL_PORT=8080\nKEYCLOAK_PORT=8081\nPOSTGRES_PASSWORD=example\n")
|
||||
with mock.patch.object(run_full_smoke, "ROOT", root):
|
||||
values = run_full_smoke.demo_environment()
|
||||
app_port = int(values["QQQ_ALL_PORT"])
|
||||
keycloak_port = int(values["KEYCLOAK_PORT"])
|
||||
self.assertTrue(20000 <= app_port < 30000)
|
||||
self.assertTrue(20000 <= keycloak_port < 30000)
|
||||
self.assertNotEqual(app_port, keycloak_port)
|
||||
self.assertNotEqual("example", values["POSTGRES_PASSWORD"])
|
||||
|
||||
def test_full_runner_supplies_generated_credentials_to_http_checks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "compose.yaml").write_text("services: {}\n")
|
||||
values = {"QQQ_ALL_PORT": "20001", "KEYCLOAK_PORT": "20002",
|
||||
"DEMO_USER_PASSWORD": "generated-user",
|
||||
"DEMO_ADMIN_PASSWORD": "generated-admin",
|
||||
"RABBITMQ_PASSWORD": "generated-rabbit"}
|
||||
|
||||
def verify_credentials(*_args):
|
||||
for key in ("DEMO_USER_PASSWORD", "DEMO_ADMIN_PASSWORD", "RABBITMQ_PASSWORD"):
|
||||
self.assertEqual(values[key], os.environ[key])
|
||||
|
||||
with (mock.patch.object(run_full_smoke, "ROOT", root),
|
||||
mock.patch.object(run_full_smoke, "demo_environment", return_value=values),
|
||||
mock.patch.object(run_full_smoke, "published_port", return_value=20003),
|
||||
mock.patch.object(run_full_smoke.Diagnostics, "pull"),
|
||||
mock.patch.object(run_full_smoke.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 0)),
|
||||
mock.patch.object(run_full_smoke, "check_full", side_effect=verify_credentials) as check,
|
||||
mock.patch.dict(os.environ, {}, clear=False)):
|
||||
run_full_smoke.main()
|
||||
check.assert_called_once()
|
||||
|
||||
def test_full_runner_queries_private_opensearch_service(self):
|
||||
command = ["docker", "compose", "-p", "smoke"]
|
||||
with mock.patch.object(run_full_smoke.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 0,
|
||||
stdout='{"hits":{"hits":[]}}')) as invoke:
|
||||
result = run_full_smoke.query_opensearch(command, {}, "customers", {"query": {}})
|
||||
self.assertEqual([], result["hits"]["hits"])
|
||||
args = invoke.call_args.args[0]
|
||||
self.assertEqual(["exec", "-T", "opensearch", "curl"], args[len(command):len(command) + 4])
|
||||
self.assertIn("http://127.0.0.1:9200/customers/_search", args)
|
||||
|
||||
def test_full_runner_fails_when_private_opensearch_query_fails(self):
|
||||
with mock.patch.object(run_full_smoke.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 22, stdout="")):
|
||||
with self.assertRaisesRegex(AssertionError, "OpenSearch query failed"):
|
||||
run_full_smoke.query_opensearch(["docker", "compose"], {}, "customers", {"query": {}})
|
||||
|
||||
def test_full_runner_prepares_sftp_import_directory(self):
|
||||
command = ["docker", "compose", "-p", "smoke"]
|
||||
with mock.patch.object(run_full_smoke.subprocess, "run",
|
||||
return_value=subprocess.CompletedProcess([], 0)) as invoke:
|
||||
run_full_smoke.prepare_sftp_import_directory(command, {}, "qqq")
|
||||
self.assertEqual([
|
||||
command + ["exec", "-T", "sftp", "mkdir", "-p", "/home/qqq/upload/imports"],
|
||||
command + ["exec", "-T", "sftp", "chown", "1001:1001", "/home/qqq/upload/imports"],
|
||||
], [call.args[0] for call in invoke.call_args_list])
|
||||
|
||||
def test_full_runner_fails_when_sftp_fixture_cannot_be_prepared(self):
|
||||
with mock.patch.object(run_full_smoke.subprocess, "run", side_effect=[
|
||||
subprocess.CompletedProcess([], 0), subprocess.CompletedProcess([], 1)]):
|
||||
with self.assertRaisesRegex(AssertionError, "SFTP import directory"):
|
||||
run_full_smoke.prepare_sftp_import_directory(["docker", "compose"], {}, "qqq")
|
||||
|
||||
def test_oidc_cookie_is_sent_only_to_loopback_http_app(self):
|
||||
from smoke import allow_loopback_http_session, require_oidc_session
|
||||
|
||||
class SecureSession(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path in ("/login", "/login-plain"):
|
||||
data = b"ok"
|
||||
self.send_response(200)
|
||||
secure = "; Secure" if self.path == "/login" else ""
|
||||
self.send_header("Set-Cookie", "sessionUUID=test-session; Path=/; HttpOnly" + secure)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
if self.path == "/protected":
|
||||
if "sessionUUID=test-session" not in self.headers.get("Cookie", ""):
|
||||
return self.send_error(403)
|
||||
return self.send_json({"ok": True})
|
||||
return super().do_GET()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), SecureSession)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "HttpOnly OIDC session"):
|
||||
require_oidc_session(client, {})
|
||||
client.request("/login")
|
||||
require_oidc_session(client, {})
|
||||
with self.assertRaisesRegex(AssertionError, "exposed"):
|
||||
require_oidc_session(client, {"uuid": "test-session"})
|
||||
cookie = next(iter(client.cookies))
|
||||
cookie._rest.clear()
|
||||
with self.assertRaisesRegex(AssertionError, "HttpOnly OIDC session"):
|
||||
require_oidc_session(client, {})
|
||||
cookie.set_nonstandard_attr("HttpOnly", None)
|
||||
with self.assertRaisesRegex(AssertionError, "HTTP 403"):
|
||||
client.request("/protected")
|
||||
allow_loopback_http_session(client)
|
||||
self.assertTrue(client.json("/protected")["ok"])
|
||||
plain = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
plain.request("/login-plain")
|
||||
allow_loopback_http_session(plain)
|
||||
self.assertTrue(plain.json("/protected")["ok"])
|
||||
remote = SmokeClient("http://example.com")
|
||||
with self.assertRaisesRegex(AssertionError, "loopback"):
|
||||
allow_loopback_http_session(remote)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_keycloak_secure_auth_cookie_is_sent_on_loopback_http(self):
|
||||
from smoke import allow_loopback_http_cookies
|
||||
|
||||
class SecureKeycloak(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/auth":
|
||||
data = b"login"
|
||||
self.send_response(200)
|
||||
self.send_header("Set-Cookie", "AUTH_SESSION_ID=test-auth; Path=/; Secure; HttpOnly")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
return super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/login":
|
||||
if "AUTH_SESSION_ID=test-auth" not in self.headers.get("Cookie", ""):
|
||||
return self.send_error(400)
|
||||
return self.send_json({"ok": True})
|
||||
return super().do_POST()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), SecureKeycloak)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
cookies = http.cookiejar.CookieJar()
|
||||
browser = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
|
||||
browser.open(base + "/auth", timeout=2).close()
|
||||
with self.assertRaises(urllib.error.HTTPError) as denied:
|
||||
browser.open(urllib.request.Request(base + "/login", data=b"x"), timeout=2)
|
||||
denied.exception.close()
|
||||
allow_loopback_http_cookies(cookies, base)
|
||||
with browser.open(urllib.request.Request(base + "/login", data=b"x"), timeout=2) as response:
|
||||
self.assertEqual({"ok": True}, json.load(response))
|
||||
with self.assertRaisesRegex(AssertionError, "loopback"):
|
||||
allow_loopback_http_cookies(cookies, "http://example.com")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_core_checks_public_http_behavior(self):
|
||||
server = SmokeServer(("127.0.0.1", 0), DemoHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
client.session_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
check_core(client, timeout=2)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_missing_backend_records_fail(self):
|
||||
server = SmokeServer(("127.0.0.1", 0), DemoHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "missing expected customer"):
|
||||
client.require_record("customer", "name", "Grace Hopper")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_core_rejects_wrong_filesystem_seed(self):
|
||||
class WrongProduct(DemoHandler):
|
||||
product_name = "Unrelated Product"
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), WrongProduct)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "missing expected product"):
|
||||
check_tables(client, CORE_TABLES)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_artemis_rejects_unrelated_trace(self):
|
||||
class WrongTrace(DemoHandler):
|
||||
trace_label = "Unrelated Process - demo"
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), WrongTrace)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "Artemis.*syncOrder"):
|
||||
check_artemis_round_trip(client, timeout=0.3)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_artemis_returns_the_new_trace_uuid(self):
|
||||
server = SmokeServer(("127.0.0.1", 0), DemoHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
self.assertEqual("trace-0", check_artemis_round_trip(client, timeout=2))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_artemis_rejects_trace_for_another_order(self):
|
||||
class WrongOrderTrace(DemoHandler):
|
||||
trace_order_id = 999
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/data/order/":
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
assert body["orderNo"].startswith("SMOKE-")
|
||||
type(self).orders += 1
|
||||
return self.send_json({"records": [{"values": {"id": 101}}]})
|
||||
return super().do_POST()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), WrongOrderTrace)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "Artemis.*syncOrder"):
|
||||
check_artemis_round_trip(client, timeout=0.3)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_rabbitmq_rejects_unrelated_completion(self):
|
||||
class WrongCompletion(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/qqq/v1/esb/messages/orderSyncEvents":
|
||||
event = {"type": "qqq.process.other.completed",
|
||||
"data": {"processName": "other", "processUUID": "trace-1"}}
|
||||
return self.send_json({"messages": [{"event": event}], "hasMore": False})
|
||||
return super().do_GET()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), WrongCompletion)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "RabbitMQ syncOrder completion"):
|
||||
check_rabbitmq(client, 0.3, "trace-1")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_rabbitmq_rejects_wrong_process_uuid(self):
|
||||
class WrongRun(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/qqq/v1/esb/messages/orderSyncEvents":
|
||||
event = {"type": "qqq.process.syncOrder.completed",
|
||||
"data": {"processName": "syncOrder", "processUUID": "other-run"}}
|
||||
return self.send_json({"messages": [{"event": event}], "hasMore": False})
|
||||
return super().do_GET()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), WrongRun)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
with self.assertRaisesRegex(AssertionError, "RabbitMQ syncOrder completion"):
|
||||
check_rabbitmq(client, 0.3, "trace-0")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_rabbitmq_accepts_matching_process_uuid(self):
|
||||
class MatchingRun(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/qqq/v1/esb/messages/orderSyncEvents":
|
||||
event = {"type": "qqq.process.syncOrder.completed",
|
||||
"data": {"processName": "syncOrder", "processUUID": "trace-0"}}
|
||||
return self.send_json({"messages": [{"event": event}], "hasMore": False})
|
||||
return super().do_GET()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), MatchingRun)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
check_rabbitmq(client, 2, "trace-0")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_rabbitmq_finds_completion_after_first_browse_page(self):
|
||||
class LaterPage(DemoHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/qqq/v1/esb/messages/orderSyncEvents":
|
||||
old = {"type": "qqq.process.syncOrder.completed",
|
||||
"data": {"processName": "syncOrder", "processUUID": "old-run"}}
|
||||
return self.send_json({"messages": [{"event": old}] * 50, "hasMore": True})
|
||||
if self.path == "/qqq/v1/esb/messages/orderSyncEvents?offset=50":
|
||||
event = {"type": "qqq.process.syncOrder.completed",
|
||||
"data": {"processName": "syncOrder", "processUUID": "trace-0"}}
|
||||
return self.send_json({"messages": [{"event": event}], "hasMore": False})
|
||||
return super().do_GET()
|
||||
|
||||
server = SmokeServer(("127.0.0.1", 0), LaterPage)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
check_rabbitmq(client, 2, "trace-0")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
@unittest.skipUnless((ROOT / "compose.yaml").is_file() and shutil.which("docker"),
|
||||
"C6 Compose integration is not available")
|
||||
def test_full_smoke_keeps_opensearch_private_and_uses_ephemeral_service_ports(self):
|
||||
command = ["docker", "compose", "--env-file", str(ROOT / ".env.example"),
|
||||
"-f", str(ROOT / "compose.yaml"),
|
||||
"-f", str(ROOT / "scripts/compose.smoke.yaml"),
|
||||
"--profile", "full", "config", "--format", "json"]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(0, result.returncode, "full smoke Compose config failed")
|
||||
services = json.loads(result.stdout)["services"]
|
||||
self.assertNotIn("ports", services["opensearch"])
|
||||
for service, target in (("rabbitmq", 15672),
|
||||
("artemis", 8161), ("mailpit", 8025), ("minio", 9001)):
|
||||
with self.subTest(service=service):
|
||||
ports = services[service]["ports"]
|
||||
self.assertEqual(1, len(ports))
|
||||
self.assertEqual(target, ports[0]["target"])
|
||||
self.assertEqual("127.0.0.1", ports[0]["host_ip"])
|
||||
self.assertFalse(ports[0].get("published"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,255 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Failure evidence must survive cleanup without leaking runtime credentials."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import run_full_smoke
|
||||
|
||||
|
||||
class DiagnosticsTest(unittest.TestCase):
|
||||
def test_startup_failure_retains_redacted_evidence_before_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'compose.yaml').touch()
|
||||
values = {'QQQ_ALL_PORT': '20001', 'KEYCLOAK_PORT': '20002',
|
||||
'POSTGRES_PASSWORD': 'private-password-123'}
|
||||
cleaned = []
|
||||
|
||||
def run(args, **kwargs):
|
||||
if 'config' in args:
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({'services': {'db': {'image': 'postgres:16-alpine'}}}), '')
|
||||
if 'up' in args:
|
||||
return subprocess.CompletedProcess(args, 1, 'postgres unhealthy private-password-123', '')
|
||||
if 'down' in args:
|
||||
receipt = root / 'target/smoke-diagnostics/startup.json'
|
||||
self.assertTrue(receipt.is_file(), 'diagnostics must exist before teardown')
|
||||
cleaned.append(True)
|
||||
return subprocess.CompletedProcess(args, 0, '', '')
|
||||
|
||||
with (mock.patch.object(run_full_smoke, 'ROOT', root),
|
||||
mock.patch.object(run_full_smoke, 'demo_environment', return_value=values),
|
||||
mock.patch.object(run_full_smoke.subprocess, 'run', side_effect=run),
|
||||
mock.patch.dict(os.environ)):
|
||||
with self.assertRaisesRegex(AssertionError, 'did not become healthy'):
|
||||
run_full_smoke.main()
|
||||
self.assertTrue(cleaned)
|
||||
output = (root / 'target/smoke-diagnostics/startup.json').read_text()
|
||||
self.assertIn('postgres unhealthy', output)
|
||||
self.assertNotIn('private-password-123', output)
|
||||
self.assertEqual(1, json.loads(output)['exit_code'])
|
||||
|
||||
def test_http_failure_stays_sanitized_when_collection_and_cleanup_fail(self):
|
||||
self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'compose.yaml').touch()
|
||||
values = {'QQQ_ALL_PORT': '20001', 'KEYCLOAK_PORT': '20002',
|
||||
'DB_PASSWORD': 'hidden-fixture'}
|
||||
def run(args, **kwargs):
|
||||
if 'config' in args:
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({'services': {'db': {'image': 'postgres:16-alpine'}}}), '')
|
||||
if 'down' in args:
|
||||
raise subprocess.TimeoutExpired(args, 120, output='hidden-fixture')
|
||||
return subprocess.CompletedProcess(args, 0, '', '')
|
||||
with (mock.patch.object(run_full_smoke, 'ROOT', root),
|
||||
mock.patch.object(run_full_smoke, 'demo_environment', return_value=values),
|
||||
mock.patch.object(run_full_smoke.subprocess, 'run', side_effect=run),
|
||||
mock.patch.object(run_full_smoke.Diagnostics, 'collect', side_effect=ValueError('hidden-fixture')),
|
||||
mock.patch.object(run_full_smoke, 'check_full', side_effect=AssertionError('HTTP failed hidden-fixture')),
|
||||
mock.patch.dict(os.environ)):
|
||||
with self.assertRaisesRegex(AssertionError, r'HTTP failed \[REDACTED\]'):
|
||||
run_full_smoke.main()
|
||||
text = ''.join(f.read_text() for f in (root / 'target/smoke-diagnostics').glob('*.json'))
|
||||
self.assertNotIn('hidden-fixture', text)
|
||||
self.assertIn('TimeoutExpired', text)
|
||||
|
||||
def test_startup_timeout_keeps_partial_sanitized_output_and_tears_down(self):
|
||||
self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'compose.yaml').touch()
|
||||
values = {'QQQ_ALL_PORT': '20001', 'KEYCLOAK_PORT': '20002',
|
||||
'DB_PASSWORD': 'hidden-fixture'}
|
||||
cleanup = []
|
||||
def run(args, **kwargs):
|
||||
if 'config' in args:
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({'services': {'db': {'image': 'postgres:16-alpine'}}}), '')
|
||||
if 'up' in args:
|
||||
raise subprocess.TimeoutExpired(args, 480, output=b'postgres unhealthy hidden-fixture')
|
||||
if 'down' in args:
|
||||
cleanup.append(True)
|
||||
return subprocess.CompletedProcess(args, 0, '', '')
|
||||
with (mock.patch.object(run_full_smoke, 'ROOT', root),
|
||||
mock.patch.object(run_full_smoke, 'demo_environment', return_value=values),
|
||||
mock.patch.object(run_full_smoke.subprocess, 'run', side_effect=run),
|
||||
mock.patch.dict(os.environ)):
|
||||
with self.assertRaisesRegex(AssertionError, 'did not become healthy'):
|
||||
run_full_smoke.main()
|
||||
self.assertTrue(cleanup)
|
||||
receipt = json.loads((root / 'target/smoke-diagnostics/startup.json').read_text())
|
||||
self.assertEqual('TimeoutExpired', receipt['error'])
|
||||
self.assertIn('postgres unhealthy', receipt['output'])
|
||||
self.assertNotIn('hidden-fixture', receipt['output'])
|
||||
|
||||
def test_pull_failure_names_service_image_registry_and_stops_before_up(self):
|
||||
module = self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'compose.yaml').touch()
|
||||
values = {'QQQ_ALL_PORT': '20001', 'KEYCLOAK_PORT': '20002',
|
||||
'DB_PASSWORD': 'pull-secret'}
|
||||
calls = []
|
||||
model = {'services': {
|
||||
'app-full': {'image': 'qqq-all:local', 'build': {'context': '.'}},
|
||||
'minio-init': {'image': 'public.ecr.aws/aws-cli/aws-cli:2.27.41',
|
||||
'environment': {'DO_NOT_RETAIN': 'raw-config-fixture'}},
|
||||
'sftp': {'image': 'atmoz/sftp:alpine'}}}
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
if 'config' in args:
|
||||
self.assertIn('--no-interpolate', args)
|
||||
self.assertIn('--no-env-resolution', args)
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps(model), '')
|
||||
if 'pull' in args:
|
||||
self.assertEqual('minio-init', args[-1])
|
||||
self.assertNotIn('--ignore-pull-failures', args)
|
||||
self.assertNotIn('--include-deps', args)
|
||||
return subprocess.CompletedProcess(args, 1, '', 'toomanyrequests: Rate exceeded pull-secret')
|
||||
return subprocess.CompletedProcess(args, 0, '', '')
|
||||
with (mock.patch.object(run_full_smoke, 'ROOT', root),
|
||||
mock.patch.object(run_full_smoke, 'demo_environment', return_value=values),
|
||||
mock.patch.object(run_full_smoke.subprocess, 'run', side_effect=run),
|
||||
mock.patch.object(run_full_smoke, 'check_full'),
|
||||
mock.patch.dict(os.environ)):
|
||||
with self.assertRaisesRegex(AssertionError, 'minio-init'):
|
||||
run_full_smoke.main()
|
||||
self.assertFalse(any('up' in args for args in calls))
|
||||
receipt = json.loads((root / 'target/smoke-diagnostics/pulls.json').read_text())
|
||||
failed = receipt['pulls'][-1]
|
||||
self.assertEqual('public.ecr.aws', failed['registry'])
|
||||
self.assertEqual(model['services']['minio-init']['image'], failed['image'])
|
||||
self.assertEqual(1, failed['result']['exit_code'])
|
||||
self.assertIn('Rate exceeded', failed['result']['output'])
|
||||
text = json.dumps(receipt)
|
||||
self.assertNotIn('pull-secret', text)
|
||||
self.assertNotIn('raw-config-fixture', text)
|
||||
|
||||
def test_successful_pulls_use_same_images_without_building_or_starting(self):
|
||||
module = self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evidence = module.Diagnostics(Path(directory), {})
|
||||
self.assertTrue(hasattr(evidence, 'pull'), 'explicit pull diagnostics not implemented')
|
||||
calls = []
|
||||
model = {'services': {'postgres': {'image': 'postgres:16-alpine'},
|
||||
'minio': {'image': 'qqq-all-minio:local', 'build': {'context': './infra/minio'}},
|
||||
'mongo': {'image': 'mongo:7'}}}
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
output = json.dumps(model) if 'config' in args else 'pulled'
|
||||
return subprocess.CompletedProcess(args, 0, output, '')
|
||||
with mock.patch.object(module.subprocess, 'run', side_effect=run):
|
||||
evidence.pull(['docker', 'compose', '-p', 'private'], Path(directory), {})
|
||||
pulls = [args for args in calls if 'pull' in args]
|
||||
self.assertEqual(['mongo', 'postgres'], [args[-1] for args in pulls])
|
||||
for args in pulls:
|
||||
self.assertEqual('1', args[args.index('--parallel') + 1])
|
||||
self.assertEqual('missing', args[args.index('--policy') + 1])
|
||||
self.assertFalse(any('up' in args or 'build' in args for args in calls))
|
||||
receipt = json.loads((Path(directory) / 'pulls.json').read_text())
|
||||
self.assertEqual(['mongo:7', 'postgres:16-alpine'], [r['image'] for r in receipt['pulls']])
|
||||
self.assertEqual(['docker.io', 'docker.io'], [r['registry'] for r in receipt['pulls']])
|
||||
|
||||
def diagnostics_module(self):
|
||||
try:
|
||||
import smoke_diagnostics
|
||||
except ImportError:
|
||||
self.fail('sanitized diagnostics collector is not implemented')
|
||||
return smoke_diagnostics
|
||||
|
||||
def test_redacts_secret_forms_and_bounds_output_after_redaction(self):
|
||||
module = self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evidence = module.Diagnostics(Path(directory), {'DB_PASSWORD': 'a+/secret',
|
||||
'GITHUB_TOKEN': 'token-1234'})
|
||||
output = evidence.sanitize('x' * 90000 + '\npassword="unknown-value" '
|
||||
'postgres://u:uri-secret@db/ Bearer bearer-secret '
|
||||
'a%2B%2Fsecret a+/secret token-1234 access_token=generated-token client_secret=generated-secret')
|
||||
for secret in ['unknown-value', 'uri-secret', 'bearer-secret',
|
||||
'a%2B%2Fsecret', 'a+/secret', 'token-1234', 'generated-token', 'generated-secret']:
|
||||
self.assertFalse(secret in output, "credential remained in sanitized output")
|
||||
self.assertLessEqual(len(output), 65536)
|
||||
self.assertIn('[REDACTED]', output)
|
||||
|
||||
def test_hashes_only_resolved_first_party_jars_not_other_cached_versions(self):
|
||||
module = self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
jar = root / 'repository/com/kingsrook/qqq/qqq-esb/4.1.0-SNAPSHOT/qqq-esb.jar'
|
||||
jar.parent.mkdir(parents=True)
|
||||
jar.write_bytes(b'actual resolved bytes')
|
||||
(jar.parent / 'unused.jar').write_bytes(b'unused')
|
||||
manifest = root / 'qqq-all-app/target/smoke-classpath.txt'
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(str(jar))
|
||||
evidence = module.Diagnostics(root / 'target/smoke-diagnostics', {})
|
||||
evidence.jar_hashes(root)
|
||||
data = json.loads((evidence.directory / 'jars.json').read_text())
|
||||
self.assertEqual([{'coordinate': 'com.kingsrook.qqq:qqq-esb:4.1.0-SNAPSHOT',
|
||||
'file': 'qqq-esb.jar',
|
||||
'sha256': hashlib.sha256(b'actual resolved bytes').hexdigest()}], data['dependencies'])
|
||||
self.assertNotIn(str(root), json.dumps(data))
|
||||
|
||||
def test_container_collection_is_selected_redacted_and_survives_command_failure(self):
|
||||
module = self.diagnostics_module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
evidence = module.Diagnostics(Path(directory), {'DB_PASSWORD': 'secret-fixture'})
|
||||
container = 'a' * 64
|
||||
completed = 'd' * 64
|
||||
image = 'sha256:' + 'b' * 64
|
||||
commands = []
|
||||
def run(args, **kwargs):
|
||||
commands.append(args)
|
||||
if 'ps' in args:
|
||||
return subprocess.CompletedProcess(args, 0, container + '\n' + completed + '\n', '')
|
||||
if 'inspect' in args and 'image' not in args:
|
||||
self.assertIn('--format', args)
|
||||
if args[-1] == completed:
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({
|
||||
'id': completed, 'service': 'mongo-init', 'image_id': image,
|
||||
'status': 'exited', 'exit_code': 0, 'oom_killed': False,
|
||||
'error': '', 'health': None}), '')
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({
|
||||
'id': container, 'service': 'postgres', 'image_id': image,
|
||||
'status': 'exited', 'exit_code': 1, 'oom_killed': False,
|
||||
'error': '', 'health': {'Status': 'unhealthy', 'Log': [
|
||||
{'ExitCode': 1, 'Output': 'failed secret-fixture'}]}}), '')
|
||||
if 'image' in args:
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps({'id': image,
|
||||
'repo_digests': ['postgres@sha256:' + 'c' * 64],
|
||||
'architecture': 'amd64', 'os': 'linux'}), '')
|
||||
if 'logs' in args:
|
||||
raise subprocess.TimeoutExpired(args, 15, output='secret-fixture')
|
||||
self.fail('unexpected command')
|
||||
with mock.patch.object(module.subprocess, 'run', side_effect=run):
|
||||
evidence.collect(['docker', 'compose', '-p', 'private'], Path(directory), {})
|
||||
text = '\n'.join(f.read_text() for f in Path(directory).glob('*.json'))
|
||||
self.assertNotIn('secret-fixture', text)
|
||||
self.assertIn('unhealthy', text)
|
||||
self.assertIn('amd64', text)
|
||||
states = json.loads((Path(directory) / 'services.json').read_text())['services']
|
||||
self.assertEqual('mongo-init', states[1]['service'])
|
||||
self.assertEqual(0, states[1]['exit_code'])
|
||||
self.assertIsNone(states[1]['health']['status'])
|
||||
self.assertIn('TimeoutExpired', text)
|
||||
self.assertFalse(any('config' in c or '--volumes' in c for c in commands))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user