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/.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/.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 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..e468a6c --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +.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 + +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/README.md b/README.md new file mode 100644 index 0000000..f2a66d7 --- /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://juno-fx.github.io/Orion-Documentation/latest/rhea/intro/), our authN and authR backend for service-to-service communication. + 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 new file mode 100644 index 0000000..09dae17 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,7 @@ +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/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.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 new file mode 100644 index 0000000..b21ba91 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +httpx==0.28.1 +kubernetes==35.0.0 +PyJWT==2.12.1 +websockets==16.0 \ No newline at end of file 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"]