From 536789bedadfed45c0426b7ff359a1d91f95b4b1 Mon Sep 17 00:00:00 2001 From: ptownley Date: Thu, 16 Apr 2026 08:38:28 +0100 Subject: [PATCH 1/5] Migrate code from closed source --- .dockerignore | 34 ++ .github/copilot-instructions.md | 13 + .github/delete-merged-branch-config.yml | 3 + .github/pull_request_template.md | 7 + .github/release.yml | 27 ++ .github/workflows/add-to-project.yaml | 25 ++ .github/workflows/branch-cleanup.yml | 21 + .github/workflows/ci.yml | 14 + .github/workflows/pr-gChat-notification.yaml | 45 +++ .github/workflows/pr-on-branch-creation.yml | 34 ++ .github/workflows/release.yml | 41 ++ .github/workflows/run-cleanup.yml | 30 ++ .gitignore | 53 +++ .ruff.toml | 392 +++++++++++++++++++ Dockerfile | 1 + Dockerfile.test | 12 + Dockerfile.testservice | 12 + Makefile | 48 +++ README.md | 6 + dev-requirements.txt | 7 + devbox.json | 20 + devbox.lock | 294 ++++++++++++++ k8s/kustomization.yaml | 2 + k8s/testservice/deployment.yaml | 34 ++ k8s/testservice/kustomization.yaml | 5 + k8s/testservice/rbac.yaml | 29 ++ k8s/testservice/rhea.configmap.yaml | 17 + k8s/testservice/service.yaml | 11 + kind.yaml | 6 + orionpy/__init__.py | 3 + orionpy/network/__init__.py | 6 + orionpy/network/orionhttpx.py | 195 +++++++++ orionpy/network/orionwebsocket.py | 32 ++ requirements.txt | 4 + setup.py | 23 ++ skaffold.yaml | 46 +++ test-service/main.py | 133 +++++++ test-service/requirements.txt | 1 + tests/__init__.py | 1 + tests/conftest.py | 11 + tests/run_tests.sh | 15 + tests/run_unit_tests.sh | 8 + tests/test_e2e_testservice.py | 66 ++++ tests/test_orionhttpx.py | 278 +++++++++++++ tests/test_orionwebsocket.py | 105 +++++ tests/test_runner.yaml | 15 + tests/test_runner_unit.yaml | 16 + ty.toml | 6 + 48 files changed, 2207 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/copilot-instructions.md create mode 100644 .github/delete-merged-branch-config.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/release.yml create mode 100644 .github/workflows/add-to-project.yaml create mode 100644 .github/workflows/branch-cleanup.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-gChat-notification.yaml create mode 100644 .github/workflows/pr-on-branch-creation.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/run-cleanup.yml create mode 100644 .gitignore create mode 100644 .ruff.toml create mode 120000 Dockerfile create mode 100644 Dockerfile.test create mode 100644 Dockerfile.testservice create mode 100644 Makefile create mode 100644 README.md create mode 100644 dev-requirements.txt create mode 100644 devbox.json create mode 100644 devbox.lock create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/testservice/deployment.yaml create mode 100644 k8s/testservice/kustomization.yaml create mode 100644 k8s/testservice/rbac.yaml create mode 100644 k8s/testservice/rhea.configmap.yaml create mode 100644 k8s/testservice/service.yaml create mode 100644 kind.yaml create mode 100644 orionpy/__init__.py create mode 100644 orionpy/network/__init__.py create mode 100644 orionpy/network/orionhttpx.py create mode 100644 orionpy/network/orionwebsocket.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 skaffold.yaml create mode 100644 test-service/main.py create mode 100644 test-service/requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100755 tests/run_tests.sh create mode 100755 tests/run_unit_tests.sh create mode 100644 tests/test_e2e_testservice.py create mode 100644 tests/test_orionhttpx.py create mode 100644 tests/test_orionwebsocket.py create mode 100644 tests/test_runner.yaml create mode 100644 tests/test_runner_unit.yaml create mode 100644 ty.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6fb3261 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov +.tox +.venv +venv + +# IDE +.vscode +.idea +*.swp +*.swo + +# Testing +test_example + +# Documentation +*.md +!README.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..30a0c00 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,13 @@ +When working on this repo, make sure to: + +- This is orionpy, a Python library for Kubernetes service-to-service authentication +- The main implementation is in `orionpy/network/orionrequests.py` which provides the OrionRequests client +- OrionRequests automatically manages service account tokens for authenticated requests between services +- Tokens are cached and automatically refreshed when they expire in < 5 minutes +- The testservice in `test-service/` is a simple HTTP service used for testing that validates auth via rhea sidecar +- E2E tests go in `tests/test_e2e_*.py` and run against deployed services in the cluster +- Unit tests go in `tests/test_*.py` and use mocks to test individual components +- Cedar policies in `k8s/testservice/rhea.configmap.yaml` control access to services +- Validate all changes in this order: first run make lint, then run unittests, then e2e tests +- Run `make test-unit` for unit tests only, `make test` for full e2e tests +- Tests use pytest with coverage reporting diff --git a/.github/delete-merged-branch-config.yml b/.github/delete-merged-branch-config.yml new file mode 100644 index 0000000..1e3de62 --- /dev/null +++ b/.github/delete-merged-branch-config.yml @@ -0,0 +1,3 @@ +delete_closed_pr: true +exclude: + - testing diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ef281b1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +### Before requesting review, I have done the following: + +- [ ] Code Formatting +- [ ] Linting +- [ ] Added Tests (if any) +- [ ] Updated Documentation (if any) +- [ ] Resolved any Merge Conflicts diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..0df4669 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,27 @@ +runs-on: self-hosted + +changelog: + exclude: + labels: + - ignore-for-release + authors: + - octocat + categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'feature request' + - 'enhancement' + - 'add' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - 'broke' + - title: '🧰 Maintenance' + labels: + - 'chore' + - title: Other Changes + labels: + - "*" \ No newline at end of file diff --git a/.github/workflows/add-to-project.yaml b/.github/workflows/add-to-project.yaml new file mode 100644 index 0000000..ff50653 --- /dev/null +++ b/.github/workflows/add-to-project.yaml @@ -0,0 +1,25 @@ +name: Add Issue to Project + +on: + issues: + types: + - opened + + +jobs: + add-to-project: + name: Add Issue to Project + runs-on: self-hosted + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.JUNO_CI_APP_ID }} + private-key: ${{ secrets.JUNO_CI_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/juno-fx/projects/12 + github-token: ${{ steps.generate-token.outputs.token }} + diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml new file mode 100644 index 0000000..2b471d8 --- /dev/null +++ b/.github/workflows/branch-cleanup.yml @@ -0,0 +1,21 @@ +name: Delete Branch on PR Close +on: + pull_request: + types: [closed] + +jobs: + delete-branch: + runs-on: + - self-hosted + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.JUNO_CI_APP_ID }} + private-key: ${{ secrets.JUNO_CI_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: delete branch + uses: SvanBoxel/delete-merged-branch@main + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2704f7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +# DO NOT EDIT THIS FILE DIRECTLY! +# This is synced from the juno-fx/ci repository in the .microservice/workflows directory and should be modified there. +name: Microservice CI/CD +on: + push: + paths-ignore: + - '.github/**' + - 'crds/**' + workflow_dispatch: + workflow_call: +jobs: + ci: + uses: juno-fx/ci/.github/workflows/ms-ci.yml@main + secrets: inherit diff --git a/.github/workflows/pr-gChat-notification.yaml b/.github/workflows/pr-gChat-notification.yaml new file mode 100644 index 0000000..20f2b08 --- /dev/null +++ b/.github/workflows/pr-gChat-notification.yaml @@ -0,0 +1,45 @@ +name: PR Message + +on: + pull_request: + types: + - review_requested + +jobs: + google-chat-action: + runs-on: self-hosted + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.JUNO_CI_APP_ID }} + private-key: ${{ secrets.JUNO_CI_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout CI repository + uses: actions/checkout@v4 + with: + repository: juno-fx/ci + ref: main + path: .juno-ci + token: ${{ steps.generate-token.outputs.token }} + - name: Run Google Chat notification + uses: ./.juno-ci/actions/workflow/pr-gChat-notification + with: + webhookUrl: ${{ secrets.GCHAT_PR_SPACE }} + title: ${{ github.repository }} + subtitle: ${{ github.head_ref }} + additionalSections: > + { + "header": "Requested Reviewer", + "widgets": [ + { + "decoratedText": { + "text": "${{ github.event.requested_reviewer && github.event.requested_reviewer.login || github.event.requested_team && github.event.requested_team.name || 'N/A' }}", + "icon": { + "knownIcon": "PERSON" + } + } + } + ] + } diff --git a/.github/workflows/pr-on-branch-creation.yml b/.github/workflows/pr-on-branch-creation.yml new file mode 100644 index 0000000..a3ad5ad --- /dev/null +++ b/.github/workflows/pr-on-branch-creation.yml @@ -0,0 +1,34 @@ +on: + create: + +permissions: + contents: write + issues: write + pull-requests: write + repository-projects: read # github's current permission scheme makes this necessary: https://github.com/cli/cli/issues/6274 + +jobs: + PR: + runs-on: X64 + if: github.event.ref_type == 'branch' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.JUNO_CI_APP_ID }} + private-key: ${{ secrets.JUNO_CI_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout CI repository + uses: actions/checkout@v4 + with: + repository: juno-fx/ci + ref: main + path: .juno-ci + token: ${{ steps.generate-token.outputs.token }} + - name: Run PR on branch creation + uses: ./.juno-ci/actions/workflow/pr-on-branch-creation + with: + gh_token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..388742c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +# DO NOT EDIT THIS FILE DIRECTLY! +# This is synced from the juno-fx/ci repository in the .microservice/workflows directory and should be modified there. +name: Microservice Tagged Release +on: + workflow_dispatch: + inputs: + bump: + type: choice + description: What to bump by. + default: patch + options: + - patch + - minor + - major +jobs: + StableReleaseProtection: + runs-on: + - self-hosted + steps: + - name: Fail of not on main + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' + run: | + echo "Not on main branch, exiting..." + exit 1 + QC: + needs: + - StableReleaseProtection + uses: juno-fx/ci/.github/workflows/ms-qc.yml@main + secrets: inherit + Test: + needs: + - QC + uses: juno-fx/ci/.github/workflows/ms-test.yml@main + secrets: inherit + Tag: + needs: + - Test + uses: juno-fx/ci/.github/workflows/bumpversion.yml@main + secrets: inherit + with: + bump: ${{ inputs.bump }} diff --git a/.github/workflows/run-cleanup.yml b/.github/workflows/run-cleanup.yml new file mode 100644 index 0000000..889858d --- /dev/null +++ b/.github/workflows/run-cleanup.yml @@ -0,0 +1,30 @@ +name: Delete old workflow runs +on: + push: + paths: + - '.github/workflows/run-cleanup.yml' + workflow_dispatch: + schedule: + - cron: '0 0 * * 1' +jobs: + del_runs: + runs-on: + - self-hosted + permissions: + actions: write + contents: read + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.JUNO_CI_APP_ID }} + private-key: ${{ secrets.JUNO_CI_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Delete workflow runs + uses: Mattraks/delete-workflow-runs@v2 + with: + token: ${{ steps.generate-token.outputs.token }} + repository: ${{ github.repository }} + retain_days: 7 + keep_minimum_runs: 6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f1007d --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +.tools + +build.json +.kind.yaml.patched diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..d3c3f7d --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,392 @@ +line-length = 100 + +[lint] +select = ["PL"] +extend-select = [ + # "PLC0103", # invalid-name + # "PLC0104", # disallowed-name + # "PLC0105", # typevar-name-incorrect-variance + "D419", # empty-docstring + # "PLC0114", # missing-module-docstring + # "PLC0115", # missing-class-docstring + # "PLC0116", # missing-function-docstring + # "PLC0117", # unnecessary-negation + # "PLC0121", # singleton-comparison + # "PLC0123", # unidiomatic-typecheck + # "PLC0131", # typevar-double-variance + # "PLC0132", # typevar-name-mismatch + # "PLC0200", # consider-using-enumerate + # "PLC0201", # consider-iterating-dictionary + # "PLC0202", # bad-classmethod-argument + # "PLC0203", # bad-mcs-method-argument + # "PLC0204", # bad-mcs-classmethod-argument + # "PLC0205", # single-string-used-for-slots + # "PLC0206", # consider-using-dict-items + # "PLC0207", # use-maxsplit-arg + # "PLC0208", # use-sequence-for-iteration + # "PLC0209", # consider-using-f-string + "E501", # line-too-long + # "PLC0302", # too-many-lines + "W291", # trailing-whitespace + # "PLC0304", # missing-final-newline + # "PLC0305", # trailing-newlines + # "PLC0321", # multiple-statements + # "PLC0325", # superfluous-parens + # "PLC0327", # mixed-line-endings + # "PLC0328", # unexpected-line-ending-format + # "PLC0401", # wrong-spelling-in-comment + # "PLC0402", # wrong-spelling-in-docstring + # "PLC0403", # invalid-characters-in-docstring + # "PLC0410", # multiple-imports + # "PLC0411", # wrong-import-order + # "PLC0412", # ungrouped-imports + # "PLC0413", # wrong-import-position + "PLC0414", # useless-import-alias + # "PLC0415", # import-outside-toplevel + # "PLC1802", # use-implicit-booleaness-not-len + # "PLC1803", # use-implicit-booleaness-not-comparison + "PLC2401", # non-ascii-name + # "PLC2403", # non-ascii-module-import + # "PLC2503", # bad-file-encoding + "PLC2801", # unnecessary-dunder-call + # "PLC3001", # unnecessary-lambda-assignment + "PLC3002", # unnecessary-direct-lambda-call + # "PLE0011", # unrecognized-inline-option + # "PLE0013", # bad-plugin-value + # "PLE0014", # bad-configuration-section + # "PLE0015", # unrecognized-option + # "PLE0100", # init-is-generator + "PLE0101", # return-in-init + # "PLE0102", # function-redefined + # "PLE0103", # not-in-loop + "F706", # return-outside-function + "F704", # yield-outside-function + # "PLE0107", # nonexistent-operator + # "PLE0108", # duplicate-argument-name + # "PLE0110", # abstract-class-instantiated + # "PLE0111", # bad-reversed-sequence + # "PLE0112", # too-many-star-expressions + # "PLE0113", # invalid-star-assignment-target + # "PLE0114", # star-needs-assignment-target + "PLE0115", # nonlocal-and-global + "PLE0116", # continue-in-finally + "PLE0117", # nonlocal-without-binding + # "PLE0118", # used-prior-global-declaration + # "PLE0119", # misplaced-format-function + # "PLE0202", # method-hidden + # "PLE0203", # access-member-before-definition + # "PLE0211", # no-method-argument + # "PLE0213", # no-self-argument + # "PLE0236", # invalid-slots-object + # "PLE0237", # assigning-non-slot + # "PLE0238", # invalid-slots + # "PLE0239", # inherit-non-class + # "PLE0240", # inconsistent-mro + "PLE0241", # duplicate-bases + # "PLE0242", # class-variable-slots-conflict + # "PLE0243", # invalid-class-object + # "PLE0244", # invalid-enum-extension + # "PLE0245", # declare-non-slot + # "PLE0301", # non-iterator-returned + "PLE0302", # unexpected-special-method-signature + # "PLE0303", # invalid-length-returned + # "PLE0304", # invalid-bool-returned + # "PLE0305", # invalid-index-returned + # "PLE0306", # invalid-repr-returned + # "PLE0307", # invalid-str-returned + # "PLE0308", # invalid-bytes-returned + # "PLE0309", # invalid-hash-returned + # "PLE0310", # invalid-length-hint-returned + # "PLE0311", # invalid-format-returned + # "PLE0312", # invalid-getnewargs-returned + # "PLE0313", # invalid-getnewargs-ex-returned + # "PLE0401", # import-error + # "PLE0402", # relative-beyond-top-level + # "PLE0601", # used-before-assignment + # "PLE0602", # undefined-variable + # "PLE0603", # undefined-all-variable + "PLE0604", # invalid-all-object + "PLE0605", # invalid-all-format + # "PLE0606", # possibly-used-before-assignment + # "PLE0611", # no-name-in-module + # "PLE0633", # unpacking-non-sequence + "PLE0643", # potential-index-error + # "PLE0701", # bad-except-order + # "PLE0702", # raising-bad-type + "PLE0704", # misplaced-bare-raise + # "PLE0705", # bad-exception-cause + # "PLE0710", # raising-non-exception + # "PLE0711", # notimplemented-raised + # "PLE0712", # catching-non-exception + # "PLE1003", # bad-super-call + # "PLE1101", # no-member + # "PLE1102", # not-callable + # "PLE1111", # assignment-from-no-return + # "PLE1120", # no-value-for-parameter + # "PLE1121", # too-many-function-args + # "PLE1123", # unexpected-keyword-arg + # "PLE1124", # redundant-keyword-arg + # "PLE1125", # missing-kwoa + # "PLE1126", # invalid-sequence-index + # "PLE1127", # invalid-slice-index + # "PLE1128", # assignment-from-none + # "PLE1129", # not-context-manager + # "PLE1130", # invalid-unary-operand-type + # "PLE1131", # unsupported-binary-operation + # "PLE1132", # repeated-keyword + # "PLE1133", # not-an-iterable + # "PLE1134", # not-a-mapping + # "PLE1135", # unsupported-membership-test + # "PLE1136", # unsubscriptable-object + # "PLE1137", # unsupported-assignment-operation + # "PLE1138", # unsupported-delete-operation + # "PLE1139", # invalid-metaclass + "PLE1141", # dict-iter-missing-items + "PLE1142", # await-outside-async + # "PLE1143", # unhashable-member + # "PLE1144", # invalid-slice-step + # "PLE1200", # logging-unsupported-format + # "PLE1201", # logging-format-truncated + "PLE1205", # logging-too-many-args + "PLE1206", # logging-too-few-args + # "PLE1300", # bad-format-character + # "PLE1301", # truncated-format-string + # "PLE1302", # mixed-format-string + # "PLE1303", # format-needs-mapping + # "PLE1304", # missing-format-string-key + # "PLE1305", # too-many-format-args + # "PLE1306", # too-few-format-args + "PLE1307", # bad-string-format-type + "PLE1310", # bad-str-strip-call + "PLE1507", # invalid-envvar-value + "PLE1519", # singledispatch-method + "PLE1520", # singledispatchmethod-function + # "PLE1700", # yield-inside-async-function + # "PLE1701", # not-async-context-manager + # "PLE2501", # invalid-unicode-codec + "PLE2502", # bidirectional-unicode + "PLE2510", # invalid-character-backspace + # "PLE2511", # invalid-character-carriage-return + "PLE2512", # invalid-character-sub + "PLE2513", # invalid-character-esc + "PLE2514", # invalid-character-nul + "PLE2515", # invalid-character-zero-width-space + # "PLE3102", # positional-only-arguments-expected + # "PLE3701", # invalid-field-call + # "PLE4702", # modified-iterating-dict + "PLE4703", # modified-iterating-set + # "PLF0001", # fatal + # "PLF0002", # astroid-error + # "PLF0010", # parse-error + # "PLF0011", # config-parse-error + # "PLF0202", # method-check-failed + # "PLI1101", # c-extension-no-member + # "PLR0022", # useless-option-value + # "PLR0123", # literal-comparison + "PLR0124", # comparison-with-itself + # "PLR0133", # comparison-of-constants + "PLR0202", # no-classmethod-decorator + "PLR0203", # no-staticmethod-decorator + "UP004", # useless-object-inheritance + "PLR0206", # property-with-parameters + # "PLR0401", # cyclic-import + # "PLR0402", # consider-using-from-import + # "PLR0801", # duplicate-code + # "PLR0901", # too-many-ancestors + # "PLR0902", # too-many-instance-attributes + # "PLR0903", # too-few-public-methods + "PLR0904", # too-many-public-methods + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0914", # too-many-locals + "PLR0915", # too-many-statements + "PLR0916", # too-many-boolean-expressions + "PLR0917", # too-many-positional-arguments + # "PLR1701", # consider-merging-isinstance + "PLR1702", # too-many-nested-blocks + # "PLR1703", # simplifiable-if-statement + "PLR1704", # redefined-argument-from-local + # "PLR1705", # no-else-return + # "PLR1706", # consider-using-ternary + # "PLR1707", # trailing-comma-tuple + # "PLR1708", # stop-iteration-return + # "PLR1709", # simplify-boolean-expression + # "PLR1710", # inconsistent-return-statements + "PLR1711", # useless-return + # "PLR1712", # consider-swap-variables + # "PLR1713", # consider-using-join + # "PLR1714", # consider-using-in + # "PLR1715", # consider-using-get + # "PLR1716", # chained-comparison + # "PLR1717", # consider-using-dict-comprehension + # "PLR1718", # consider-using-set-comprehension + # "PLR1719", # simplifiable-if-expression + # "PLR1720", # no-else-raise + "C416", # unnecessary-comprehension + # "PLR1722", # consider-using-sys-exit + # "PLR1723", # no-else-break + # "PLR1724", # no-else-continue + # "PLR1725", # super-with-arguments + # "PLR1726", # simplifiable-condition + # "PLR1727", # condition-evals-to-constant + # "PLR1728", # consider-using-generator + # "PLR1729", # use-a-generator + # "PLR1730", # consider-using-min-builtin + # "PLR1731", # consider-using-max-builtin + # "PLR1732", # consider-using-with + "PLR1733", # unnecessary-dict-index-lookup + # "PLR1734", # use-list-literal + # "PLR1735", # use-dict-literal + "PLR1736", # unnecessary-list-index-lookup + # "PLR1737", # use-yield-from + # "PLW0012", # unknown-option-value + # "PLW0101", # unreachable + # "PLW0102", # dangerous-default-value + # "PLW0104", # pointless-statement + # "PLW0105", # pointless-string-statement + # "PLW0106", # expression-not-assigned + # "PLW0107", # unnecessary-pass + "PLW0108", # unnecessary-lambda + # "PLW0109", # duplicate-key + "PLW0120", # useless-else-on-loop + # "PLW0122", # exec-used + # "PLW0123", # eval-used + # "PLW0124", # confusing-with-statement + # "PLW0125", # using-constant-test + # "PLW0126", # missing-parentheses-for-call-in-test + "PLW0127", # self-assigning-variable + "PLW0128", # redeclared-assigned-name + "PLW0129", # assert-on-string-literal + "B033", # duplicate-value + "PLW0131", # named-expr-without-context + # "PLW0133", # pointless-exception-statement + # "PLW0134", # return-in-finally + # "PLW0135", # contextmanager-generator-missing-cleanup + # "PLW0143", # comparison-with-callable + # "PLW0150", # lost-exception + "PLW0177", # nan-comparison + # "PLW0199", # assert-on-tuple + # "PLW0201", # attribute-defined-outside-init + "PLW0211", # bad-staticmethod-argument + # "PLW0212", # protected-access + # "PLW0213", # implicit-flag-alias + # "PLW0221", # arguments-differ + # "PLW0222", # signature-differs + # "PLW0223", # abstract-method + # "PLW0231", # super-init-not-called + # "PLW0233", # non-parent-init-called + # "PLW0236", # invalid-overridden-method + # "PLW0237", # arguments-renamed + # "PLW0238", # unused-private-member + # "PLW0239", # overridden-final-method + # "PLW0240", # subclassed-final-class + # "PLW0244", # redefined-slots-in-subclass + "PLW0245", # super-without-brackets + # "PLW0246", # useless-parent-delegation + # "PLW0301", # unnecessary-semicolon + # "PLW0311", # bad-indentation + # "PLW0401", # wildcard-import + # "PLW0404", # reimported + "PLW0406", # import-self + # "PLW0407", # preferred-module + # "PLW0410", # misplaced-future + # "PLW0416", # shadowed-import + # "PLW0511", + # "PLW0601", # global-variable-undefined + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "PLW0604", # global-at-module-level + "F401", # unused-import + "F841", # unused-variable + # "PLW0613", # unused-argument + # "PLW0614", # unused-wildcard-import + # "PLW0621", # redefined-outer-name + # "PLW0622", # redefined-builtin + # "PLW0631", # undefined-loop-variable + # "PLW0632", # unbalanced-tuple-unpacking + # "PLW0640", # cell-var-from-loop + # "PLW0641", # possibly-unused-variable + # "PLW0642", # self-cls-assignment + # "PLW0644", # unbalanced-dict-unpacking + "E722", # bare-except + # "PLW0705", # duplicate-except + # "PLW0706", # try-except-raise + # "PLW0707", # raise-missing-from + "PLW0711", # binary-op-exception + # "PLW0715", # raising-format-tuple + # "PLW0716", # wrong-exception-operation + # "PLW0718", # broad-exception-caught + # "PLW0719", # broad-exception-raised + # "PLW1113", # keyword-arg-before-vararg + # "PLW1114", # arguments-out-of-order + # "PLW1115", # non-str-assignment-to-dunder-name + # "PLW1116", # isinstance-second-argument-not-valid-type + # "PLW1117", # kwarg-superseded-by-positional-arg + # "PLW1201", # logging-not-lazy + # "PLW1202", # logging-format-interpolation + # "PLW1203", # logging-fstring-interpolation + # "PLW1300", # bad-format-string-key + # "PLW1301", # unused-format-string-key + # "PLW1302", # bad-format-string + # "PLW1303", # missing-format-argument-key + # "PLW1304", # unused-format-string-argument + # "PLW1305", # format-combined-specification + # "PLW1306", # missing-format-attribute + # "PLW1307", # invalid-format-index + # "PLW1308", # duplicate-string-formatting-argument + # "PLW1309", # f-string-without-interpolation + # "PLW1310", # format-string-without-interpolation + # "PLW1401", # anomalous-backslash-in-string + # "PLW1402", # anomalous-unicode-escape-in-string + # "PLW1404", # implicit-str-concat + # "PLW1405", # inconsistent-quotes + # "PLW1406", # redundant-u-string-prefix + "PLW1501", # bad-open-mode + # "PLW1503", # redundant-unittest-assert + # "PLW1506", # bad-thread-instantiation + # "PLW1507", # shallow-copy-environ + "PLW1508", # invalid-envvar-default + "PLW1509", # subprocess-popen-preexec-fn + # "PLW1510", # subprocess-run-check + "PLW1514", # unspecified-encoding + # "PLW1515", # forgotten-debug-statement + # "PLW1518", # method-cache-max-size-none + "PLW2101", # useless-with-lock + # "PLW2301", # unnecessary-ellipsis + # "PLW2402", # non-ascii-file-name + # "PLW2601", # using-f-string-in-unsupported-version + # "PLW2602", # using-final-decorator-in-unsupported-version + # "PLW2603", # using-exception-groups-in-unsupported-version + # "PLW2604", # using-generic-type-syntax-in-unsupported-version + # "PLW2605", # using-assignment-expression-in-unsupported-version + # "PLW2606", # using-positional-only-args-in-unsupported-version + # "PLW3101", # missing-timeout + "PLW3301", # nested-min-max + # "PLW3601", # bad-chained-comparison + # "PLW4701", # modified-iterating-list + # "PLW4901", # deprecated-module + # "PLW4902", # deprecated-method + # "PLW4903", # deprecated-argument + # "PLW4904", # deprecated-class + # "PLW4905", # deprecated-decorator + # "PLW4906", # deprecated-attribute +] + +ignore = [ + "PLC0414", + "PLR2004", + "PLW1514", + "PLW0603", + "PLR0914", + # "PLC1804", # use-implicit-booleaness-not-comparison-to-string + # "PLC1805", # use-implicit-booleaness-not-comparison-to-zero + # "PLI0001", # raw-checker-failed + # "PLI0010", # bad-inline-option + # "PLI0011", # locally-disabled + # "PLI0013", # file-ignored + # "PLI0020", # suppressed-message + # "PLI0021", # useless-suppression + # "PLI0022", # deprecated-pragma + # "PLI0023", # use-symbolic-message-instead +] diff --git a/Dockerfile b/Dockerfile new file mode 120000 index 0000000..5319784 --- /dev/null +++ b/Dockerfile @@ -0,0 +1 @@ +Dockerfile.test \ No newline at end of file diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..909db60 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt dev-requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r dev-requirements.txt + +COPY orionpy/ ./orionpy/ +COPY tests/ ./tests/ +COPY setup.py ./ + +CMD ["bash", "tests/run_tests.sh"] diff --git a/Dockerfile.testservice b/Dockerfile.testservice new file mode 100644 index 0000000..4b38df8 --- /dev/null +++ b/Dockerfile.testservice @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY test-service/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY test-service/main.py . + +EXPOSE 3000 + +CMD ["python", "main.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..96bea6e --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: test test-local kind-create kind-delete install clean + +PYTHON := true +ENV := PYTHON=$(PYTHON) +VENV_NAME := venv +VENV := $(VENV_NAME)/bin + +update-tools: + @ echo " >> Pulling Latest Tools << " + @ rm -rf Development-Tools + @ git clone https://github.com/juno-fx/Development-Tools.git + @ rm -rf .tools + @ mv -v Development-Tools/.tools .tools + @ rm -rf Development-Tools + @ echo " >> Tools Updated << " + +.tools/cluster.Makefile: + @ $(MAKE) update-tools + +test: .tools/cluster.Makefile + @ $(MAKE) -f .tools/cluster.Makefile test --no-print-directory + +down: .tools/cluster.Makefile + @ $(MAKE) -f .tools/cluster.Makefile down --no-print-directory + +dev: .tools/cluster.Makefile + @ $(MAKE) -f .tools/cluster.Makefile dev --no-print-directory + +dependencies: + @ # This target must exist for Development-Tools to work. + +lint: .tools/dev.Makefile + @ $(VENV)/ruff check orionpy --fix --preview + +format: .tools/dev.Makefile + @ $(VENV)/ruff format orionpy --preview + @ $(VENV)/ty check + +check: .tools/dev.Makefile + @ echo " >> Running Format Check... << " + @ $(VENV)/ruff format orionpy --preview --check + @ echo + @ echo " >> Running Lint Check... << " + @ $(VENV)/ruff check orionpy --preview + @ $(VENV)/ty check --no-progress + +install: .tools/dev.Makefile + @ $(MAKE) -f .tools/dev.Makefile install $(ENV) --no-print-directory diff --git a/README.md b/README.md new file mode 100644 index 0000000..9483346 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# orionpy + +OrionPY is the client we use for Orion services to interact with each other. + +It contains the authorization logic needed to interact with [`rhea`](https://github.com/juno-fx/rhea.git), our authN and authR backend for service-to-service communication. + diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..311e9e4 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,7 @@ +pytest +pytest-cov +pytest-asyncio +coverage +ruff +pylint +ty diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..3c3dd17 --- /dev/null +++ b/devbox.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.12.0/.schema/devbox.schema.json", + "packages": [ + "docker@latest", + "kubectl@latest", + "skaffold@latest", + "python312@latest", + "kind@0.24.0" + ], + "env": { + "VENV_DIR": "venv", + "UV_PYTHON": "venv/bin/python" + }, + "shell": { + "init_hook": [ + "make update-tools", + "make install" + ] + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..f8ede5e --- /dev/null +++ b/devbox.lock @@ -0,0 +1,294 @@ +{ + "lockfile_version": "1", + "packages": { + "docker@latest": { + "last_modified": "2025-10-10T13:35:32Z", + "resolved": "github:NixOS/nixpkgs/870493f9a8cb0b074ae5b411b2f232015db19a65#docker", + "source": "devbox-search", + "version": "28.4.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bnvsfm00pfigq7mfra4ychhpzkixm37g-docker-28.4.0", + "default": true + } + ], + "store_path": "/nix/store/bnvsfm00pfigq7mfra4ychhpzkixm37g-docker-28.4.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/6a6jlpi39n13614qkxsrcw7fj8wg2rpf-docker-28.4.0", + "default": true + } + ], + "store_path": "/nix/store/6a6jlpi39n13614qkxsrcw7fj8wg2rpf-docker-28.4.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/g1da19zv5n6riyj3dlqp9agjrwh4xnr6-docker-28.4.0", + "default": true + } + ], + "store_path": "/nix/store/g1da19zv5n6riyj3dlqp9agjrwh4xnr6-docker-28.4.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/x0zyq8g4b0d3plfnrf1qh532mnv6z5ql-docker-28.4.0", + "default": true + } + ], + "store_path": "/nix/store/x0zyq8g4b0d3plfnrf1qh532mnv6z5ql-docker-28.4.0" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2025-10-16T06:36:44Z", + "resolved": "github:NixOS/nixpkgs/3cbe716e2346710d6e1f7c559363d14e11c32a43?lastModified=1760596604&narHash=sha256-J%2Fi5K6AAz%2Fy5dBePHQOuzC7MbhyTOKsd%2FGLezSbEFiM%3D" + }, + "kind@0.24.0": { + "last_modified": "2024-12-03T12:40:06Z", + "resolved": "github:NixOS/nixpkgs/566e53c2ad750c84f6d31f9ccb9d00f823165550#kind", + "source": "devbox-search", + "version": "0.24.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/na5pm2mpclxn6i9mcdnfmbba2gmh2f1y-kind-0.24.0", + "default": true + } + ], + "store_path": "/nix/store/na5pm2mpclxn6i9mcdnfmbba2gmh2f1y-kind-0.24.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vwkcf7lfvmifzdqly937i10lglfx7mar-kind-0.24.0", + "default": true + } + ], + "store_path": "/nix/store/vwkcf7lfvmifzdqly937i10lglfx7mar-kind-0.24.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8j9l98im5r5g71grjjwd2nq89pj8qck6-kind-0.24.0", + "default": true + } + ], + "store_path": "/nix/store/8j9l98im5r5g71grjjwd2nq89pj8qck6-kind-0.24.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yd1lvvwfqjpssc0dgw6v7lac4pxidwg0-kind-0.24.0", + "default": true + } + ], + "store_path": "/nix/store/yd1lvvwfqjpssc0dgw6v7lac4pxidwg0-kind-0.24.0" + } + } + }, + "kubectl@latest": { + "last_modified": "2025-10-07T08:41:47Z", + "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c#kubectl", + "source": "devbox-search", + "version": "1.34.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/2ih54prydy1k2s0zsjhkplk8sgcj95kc-kubectl-1.34.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/3rvrkr29q5rsb9scrxaw7bnxqplj8lzc-kubectl-1.34.1-man", + "default": true + }, + { + "name": "convert", + "path": "/nix/store/3xinznz678q5layrhy76q8chl9g7q0m6-kubectl-1.34.1-convert" + } + ], + "store_path": "/nix/store/2ih54prydy1k2s0zsjhkplk8sgcj95kc-kubectl-1.34.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/kwyzcnrnmp32rh8yamhckddjnsyb7wv8-kubectl-1.34.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/1lib1cii7f7ks82bgq3shf3pa4bvfwlw-kubectl-1.34.1-man", + "default": true + }, + { + "name": "convert", + "path": "/nix/store/b2i2xpyf726nvf0wznanvk9wx4kyhxn0-kubectl-1.34.1-convert" + } + ], + "store_path": "/nix/store/kwyzcnrnmp32rh8yamhckddjnsyb7wv8-kubectl-1.34.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/nsldgqqryn057z8356yip0lxnhhn7azy-kubectl-1.34.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/97ky0g57awcjskva2zyyczqvg5ab9iq4-kubectl-1.34.1-man", + "default": true + }, + { + "name": "convert", + "path": "/nix/store/wwy8yalmwwz6m2rjc2fasdihc83gh9kv-kubectl-1.34.1-convert" + } + ], + "store_path": "/nix/store/nsldgqqryn057z8356yip0lxnhhn7azy-kubectl-1.34.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/5fs5bi2b03j7b1s05h317iszhm8q23x7-kubectl-1.34.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/zsl5hlc1rgy52ljdkl0f5vfcp4vfs9gr-kubectl-1.34.1-man", + "default": true + }, + { + "name": "convert", + "path": "/nix/store/0xapfmzqh1lwmm7xkwca43k5pr65p32b-kubectl-1.34.1-convert" + } + ], + "store_path": "/nix/store/5fs5bi2b03j7b1s05h317iszhm8q23x7-kubectl-1.34.1" + } + } + }, + "python312@latest": { + "last_modified": "2025-10-07T08:41:47Z", + "plugin_version": "0.0.4", + "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c#python312", + "source": "devbox-search", + "version": "3.12.11", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d5bvj78dzx6wjnz13vawcjb3pa5hpdkv-python3-3.12.11", + "default": true + } + ], + "store_path": "/nix/store/d5bvj78dzx6wjnz13vawcjb3pa5hpdkv-python3-3.12.11" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/n9fq90ybwm7flzhswka4i8siss56g4hq-python3-3.12.11", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/vlpgzwscnw0jv7109k2s5z9f9yw3f4ws-python3-3.12.11-debug" + } + ], + "store_path": "/nix/store/n9fq90ybwm7flzhswka4i8siss56g4hq-python3-3.12.11" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lykhqadwwlc89lxiyvpms7hkx1nxznw6-python3-3.12.11", + "default": true + } + ], + "store_path": "/nix/store/lykhqadwwlc89lxiyvpms7hkx1nxznw6-python3-3.12.11" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/27fr7ynpilzb7rzmay73g905jbl8br4a-python3-3.12.11", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/h4m4mz0j2iyfj17zmq49n0g5i8zywic6-python3-3.12.11-debug" + } + ], + "store_path": "/nix/store/27fr7ynpilzb7rzmay73g905jbl8br4a-python3-3.12.11" + } + } + }, + "skaffold@latest": { + "last_modified": "2025-10-07T08:41:47Z", + "resolved": "github:NixOS/nixpkgs/bce5fe2bb998488d8e7e7856315f90496723793c#skaffold", + "source": "devbox-search", + "version": "2.16.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/cbh74kcr9632pws0ikifmxm94fycf8py-skaffold-2.16.1", + "default": true + } + ], + "store_path": "/nix/store/cbh74kcr9632pws0ikifmxm94fycf8py-skaffold-2.16.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v0s1rvbplzc335bsb8m4w3wxn1kghrlf-skaffold-2.16.1", + "default": true + } + ], + "store_path": "/nix/store/v0s1rvbplzc335bsb8m4w3wxn1kghrlf-skaffold-2.16.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yfbkszqa9gcx242pnsdksgxzi374i1da-skaffold-2.16.1", + "default": true + } + ], + "store_path": "/nix/store/yfbkszqa9gcx242pnsdksgxzi374i1da-skaffold-2.16.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vmn02fgw790iq9a03nmpbq5chxi2d17b-skaffold-2.16.1", + "default": true + } + ], + "store_path": "/nix/store/vmn02fgw790iq9a03nmpbq5chxi2d17b-skaffold-2.16.1" + } + } + } + } +} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..d2fde55 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- testservice/ diff --git a/k8s/testservice/deployment.yaml b/k8s/testservice/deployment.yaml new file mode 100644 index 0000000..e7ee572 --- /dev/null +++ b/k8s/testservice/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: testservice +spec: + replicas: 1 + selector: + matchLabels: + app: testservice + template: + metadata: + labels: + app: testservice + spec: + serviceAccountName: orionpy-test + containers: + - name: testservice + image: testservice + ports: + - containerPort: 3000 + name: http + - name: rhea + image: junoinnovations/rhea:unstable + ports: + - containerPort: 13000 + name: auth + volumeMounts: + - name: policies + mountPath: /etc/rhea/policies + readOnly: true + volumes: + - name: policies + configMap: + name: rhea-policies diff --git a/k8s/testservice/kustomization.yaml b/k8s/testservice/kustomization.yaml new file mode 100644 index 0000000..ab8bea8 --- /dev/null +++ b/k8s/testservice/kustomization.yaml @@ -0,0 +1,5 @@ +resources: + - deployment.yaml + - service.yaml + - rhea.configmap.yaml + - rbac.yaml diff --git a/k8s/testservice/rbac.yaml b/k8s/testservice/rbac.yaml new file mode 100644 index 0000000..a4dbbd3 --- /dev/null +++ b/k8s/testservice/rbac.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: orionpy-test + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: orionpy-token-creator + namespace: default +rules: + - apiGroups: [""] + resources: ["serviceaccounts/token"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: orionpy-token-creator-binding + namespace: default +subjects: + - kind: ServiceAccount + name: orionpy-test + namespace: default +roleRef: + kind: Role + name: orionpy-token-creator + apiGroup: rbac.authorization.k8s.io diff --git a/k8s/testservice/rhea.configmap.yaml b/k8s/testservice/rhea.configmap.yaml new file mode 100644 index 0000000..e857db1 --- /dev/null +++ b/k8s/testservice/rhea.configmap.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: rhea-policies + namespace: default +data: + policies.cedar: | + permit( + principal == default::KubernetesServiceAccount::"orionpy-test", + action == Action::"GET", + resource == argocd::Service::"testservice" + ); + permit( + principal == default::KubernetesServiceAccount::"orionpy-test", + action == Action::"POST", + resource == argocd::Service::"testservice" + ); diff --git a/k8s/testservice/service.yaml b/k8s/testservice/service.yaml new file mode 100644 index 0000000..d6a2e55 --- /dev/null +++ b/k8s/testservice/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: testservice +spec: + selector: + app: testservice + ports: + - name: http + port: 3000 + targetPort: 3000 diff --git a/kind.yaml b/kind.yaml new file mode 100644 index 0000000..bcc95ad --- /dev/null +++ b/kind.yaml @@ -0,0 +1,6 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + labels: + orionpy-test: "true" diff --git a/orionpy/__init__.py b/orionpy/__init__.py new file mode 100644 index 0000000..eeb9dfc --- /dev/null +++ b/orionpy/__init__.py @@ -0,0 +1,3 @@ +"""Orionpy - Kubernetes service-to-service HTTP client with automatic token management.""" + +__version__ = "0.2.0" diff --git a/orionpy/network/__init__.py b/orionpy/network/__init__.py new file mode 100644 index 0000000..9e43662 --- /dev/null +++ b/orionpy/network/__init__.py @@ -0,0 +1,6 @@ +"""Network utilities for Kubernetes service communication.""" + +from .orionhttpx import OrionHttpx +from .orionwebsocket import OrionWebSocket + +__all__ = ["OrionHttpx", "OrionWebSocket"] diff --git a/orionpy/network/orionhttpx.py b/orionpy/network/orionhttpx.py new file mode 100644 index 0000000..dd6c913 --- /dev/null +++ b/orionpy/network/orionhttpx.py @@ -0,0 +1,195 @@ +""" +OrionHttpx - Async HTTP client for Kubernetes service-to-service +communication with automatic token management. +""" + +import asyncio +import time +from typing import Any, Dict + +import httpx +import jwt +from kubernetes import client, config + + +class OrionHttpx: + """ + Async HTTP client for Kubernetes service-to-service communication. + + Automatically manages service account tokens with caching and refresh logic. + Thread-safe and async-safe for use in FastAPI endpoints. + """ + + # Class-level token cache shared across all instances + _token_cache: Dict[str, Dict[str, Any]] = {} + _cache_lock = asyncio.Lock() + + def __init__(self): + """Initialize OrionHttpx client with in-cluster config.""" + config.load_incluster_config() + self._core_api = client.CoreV1Api() + self._auth_api = client.AuthenticationV1Api() + + # Read namespace from the mounted service account + ns_path = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + with open(ns_path, "r", encoding="utf-8") as f: + self._namespace = f.read().strip() + + # Read and decode the service account token to get the SA name + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + with open(token_path, "r", encoding="utf-8") as f: + token = f.read() + decoded = jwt.decode(token, options={"verify_signature": False}) + # SA name is in 'sub' field as system:serviceaccount:namespace:name + sub = decoded.get("sub", "") + parts = sub.split(":") + self._service_account_name = parts[3] if len(parts) >= 4 else "default" + + @staticmethod + def _get_service_key(namespace: str, service: str) -> str: + """Generate cache key for a service.""" + return f"{namespace}::{service}" + + async def _get_token(self, namespace: str, service: str) -> str: + """ + Get or refresh the service account token for the given service. + + Args: + namespace: Kubernetes namespace + service: Service name + + Returns: + Valid JWT token + """ + service_key = self._get_service_key(namespace, service) + + async with self._cache_lock: + # Check if we have a cached token + if service_key in self._token_cache: + cached = self._token_cache[service_key] + token = cached["token"] + exp = cached["exp"] + + # Check if token has more than 5 minutes remaining + time_remaining = exp - time.time() + if time_remaining > 300: # 5 minutes in seconds + return token + + # Need to refresh token - run in thread pool since k8s client is sync + token = await asyncio.to_thread(self._create_token, namespace, service) + + # Decode to get expiry time + decoded = jwt.decode(token, options={"verify_signature": False}) + exp = decoded["exp"] + + # Cache the token + self._token_cache[service_key] = {"token": token, "exp": exp} + + return token + + def _create_token(self, namespace: str, service: str) -> str: + """ + Create a new service account token using Kubernetes TokenRequest API. + + Args: + namespace: Kubernetes namespace + service: Service name + + Returns: + JWT token string + """ + audience = f'{namespace}::Service::"{service}"' + + token_request = client.AuthenticationV1TokenRequest( + spec=client.V1TokenRequestSpec( + audiences=[audience], + expiration_seconds=600, # 10 minutes + ) + ) + + # Request token for the current service account in the current namespace + response = self._core_api.create_namespaced_service_account_token( + name=self._service_account_name, namespace=self._namespace, body=token_request + ) + + return response.status.token + + @staticmethod + def _build_url(namespace: str, service: str, port: int, path: str = "") -> str: + """ + Build the service URL. + + Args: + namespace: Kubernetes namespace + service: Service name + port: Service port + path: URL path (should start with / if provided) + + Returns: + Full service URL + """ + base_url = f"http://{service}.{namespace}.svc.cluster.local:{port}" + if path and not path.startswith("/"): + path = "/" + path + return base_url + path + + async def _make_request( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, method: str, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """ + Make an HTTP request to a Kubernetes service. + + Args: + method: HTTP method (GET, POST, etc.) + namespace: Kubernetes namespace + service: Service name + port: Service port + path: URL path + **kwargs: Additional arguments to pass to httpx + + Returns: + Response object from httpx library + """ + token = await self._get_token(namespace, service) + url = self._build_url(namespace, service, port, path) + + # Inject the authentication header + headers = kwargs.get("headers", {}) + headers["X-ORION-SERVICE-AUTH"] = token + kwargs["headers"] = headers + + # Set a default timeout if not provided + timeout = kwargs.pop("timeout", 30) + + async with httpx.AsyncClient(timeout=timeout) as http_client: + return await http_client.request(method, url, **kwargs) + + async def get( + self, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """Make a GET request.""" + return await self._make_request("GET", namespace, service, port, path, **kwargs) + + async def post( + self, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """Make a POST request.""" + return await self._make_request("POST", namespace, service, port, path, **kwargs) + + async def put( + self, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """Make a PUT request.""" + return await self._make_request("PUT", namespace, service, port, path, **kwargs) + + async def delete( + self, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """Make a DELETE request.""" + return await self._make_request("DELETE", namespace, service, port, path, **kwargs) + + async def patch( + self, namespace: str, service: str, port: int, path: str = "", **kwargs + ) -> httpx.Response: + """Make a PATCH request.""" + return await self._make_request("PATCH", namespace, service, port, path, **kwargs) diff --git a/orionpy/network/orionwebsocket.py b/orionpy/network/orionwebsocket.py new file mode 100644 index 0000000..e85cca7 --- /dev/null +++ b/orionpy/network/orionwebsocket.py @@ -0,0 +1,32 @@ +""" +OrionWebSocket - Async Websocket client for Kubernetes service-to-service +communication with automatic token management. +""" + +# 3rd +import websockets + +# Local +from .orionhttpx import OrionHttpx + + +class OrionWebSocket(OrionHttpx): + async def connect( + self, + namespace: str, + service: str, + port: int, + path: str, + ): + token = await self._get_token(namespace, service) + + url = f"ws://{service}.{namespace}.svc.cluster.local:{port}{path}" + + headers = {"X-ORION-SERVICE-AUTH": token} + + return await websockets.connect( + url, + additional_headers=headers, + ping_interval=20, + ping_timeout=20, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19c1798 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +httpx +kubernetes>=28.0.0 +PyJWT>=2.8.0 +websockets diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a17ebf1 --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup, find_packages + +with open("requirements.txt") as f: + requirements = f.read().splitlines() + +setup( + name="orionpy", + version="1.1.1", + description="Kubernetes service-to-service HTTP client with automatic token management", + packages=find_packages(), + install_requires=requirements, + python_requires=">=3.7", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], +) diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..6013ca3 --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,46 @@ +apiVersion: skaffold/v4beta6 +kind: Config +build: + local: + push: false + tagPolicy: + sha256: {} + artifacts: + - image: testservice + docker: + dockerfile: Dockerfile.testservice + - image: orionpy-test + docker: + dockerfile: Dockerfile.test + - image: orionpy-test-unit + docker: + dockerfile: Dockerfile.test + +manifests: + kustomize: + paths: + - k8s + +verify: + # run integration tests + - name: orionpy-test + container: + image: orionpy-test + name: orionpy-test + timeout: 600 + executionMode: + kubernetesCluster: + jobManifestPath: tests/test_runner.yaml + +profiles: + - name: unit-tests + verify: + # run unit tests only + - name: orionpy-test-unit + container: + image: orionpy-test-unit + name: orionpy-test-unit + timeout: 300 + executionMode: + kubernetesCluster: + jobManifestPath: tests/test_runner_unit.yaml diff --git a/test-service/main.py b/test-service/main.py new file mode 100644 index 0000000..23d3a47 --- /dev/null +++ b/test-service/main.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import json +import logging +from http.server import HTTPServer, BaseHTTPRequestHandler +import urllib.request +import urllib.error + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +AUTH_SERVICE_URL = "http://localhost:13000/rhea/api/v1/auth/validate" +TARGET_IDENTITY = 'argocd::Service:"testservice"' +PORT = 3000 + + +class TestServiceHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/testservice/v1/hello": + self.handle_hello() + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/testservice/v1/echo": + self.handle_echo() + else: + self.send_response(404) + self.end_headers() + + def handle_hello(self): + auth_header = self.headers.get("X-ORION-SERVICE-AUTH") + + if not auth_header: + logger.error("Missing X-ORION-SERVICE-AUTH header") + self.send_response(401) + self.end_headers() + return + + auth_body = { + "target_url": "/testservice/v1/hello", + "target_identity": TARGET_IDENTITY, + "target_method": "GET" + } + + try: + req = urllib.request.Request( + AUTH_SERVICE_URL, + data=json.dumps(auth_body).encode('utf-8'), + headers={ + "X-ORION-SERVICE-AUTH": auth_header, + "Content-Type": "application/json" + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=5) as response: + if response.status == 200: + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"hello") + else: + logger.error(f"Auth service returned status: {response.status}") + self.send_response(response.status) + self.end_headers() + + except urllib.error.HTTPError as e: + logger.error(f"Auth service error: {e.code} - {e.reason}") + self.send_response(e.code) + self.end_headers() + except Exception as e: + logger.error(f"Failed to validate auth: {str(e)}") + self.send_response(500) + self.end_headers() + + def handle_echo(self): + auth_header = self.headers.get("X-ORION-SERVICE-AUTH") + + if not auth_header: + logger.error("Missing X-ORION-SERVICE-AUTH header") + self.send_response(401) + self.end_headers() + return + + content_length = int(self.headers.get('Content-Length', 0)) + request_body = self.rfile.read(content_length) + + auth_body = { + "target_url": "/testservice/v1/echo", + "target_identity": TARGET_IDENTITY, + "target_method": "POST" + } + + try: + req = urllib.request.Request( + AUTH_SERVICE_URL, + data=json.dumps(auth_body).encode('utf-8'), + headers={ + "X-ORION-SERVICE-AUTH": auth_header, + "Content-Type": "application/json" + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=5) as response: + if response.status == 200: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(request_body) + else: + logger.error(f"Auth service returned status: {response.status}") + self.send_response(response.status) + self.end_headers() + + except urllib.error.HTTPError as e: + logger.error(f"Auth service error: {e.code} - {e.reason}") + self.send_response(e.code) + self.end_headers() + except Exception as e: + logger.error(f"Failed to validate auth: {str(e)}") + self.send_response(500) + self.end_headers() + + def log_message(self, format, *args): + logger.info(f"{self.address_string()} - {format % args}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", PORT), TestServiceHandler) + logger.info(f"Test service listening on port {PORT}") + server.serve_forever() diff --git a/test-service/requirements.txt b/test-service/requirements.txt new file mode 100644 index 0000000..2819c1e --- /dev/null +++ b/test-service/requirements.txt @@ -0,0 +1 @@ +# No external dependencies required - using Python stdlib only diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..69a2941 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for orionpy module.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..17eb9b9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +""" +Pytest configuration file for orionpy tests. +""" +import os + + +def pytest_configure(config): + """Configure test environment.""" + # Set up any required environment variables + os.environ['TEST_NAMESPACE'] = os.environ.get('TEST_NAMESPACE', 'default') + os.environ['TEST_SERVICE'] = os.environ.get('TEST_SERVICE', 'test-service') diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..9ba87fb --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +coverage run -m pytest -x -s -vvv --durations=5 --color=yes tests/ -W ignore::DeprecationWarning +cov_exit_code=$? + +coverage report -m --fail-under=100 + +test_exit_code=$? +exit_code=$((cov_exit_code + test_exit_code)) + +echo "Coverage exit code: $cov_exit_code" +echo "Test exit code: $test_exit_code" +echo "Exit code: $exit_code" + +exit "$exit_code" diff --git a/tests/run_unit_tests.sh b/tests/run_unit_tests.sh new file mode 100755 index 0000000..9f72623 --- /dev/null +++ b/tests/run_unit_tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +pytest tests/test_orionrequests.py -x -s -vvv --durations=5 --color=yes -W ignore::DeprecationWarning +exit_code=$? + +echo "Unit test exit code: $exit_code" + +exit "$exit_code" diff --git a/tests/test_e2e_testservice.py b/tests/test_e2e_testservice.py new file mode 100644 index 0000000..739bd8b --- /dev/null +++ b/tests/test_e2e_testservice.py @@ -0,0 +1,66 @@ +"""E2E tests for testservice endpoint.""" +import json +import pytest +from orionpy.network.orionhttpx import OrionHttpx + + +class TestTestServiceE2E: + """End-to-end tests for testservice.""" + + @pytest.fixture + def orion_client(self): + """Create OrionHttpx client for e2e tests.""" + return OrionHttpx() + + @pytest.mark.asyncio + async def test_hello_endpoint_authenticated(self, orion_client): + """Test authenticated request to hello endpoint returns 200.""" + response = await orion_client.get( + namespace='default', + service='testservice', + port=3000, + path='/testservice/v1/hello' + ) + + assert response.status_code == 200 + assert response.text == 'hello' + + @pytest.mark.asyncio + async def test_hello_endpoint_unauthenticated(self, orion_client): + """Test unauthenticated request to hello endpoint returns 401.""" + import httpx + + # Make request without authentication header + url = 'http://testservice.default.svc.cluster.local:3000/testservice/v1/hello' + async with httpx.AsyncClient() as client: + response = await client.get(url) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_echo_endpoint_authenticated(self, orion_client): + """Test authenticated POST request to echo endpoint returns request body.""" + test_data = {"message": "hello world", "value": 42} + + response = await orion_client.post( + namespace='default', + service='testservice', + port=3000, + path='/testservice/v1/echo', + json=test_data + ) + + assert response.status_code == 200 + assert response.json() == test_data + + @pytest.mark.asyncio + async def test_echo_endpoint_unauthenticated(self, orion_client): + """Test unauthenticated POST request to echo endpoint returns 401.""" + import httpx + + test_data = {"message": "hello world", "value": 42} + url = 'http://testservice.default.svc.cluster.local:3000/testservice/v1/echo' + async with httpx.AsyncClient() as client: + response = await client.post(url, json=test_data) + + assert response.status_code == 401 diff --git a/tests/test_orionhttpx.py b/tests/test_orionhttpx.py new file mode 100644 index 0000000..f786589 --- /dev/null +++ b/tests/test_orionhttpx.py @@ -0,0 +1,278 @@ +"""Tests for OrionHttpx class.""" +import time +import pytest +from unittest.mock import Mock, patch, AsyncMock +import jwt +import httpx + +from orionpy.network.orionhttpx import OrionHttpx + + +class TestOrionHttpx: + """Test the OrionHttpx class.""" + + @pytest.fixture + def mock_k8s_config(self): + """Mock Kubernetes configuration.""" + with patch('orionpy.network.orionhttpx.config.load_incluster_config'): + yield + + @pytest.fixture + def mock_k8s_clients(self): + """Mock Kubernetes client APIs.""" + with patch('orionpy.network.orionhttpx.client.CoreV1Api') as core_api, \ + patch('orionpy.network.orionhttpx.client.AuthenticationV1Api') as auth_api: + yield {'core': core_api, 'auth': auth_api} + + @pytest.fixture + def orion_client(self, mock_k8s_config, mock_k8s_clients): + """Create an OrionHttpx client with mocked dependencies.""" + # Clear the token cache between tests + OrionHttpx._token_cache.clear() + return OrionHttpx() + + @pytest.mark.asyncio + async def test_service_key_generation(self, orion_client): + """Test service key generation for cache.""" + key = orion_client._get_service_key('default', 'my-service') + assert key == 'default::my-service' + + @pytest.mark.asyncio + async def test_build_url(self, orion_client): + """Test URL building.""" + url = orion_client._build_url('default', 'my-service', 8000, '/api/test') + assert url == 'http://my-service.default.svc.cluster.local:8000/api/test' + + url = orion_client._build_url('default', 'my-service', 8000, 'api/test') + assert url == 'http://my-service.default.svc.cluster.local:8000/api/test' + + url = orion_client._build_url('default', 'my-service', 8000) + assert url == 'http://my-service.default.svc.cluster.local:8000' + + @pytest.mark.asyncio + async def test_create_token(self, orion_client): + """Test token creation.""" + # Mock the token response + mock_token_response = Mock() + mock_token_response.status.token = 'test-token-123' + + orion_client._core_api.create_namespaced_service_account_token = Mock( + return_value=mock_token_response + ) + + token = orion_client._create_token('default', 'my-service') + + assert token == 'test-token-123' + orion_client._core_api.create_namespaced_service_account_token.assert_called_once() + call_args = orion_client._core_api.create_namespaced_service_account_token.call_args + # Should use the in-cluster service account, not 'default' + assert call_args[1]['name'] == orion_client._service_account_name + assert call_args[1]['namespace'] == orion_client._namespace + + @pytest.mark.asyncio + async def test_get_token_caching(self, orion_client): + """Test token caching and reuse.""" + # Create a valid token with 10 minute expiry + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + # Mock the create_token method + orion_client._create_token = Mock(return_value=test_token) + + # First call should create token + token1 = await orion_client._get_token('default', 'my-service') + assert token1 == test_token + assert orion_client._create_token.call_count == 1 + + # Second call should use cached token + token2 = await orion_client._get_token('default', 'my-service') + assert token2 == test_token + assert orion_client._create_token.call_count == 1 # Still 1, not called again + + @pytest.mark.asyncio + async def test_get_token_refresh(self, orion_client): + """Test token refresh when expiring soon.""" + # Create a token that expires in 4 minutes (should trigger refresh) + exp_time_old = int(time.time()) + 240 + token_payload_old = {'exp': exp_time_old, 'aud': 'test'} + old_token = jwt.encode(token_payload_old, 'secret', algorithm='HS256') + + # Create a new token with 10 minute expiry + exp_time_new = int(time.time()) + 600 + token_payload_new = {'exp': exp_time_new, 'aud': 'test'} + new_token = jwt.encode(token_payload_new, 'secret', algorithm='HS256') + + # Mock the create_token method + orion_client._create_token = Mock(side_effect=[old_token, new_token]) + + # First call creates token + token1 = await orion_client._get_token('default', 'my-service') + assert token1 == old_token + + # Second call should refresh the token since it expires in < 5 minutes + token2 = await orion_client._get_token('default', 'my-service') + assert token2 == new_token + assert orion_client._create_token.call_count == 2 + + @pytest.mark.asyncio + async def test_make_request(self, orion_client): + """Test making HTTP requests.""" + # Create a valid token + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + + # Mock httpx AsyncClient + mock_response = Mock() + mock_response.status_code = 200 + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + response = await orion_client._make_request( + 'GET', 'default', 'my-service', 8000, '/api/test' + ) + + assert response.status_code == 200 + mock_client.request.assert_called_once() + call_args = mock_client.request.call_args + assert call_args[0][0] == 'GET' + assert call_args[0][1] == 'http://my-service.default.svc.cluster.local:8000/api/test' + assert call_args[1]['headers']['X-ORION-SERVICE-AUTH'] == test_token + + @pytest.mark.asyncio + async def test_get_method(self, orion_client): + """Test GET method.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + await orion_client.get('default', 'my-service', 8000, '/api/test') + + assert mock_client.request.call_args[0][0] == 'GET' + + @pytest.mark.asyncio + async def test_post_method(self, orion_client): + """Test POST method.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + await orion_client.post('default', 'my-service', 8000, '/api/test', json={'key': 'value'}) + + assert mock_client.request.call_args[0][0] == 'POST' + assert mock_client.request.call_args[1]['json'] == {'key': 'value'} + + @pytest.mark.asyncio + async def test_put_method(self, orion_client): + """Test PUT method.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + await orion_client.put('default', 'my-service', 8000, '/api/test') + + assert mock_client.request.call_args[0][0] == 'PUT' + + @pytest.mark.asyncio + async def test_delete_method(self, orion_client): + """Test DELETE method.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + await orion_client.delete('default', 'my-service', 8000, '/api/test') + + assert mock_client.request.call_args[0][0] == 'DELETE' + + @pytest.mark.asyncio + async def test_patch_method(self, orion_client): + """Test PATCH method.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + await orion_client.patch('default', 'my-service', 8000, '/api/test') + + assert mock_client.request.call_args[0][0] == 'PATCH' + + @pytest.mark.asyncio + async def test_custom_headers_preserved(self, orion_client): + """Test that custom headers are preserved when making requests.""" + exp_time = int(time.time()) + 600 + token_payload = {'exp': exp_time, 'aud': 'test'} + test_token = jwt.encode(token_payload, 'secret', algorithm='HS256') + + orion_client._create_token = Mock(return_value=test_token) + mock_response = Mock() + + with patch('orionpy.network.orionhttpx.httpx.AsyncClient') as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.request = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + custom_headers = {'X-Custom-Header': 'custom-value'} + await orion_client.get('default', 'my-service', 8000, '/api/test', headers=custom_headers) + + call_headers = mock_client.request.call_args[1]['headers'] + assert call_headers['X-Custom-Header'] == 'custom-value' + assert call_headers['X-ORION-SERVICE-AUTH'] == test_token diff --git a/tests/test_orionwebsocket.py b/tests/test_orionwebsocket.py new file mode 100644 index 0000000..c216fb4 --- /dev/null +++ b/tests/test_orionwebsocket.py @@ -0,0 +1,105 @@ +"""Tests for OrionWebSocket class.""" + +import time +import pytest +from unittest.mock import Mock, patch, AsyncMock +import jwt + +from orionpy.network.orionwebsocket import OrionWebSocket + + +class TestOrionWebSocket: + """Test the OrionWebSocket class.""" + + @pytest.fixture + def mock_k8s_config(self): + """Mock Kubernetes configuration.""" + with patch("orionpy.network.orionhttpx.config.load_incluster_config"): + yield + + @pytest.fixture + def mock_k8s_clients(self): + """Mock Kubernetes client APIs.""" + with patch("orionpy.network.orionhttpx.client.CoreV1Api"), \ + patch("orionpy.network.orionhttpx.client.AuthenticationV1Api"): + yield + + @pytest.fixture + def orion_ws(self, mock_k8s_config, mock_k8s_clients): + """Create an OrionWebSocket client with mocked dependencies.""" + OrionWebSocket._token_cache.clear() + return OrionWebSocket() + + @pytest.mark.asyncio + async def test_connect_builds_correct_url(self, orion_ws): + """Ensure WebSocket URL is built correctly.""" + exp_time = int(time.time()) + 600 + token = jwt.encode({"exp": exp_time, "aud": "test"}, "secret", algorithm="HS256") + + orion_ws._create_token = Mock(return_value=token) + + with patch("orionpy.network.orionwebsocket.websockets.connect", new_callable=AsyncMock) as mock_connect: + await orion_ws.connect( + namespace="default", + service="kuiper", + port=8000, + path="/kuiper/.stream", + ) + + mock_connect.assert_awaited_once() + url = mock_connect.call_args[0][0] + assert url == "ws://kuiper.default.svc.cluster.local:8000/kuiper/.stream" + + @pytest.mark.asyncio + async def test_connect_injects_auth_header(self, orion_ws): + """Ensure auth header is passed during WebSocket handshake.""" + exp_time = int(time.time()) + 600 + token = jwt.encode({"exp": exp_time, "aud": "test"}, "secret", algorithm="HS256") + + orion_ws._create_token = Mock(return_value=token) + + with patch("orionpy.network.orionwebsocket.websockets.connect", new_callable=AsyncMock) as mock_connect: + await orion_ws.connect( + namespace="default", + service="kuiper", + port=8000, + path="/kuiper/.stream", + ) + + # Updated key to match current implementation + headers = mock_connect.call_args.kwargs["additional_headers"] + assert headers["X-ORION-SERVICE-AUTH"] == token + + @pytest.mark.asyncio + async def test_connect_sets_ping_options(self, orion_ws): + """Ensure ping settings are passed through.""" + exp_time = int(time.time()) + 600 + token = jwt.encode({"exp": exp_time, "aud": "test"}, "secret", algorithm="HS256") + + orion_ws._create_token = Mock(return_value=token) + + with patch("orionpy.network.orionwebsocket.websockets.connect", new_callable=AsyncMock) as mock_connect: + await orion_ws.connect( + namespace="default", + service="kuiper", + port=8000, + path="/kuiper/.stream", + ) + + assert mock_connect.call_args.kwargs["ping_interval"] == 20 + assert mock_connect.call_args.kwargs["ping_timeout"] == 20 + + @pytest.mark.asyncio + async def test_token_is_cached_between_connections(self, orion_ws): + """Ensure token is reused from cache for multiple connections.""" + exp_time = int(time.time()) + 600 + token = jwt.encode({"exp": exp_time, "aud": "test"}, "secret", algorithm="HS256") + + orion_ws._create_token = Mock(return_value=token) + + with patch("orionpy.network.orionwebsocket.websockets.connect", new_callable=AsyncMock): + await orion_ws.connect("default", "kuiper", 8000, "/kuiper/.stream") + await orion_ws.connect("default", "kuiper", 8000, "/kuiper/.stream") + + # The token should only be generated once + assert orion_ws._create_token.call_count == 1 diff --git a/tests/test_runner.yaml b/tests/test_runner.yaml new file mode 100644 index 0000000..da17f84 --- /dev/null +++ b/tests/test_runner.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: orionpy-test-runner + namespace: default +spec: + backoffLimit: 0 + template: + spec: + restartPolicy: Never + serviceAccountName: orionpy-test + containers: + - name: orionpy-test-runner + image: orionpy-test diff --git a/tests/test_runner_unit.yaml b/tests/test_runner_unit.yaml new file mode 100644 index 0000000..341f549 --- /dev/null +++ b/tests/test_runner_unit.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: orionpy-test-runner-unit + namespace: default +spec: + backoffLimit: 0 + template: + spec: + restartPolicy: Never + serviceAccountName: default + containers: + - name: orionpy-test-runner-unit + image: orionpy-test-unit + command: ["bash", "tests/run_unit_tests.sh"] diff --git a/ty.toml b/ty.toml new file mode 100644 index 0000000..e91e5fb --- /dev/null +++ b/ty.toml @@ -0,0 +1,6 @@ +[environment] +python = "./venv" +root = ["."] + +[src] +exclude = ["./tests", "setup.py"] From f567cb2164766f47c01b5f2f51449f1f9ed33523 Mon Sep 17 00:00:00 2001 From: ptownley Date: Thu, 16 Apr 2026 16:42:38 +0100 Subject: [PATCH 2/5] adding .kind.yaml --- .kind.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .kind.yaml diff --git a/.kind.yaml b/.kind.yaml new file mode 100644 index 0000000..66aa3f5 --- /dev/null +++ b/.kind.yaml @@ -0,0 +1,7 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + extraMounts: + - hostPath: ./ + containerPath: /orionpy From b403d425923764743e0730b5dc40030d4ea5e79c Mon Sep 17 00:00:00 2001 From: ptownley Date: Fri, 17 Apr 2026 15:20:02 +0100 Subject: [PATCH 3/5] add compile-reqs and lock versions --- Makefile | 4 ++++ dev-requirements.lock | 45 +++++++++++++++++++++++++++++++++++ dev-requirements.txt | 14 +++++------ requirements.lock | 55 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 8 +++---- 5 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 dev-requirements.lock create mode 100644 requirements.lock diff --git a/Makefile b/Makefile index 96bea6e..e468a6c 100644 --- a/Makefile +++ b/Makefile @@ -46,3 +46,7 @@ check: .tools/dev.Makefile install: .tools/dev.Makefile @ $(MAKE) -f .tools/dev.Makefile install $(ENV) --no-print-directory + +compile-reqs: + @uv pip compile requirements.txt -o requirements.lock + @uv pip compile dev-requirements.txt -o dev-requirements.lock \ No newline at end of file diff --git a/dev-requirements.lock b/dev-requirements.lock new file mode 100644 index 0000000..e27a51f --- /dev/null +++ b/dev-requirements.lock @@ -0,0 +1,45 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile dev-requirements.txt -o dev-requirements.lock +astroid==4.0.4 + # via pylint +coverage==7.13.5 + # via + # -r dev-requirements.txt + # pytest-cov +dill==0.4.1 + # via pylint +iniconfig==2.3.0 + # via pytest +isort==8.0.1 + # via pylint +mccabe==0.7.0 + # via pylint +packaging==26.1 + # via pytest +platformdirs==4.9.6 + # via pylint +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pygments==2.20.0 + # via pytest +pylint==4.0.5 + # via -r dev-requirements.txt +pytest==9.0.3 + # via + # -r dev-requirements.txt + # pytest-asyncio + # pytest-cov +pytest-asyncio==1.3.0 + # via -r dev-requirements.txt +pytest-cov==7.1.0 + # via -r dev-requirements.txt +ruff==0.15.11 + # via -r dev-requirements.txt +tomlkit==0.14.0 + # via pylint +ty==0.0.31 + # via -r dev-requirements.txt +typing-extensions==4.15.0 + # via pytest-asyncio diff --git a/dev-requirements.txt b/dev-requirements.txt index 311e9e4..09dae17 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,7 +1,7 @@ -pytest -pytest-cov -pytest-asyncio -coverage -ruff -pylint -ty +pytest==9.0.3 +pytest-cov==7.1.0 +pytest-asyncio==1.3.0 +coverage==7.13.5 +ruff==0.15.11 +pylint==4.0.5 +ty==0.0.31 diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..2c80997 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,55 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.txt -o requirements.lock +anyio==4.13.0 + # via httpx +certifi==2026.2.25 + # via + # httpcore + # httpx + # kubernetes + # requests +charset-normalizer==3.4.7 + # via requests +durationpy==0.10 + # via kubernetes +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via -r requirements.txt +idna==3.11 + # via + # anyio + # httpx + # requests +kubernetes==35.0.0 + # via -r requirements.txt +oauthlib==3.3.1 + # via requests-oauthlib +pyjwt==2.12.1 + # via -r requirements.txt +python-dateutil==2.9.0.post0 + # via kubernetes +pyyaml==6.0.3 + # via kubernetes +requests==2.33.1 + # via + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 + # via kubernetes +six==1.17.0 + # via + # kubernetes + # python-dateutil +typing-extensions==4.15.0 + # via anyio +urllib3==2.6.3 + # via + # kubernetes + # requests +websocket-client==1.9.0 + # via kubernetes +websockets==16.0 + # via -r requirements.txt diff --git a/requirements.txt b/requirements.txt index 19c1798..b21ba91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -httpx -kubernetes>=28.0.0 -PyJWT>=2.8.0 -websockets +httpx==0.28.1 +kubernetes==35.0.0 +PyJWT==2.12.1 +websockets==16.0 \ No newline at end of file From a699e052916f44ddec9b2371c799568f9a2a5382 Mon Sep 17 00:00:00 2001 From: ptownley Date: Fri, 17 Apr 2026 16:28:52 +0100 Subject: [PATCH 4/5] Update rhea link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9483346..35cd771 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,5 @@ OrionPY is the client we use for Orion services to interact with each other. -It contains the authorization logic needed to interact with [`rhea`](https://github.com/juno-fx/rhea.git), our authN and authR backend for service-to-service communication. +It contains the authorization logic needed to interact with [`rhea`](https://juno-fx.github.io/Orion-Documentation/genesis3.0.2-orion3.1.0/rhea/intro/), our authN and authR backend for service-to-service communication. From e796a1d164dbdd0b46bed25b40aef00768bb6785 Mon Sep 17 00:00:00 2001 From: ptownley Date: Fri, 17 Apr 2026 16:43:04 +0100 Subject: [PATCH 5/5] Use latest --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 35cd771..f2a66d7 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,5 @@ OrionPY is the client we use for Orion services to interact with each other. -It contains the authorization logic needed to interact with [`rhea`](https://juno-fx.github.io/Orion-Documentation/genesis3.0.2-orion3.1.0/rhea/intro/), our authN and authR backend for service-to-service communication. +It contains the authorization logic needed to interact with [`rhea`](https://juno-fx.github.io/Orion-Documentation/latest/rhea/intro/), our authN and authR backend for service-to-service communication.