├── .cargo └── config.toml ├── .github ├── actions │ ├── node-reference-docs │ │ └── action.yaml │ └── python-reference-docs │ │ └── action.yaml ├── dependabot.yml └── workflows │ ├── docs-reference.yml │ ├── docs-user-guide.yml │ ├── junction-ci.yml │ ├── junction-node-ci.yml │ ├── junction-python-ci.yml │ ├── release-node.yaml │ ├── release-python.yaml │ └── smoke-test.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── biome.json ├── crates ├── README.md ├── junction-api-gen │ ├── Cargo.toml │ └── src │ │ ├── main.rs │ │ └── python.rs ├── junction-api │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── backend.rs │ │ ├── error.rs │ │ ├── http.rs │ │ ├── kube.rs │ │ ├── kube │ │ ├── backend.rs │ │ └── http.rs │ │ ├── lib.rs │ │ ├── shared.rs │ │ ├── xds.rs │ │ └── xds │ │ ├── backend.rs │ │ ├── http.rs │ │ └── shared.rs ├── junction-core │ ├── Cargo.toml │ ├── README.md │ ├── examples │ │ ├── dns-backend.rs │ │ └── get-endpoints.rs │ └── src │ │ ├── client.rs │ │ ├── dns.rs │ │ ├── endpoints.rs │ │ ├── error.rs │ │ ├── hash.rs │ │ ├── lib.rs │ │ ├── load_balancer.rs │ │ ├── rand.rs │ │ ├── url.rs │ │ ├── xds.rs │ │ └── xds │ │ ├── cache.rs │ │ ├── csds.rs │ │ ├── resources.rs │ │ └── test.rs ├── junction-typeinfo-derive │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── junction-typeinfo │ ├── Cargo.toml │ ├── src │ └── lib.rs │ └── tests │ └── test_derive.rs ├── docs ├── README.md ├── mkdocs.yml └── source │ ├── _build │ ├── API_REFERENCE_LINKS.yml │ ├── assets │ │ └── logo.png │ ├── overrides │ │ └── 404.html │ └── scripts │ │ ├── macro.py │ │ └── people.py │ ├── getting-started │ ├── configuring-junction.md │ ├── ezbake.md │ ├── index.md │ ├── node.md │ ├── python.md │ └── rust.md │ ├── guides │ ├── configuration.md │ ├── debugging.md │ └── index.md │ ├── index.md │ ├── mlc-config.json │ ├── overview │ └── core-concepts.md │ ├── pyproject.toml │ ├── reference │ └── api.md │ └── requirements.txt ├── junction-node ├── .gitignore ├── Cargo.toml ├── README.md ├── biome.json ├── build.rs ├── package-lock.json ├── package.json ├── platforms │ ├── darwin-arm64 │ │ ├── README.md │ │ └── package.json │ ├── darwin-x64 │ │ ├── README.md │ │ └── package.json │ ├── linux-arm64-gnu │ │ ├── README.md │ │ └── package.json │ ├── linux-x64-gnu │ │ ├── README.md │ │ └── package.json │ └── win32-x64-msvc │ │ ├── README.md │ │ └── package.json ├── scripts │ └── postinstall.js ├── src │ └── lib.rs ├── ts │ ├── core.cts │ ├── fetch.cts │ ├── index.cts │ ├── index.mts │ └── load.cts └── tsconfig.json ├── junction-python ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── docs │ ├── .gitignore │ ├── requirements.txt │ ├── run_live_docs_server.py │ └── source │ │ ├── _static │ │ ├── css │ │ │ └── custom.css │ │ └── version_switcher.json │ │ ├── conf.py │ │ ├── index.rst │ │ └── reference │ │ ├── config.rst │ │ ├── index.rst │ │ ├── junction.rst │ │ ├── requests.rst │ │ └── urllib3.rst ├── junction │ ├── __init__.py │ ├── config.py │ ├── requests.py │ └── urllib3.py ├── pyproject.toml ├── requirements-dev.txt ├── samples │ └── smoke-test │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── client.py │ │ ├── deploy │ │ ├── client-cluster-role-binding.yml │ │ └── jct-simple-app.yml │ │ └── server.py ├── src │ ├── lib.rs │ └── runtime.rs ├── tests │ ├── __init__.py │ ├── test_routes.py │ └── test_urllib3.py └── tox.ini └── xtask ├── Cargo.toml ├── hooks └── pre-commit └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --quiet --package xtask --" 3 | -------------------------------------------------------------------------------- /.github/actions/node-reference-docs/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish Node Reference Docs" 2 | description: | 3 | Publish Node stable and dev reference docs for Node. 4 | inputs: 5 | node-version: 6 | description: "The version of node used to build the docs. Passed to actions/setup-node." 7 | required: true 8 | doc-version: 9 | description: | 10 | The Junction version that docs are being built at. This may 11 | be a a string like dev or a semver version. 12 | required: true 13 | build-dev: 14 | description: "Build dev docs" 15 | required: false 16 | default: "" 17 | build-stable: 18 | description: "Build stable docs" 19 | required: false 20 | default: "" 21 | runs: 22 | using: "composite" 23 | steps: 24 | - name: Set up Node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ inputs.node-version }} 28 | 29 | - name: Build node 30 | shell: bash 31 | run: cargo xtask node build 32 | 33 | - name: Build node documentation 34 | shell: bash 35 | run: cargo xtask node docs 36 | 37 | - name: Deploy Node dev docs 38 | uses: JamesIves/github-pages-deploy-action@v4 39 | if: ${{ inputs.build-dev == 'true' }} 40 | with: 41 | folder: junction-node/docs 42 | target-folder: api/node/dev 43 | single-commit: true 44 | 45 | # NOTE: we're deploying a per-version copy of the docs here, but we're not 46 | # DOING anything with it. there's no way to switch to them, but they're 47 | # there. 48 | - name: Deploy versioned docs 49 | uses: JamesIves/github-pages-deploy-action@v4 50 | if: ${{ inputs.doc-version && inputs.build-stable == 'true' }} 51 | with: 52 | folder: junction-node/docs 53 | target-folder: api/node/${{ inputs.doc-version }} 54 | single-commit: true 55 | 56 | - name: Deploy Node stable docs 57 | uses: JamesIves/github-pages-deploy-action@v4 58 | if: ${{ inputs.build-stable == 'true' }} 59 | with: 60 | folder: junction-node/docs 61 | target-folder: api/node/stable 62 | single-commit: true 63 | -------------------------------------------------------------------------------- /.github/actions/python-reference-docs/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish Python Reference Docs" 2 | description: | 3 | Publish versioned and dev reference docs for Python. 4 | inputs: 5 | doc-version: 6 | description: | 7 | The Junction version that docs are being built at. This may 8 | be a a string like dev or a semver version. 9 | required: true 10 | python-version: 11 | description: "The version of Python to build docs with." 12 | required: true 13 | default: "3.13" 14 | build-dev: 15 | description: "Build dev docs" 16 | required: false 17 | default: "" 18 | build-stable: 19 | description: "Build stable docs" 20 | required: false 21 | default: "" 22 | runs: 23 | using: "composite" 24 | steps: 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ inputs.python-version }} 29 | 30 | - name: Build python 31 | shell: bash 32 | run: cargo xtask python build 33 | 34 | - name: Build python documentation 35 | shell: bash 36 | run: cargo xtask python docs 37 | env: 38 | JUNCTION_VERSION: ${{ inputs.doc-version }} 39 | 40 | - name: Deploy Python dev docs 41 | if: ${{ inputs.build-dev == 'true' }} 42 | uses: JamesIves/github-pages-deploy-action@v4 43 | with: 44 | folder: junction-python/docs/build/html 45 | target-folder: api/python/dev 46 | single-commit: true 47 | 48 | # NOTE: we're deploying a per-version copy of the docs here, but we're not 49 | # DOING anything with it. the sphinx version switcher is still broken. 50 | - name: Deploy versioned docs 51 | uses: JamesIves/github-pages-deploy-action@v4 52 | if: ${{ inputs.doc-version && inputs.build-stable == 'true' }} 53 | with: 54 | folder: junction-python/docs/build/html 55 | target-folder: api/python/${{ inputs.doc-version }} 56 | single-commit: true 57 | 58 | - name: Deploy Python stable docs 59 | uses: JamesIves/github-pages-deploy-action@v4 60 | if: ${{ inputs.build-stable == 'true' }} 61 | with: 62 | folder: junction-python/docs/build/html 63 | target-folder: api/python/stable 64 | single-commit: true 65 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/docs-reference.yml: -------------------------------------------------------------------------------- 1 | name: Build Reference documentation 2 | # 3 | # Why all in the one GHA? To reduce contention on github-pages-deploy-action 4 | # where synchronous changes have uncertain impact, and concurrency controls 5 | # allow a max queue depth of 1. This keeps things nice and serialized. 6 | # 7 | 8 | on: 9 | pull_request: 10 | branches: ["main"] 11 | push: 12 | branches: ["main"] 13 | repository_dispatch: 14 | types: 15 | - python-release 16 | - node-release 17 | ## Note if you add to this list, the job that _calls in_ must have its permissions 18 | ## already set for some reason: 19 | ## permissions: 20 | ## contents: write 21 | 22 | env: 23 | rust_stable: stable 24 | 25 | permissions: 26 | contents: write 27 | 28 | jobs: 29 | what: 30 | name: doc vars 31 | runs-on: ubuntu-latest 32 | outputs: 33 | dev: ${{ steps.vars.outputs.dev }} 34 | version: ${{ steps.vars.outputs.version }} 35 | node: ${{ steps.vars.outputs.node}} 36 | python: ${{ steps.vars.outputs.python }} 37 | steps: 38 | - name: handle inputs 39 | id: vars 40 | shell: bash 41 | run: | 42 | echo "dev=${{ github.event_name == 'push' && github.ref_name == 'main' }}" >> $GITHUB_OUTPUT 43 | echo "version=${{ github.event.client_payload.version || 'dev' }}" >> $GITHUB_OUTPUT 44 | echo "node=${{ github.event_name == 'repository_dispatch' && github.event.action == 'node-release' }}" >> $GITHUB_OUTPUT 45 | echo "python=${{ github.event_name == 'repository_dispatch' && github.event.action == 'python-release' }}" >> $GITHUB_OUTPUT 46 | 47 | build-docs: 48 | runs-on: ubuntu-latest 49 | needs: [what] 50 | steps: 51 | - uses: actions/checkout@v4 52 | with: 53 | ref: ${{ github.event.client_payload.sha }} 54 | 55 | - name: "Install Rust @ ${{ env.rust_stable }}" 56 | uses: dtolnay/rust-toolchain@stable 57 | with: 58 | toolchain: ${{ env.rust_stable }} 59 | - uses: Swatinem/rust-cache@v2 60 | 61 | - name: node reference docs 62 | if: ${{ needs.what.outputs.dev == 'true' || needs.what.outputs.node == 'true' }} 63 | uses: ./.github/actions/node-reference-docs 64 | with: 65 | node-version: 22 66 | doc-version: ${{ needs.what.outputs.version }} 67 | build-dev: ${{ needs.what.outputs.dev }} 68 | build-stable: ${{ needs.what.outputs.node }} 69 | 70 | - name: Python reference docs 71 | if: ${{ needs.what.outputs.dev == 'true' || needs.what.outputs.python == 'true' }} 72 | uses: ./.github/actions/python-reference-docs 73 | with: 74 | python-version: 3.12 75 | doc-version: ${{ needs.what.outputs.version }} 76 | build-dev: ${{ needs.what.outputs.dev }} 77 | build-stable: ${{ needs.what.outputs.python }} 78 | 79 | # Rustdoc only deploys to dev, since the stable versions are all hosted on 80 | # docs.rs when we release. 81 | # 82 | # TODO: move this into a composite action so we don't have to `if` every 83 | # step separately. 84 | - name: Build Rust documentation 85 | if: ${{ needs.what.outputs.dev == 'true' }} 86 | run: cargo xtask core doc 87 | 88 | - name: Deploy Rust dev docs 89 | if: ${{ needs.what.outputs.dev == 'true' }} 90 | uses: JamesIves/github-pages-deploy-action@v4 91 | with: 92 | folder: target/doc/ 93 | target-folder: api/rust/dev 94 | single-commit: true 95 | 96 | - name: Clean up rust dev docs artifacts 97 | if: ${{ needs.what.outputs.dev == 'true' }} 98 | run: rm -rf target/doc 99 | -------------------------------------------------------------------------------- /.github/workflows/docs-user-guide.yml: -------------------------------------------------------------------------------- 1 | name: Build user guide 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - docs/** 7 | - .github/workflows/docs-user-guide.yml 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - docs/** 13 | - .github/workflows/docs-user-guide.yml 14 | workflow_dispatch: 15 | 16 | env: 17 | rust_stable: stable 18 | 19 | permissions: 20 | contents: write 21 | 22 | jobs: 23 | lint: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.event.client_payload.sha }} 29 | 30 | - name: Get ruff version from requirements file 31 | id: version 32 | run: | 33 | VERSION=$(grep -m 1 -oP 'ruff==\K(.*)' junction-python/requirements-dev.txt) 34 | echo "version=$VERSION" >> $GITHUB_OUTPUT 35 | 36 | - uses: chartboost/ruff-action@v1 37 | with: 38 | src: docs/source/ 39 | version: ${{ steps.version.outputs.version }} 40 | args: check --no-fix 41 | 42 | - uses: chartboost/ruff-action@v1 43 | with: 44 | src: docs/source/ 45 | version: ${{ steps.version.outputs.version }} 46 | args: format --diff 47 | 48 | deploy: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | with: 53 | ref: ${{ github.event.client_payload.sha }} 54 | 55 | - name: "Install Rust @ ${{ env.rust_stable }}" 56 | uses: dtolnay/rust-toolchain@stable 57 | with: 58 | toolchain: ${{ env.rust_stable }} 59 | - uses: Swatinem/rust-cache@v2 60 | 61 | - name: Set up Python 62 | uses: actions/setup-python@v5 63 | with: 64 | python-version: "3.12" 65 | 66 | - name: Create virtual environment 67 | run: | 68 | curl -LsSf https://astral.sh/uv/install.sh | sh 69 | uv venv 70 | echo "$GITHUB_WORKSPACE/.venv/bin" >> $GITHUB_PATH 71 | echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> $GITHUB_ENV 72 | 73 | - name: Install Python dependencies 74 | run: uv pip install -r docs/source/requirements.txt 75 | 76 | - name: Build documentation 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | working-directory: docs 80 | run: mkdocs build 81 | 82 | - name: Add .nojekyll 83 | if: github.event_name == 'push' && github.ref_name == 'main' 84 | working-directory: docs/site 85 | run: touch .nojekyll 86 | 87 | - name: Deploy docs 88 | if: github.event_name == 'push' && github.ref_name == 'main' 89 | uses: JamesIves/github-pages-deploy-action@v4 90 | with: 91 | folder: docs/site 92 | clean-exclude: | 93 | api/python/ 94 | api/node/ 95 | api/rust/ 96 | single-commit: true 97 | 98 | # Make sure artifacts are not cached 99 | - name: Clean up documentation artifacts 100 | run: rm -rf docs/site 101 | -------------------------------------------------------------------------------- /.github/workflows/junction-ci.yml: -------------------------------------------------------------------------------- 1 | # CI for all of the core Junction client crates. 2 | # 3 | # CI actually covering everything in core depends on having all of the crates 4 | # in crates/* listed as default-members of the workspace in Cargo.toml. 5 | 6 | name: junction-core CI 7 | 8 | on: 9 | push: 10 | branches: ["main"] 11 | paths: 12 | - "Cargo.toml" 13 | - "Cargo.lock" 14 | - "crates/**" 15 | - ".github/workflows/junction-ci.yml" 16 | 17 | pull_request: 18 | branches: ["main"] 19 | paths: 20 | - "Cargo.toml" 21 | - "Cargo.lock" 22 | - "crates/**" 23 | - ".github/workflows/junction-ci.yml" 24 | 25 | env: 26 | CARGO_TERM_COLOR: always 27 | rust_stable: stable 28 | rust_min: 1.81 29 | 30 | jobs: 31 | msrv: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: "Install Rust @${{ env.rust_min }}" 36 | uses: dtolnay/rust-toolchain@stable 37 | with: 38 | toolchain: ${{ env.rust_min }} 39 | - uses: Swatinem/rust-cache@v2 40 | - name: check 41 | run: cargo check 42 | 43 | tests: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: "Install Rust @${{ env.rust_stable }}" 48 | uses: dtolnay/rust-toolchain@stable 49 | with: 50 | toolchain: ${{ env.rust_stable }} 51 | - uses: Swatinem/rust-cache@v2 52 | - name: test 53 | run: cargo xtask core test 54 | 55 | clippy: 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: "Install Rust @ ${{ env.rust_stable }}" 60 | uses: dtolnay/rust-toolchain@stable 61 | with: 62 | toolchain: ${{ env.rust_stable }} 63 | - uses: Swatinem/rust-cache@v2 64 | - name: clippy 65 | run: cargo xtask core clippy 66 | 67 | fmt: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | - name: "Install Rust @ ${{ env.rust_stable }}" 72 | uses: dtolnay/rust-toolchain@stable 73 | with: 74 | toolchain: ${{ env.rust_stable }} 75 | - uses: Swatinem/rust-cache@v2 76 | - name: fmt 77 | run: | 78 | if ! rustfmt --check --edition 2021 $(git ls-files 'crates/*.rs'); then 79 | echo "rustfmt found un-formatted files" >&2 80 | exit 1 81 | fi 82 | -------------------------------------------------------------------------------- /.github/workflows/junction-node-ci.yml: -------------------------------------------------------------------------------- 1 | name: junction-node CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "junction-node/**" 8 | - ".github/workflows/junction-node-ci.yml" 9 | 10 | pull_request: 11 | branches: ["main"] 12 | paths: 13 | - "junction-node/**" 14 | - ".github/workflows/junction-node-ci.yml" 15 | 16 | env: 17 | CARGO_TERM_COLOR: always 18 | rust_stable: stable 19 | rust_min: 1.81 20 | node_lts: 20.x 21 | 22 | jobs: 23 | # Rust CI steps 24 | # 25 | # These should be largely the same as the steps in junction-core-ci.yml but 26 | # testing a different package. 27 | msrv: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: "Install Rust @${{ env.rust_min }}" 33 | uses: dtolnay/rust-toolchain@stable 34 | with: 35 | toolchain: ${{ env.rust_min }} 36 | 37 | - uses: Swatinem/rust-cache@v2 38 | 39 | - name: check 40 | run: cargo xtask node build 41 | 42 | test-node: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: "Install Rust @ ${{ env.rust_stable }}" 48 | uses: dtolnay/rust-toolchain@stable 49 | with: 50 | toolchain: ${{ env.rust_stable }} 51 | - uses: Swatinem/rust-cache@v2 52 | 53 | - name: Install Node 54 | uses: actions/setup-node@v4 55 | with: 56 | node-version: ${{ env.node_lts }} 57 | cache: npm 58 | cache-dependency-path: junction-node/package-lock.json 59 | 60 | - name: build 61 | run: cargo xtask node build --clean-install 62 | 63 | - name: lint 64 | run: cargo xtask node lint 65 | 66 | - name: check for uncommitted changes 67 | run: cargo xtask check-diffs 68 | 69 | # TODO: add some node tests 70 | # - name: run node tests 71 | # run: cargo xtask node test 72 | -------------------------------------------------------------------------------- /.github/workflows/junction-python-ci.yml: -------------------------------------------------------------------------------- 1 | name: junction-python CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "junction-python/**" 8 | - ".github/workflows/junction-python-ci.yml" 9 | 10 | pull_request: 11 | branches: ["main"] 12 | paths: 13 | - "junction-python/**" 14 | - ".github/workflows/junction-python-ci.yml" 15 | 16 | env: 17 | CARGO_TERM_COLOR: always 18 | rust_stable: stable 19 | rust_min: 1.81 20 | python_stable: 3.12 21 | python_min: 3.9 22 | 23 | jobs: 24 | # Python CI steps 25 | # 26 | # these lean on xtask tasks so they're similar to what you'd run locally 27 | test-python: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | version: 32 | - python: 3.12 33 | rust: stable 34 | - python: 3.12 35 | rust: 1.81 36 | - python: 3.9 37 | rust: stable 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: "Install Rust @ ${{ matrix.version.rust }}" 42 | uses: dtolnay/rust-toolchain@stable 43 | with: 44 | toolchain: ${{ matrix.version.rust }} 45 | components: clippy, rustfmt 46 | 47 | - uses: Swatinem/rust-cache@v2 48 | 49 | - name: "Set up Python @ ${{ matrix.version.python }}" 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: ${{ matrix.version.python }} 53 | 54 | - name: build python 55 | run: cargo xtask python build 56 | 57 | - name: check for uncommitted changes 58 | run: cargo xtask check-diffs 59 | 60 | - name: lint 61 | run: cargo xtask python lint 62 | 63 | - name: run tests 64 | run: cargo xtask python test 65 | -------------------------------------------------------------------------------- /.github/workflows/release-node.yaml: -------------------------------------------------------------------------------- 1 | name: Release Node 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | sha: 7 | description: Git Commit SHA. Use the latest commit on main if left blank. 8 | type: string 9 | dry-run: 10 | description: Dry run. Defaults to true. Won't release to NPM by default. 11 | type: boolean 12 | default: true 13 | include-docs: 14 | description: Publish docs to the Junction Labs website 15 | type: boolean 16 | default: true 17 | include-npm: 18 | description: Publish packages to NPM 19 | type: boolean 20 | default: true 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.ref }} 24 | cancel-in-progress: true 25 | 26 | env: 27 | CARGO_TERM_COLOR: always 28 | rust_stable: stable 29 | node_lts: 20.x 30 | 31 | jobs: 32 | # build the ts-only package. 33 | # 34 | # does not install a Rust toolchain 35 | ts-package: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | ref: ${{ inputs.sha }} 41 | 42 | - name: "Install Rust @ ${{ env.rust_stable }}" 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | toolchain: ${{ env.rust_stable }} 46 | 47 | - name: Install Node 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: ${{ env.node_lts }} 51 | cache: npm 52 | cache-dependency-path: junction-node/package-lock.json 53 | 54 | - name: check versions 55 | run: | 56 | cargo xtask node version 57 | cargo xtask check-diffs 58 | 59 | - name: package 60 | run: | 61 | cargo xtask node build --clean-install 62 | cargo xtask node pack 63 | env: 64 | JUNCTION_CLIENT_SKIP_POSTINSTALL: "true" 65 | 66 | - name: upload packages 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: "javascript" 70 | path: junction-node/dist/*.tgz 71 | 72 | # build a platform specific package on each platform we support 73 | # 74 | # hard codes every platform we support and its Neon platform string so we can 75 | # make sure we don't accidentally lose a platform if the output of 76 | # `neon show platforms` changes. 77 | native-packages: 78 | runs-on: ${{ matrix.platform.runner }} 79 | strategy: 80 | matrix: 81 | platform: 82 | - runner: windows-latest 83 | neon_platform: win32-x64-msvc 84 | target: x86_64-pc-windows-msvc 85 | - runner: macos-latest 86 | neon_platform: darwin-x64 87 | target: x86_64-apple-darwin 88 | - runner: macos-latest 89 | target: aarch64-apple-darwin 90 | neon_platform: darwin-arm64 91 | - runner: ubuntu-latest 92 | target: x86_64-unknown-linux-gnu 93 | neon_platform: linux-x64-gnu 94 | - runner: ubuntu-latest 95 | target: aarch64-unknown-linux-gnu 96 | neon_platform: linux-arm64-gnu 97 | neon_build: cross-release 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | with: 102 | ref: ${{ inputs.sha }} 103 | 104 | - name: "Install Rust @ ${{ env.rust_stable }}" 105 | uses: dtolnay/rust-toolchain@stable 106 | with: 107 | toolchain: ${{ env.rust_stable }} 108 | targets: ${{ matrix.platform.target }} 109 | - name: "install cross" 110 | if: ${{ startsWith(matrix.platform.neon_build, 'cross') }} 111 | run: | 112 | cargo install cross --git https://github.com/cross-rs/cross 113 | 114 | - name: Install Node 115 | uses: actions/setup-node@v4 116 | with: 117 | node-version: ${{ env.node_lts }} 118 | 119 | # this step doesn't use xtask because we haven't yet figured out how to 120 | # make xtask commands work well on windows. if you are reading this and 121 | # bothered by this, i wish you the best of luck. 122 | - name: build 123 | shell: bash 124 | run: | 125 | npm --prefix ./junction-node ci --fund=false 126 | npm --prefix ./junction-node run ${{ matrix.platform.neon_build || 'build-release' }} 127 | mkdir -p ./junction-node/dist 128 | npm --prefix ./junction-node pack ./junction-node/platforms/${{ matrix.platform.neon_platform }} --pack-destination ./junction-node/dist 129 | env: 130 | CARGO_BUILD_TARGET: ${{ matrix.platform.target }} 131 | NEON_BUILD_PLATFORM: ${{ matrix.platform.neon_platform }} 132 | JUNCTION_CLIENT_SKIP_POSTINSTALL: "true" 133 | 134 | - name: upload 135 | uses: actions/upload-artifact@v4 136 | with: 137 | name: ${{ matrix.platform.neon_platform }} 138 | path: junction-node/dist/*.tgz 139 | 140 | npm-publish: 141 | needs: [ts-package, native-packages] 142 | runs-on: ubuntu-latest 143 | if: ${{ inputs.include-npm }} 144 | 145 | environment: 146 | name: release-node 147 | permissions: 148 | contents: read 149 | id-token: write 150 | 151 | steps: 152 | - uses: actions/checkout@v4 153 | - name: Install Node 154 | uses: actions/setup-node@v4 155 | with: 156 | node-version: ${{ env.node_lts }} 157 | registry-url: "https://registry.npmjs.org" 158 | cache: npm 159 | cache-dependency-path: junction-node/package-lock.json 160 | - name: download artifacts 161 | uses: actions/download-artifact@v4 162 | with: 163 | path: dist 164 | merge-multiple: true 165 | - name: show artifacts 166 | run: ls -lah dist/* 167 | - name: publish 168 | if: inputs.dry-run == false 169 | env: 170 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 171 | shell: bash 172 | run: | 173 | for package in ./dist/*.tgz; do 174 | npm publish --provenance --access public $package 175 | done 176 | 177 | publish-to-github: 178 | needs: [ts-package] 179 | runs-on: ubuntu-latest 180 | if: ${{ inputs.include-docs }} 181 | 182 | #need this here for the docs publishing step to work 183 | permissions: 184 | contents: write 185 | 186 | steps: 187 | - uses: actions/checkout@v4 188 | with: 189 | ref: ${{ inputs.sha }} 190 | 191 | - name: Get version from Cargo.toml 192 | id: version 193 | run: | 194 | echo "payload=$(cargo xtask version --json -p junction-node)" >> "$GITHUB_OUTPUT" 195 | 196 | - name: Trigger other workflows related to the release 197 | if: inputs.dry-run == false 198 | uses: peter-evans/repository-dispatch@v3 199 | with: 200 | event-type: node-release 201 | client-payload: "${{ steps.version.outputs.payload }}" 202 | -------------------------------------------------------------------------------- /.github/workflows/release-python.yaml: -------------------------------------------------------------------------------- 1 | name: Release Python 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | sha: 7 | description: Git Commit SHA. Use the latest commit on main if left blank. 8 | type: string 9 | dry-run: 10 | description: Dry run. Defaults to true. Won't release to PyPI by default. 11 | type: boolean 12 | default: true 13 | include-docs: 14 | description: Publish docs to the Junction Labs website 15 | type: boolean 16 | default: true 17 | include-pypi: 18 | description: Publish packages to PyPI 19 | type: boolean 20 | default: true 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.ref }} 24 | cancel-in-progress: true 25 | 26 | permissions: 27 | contents: write 28 | 29 | jobs: 30 | linux: 31 | runs-on: ${{ matrix.platform.runner }} 32 | strategy: 33 | matrix: 34 | platform: 35 | - runner: ubuntu-latest 36 | target: x86_64 37 | - runner: ubuntu-latest 38 | target: aarch64 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | ref: ${{ inputs.sha }} 43 | 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: 3.x 47 | - name: build wheels 48 | uses: PyO3/maturin-action@v1 49 | with: 50 | target: ${{ matrix.platform.target }} 51 | args: --release --out dist --manifest-path junction-python/Cargo.toml 52 | sccache: "true" 53 | # cargo culted from Polars. this is probably this ring issue: 54 | # 55 | # https://github.com/pola-rs/polars/blob/abe5139f471f7b63104490813d316fc8497373c1/.github/workflows/release-python.yml#L200 56 | # https://github.com/briansmith/ring/issues/1728 57 | manylinux: ${{ matrix.platform.target == 'aarch64' && '2_24' || 'auto' }} 58 | - name: upload wheels 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: wheels-linux-${{ matrix.platform.target }} 62 | path: dist/*.whl 63 | 64 | musllinux: 65 | runs-on: ${{ matrix.platform.runner }} 66 | strategy: 67 | matrix: 68 | platform: 69 | - runner: ubuntu-latest 70 | target: x86_64 71 | - runner: ubuntu-latest 72 | target: aarch64 73 | steps: 74 | - uses: actions/checkout@v4 75 | - uses: actions/setup-python@v5 76 | with: 77 | python-version: 3.x 78 | - name: build wheels 79 | uses: PyO3/maturin-action@v1 80 | with: 81 | target: ${{ matrix.platform.target }} 82 | args: --release --out dist --manifest-path junction-python/Cargo.toml 83 | sccache: "true" 84 | manylinux: musllinux_1_2 85 | - name: upload wheels 86 | uses: actions/upload-artifact@v4 87 | with: 88 | name: wheels-musllinux-${{ matrix.platform.target }} 89 | path: dist 90 | 91 | windows: 92 | runs-on: ${{ matrix.platform.runner }} 93 | strategy: 94 | matrix: 95 | platform: 96 | - runner: windows-latest 97 | target: x64 98 | steps: 99 | - uses: actions/checkout@v4 100 | - uses: actions/setup-python@v5 101 | with: 102 | python-version: 3.x 103 | architecture: ${{ matrix.platform.target }} 104 | - name: build wheels 105 | uses: PyO3/maturin-action@v1 106 | with: 107 | target: ${{ matrix.platform.target }} 108 | args: --release --out dist --manifest-path junction-python/Cargo.toml 109 | sccache: "true" 110 | - name: upload wheels 111 | uses: actions/upload-artifact@v4 112 | with: 113 | name: wheels-windows-${{ matrix.platform.target }} 114 | path: dist 115 | 116 | macos: 117 | runs-on: ${{ matrix.platform.runner }} 118 | strategy: 119 | matrix: 120 | platform: 121 | - runner: macos-latest 122 | target: x86_64 123 | - runner: macos-latest 124 | target: aarch64 125 | steps: 126 | - uses: actions/checkout@v4 127 | - uses: actions/setup-python@v5 128 | with: 129 | python-version: 3.x 130 | - name: Build wheels 131 | uses: PyO3/maturin-action@v1 132 | with: 133 | target: ${{ matrix.platform.target }} 134 | args: --release --out dist --manifest-path junction-python/Cargo.toml 135 | sccache: "true" 136 | - name: Upload wheels 137 | uses: actions/upload-artifact@v4 138 | with: 139 | name: wheels-macos-${{ matrix.platform.target }} 140 | path: dist 141 | 142 | sdist: 143 | runs-on: ubuntu-latest 144 | steps: 145 | - uses: actions/checkout@v4 146 | - name: Build sdist 147 | uses: PyO3/maturin-action@v1 148 | with: 149 | command: sdist 150 | args: --out dist --manifest-path junction-python/Cargo.toml 151 | - name: Upload sdist 152 | uses: actions/upload-artifact@v4 153 | with: 154 | name: wheels-sdist 155 | path: dist 156 | 157 | pypi-publish: 158 | if: ${{ inputs.include-pypi }} 159 | needs: [linux, musllinux, windows, macos, sdist] 160 | environment: 161 | name: release-python 162 | url: https://pypi.org/project/junction-python/ 163 | runs-on: ubuntu-latest 164 | permissions: 165 | id-token: write 166 | 167 | steps: 168 | - uses: actions/checkout@v4 169 | - name: download artifacts 170 | uses: actions/download-artifact@v4 171 | with: 172 | path: dist 173 | merge-multiple: true 174 | - name: show artifacts 175 | run: ls -lah dist/* 176 | - name: publish 177 | if: inputs.dry-run == false 178 | uses: pypa/gh-action-pypi-publish@release/v1 179 | with: 180 | verbose: true 181 | 182 | docs-publish: 183 | if: ${{ inputs.include-docs }} 184 | needs: [linux, musllinux, windows, macos, sdist] 185 | runs-on: ubuntu-latest 186 | steps: 187 | - uses: actions/checkout@v4 188 | with: 189 | ref: ${{ inputs.sha }} 190 | 191 | - name: Get version from Cargo.toml 192 | id: version 193 | working-directory: junction-python 194 | run: | 195 | echo "payload=$(cargo xtask version --json -p junction-python)" >> "$GITHUB_OUTPUT" 196 | 197 | - name: Trigger other workflows related to the release 198 | if: inputs.dry-run == false 199 | uses: peter-evans/repository-dispatch@v3 200 | with: 201 | event-type: python-release 202 | client-payload: "${{ steps.version.outputs.payload }}" 203 | -------------------------------------------------------------------------------- /.github/workflows/smoke-test.yml: -------------------------------------------------------------------------------- 1 | # run a smoke test of junction against the latest ezbake image 2 | # 3 | # this builds a wheel at the current version, installs it in the most vanilla 4 | # python image possible, and then runs the tests in a k3d cluster. 5 | # 6 | # at some point it should be possible to re-use this wheel to run pytests 7 | # and combine this job with junction-python-ci. 8 | name: smoke test 9 | 10 | on: 11 | push: 12 | branches: ["main"] 13 | paths: 14 | - "Cargo.toml" 15 | - "Cargo.lock" 16 | - "junction-python/**" 17 | - "crates/**" 18 | - ".github/workflows/smoke-test.yml" 19 | 20 | pull_request: 21 | branches: ["main"] 22 | paths: 23 | - "Cargo.toml" 24 | - "Cargo.lock" 25 | - "junction-python/**" 26 | - "crates/**" 27 | - ".github/workflows/smoke-test.yml" 28 | 29 | env: 30 | CARGO_TERM_COLOR: always 31 | rust_stable: stable 32 | rust_min: 1.79 33 | 34 | jobs: 35 | build-wheel: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | with: 41 | python-version: 3.x 42 | - name: build wheels 43 | uses: PyO3/maturin-action@v1 44 | with: 45 | target: x86_64 46 | args: --release --out dist --manifest-path junction-python/Cargo.toml 47 | sccache: "true" 48 | manylinux: auto 49 | - name: upload wheels 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: wheels-linux-x86_64 53 | path: dist/*.whl 54 | 55 | smoke-test: 56 | runs-on: ubuntu-latest 57 | needs: [build-wheel] 58 | steps: 59 | - name: checkout code 60 | uses: actions/checkout@master 61 | - uses: actions/download-artifact@v4 62 | with: 63 | path: dist 64 | - uses: AbsaOSS/k3d-action@v2 65 | name: "Create single k3d Cluster with imported Registry" 66 | with: 67 | cluster-name: test-cluster 68 | args: >- 69 | --agents 1 70 | --no-lb 71 | --k3s-arg "--no-deploy=traefik,servicelb,metrics-server@server:*" 72 | - name: build docker image 73 | run: | 74 | docker build --tag jct_simple_app:latest \ 75 | --file junction-python/samples/smoke-test/Dockerfile . \ 76 | --build-arg junction_wheel=dist/**/*.whl 77 | k3d image import jct_simple_app:latest -c test-cluster 78 | - name: set up kube manifests 79 | run: | 80 | kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 81 | kubectl apply -f https://github.com/junction-labs/ezbake/releases/latest/download/install-for-cluster.yml 82 | kubectl apply -f junction-python/samples/smoke-test/deploy/client-cluster-role-binding.yml 83 | kubectl apply -f junction-python/samples/smoke-test/deploy/jct-simple-app.yml 84 | - name: wait for rollouts 85 | run: | 86 | kubectl rollout status --watch -n default deploy/jct-simple-app 87 | kubectl rollout status --watch -n default deploy/jct-simple-app-v2 88 | kubectl rollout status --watch -n junction deploy/ezbake 89 | sleep 5 # this is a load-bearing sleep, we're waiting for ezbake to settle down 90 | - name: run tests static mode 91 | run: | 92 | kubectl run jct-client --image=jct_simple_app:latest --image-pull-policy=IfNotPresent --env="JUNCTION_ADS_SERVER=grpc://ezbake.junction.svc.cluster.local:8008" --restart=Never --attach -- python /app/client.py 93 | - name: run tests gateway api mode 94 | run: | 95 | kubectl run jct-client2 --image=jct_simple_app:latest --image-pull-policy=IfNotPresent --env="JUNCTION_ADS_SERVER=grpc://ezbake.junction.svc.cluster.local:8008" --restart=Never --attach -- python /app/client.py --use-gateway-api 96 | - name: debug state 97 | if: ${{ ! cancelled() }} 98 | run: | 99 | docker images -a 100 | docker ps -a 101 | kubectl get pods -o wide --all-namespaces 102 | kubectl get svc -o wide --all-namespaces 103 | kubectl get po -n junction -o jsonpath='{.items[].metadata.name}' | xargs -IQ kubectl logs -n junction Q 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | docs/site 3 | .venv*/ 4 | __pycache__/ 5 | .ruff_cache/ 6 | .pytest_cache/ 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to junction-client 2 | 3 | Thanks for contributing to the junction-client repo! 4 | 5 | ## Code of Conduct 6 | 7 | All Junction Labs repos adhere to the [Rust Code of Conduct][coc], without exception. 8 | 9 | [coc]: https://www.rust-lang.org/policies/code-of-conduct 10 | 11 | ## Required Dependencies 12 | 13 | This project depends on having a working `rust` toolchain. We currently do not have an 14 | MSRV policy. 15 | 16 | Working on `junction-python` or `junction-node` also requires having your own versions 17 | of Python (>=3.8) or NodeJS installed. 18 | 19 | ## Building and Testing 20 | 21 | This repo is managed as a Cargo workspace. Individual client bindings require their own 22 | toolchains and may need to be managed on their own. For everyday development, we glue 23 | things together with [cargo xtask](https://github.com/matklad/cargo-xtask) - if you need 24 | to do something in development, it should be a standard `cargo` command or an `xtask`. 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*", "junction-node", "junction-python", "xtask"] 3 | default-members = ["crates/*"] 4 | resolver = "2" 5 | 6 | [workspace.package] 7 | version = "0.3.2" 8 | edition = "2021" 9 | homepage = "https://junctionlabs.io" 10 | repository = "https://github.com/junction-labs/junction-client" 11 | license = "Apache-2.0" 12 | 13 | # kube's msrv keeps climbing. we're picking this up through the transitive dep 14 | # on `home`. once we drop the direct kube dependency, try downgrading. 15 | rust-version = "1.81" 16 | 17 | [workspace.dependencies] 18 | arc-swap = "1.7" 19 | bytes = "1.7" 20 | crossbeam-skiplist = "0.1" 21 | enum-map = "2.7" 22 | form_urlencoded = "1.1.1" 23 | futures = "0.3" 24 | h2 = "0.3" 25 | http = "1.1" 26 | tokio = { version = "1.40", default-features = false } 27 | tokio-stream = "0.1" 28 | tonic = "0.12" 29 | tonic-reflection = "0.12" 30 | once_cell = "1.21" 31 | petgraph = "0.6" 32 | prost = "0.13" 33 | rand = "0.8" 34 | regex = "1.11.1" 35 | serde = { version = "1.0", default-features = false } 36 | serde_json = "1.0" 37 | serde_yml = "0.0.12" 38 | smol_str = "0.3" 39 | thiserror = "2.0" 40 | tracing = "0.1" 41 | tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 42 | xds-api = { version = "0.2", default-features = false} 43 | 44 | xxhash-rust = { version = "0.8.15", features = ["xxh64"] } 45 | 46 | junction-api = { version = "0.3.2", path = "crates/junction-api" } 47 | junction-core = { version = "0.3.2", path = "crates/junction-core" } 48 | junction-typeinfo = { version = "0.3.2", path = "crates/junction-typeinfo" } 49 | junction-typeinfo-derive = { version = "0.3.2", path = "crates/junction-typeinfo-derive" } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | An xDS dynamically-configurable API load-balancer library. 4 | 5 | ## What is it? 6 | 7 | Junction is a library that allows you to dynamically configure application 8 | level HTTP routing, load balancing, and resilience by writing a few lines of 9 | configuration and dynamically pushing it to your client. Imagine all of the 10 | features of a rich HTTP proxy that's as easy to work with as the HTTP library 11 | you're already using. 12 | 13 | Junction does that by pulling endpoints and configuration from an [xDS] 14 | control plane, as follows: 15 | 16 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 17 | 18 | 19 | ``` 20 | ┌─────────────────────┐ 21 | │ Client Service │ 22 | ├──────────┬──────────┤ ┌───────────┐ 23 | │ Existing │ Junction ◄────┤ xDS │ 24 | │ HTTP │ Client │ │ Control │ 25 | │ Library │ Library │ │ Plane │ 26 | └────┬─┬───┴──────────┘ └─────▲─────┘ 27 | │ │ │ 28 | │ └──────────┐ │ 29 | ┌────▼────┐ ┌────▼────┐ ┌─────┴─────┐ 30 | │ Your │ │ Your │ │ K8s API │ 31 | │ Service │ │ Service │ │ Server │ 32 | └─────────┘ └─────────┘ └───────────┘ 33 | ``` 34 | 35 | Junction is developed by [Junction Labs](https://www.junctionlabs.io/). 36 | 37 | ## Features 38 | 39 | Today, Junction allows you to dynamically configure: 40 | 41 | - Routing traffic based on HTTP method, path, headers, or query parameters 42 | - Timeouts 43 | - Retries 44 | - Weighted traffic splitting 45 | - Load balancing (Ring-Hash or WRR) 46 | 47 | On our roadmap are features like: 48 | 49 | - multi-cluster federation 50 | - zone-based load balancing 51 | - rate limiting 52 | - subsetting 53 | - circuit breaking 54 | 55 | ## Supported xDS Control Planes 56 | 57 | Today the only xDS server the junction-client regression tests against is 58 | [ezbake], developed by Junction Labs. [ezbake] is a simple xDS control plane for 59 | Junction, which uses the [gateway_api] to support dynamic configuration. 60 | `ezbake` runs in a Kubernetes cluster, watches its running services, and creates 61 | the xds configuration to drive the Junction Client. 62 | 63 | [ezbake]: https://github.com/junction-labs/ezbake 64 | [gateway_api]: https://gateway-api.sigs.k8s.io/ 65 | 66 | ## Supported languages and HTTP Libraries 67 | 68 | | Language | Integrated HTTP Libraries | 69 | |-------------|---------------------------| 70 | | [Rust] | None | 71 | | [Python] | [requests], [urllib3] | 72 | | [Node.js] | [fetch()] | 73 | 74 | [Rust]: https://docs.junctionlabs.io/getting-started/rust 75 | [Python]: https://docs.junctionlabs.io/getting-started/python 76 | [Node.js]: https://docs.junctionlabs.io/getting-started/node 77 | [requests]: https://pypi.org/project/requests/ 78 | [urllib3]: https://github.com/urllib3/urllib3 79 | [fetch()]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch 80 | 81 | ## Getting started 82 | 83 | [See here](https://docs.junctionlabs.io/getting-started/) 84 | 85 | ## Project status 86 | 87 | Junction is alpha software, developed in the open. We're still iterating rapidly 88 | on our client facing API, and our integration into xDS. At this stage you should 89 | expect occasional breaking changes as the library evolves. 90 | 91 | ## License 92 | 93 | The Junction client is [Apache 2.0 licensed](https://github.com/junction-labs/junction-client/blob/main/LICENSE). 94 | 95 | ## Contact Us 96 | 97 | [info@junctionlabs.io](mailto:info@junctionlabs.io) 98 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | junction-node/biome.json -------------------------------------------------------------------------------- /crates/README.md: -------------------------------------------------------------------------------- 1 | # junction-client 2 | 3 | An xDS dynamically-configurable API load-balancer library. 4 | 5 | * [Getting Started](https://docs.junctionlabs.io/getting-started/rust) 6 | 7 | ## Junction Core [![Latest Version](https://img.shields.io/crates/v/junction-core.svg)](https://crates.io/crates/junction-core) 8 | 9 | The core implementation for Junction. 10 | 11 | * [Documentation](https://docs.rs/junction-core) 12 | 13 | ## Junction API [![Latest Version](https://img.shields.io/crates/v/junction-api.svg)](https://crates.io/crates/junction-api) 14 | 15 | Common API Types for Junction. 16 | 17 | * [Documentation](https://docs.rs/junction-api) 18 | -------------------------------------------------------------------------------- /crates/junction-api-gen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-api-gen" 3 | version.workspace = true 4 | edition.workspace = true 5 | homepage.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | rust-version.workspace = true 9 | description = """ 10 | cross-language API generation for Junction 11 | """ 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | askama = "0.12" 16 | regex = { workspace = true } 17 | once_cell = { workspace = true } 18 | 19 | junction-typeinfo = { workspace = true } 20 | junction-api = { workspace = true, features = ["typeinfo"] } 21 | -------------------------------------------------------------------------------- /crates/junction-api-gen/src/main.rs: -------------------------------------------------------------------------------- 1 | mod python; 2 | 3 | use junction_typeinfo::TypeInfo as _; 4 | 5 | /// Code generation tools for cross-language APIs that can't (or shouldn't) be 6 | /// expressed through FFI. 7 | /// 8 | /// This is not a general purpose tool. 9 | fn main() { 10 | let items = vec![ 11 | junction_api::Service::item(), 12 | junction_api::Fraction::item(), 13 | junction_api::http::BackendRef::item(), 14 | junction_api::http::RouteTimeouts::item(), 15 | junction_api::http::RouteRetry::item(), 16 | junction_api::http::HeaderValue::item(), 17 | junction_api::http::HeaderMatch::item(), 18 | junction_api::http::QueryParamMatch::item(), 19 | junction_api::http::PathMatch::item(), 20 | junction_api::http::RouteMatch::item(), 21 | // NOTE: filters are currently hidden from the docs, don't generate 22 | // typing info for them. 23 | // 24 | // junction_api::http::HeaderFilter::item(), 25 | // junction_api::http::RequestMirrorFilter::item(), 26 | // junction_api::http::PathModifier::item(), 27 | // junction_api::http::RequestRedirectFilter::item(), 28 | // junction_api::http::UrlRewriteFilter::item(), 29 | // junction_api::http::RouteFilter::item(), 30 | junction_api::http::RouteRule::item(), 31 | junction_api::http::Route::item(), 32 | junction_api::backend::BackendId::item(), 33 | junction_api::backend::RequestHashPolicy::item(), 34 | junction_api::backend::LbPolicy::item(), 35 | junction_api::backend::Backend::item(), 36 | ]; 37 | 38 | let mut buf = String::with_capacity(4 * 1024); 39 | python::generate(&mut buf, items).unwrap(); 40 | println!("{buf}"); 41 | } 42 | -------------------------------------------------------------------------------- /crates/junction-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-api" 3 | edition = "2021" 4 | description = """ 5 | Common API Types for Junction - an xDS dynamically-configurable API load-balancer library. 6 | """ 7 | version.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | license.workspace = true 11 | categories = ["api-bindings", "network-programming"] 12 | rust-version.workspace = true 13 | 14 | [dependencies] 15 | http = { workspace = true } 16 | regex = { workspace = true } 17 | serde = { workspace = true, default-features = false, features = ["derive"] } 18 | serde_json = { workspace = true } 19 | smol_str = { workspace = true } 20 | thiserror = { workspace = true } 21 | once_cell = { workspace = true } 22 | 23 | # typeinfo 24 | junction-typeinfo = { workspace = true, optional = true } 25 | 26 | # xds 27 | xds-api = { workspace = true, default-features = false, optional = true } 28 | 29 | # kube 30 | # 31 | # k8s-openapi and the gateway-api crates have to be kept in sync, or we'll have 32 | # a bad time with multiple versions of types and feature flags won't be set. 33 | k8s-openapi = { version = "=0.23", optional = true } 34 | gateway-api = { version = "=0.14.0", optional = true } 35 | kube = { version = "0.96", optional = true } 36 | 37 | [dev-dependencies] 38 | serde_yml = "0.0.12" 39 | arbtest = "0.3" 40 | arbitrary = "1.3" 41 | rand = "0.8" 42 | 43 | [features] 44 | default = [] 45 | 46 | # enable reflection. this is intended to only be used at build time to generate 47 | # cross-language APIs. 48 | typeinfo = ["dep:junction-typeinfo"] 49 | 50 | 51 | # enable xds conversion for core types. 52 | xds = ["dep:xds-api"] 53 | 54 | # enable kube conversion for core types. this is a shorthand for the minimum 55 | # available kube_v1_* feature. 56 | kube = ["kube_v1_29"] 57 | 58 | # each of these flags sets a MINIMUM version of the kubernetes API to be compatible 59 | # with. 60 | # 61 | # because k8s-openapi does kube version selection with Cargo features, we're 62 | # priced in to doing it as well. doing it this way means downstream crates don't 63 | # have to ALSO depend on k8s-openapi and pick exactly the same version/feature 64 | # flag combination. 65 | kube_v1_29 = [ 66 | "dep:kube", 67 | "dep:gateway-api", 68 | "dep:gateway-api", 69 | "k8s-openapi/v1_29", 70 | ] 71 | kube_v1_30 = [ 72 | "dep:kube", 73 | "dep:gateway-api", 74 | "dep:gateway-api", 75 | "k8s-openapi/v1_30", 76 | ] 77 | kube_v1_31 = [ 78 | "dep:kube", 79 | "dep:gateway-api", 80 | "dep:gateway-api", 81 | "k8s-openapi/v1_31", 82 | ] 83 | -------------------------------------------------------------------------------- /crates/junction-api/README.md: -------------------------------------------------------------------------------- 1 | # junction-api 2 | 3 | API configuration types for Junction. Check out the 4 | [Junction documentation](https//docs.junctionlabs.io) for more information on 5 | what Junction is and how to get started. 6 | -------------------------------------------------------------------------------- /crates/junction-api/src/backend.rs: -------------------------------------------------------------------------------- 1 | //! Backends are the logical target of network traffic. They have an identity and 2 | //! a load-balancing policy. See [Backend] to get started. 3 | 4 | use crate::{Error, Service}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[cfg(feature = "typeinfo")] 8 | use junction_typeinfo::TypeInfo; 9 | 10 | /// A Backend is uniquely identifiable by a combination of Service and port. 11 | /// 12 | /// [Backend][crate::backend::Backend]. 13 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] 14 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 15 | pub struct BackendId { 16 | /// The logical traffic target that this backend configures. 17 | #[serde(flatten)] 18 | pub service: Service, 19 | 20 | /// The port backend traffic is sent on. 21 | pub port: u16, 22 | } 23 | 24 | impl std::fmt::Display for BackendId { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | self.write_name(f) 27 | } 28 | } 29 | 30 | impl std::str::FromStr for BackendId { 31 | type Err = Error; 32 | 33 | fn from_str(name: &str) -> Result { 34 | let (name, port) = super::parse_port(name)?; 35 | let port = 36 | port.ok_or_else(|| Error::new_static("expected a fully qualified name with a port"))?; 37 | let service = Service::from_str(name)?; 38 | 39 | Ok(Self { service, port }) 40 | } 41 | } 42 | 43 | impl BackendId { 44 | /// The cannonical name of this ID. This is an alias for the 45 | /// [Display][std::fmt::Display] representation of this ID. 46 | pub fn name(&self) -> String { 47 | let mut buf = String::new(); 48 | self.write_name(&mut buf).unwrap(); 49 | buf 50 | } 51 | 52 | fn write_name(&self, w: &mut impl std::fmt::Write) -> std::fmt::Result { 53 | self.service.write_name(w)?; 54 | write!(w, ":{port}", port = self.port)?; 55 | 56 | Ok(()) 57 | } 58 | 59 | #[doc(hidden)] 60 | pub fn lb_config_route_name(&self) -> String { 61 | let mut buf = String::new(); 62 | self.write_lb_config_route_name(&mut buf).unwrap(); 63 | buf 64 | } 65 | 66 | fn write_lb_config_route_name(&self, w: &mut impl std::fmt::Write) -> std::fmt::Result { 67 | self.service.write_lb_config_route_name(w)?; 68 | write!(w, ":{port}", port = self.port)?; 69 | Ok(()) 70 | } 71 | 72 | #[doc(hidden)] 73 | pub fn from_lb_config_route_name(name: &str) -> Result { 74 | let (name, port) = super::parse_port(name)?; 75 | let port = 76 | port.ok_or_else(|| Error::new_static("expected a fully qualified name with a port"))?; 77 | 78 | let target = Service::from_lb_config_route_name(name)?; 79 | 80 | Ok(Self { 81 | service: target, 82 | port, 83 | }) 84 | } 85 | } 86 | 87 | /// A Backend is a logical target for network traffic. 88 | /// 89 | /// A backend configures how all traffic for its `target` is handled. Any 90 | /// traffic routed to this backend will use the configured load balancing policy 91 | /// to spread traffic across available endpoints. 92 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 93 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 94 | pub struct Backend { 95 | /// A unique identifier for this backend. 96 | pub id: BackendId, 97 | 98 | /// How traffic to this target should be load balanced. 99 | pub lb: LbPolicy, 100 | } 101 | 102 | // TODO: figure out how we want to support the filter_state/connection_properties style of hashing 103 | // based on source ip or grpc channel. 104 | // 105 | // TODO: add support for query parameter based hashing, which involves parsing query parameters, 106 | // which http::uri just doesn't do. switch the whole crate to url::Url or something. 107 | // 108 | // TODO: Random, Maglev 109 | // 110 | /// A policy describing how traffic to this target should be load balanced. 111 | #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] 112 | #[serde(tag = "type")] 113 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 114 | pub enum LbPolicy { 115 | /// A simple round robin load balancing policy. Endpoints are picked in sequential order, but 116 | /// that order may vary client to client. 117 | RoundRobin, 118 | 119 | /// Use a ketama-style consistent hashing algorithm to route this request. 120 | RingHash(RingHashParams), 121 | 122 | /// No load balancing algorithm was specified. Clients may decide how load balancing happens 123 | /// for this target. 124 | #[default] 125 | Unspecified, 126 | } 127 | 128 | impl LbPolicy { 129 | /// Return `true` if this policy is [LbPolicy::Unspecified]. 130 | pub fn is_unspecified(&self) -> bool { 131 | matches!(self, Self::Unspecified) 132 | } 133 | } 134 | 135 | /// Policy for configuring a ketama-style consistent hashing algorithm. 136 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 137 | #[serde(deny_unknown_fields)] 138 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 139 | pub struct RingHashParams { 140 | /// The minimum size of the hash ring 141 | #[serde(default = "default_min_ring_size", alias = "minRingSize")] 142 | pub min_ring_size: u32, 143 | 144 | /// How to hash an outgoing request into the ring. 145 | /// 146 | /// Hash parameters are applied in order. If the request is missing an input, it has no effect 147 | /// on the final hash. Hashing stops when only when all polices have been applied or a 148 | /// `terminal` policy matches part of an incoming request. 149 | /// 150 | /// This allows configuring a fallback-style hash, where the value of `HeaderA` gets used, 151 | /// falling back to the value of `HeaderB`. 152 | /// 153 | /// If no policies match, a random hash is generated for each request. 154 | #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "hashParams")] 155 | pub hash_params: Vec, 156 | } 157 | 158 | pub(crate) const fn default_min_ring_size() -> u32 { 159 | 1024 160 | } 161 | 162 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 163 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 164 | pub struct RequestHashPolicy { 165 | /// Whether to stop immediately after hashing this value. 166 | /// 167 | /// This is useful if you want to try to hash a value, and then fall back to 168 | /// another as a default if it wasn't set. 169 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] 170 | pub terminal: bool, 171 | 172 | #[serde(flatten)] 173 | pub hasher: RequestHasher, 174 | } 175 | 176 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 177 | #[serde(tag = "type")] 178 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 179 | pub enum RequestHasher { 180 | /// Hash the value of a header. If the header has multiple values, they will 181 | /// all be used as hash input. 182 | #[serde(alias = "header")] 183 | Header { 184 | /// The name of the header to use as hash input. 185 | name: String, 186 | }, 187 | 188 | /// Hash the value of an HTTP query parameter. 189 | #[serde(alias = "query")] 190 | QueryParam { 191 | /// The name of the query parameter to hash 192 | name: String, 193 | }, 194 | } 195 | 196 | #[cfg(test)] 197 | mod test { 198 | use std::fmt::Debug; 199 | 200 | use serde_json::json; 201 | 202 | use super::*; 203 | 204 | #[test] 205 | fn test_lb_policy_json() { 206 | assert_round_trip::(json!({ 207 | "type":"Unspecified", 208 | })); 209 | assert_round_trip::(json!({ 210 | "type":"RoundRobin", 211 | })); 212 | assert_round_trip::(json!({ 213 | "type":"RingHash", 214 | "min_ring_size": 100, 215 | "hash_params": [ 216 | {"type": "Header", "name": "x-user", "terminal": true}, 217 | {"type": "QueryParam", "name": "u"}, 218 | ] 219 | })); 220 | } 221 | 222 | #[test] 223 | fn test_backend_json() { 224 | assert_round_trip::(json!({ 225 | "id": {"type": "kube", "name": "foo", "namespace": "bar", "port": 789}, 226 | "lb": { 227 | "type": "Unspecified", 228 | }, 229 | })) 230 | } 231 | 232 | #[track_caller] 233 | fn assert_round_trip Deserialize<'a>>(value: serde_json::Value) { 234 | let from_json: T = serde_json::from_value(value.clone()).expect("failed to deserialize"); 235 | let round_tripped = serde_json::to_value(&from_json).expect("failed to serialize"); 236 | 237 | assert_eq!(value, round_tripped, "serialized value should round-trip") 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /crates/junction-api/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fmt::Write as _, str::FromStr}; 2 | 3 | /// An error converting a Junction API type into another type. 4 | /// 5 | /// Errors should be treated as opaque, and contain a message about what went 6 | /// wrong and a jsonpath style path to the field that caused problems. 7 | #[derive(Clone, thiserror::Error)] 8 | pub struct Error { 9 | // an error message 10 | message: String, 11 | 12 | // the reversed path to the field where the conversion error happened. 13 | // 14 | // the leaf of the path is built up at path[0] with the root of the 15 | // struct at the end. see ErrorContext for how this gets done. 16 | path: Vec, 17 | } 18 | 19 | impl std::fmt::Display for Error { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | if !self.path.is_empty() { 22 | write!(f, "{}: ", self.path())?; 23 | } 24 | 25 | f.write_str(&self.message) 26 | } 27 | } 28 | 29 | impl std::fmt::Debug for Error { 30 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 31 | f.debug_struct("Error") 32 | .field("message", &self.message) 33 | .field("path", &self.path()) 34 | .finish() 35 | } 36 | } 37 | 38 | impl Error { 39 | pub fn path(&self) -> String { 40 | path_str(None, self.path.iter().rev()) 41 | } 42 | 43 | /// Create a new error with a static message. 44 | pub(crate) fn new_static(message: &'static str) -> Self { 45 | Self { 46 | message: message.to_string(), 47 | path: vec![], 48 | } 49 | } 50 | } 51 | 52 | // these are handy, but mostly used in xds/kube conversion. don't gate them 53 | // behind feature flags for now, just allow them to be unused. 54 | #[allow(unused)] 55 | impl Error { 56 | /// Create a new error with a message. 57 | pub(crate) fn new(message: String) -> Self { 58 | Self { 59 | message, 60 | path: vec![], 61 | } 62 | } 63 | 64 | /// Append a new field to this error's path. 65 | pub(crate) fn with_field(mut self, field: &'static str) -> Self { 66 | self.path.push(PathEntry::from(field)); 67 | self 68 | } 69 | 70 | /// Append a new field index to this error's path. 71 | pub(crate) fn with_index(mut self, index: usize) -> Self { 72 | self.path.push(PathEntry::Index(index)); 73 | self 74 | } 75 | } 76 | 77 | /// Join an iterator of PathEntry together into a path string. 78 | /// 79 | /// This isn't quite `entries.join('.')` because index fields exist and have to 80 | /// be bracketed. 81 | pub(crate) fn path_str<'a, I, Iter>(prefix: Option<&'static str>, path: I) -> String 82 | where 83 | I: IntoIterator, 84 | Iter: Iterator + DoubleEndedIterator, 85 | { 86 | let path_iter = path.into_iter(); 87 | // this is a random guess based on the fact that we'll often be allocating 88 | // something, but probably won't ever be allocating much. 89 | let mut buf = String::with_capacity(16 + prefix.map_or(0, |s| s.len())); 90 | 91 | if let Some(prefix) = prefix { 92 | let _ = buf.write_fmt(format_args!("{prefix}/")); 93 | } 94 | 95 | for (i, path_entry) in path_iter.enumerate() { 96 | if i > 0 && path_entry.is_field() { 97 | buf.push('.'); 98 | } 99 | let _ = write!(&mut buf, "{}", path_entry); 100 | } 101 | 102 | buf 103 | } 104 | 105 | /// Add field-path context to an error by appending an entry to its path. Because 106 | /// Context is added at the callsite this means a function can add its own fields 107 | /// and the path ends up in the appropriate order. 108 | /// 109 | /// This trait isn't meant to be implemented, but it's not explicitly sealed 110 | /// because it's only `pub(crate)`. Don't implement it! 111 | /// 112 | /// This trait is mostly used in xds/kube conversions, but leave it available 113 | /// for now. It's not much code and may be helpful for identifying errors in 114 | /// routes etc. 115 | #[allow(unused)] 116 | pub(crate) trait ErrorContext: Sized { 117 | fn with_field(self, field: &'static str) -> Result; 118 | fn with_index(self, index: usize) -> Result; 119 | 120 | /// Shorthand for `with_field(b).with_field(a)` but in a more intuitive 121 | /// order. 122 | fn with_fields(self, a: &'static str, b: &'static str) -> Result { 123 | self.with_field(b).with_field(a) 124 | } 125 | 126 | /// Shorthand for `with_index(idx).with_field(name)`, but in a slightly more 127 | /// inutitive order. 128 | fn with_field_index(self, field: &'static str, index: usize) -> Result { 129 | self.with_index(index).with_field(field) 130 | } 131 | } 132 | 133 | /// A JSON-path style path entry. An entry is either a field name or an index 134 | /// into a sequence. 135 | #[derive(Debug, PartialEq, Eq, Clone)] 136 | pub(crate) enum PathEntry { 137 | Field(Cow<'static, str>), 138 | Index(usize), 139 | } 140 | 141 | impl PathEntry { 142 | fn is_field(&self) -> bool { 143 | matches!(self, PathEntry::Field(_)) 144 | } 145 | } 146 | 147 | impl std::fmt::Display for PathEntry { 148 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 149 | match self { 150 | PathEntry::Field(field) => f.write_str(field), 151 | PathEntry::Index(idx) => f.write_fmt(format_args!("[{idx}]")), 152 | } 153 | } 154 | } 155 | 156 | impl FromStr for PathEntry { 157 | type Err = &'static str; 158 | 159 | fn from_str(s: &str) -> Result { 160 | // an index is always at least 3 chars, starts with [ and ends with ] 161 | if s.starts_with('[') { 162 | if s.len() <= 2 || !s.ends_with(']') { 163 | return Err("invalid field index: missing closing bracket"); 164 | } 165 | 166 | // safety: we know the first and last chars are [] so it's safe to 167 | // slice single bytes off the front and back. 168 | let idx_str = &s[1..s.len() - 1]; 169 | let idx = idx_str 170 | .parse() 171 | .map_err(|_| "invalid field index: field index must be a number")?; 172 | 173 | return Ok(PathEntry::Index(idx)); 174 | } 175 | 176 | // parse anything that's not an index as a field name 177 | Ok(PathEntry::from(s.to_string())) 178 | } 179 | } 180 | 181 | impl From for PathEntry { 182 | fn from(value: String) -> Self { 183 | PathEntry::Field(Cow::Owned(value)) 184 | } 185 | } 186 | 187 | impl From<&'static str> for PathEntry { 188 | fn from(value: &'static str) -> Self { 189 | PathEntry::Field(Cow::Borrowed(value)) 190 | } 191 | } 192 | 193 | impl ErrorContext for Result { 194 | fn with_field(self, field: &'static str) -> Result { 195 | match self { 196 | Ok(v) => Ok(v), 197 | Err(err) => Err(err.with_field(field)), 198 | } 199 | } 200 | 201 | fn with_index(self, index: usize) -> Result { 202 | match self { 203 | Ok(v) => Ok(v), 204 | Err(err) => Err(err.with_index(index)), 205 | } 206 | } 207 | } 208 | 209 | #[cfg(test)] 210 | mod test { 211 | use super::*; 212 | 213 | #[test] 214 | fn test_error_message() { 215 | fn baz() -> Result<(), Error> { 216 | Err(Error::new_static("it broke")) 217 | } 218 | 219 | fn bar() -> Result<(), Error> { 220 | baz().with_field_index("baz", 2) 221 | } 222 | 223 | fn foo() -> Result<(), Error> { 224 | bar().with_field("bar") 225 | } 226 | 227 | assert_eq!(foo().unwrap_err().to_string(), "bar.baz[2]: it broke",) 228 | } 229 | 230 | #[test] 231 | fn test_path_strings() { 232 | let path = &[ 233 | PathEntry::Index(0), 234 | PathEntry::from("hi"), 235 | PathEntry::from("dr"), 236 | PathEntry::Index(2), 237 | PathEntry::from("nick"), 238 | ]; 239 | let string = "[0].hi.dr[2].nick"; 240 | assert_eq!(path_str(None, path), string); 241 | 242 | let path = &[ 243 | PathEntry::from("hi"), 244 | PathEntry::from("dr"), 245 | PathEntry::from("nick"), 246 | ]; 247 | let string = "prefix/hi.dr.nick"; 248 | assert_eq!(path_str(Some("prefix"), path), string); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /crates/junction-api/src/kube.rs: -------------------------------------------------------------------------------- 1 | //! Types and re-exports for converting Junction types to Kubernetes objects. 2 | //! 3 | //! When the `kube` feature of this crate is active, 4 | //! [Route](crate::http::Route)s and [Backend](crate::backend::Backend)s can be 5 | //! converted into Kubernetes API types. To help avoid dependency conflicts with 6 | //! different versions of [`k8s-openapi`](https://crates.io/crates/k8s-openapi) 7 | //! and [`gateway-api`](https://crates.io/crates/gateway-api), this crate 8 | //! re-exports its versions of those dependencies. 9 | //! 10 | //! ```no_run 11 | //! // Import re-exported deps to make sure versions match 12 | //! use junction_api::kube::k8s_openapi; 13 | //! 14 | //! // Use the re-exported version as normal 15 | //! use k8s_openapi::api::core::v1::PodSpec; 16 | //! let spec = PodSpec::default(); 17 | //! ``` 18 | //! 19 | //! This crate does not set a [`k8s-openapi` version feature](https://docs.rs/k8s-openapi/latest/k8s_openapi/#crate-features), 20 | //! application authors (but not library authors!) who depend on `junction-api` 21 | //! with the `kube` feature enabled will still need to include `k8s-openapi` as 22 | //! a direct dependency and set the appropriate cargo feature. 23 | 24 | mod backend; 25 | mod http; 26 | 27 | pub use gateway_api; 28 | pub use k8s_openapi; 29 | -------------------------------------------------------------------------------- /crates/junction-api/src/shared.rs: -------------------------------------------------------------------------------- 1 | //! Shared configuration. 2 | 3 | use core::fmt; 4 | use serde::de::{self, Visitor}; 5 | use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; 6 | use std::str::FromStr; 7 | use std::time::Duration as StdDuration; 8 | 9 | #[cfg(feature = "typeinfo")] 10 | use junction_typeinfo::TypeInfo; 11 | 12 | /// A fraction, expressed as a numerator and a denominator. 13 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 14 | #[cfg_attr(feature = "typeinfo", derive(TypeInfo))] 15 | pub struct Fraction { 16 | pub numerator: i32, 17 | 18 | #[serde(default, skip_serializing_if = "Option::is_none")] 19 | pub denominator: Option, 20 | } 21 | 22 | /// A regular expression. 23 | /// 24 | /// `Regex` has same syntax and semantics as Rust's [`regex` crate](https://docs.rs/regex/latest/regex/). 25 | #[derive(Clone)] 26 | pub struct Regex(regex::Regex); 27 | 28 | impl PartialEq for Regex { 29 | fn eq(&self, other: &Self) -> bool { 30 | self.0.as_str() == other.0.as_str() 31 | } 32 | } 33 | 34 | impl Eq for Regex {} 35 | 36 | impl std::fmt::Debug for Regex { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 38 | f.write_str(self.0.as_str()) 39 | } 40 | } 41 | 42 | impl std::ops::Deref for Regex { 43 | type Target = regex::Regex; 44 | 45 | fn deref(&self) -> &Self::Target { 46 | &self.0 47 | } 48 | } 49 | 50 | impl AsRef for Regex { 51 | fn as_ref(&self) -> ®ex::Regex { 52 | &self.0 53 | } 54 | } 55 | 56 | impl FromStr for Regex { 57 | type Err = String; 58 | 59 | fn from_str(s: &str) -> Result { 60 | match regex::Regex::try_from(s) { 61 | Ok(e) => Ok(Self(e)), 62 | Err(e) => Err(e.to_string()), 63 | } 64 | } 65 | } 66 | 67 | #[cfg(feature = "typeinfo")] 68 | impl TypeInfo for Regex { 69 | fn kind() -> junction_typeinfo::Kind { 70 | junction_typeinfo::Kind::String 71 | } 72 | } 73 | 74 | impl serde::Serialize for Regex { 75 | fn serialize(&self, serializer: S) -> Result 76 | where 77 | S: serde::Serializer, 78 | { 79 | serializer.serialize_str(self.0.as_str()) 80 | } 81 | } 82 | 83 | impl<'de> Deserialize<'de> for Regex { 84 | fn deserialize(deserializer: D) -> Result 85 | where 86 | D: Deserializer<'de>, 87 | { 88 | struct RegexVisitor; 89 | 90 | impl Visitor<'_> for RegexVisitor { 91 | type Value = Regex; 92 | 93 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 94 | formatter.write_str("a Regex") 95 | } 96 | 97 | fn visit_str(self, value: &str) -> Result 98 | where 99 | E: de::Error, 100 | { 101 | match Regex::from_str(value) { 102 | Ok(s) => Ok(s), 103 | Err(e) => Err(E::custom(format!("could not parse {}: {}", value, e))), 104 | } 105 | } 106 | } 107 | deserializer.deserialize_string(RegexVisitor) 108 | } 109 | } 110 | 111 | /// A wrapper around [std::time::Duration] that serializes to and from a f64 112 | /// number of seconds. 113 | #[derive(Copy, Clone, PartialEq, Eq)] 114 | pub struct Duration(StdDuration); 115 | 116 | impl Duration { 117 | pub const fn new(secs: u64, nanos: u32) -> Duration { 118 | Duration(StdDuration::new(secs, nanos)) 119 | } 120 | } 121 | 122 | impl AsRef for Duration { 123 | fn as_ref(&self) -> &StdDuration { 124 | &self.0 125 | } 126 | } 127 | 128 | impl std::ops::Deref for Duration { 129 | type Target = StdDuration; 130 | 131 | fn deref(&self) -> &Self::Target { 132 | &self.0 133 | } 134 | } 135 | 136 | impl std::fmt::Debug for Duration { 137 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 138 | self.0.fmt(f) 139 | } 140 | } 141 | 142 | impl std::fmt::Display for Duration { 143 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 144 | write!(f, "{}", self.as_secs_f64()) 145 | } 146 | } 147 | 148 | #[cfg(feature = "typeinfo")] 149 | impl TypeInfo for Duration { 150 | fn kind() -> junction_typeinfo::Kind { 151 | junction_typeinfo::Kind::Duration 152 | } 153 | } 154 | 155 | impl From for StdDuration { 156 | fn from(val: Duration) -> Self { 157 | val.0 158 | } 159 | } 160 | 161 | impl From for Duration { 162 | fn from(duration: StdDuration) -> Self { 163 | Duration(duration) 164 | } 165 | } 166 | 167 | macro_rules! duration_from { 168 | ($($(#[$attr:meta])* $method:ident: $arg:ty),* $(,)*) => { 169 | impl Duration { 170 | $( 171 | $(#[$attr])* 172 | pub fn $method(val: $arg) -> Self { 173 | Duration(StdDuration::$method(val)) 174 | } 175 | )* 176 | } 177 | }; 178 | } 179 | 180 | duration_from! { 181 | /// Create a new `Duration` from a whole number of seconds. See 182 | /// [Duration::from_secs][std::time::Duration::from_secs]. 183 | from_secs: u64, 184 | 185 | /// Create a new `Duration` from a whole number of milliseconds. See 186 | /// [Duration::from_millis][std::time::Duration::from_millis]. 187 | from_millis: u64, 188 | 189 | /// Create a new `Duration` from a whole number of microseconds. See 190 | /// [Duration::from_micros][std::time::Duration::from_micros]. 191 | from_micros: u64, 192 | 193 | /// Create a new `Duration` from a floating point number of seconds. See 194 | /// [Duration::from_secs_f32][std::time::Duration::from_secs_f32]. 195 | from_secs_f32: f32, 196 | 197 | 198 | /// Create a new `Duration` from a floating point number of seconds. See 199 | /// [Duration::from_secs_f64][std::time::Duration::from_secs_f64]. 200 | from_secs_f64: f64, 201 | } 202 | 203 | impl Serialize for Duration { 204 | fn serialize(&self, serializer: S) -> Result 205 | where 206 | S: Serializer, 207 | { 208 | serializer.serialize_f64(self.as_secs_f64()) 209 | } 210 | } 211 | 212 | impl<'de> Deserialize<'de> for Duration { 213 | fn deserialize(deserializer: D) -> Result 214 | where 215 | D: Deserializer<'de>, 216 | { 217 | // deserialize as a number of seconds, may be any int or float 218 | // 219 | // https://serde.rs/string-or-struct.html 220 | struct DurationVisitor; 221 | 222 | impl Visitor<'_> for DurationVisitor { 223 | type Value = Duration; 224 | 225 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 226 | formatter.write_str("a Duration expressed as a number of seconds") 227 | } 228 | 229 | fn visit_f64(self, v: f64) -> Result 230 | where 231 | E: de::Error, 232 | { 233 | Ok(Duration::from(StdDuration::from_secs_f64(v))) 234 | } 235 | 236 | fn visit_u64(self, v: u64) -> Result 237 | where 238 | E: de::Error, 239 | { 240 | Ok(Duration::from(StdDuration::from_secs(v))) 241 | } 242 | 243 | fn visit_i64(self, v: i64) -> Result 244 | where 245 | E: de::Error, 246 | { 247 | let v: u64 = v 248 | .try_into() 249 | .map_err(|_| E::custom("Duration cannot be negative"))?; 250 | 251 | Ok(Duration::from(StdDuration::from_secs(v))) 252 | } 253 | } 254 | 255 | deserializer.deserialize_any(DurationVisitor) 256 | } 257 | } 258 | 259 | #[cfg(test)] 260 | mod test_duration { 261 | use super::*; 262 | 263 | #[test] 264 | /// Duration should deserialize from strings, an int number of seconds, or a 265 | /// float number of seconds. 266 | fn test_duration_deserialize() { 267 | #[derive(Debug, Deserialize, PartialEq, Eq)] 268 | struct SomeValue { 269 | float_duration: Duration, 270 | int_duration: Duration, 271 | } 272 | 273 | let value = serde_json::json!({ 274 | "string_duration": "1h23m4s", 275 | "float_duration": 1.234, 276 | "int_duration": 1234, 277 | }); 278 | 279 | assert_eq!( 280 | serde_json::from_value::(value).unwrap(), 281 | SomeValue { 282 | float_duration: Duration::from_millis(1234), 283 | int_duration: Duration::from_secs(1234), 284 | } 285 | ); 286 | } 287 | 288 | #[test] 289 | /// Duration should always serialize to the string representation. 290 | fn test_duration_serialize() { 291 | #[derive(Debug, Serialize, PartialEq, Eq)] 292 | struct SomeValue { 293 | duration: Duration, 294 | } 295 | 296 | assert_eq!( 297 | serde_json::json!({ 298 | "duration": 123.456, 299 | }), 300 | serde_json::to_value(SomeValue { 301 | duration: Duration::from_secs_f64(123.456), 302 | }) 303 | .unwrap(), 304 | ); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /crates/junction-api/src/xds.rs: -------------------------------------------------------------------------------- 1 | mod backend; 2 | mod http; 3 | mod shared; 4 | 5 | use xds_api::pb::envoy::config::core::v3 as xds_core; 6 | 7 | pub(crate) fn ads_config_source() -> xds_core::ConfigSource { 8 | xds_core::ConfigSource { 9 | config_source_specifier: Some(xds_core::config_source::ConfigSourceSpecifier::Ads( 10 | xds_core::AggregatedConfigSource {}, 11 | )), 12 | resource_api_version: xds_core::ApiVersion::V3 as i32, 13 | ..Default::default() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /crates/junction-api/src/xds/shared.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use crate::{error::Error, shared::Regex, Duration}; 4 | use std::time::Duration as StdDuration; 5 | 6 | pub(crate) fn parse_xds_regex( 7 | p: &xds_api::pb::envoy::r#type::matcher::v3::RegexMatcher, 8 | ) -> Result { 9 | Regex::from_str(&p.regex).map_err(|e| Error::new(format!("invalid regex: {e}"))) 10 | } 11 | 12 | pub(crate) fn regex_matcher( 13 | regex: &Regex, 14 | ) -> xds_api::pb::envoy::r#type::matcher::v3::RegexMatcher { 15 | xds_api::pb::envoy::r#type::matcher::v3::RegexMatcher { 16 | regex: regex.to_string(), 17 | engine_type: None, 18 | } 19 | } 20 | 21 | impl TryFrom for Duration { 22 | type Error = Error; 23 | 24 | fn try_from( 25 | proto_duration: xds_api::pb::google::protobuf::Duration, 26 | ) -> Result { 27 | let duration: StdDuration = proto_duration 28 | .try_into() 29 | .map_err(|e| Error::new(format!("invalid duration: {e}")))?; 30 | 31 | Ok(duration.into()) 32 | } 33 | } 34 | 35 | impl TryFrom for xds_api::pb::google::protobuf::Duration { 36 | type Error = std::num::TryFromIntError; 37 | 38 | fn try_from(value: Duration) -> Result { 39 | let seconds = value.as_secs().try_into()?; 40 | let nanos = value.subsec_nanos().try_into()?; 41 | Ok(xds_api::pb::google::protobuf::Duration { seconds, nanos }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /crates/junction-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-core" 3 | edition = "2021" 4 | description = """ 5 | The core implementation for Junction - an xDS dynamically-configurable API load-balancer library. 6 | """ 7 | version.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | license.workspace = true 11 | categories = [ 12 | "api-bindings", 13 | "network-programming", 14 | "web-programming::http-client", 15 | ] 16 | 17 | rust-version.workspace = true 18 | 19 | [dependencies] 20 | arc-swap = { workspace = true } 21 | bytes = { workspace = true } 22 | crossbeam-skiplist = { workspace = true } 23 | enum-map = { workspace = true } 24 | form_urlencoded = { workspace = true } 25 | futures = { workspace = true } 26 | h2 = { workspace = true } 27 | http = { workspace = true } 28 | once_cell = { workspace = true } 29 | petgraph = { workspace = true } 30 | prost = { workspace = true } 31 | rand = { workspace = true } 32 | regex = { workspace = true } 33 | serde = { workspace = true, features = ["derive", "rc"] } 34 | serde_json = { workspace = true } 35 | serde_yml = { workspace = true } 36 | smol_str = { workspace = true } 37 | thiserror = { workspace = true } 38 | tokio = { workspace = true, features = ["time", "macros"] } 39 | tokio-stream = { workspace = true } 40 | tonic = { workspace = true } 41 | tonic-reflection = { workspace = true } 42 | tracing = { workspace = true } 43 | xds-api = { workspace = true, features = ["descriptor"] } 44 | xxhash-rust = { workspace = true } 45 | 46 | junction-api = { workspace = true, features = ["xds"] } 47 | 48 | [dev-dependencies] 49 | pretty_assertions = "1.4" 50 | tokio = { workspace = true, features = ["rt-multi-thread"] } 51 | tracing-subscriber = { workspace = true } 52 | -------------------------------------------------------------------------------- /crates/junction-core/README.md: -------------------------------------------------------------------------------- 1 | # junction-core 2 | 3 | A dynamically configurable xDS HTTP client. 4 | 5 | See the [getting started guide](https://docs.junctionlabs.io/getting-started/rust) 6 | guide for more info on how to set up a Junction client. 7 | -------------------------------------------------------------------------------- /crates/junction-core/examples/dns-backend.rs: -------------------------------------------------------------------------------- 1 | use std::{env, time::Duration}; 2 | 3 | use http::Method; 4 | use junction_api::{ 5 | backend::{Backend, LbPolicy}, 6 | Service, 7 | }; 8 | use junction_core::Client; 9 | use tracing_subscriber::EnvFilter; 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | tracing_subscriber::fmt() 14 | .with_env_filter(EnvFilter::from_default_env()) 15 | .init(); 16 | 17 | let server_addr = 18 | env::var("JUNCTION_ADS_SERVER").unwrap_or("http://127.0.0.1:8008".to_string()); 19 | 20 | let client = Client::build( 21 | server_addr, 22 | "example-client".to_string(), 23 | "example-cluster".to_string(), 24 | ) 25 | .await 26 | .unwrap(); 27 | 28 | let httpbin = Service::dns("httpbin.org").unwrap(); 29 | 30 | let backends = vec![ 31 | Backend { 32 | id: httpbin.as_backend_id(80), 33 | lb: LbPolicy::RoundRobin, 34 | }, 35 | Backend { 36 | id: httpbin.as_backend_id(443), 37 | lb: LbPolicy::RoundRobin, 38 | }, 39 | ]; 40 | 41 | let client = client.with_static_config(vec![], backends); 42 | 43 | eprintln!("{:?}", client.dump_routes()); 44 | 45 | let http_url: junction_core::Url = "http://httpbin.org".parse().unwrap(); 46 | let https_url: junction_core::Url = "https://httpbin.org".parse().unwrap(); 47 | let headers = http::HeaderMap::new(); 48 | 49 | loop { 50 | match client.resolve_http(&Method::GET, &http_url, &headers).await { 51 | Ok(endpoint) => { 52 | eprintln!(" http: {:>15}", &endpoint.addr()); 53 | } 54 | Err(e) => eprintln!("http: something went wrong: {e}"), 55 | } 56 | match client 57 | .resolve_http(&Method::GET, &https_url, &headers) 58 | .await 59 | { 60 | Ok(endpoint) => { 61 | eprintln!("https: {:>15}", &endpoint.addr()); 62 | } 63 | Err(e) => eprintln!("https: something went wrong: {e}"), 64 | } 65 | 66 | tokio::time::sleep(Duration::from_secs(2)).await; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /crates/junction-core/examples/get-endpoints.rs: -------------------------------------------------------------------------------- 1 | use http::HeaderValue; 2 | use junction_api::{ 3 | backend::{Backend, LbPolicy}, 4 | http::{BackendRef, HeaderMatch, Route, RouteMatch, RouteRule}, 5 | Name, Regex, Service, 6 | }; 7 | use junction_core::Client; 8 | use std::{env, str::FromStr, time::Duration}; 9 | use tracing_subscriber::EnvFilter; 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | tracing_subscriber::fmt() 14 | .with_env_filter(EnvFilter::from_default_env()) 15 | .init(); 16 | 17 | let server_addr = 18 | env::var("JUNCTION_ADS_SERVER").unwrap_or("http://127.0.0.1:8008".to_string()); 19 | 20 | let client = Client::build( 21 | server_addr, 22 | "example-client".to_string(), 23 | "example-cluster".to_string(), 24 | ) 25 | .await 26 | .unwrap(); 27 | 28 | // spawn a CSDS server that allows inspecting xDS over gRPC while the client 29 | // is running 30 | tokio::spawn(client.clone().csds_server(8009)); 31 | 32 | let nginx = Service::kube("default", "nginx").unwrap(); 33 | let nginx_staging = Service::kube("default", "nginx-staging").unwrap(); 34 | 35 | let routes = vec![Route { 36 | id: Name::from_static("nginx"), 37 | hostnames: vec![nginx.hostname().into()], 38 | ports: vec![], 39 | tags: Default::default(), 40 | rules: vec![ 41 | RouteRule { 42 | matches: vec![RouteMatch { 43 | headers: vec![HeaderMatch::RegularExpression { 44 | name: "x-demo-staging".to_string(), 45 | value: Regex::from_str(".*").unwrap(), 46 | }], 47 | ..Default::default() 48 | }], 49 | backends: vec![BackendRef { 50 | service: nginx_staging.clone(), 51 | port: Some(80), 52 | weight: 1, 53 | }], 54 | ..Default::default() 55 | }, 56 | RouteRule { 57 | backends: vec![BackendRef { 58 | service: nginx.clone(), 59 | port: Some(80), 60 | weight: 1, 61 | }], 62 | ..Default::default() 63 | }, 64 | ], 65 | }]; 66 | let backends = vec![ 67 | Backend { 68 | id: nginx.as_backend_id(80), 69 | lb: LbPolicy::Unspecified, 70 | }, 71 | Backend { 72 | id: nginx_staging.as_backend_id(80), 73 | lb: LbPolicy::Unspecified, 74 | }, 75 | ]; 76 | let client = client.with_static_config(routes, backends); 77 | 78 | let url: junction_core::Url = "https://nginx.default.svc.cluster.local".parse().unwrap(); 79 | let prod_headers = http::HeaderMap::new(); 80 | let staging_headers = { 81 | let mut headers = http::HeaderMap::new(); 82 | headers.insert("x-demo-staging", HeaderValue::from_static("true")); 83 | headers 84 | }; 85 | 86 | loop { 87 | let prod_endpoints = client 88 | .resolve_http(&http::Method::GET, &url, &prod_headers) 89 | .await; 90 | let staging_endpoints = client 91 | .resolve_http(&http::Method::GET, &url, &staging_headers) 92 | .await; 93 | 94 | let mut error = false; 95 | if let Err(e) = &prod_endpoints { 96 | eprintln!("error: prod: {e:?}"); 97 | error = true; 98 | } 99 | if let Err(e) = &staging_endpoints { 100 | eprintln!("error: staging: {e:?}"); 101 | error = true; 102 | } 103 | 104 | if error { 105 | tokio::time::sleep(Duration::from_secs(3)).await; 106 | continue; 107 | } 108 | 109 | let prod = prod_endpoints.unwrap(); 110 | let staging = staging_endpoints.unwrap(); 111 | println!("prod={:<20} staging={:<20}", prod.addr(), staging.addr()); 112 | 113 | tokio::time::sleep(Duration::from_millis(1500)).await; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /crates/junction-core/src/endpoints.rs: -------------------------------------------------------------------------------- 1 | use junction_api::{ 2 | backend::BackendId, 3 | http::{RouteRetry, RouteTimeouts}, 4 | }; 5 | use std::{collections::BTreeMap, net::SocketAddr, sync::Arc}; 6 | 7 | use crate::{error::Trace, hash::thread_local_xxhash, HttpResult}; 8 | 9 | // TODO: move to Client? all these fields can be private then. 10 | // TODO: this is way more than just a resolved endpoint, it's the whole request 11 | // context and the history of any retries that were made. it needs suuuuch a 12 | // better name. 13 | #[derive(Debug, Clone)] 14 | pub struct Endpoint { 15 | // request data 16 | pub(crate) method: http::Method, 17 | pub(crate) url: crate::Url, 18 | pub(crate) headers: http::HeaderMap, 19 | 20 | // matched route info 21 | // TODO: do we need the matched route here???? is it enough to have the name and 22 | // version? is it enough to have it in the trace? 23 | pub(crate) backend: BackendId, 24 | pub(crate) address: SocketAddr, 25 | pub(crate) timeouts: Option, 26 | pub(crate) retry: Option, 27 | 28 | // debugging data 29 | pub(crate) trace: Trace, 30 | pub(crate) previous_addrs: Vec, 31 | } 32 | 33 | impl Endpoint { 34 | pub fn method(&self) -> &http::Method { 35 | &self.method 36 | } 37 | 38 | pub fn url(&self) -> &crate::Url { 39 | &self.url 40 | } 41 | 42 | pub fn headers(&self) -> &http::HeaderMap { 43 | &self.headers 44 | } 45 | 46 | pub fn addr(&self) -> SocketAddr { 47 | self.address 48 | } 49 | 50 | pub fn timeouts(&self) -> &Option { 51 | &self.timeouts 52 | } 53 | 54 | pub fn retry(&self) -> &Option { 55 | &self.retry 56 | } 57 | 58 | pub(crate) fn should_retry(&self, result: HttpResult) -> bool { 59 | let Some(retry) = &self.retry else { 60 | return false; 61 | }; 62 | let Some(allowed) = &retry.attempts else { 63 | return false; 64 | }; 65 | let allowed = *allowed as usize; 66 | 67 | match result { 68 | HttpResult::StatusError(code) if !retry.codes.contains(&code.as_u16()) => return false, 69 | _ => (), 70 | } 71 | 72 | // total number of attempts taken is history + 1 because we include the 73 | // the current addr as an attempt. 74 | let attempts = self.previous_addrs.len() + 1; 75 | 76 | attempts < allowed 77 | } 78 | 79 | // FIXME: lol 80 | pub fn print_trace(&self) { 81 | let start = self.trace.start(); 82 | let mut phase = None; 83 | 84 | for event in self.trace.events() { 85 | if phase != Some(event.phase) { 86 | eprintln!("{:?}", event.phase); 87 | phase = Some(event.phase); 88 | } 89 | 90 | let elapsed = event.at.duration_since(start).as_secs_f64(); 91 | eprint!(" {elapsed:.06}: {name:>16?}", name = event.kind); 92 | if !event.kv.is_empty() { 93 | eprint!(":"); 94 | 95 | for (k, v) in &event.kv { 96 | eprint!(" {k}={v}") 97 | } 98 | } 99 | eprintln!(); 100 | } 101 | } 102 | } 103 | 104 | #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 105 | pub(crate) enum Locality { 106 | Unknown, 107 | #[allow(unused)] 108 | Known(LocalityInfo), 109 | } 110 | 111 | #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 112 | pub(crate) struct LocalityInfo { 113 | pub(crate) region: String, 114 | pub(crate) zone: String, 115 | } 116 | 117 | /// A snapshot of endpoint data. 118 | pub struct EndpointIter { 119 | endpoint_group: Arc, 120 | } 121 | 122 | impl From> for EndpointIter { 123 | fn from(endpoint_group: Arc) -> Self { 124 | Self { endpoint_group } 125 | } 126 | } 127 | 128 | // TODO: add a way to see endpoints grouped by locality. have to decide how 129 | // to publicly expose Locality. 130 | impl EndpointIter { 131 | /// Iterate over all of the addresses in this group, without any locality 132 | /// information. 133 | pub fn addrs(&self) -> impl Iterator { 134 | self.endpoint_group.iter() 135 | } 136 | } 137 | 138 | #[derive(Debug, Default, Hash, PartialEq, Eq)] 139 | pub(crate) struct EndpointGroup { 140 | pub(crate) hash: u64, 141 | endpoints: BTreeMap>, 142 | } 143 | 144 | impl EndpointGroup { 145 | pub(crate) fn new(endpoints: BTreeMap>) -> Self { 146 | let hash = thread_local_xxhash::hash(&endpoints); 147 | Self { hash, endpoints } 148 | } 149 | 150 | pub(crate) fn from_dns_addrs(addrs: impl IntoIterator) -> Self { 151 | let mut endpoints = BTreeMap::new(); 152 | let endpoint_addrs = addrs.into_iter().collect(); 153 | endpoints.insert(Locality::Unknown, endpoint_addrs); 154 | 155 | Self::new(endpoints) 156 | } 157 | 158 | pub(crate) fn len(&self) -> usize { 159 | self.endpoints.values().map(|v| v.len()).sum() 160 | } 161 | 162 | /// Returns an iterator over all endpoints in the group. 163 | /// 164 | /// Iteration order is guaranteed to be stable as long as the EndpointGroup is 165 | /// not modified, and guaranteed to consecutively produce all addresses in a single 166 | /// locality. 167 | pub(crate) fn iter(&self) -> impl Iterator { 168 | self.endpoints.values().flatten() 169 | } 170 | 171 | /// Return the nth address in this group. The order 172 | pub(crate) fn nth(&self, n: usize) -> Option<&SocketAddr> { 173 | let mut n = n; 174 | for endpoints in self.endpoints.values() { 175 | if n < endpoints.len() { 176 | return Some(&endpoints[n]); 177 | } 178 | n -= endpoints.len(); 179 | } 180 | 181 | None 182 | } 183 | } 184 | 185 | #[cfg(test)] 186 | mod test { 187 | use std::net::Ipv4Addr; 188 | 189 | use http::StatusCode; 190 | use junction_api::{Duration, Service}; 191 | 192 | use crate::Url; 193 | 194 | use super::*; 195 | 196 | #[test] 197 | fn test_endpoint_should_retry_no_policy() { 198 | let mut endpoint = new_endpoint(); 199 | endpoint.retry = None; 200 | 201 | assert!(!endpoint.should_retry(HttpResult::StatusFailed)); 202 | assert!(!endpoint.should_retry(HttpResult::StatusError( 203 | http::StatusCode::SERVICE_UNAVAILABLE 204 | ))); 205 | } 206 | 207 | #[test] 208 | fn test_endpoint_should_retry_with_policy() { 209 | let mut endpoint = new_endpoint(); 210 | endpoint.retry = Some(RouteRetry { 211 | codes: vec![StatusCode::BAD_REQUEST.as_u16()], 212 | attempts: Some(3), 213 | backoff: Some(Duration::from_secs(2)), 214 | }); 215 | 216 | assert!(endpoint.should_retry(HttpResult::StatusFailed)); 217 | assert!(endpoint.should_retry(HttpResult::StatusError(StatusCode::BAD_REQUEST))); 218 | assert!(!endpoint.should_retry(HttpResult::StatusError(StatusCode::SERVICE_UNAVAILABLE))); 219 | } 220 | 221 | #[test] 222 | fn test_endpoint_should_retry_with_history() { 223 | let mut endpoint = new_endpoint(); 224 | endpoint.retry = Some(RouteRetry { 225 | codes: vec![StatusCode::BAD_REQUEST.as_u16()], 226 | attempts: Some(3), 227 | backoff: Some(Duration::from_secs(2)), 228 | }); 229 | 230 | // first endpoint was the first attempt 231 | assert!(endpoint.should_retry(HttpResult::StatusFailed)); 232 | assert!(endpoint.should_retry(HttpResult::StatusError(StatusCode::BAD_REQUEST))); 233 | assert!(!endpoint.should_retry(HttpResult::StatusError(StatusCode::SERVICE_UNAVAILABLE))); 234 | 235 | // add on ip to history - this is the second attempt 236 | endpoint 237 | .previous_addrs 238 | .push(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 443)); 239 | assert!(endpoint.should_retry(HttpResult::StatusFailed),); 240 | assert!(endpoint.should_retry(HttpResult::StatusError(StatusCode::BAD_REQUEST)),); 241 | 242 | // two ips in history and one current ip, three attempts have been made, shouldn't retry again 243 | endpoint 244 | .previous_addrs 245 | .push(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 443)); 246 | assert!(!endpoint.should_retry(HttpResult::StatusFailed)); 247 | assert!(!endpoint.should_retry(HttpResult::StatusError(StatusCode::BAD_REQUEST))); 248 | } 249 | 250 | fn new_endpoint() -> Endpoint { 251 | let url: Url = "http://example.com".parse().unwrap(); 252 | let backend = Service::dns(url.hostname()).unwrap().as_backend_id(443); 253 | let address = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 443); 254 | 255 | Endpoint { 256 | method: http::Method::GET, 257 | url, 258 | headers: Default::default(), 259 | backend, 260 | address, 261 | timeouts: None, 262 | retry: None, 263 | trace: Trace::new(), 264 | previous_addrs: vec![], 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /crates/junction-core/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, net::SocketAddr, time::Instant}; 2 | 3 | use junction_api::{backend::BackendId, http::Route, Name}; 4 | use smol_str::{SmolStr, ToSmolStr}; 5 | 6 | #[derive(Clone, Debug)] 7 | pub(crate) struct Trace { 8 | start: Instant, 9 | phase: TracePhase, 10 | events: Vec, 11 | } 12 | 13 | #[derive(Clone, Debug)] 14 | pub(crate) struct TraceEvent { 15 | pub(crate) phase: TracePhase, 16 | pub(crate) kind: TraceEventKind, 17 | pub(crate) at: Instant, 18 | pub(crate) kv: Vec, 19 | } 20 | 21 | type TraceData = (&'static str, SmolStr); 22 | 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 24 | pub(crate) enum TracePhase { 25 | RouteResolution, 26 | EndpointSelection(u8), 27 | } 28 | 29 | #[derive(Clone, Copy, Debug)] 30 | pub(crate) enum TraceEventKind { 31 | RouteLookup, 32 | RouteRuleMatched, 33 | BackendSelected, 34 | BackendLookup, 35 | EndpointsLookup, 36 | SelectAddr, 37 | } 38 | 39 | impl Trace { 40 | pub(crate) fn new() -> Self { 41 | Trace { 42 | start: Instant::now(), 43 | phase: TracePhase::RouteResolution, 44 | events: Vec::new(), 45 | } 46 | } 47 | 48 | pub(crate) fn events(&self) -> impl Iterator { 49 | self.events.iter() 50 | } 51 | 52 | pub(crate) fn start(&self) -> Instant { 53 | self.start 54 | } 55 | 56 | pub(crate) fn lookup_route(&mut self, route: &Route) { 57 | debug_assert!(matches!(self.phase, TracePhase::RouteResolution)); 58 | 59 | self.events.push(TraceEvent { 60 | kind: TraceEventKind::RouteLookup, 61 | phase: TracePhase::RouteResolution, 62 | at: Instant::now(), 63 | kv: vec![("route", route.id.to_smolstr())], 64 | }) 65 | } 66 | 67 | pub(crate) fn matched_rule(&mut self, rule: usize, rule_name: Option<&Name>) { 68 | debug_assert!(matches!(self.phase, TracePhase::RouteResolution)); 69 | 70 | let kv = match rule_name { 71 | Some(name) => vec![("rule-name", name.to_smolstr())], 72 | None => vec![("rule-idx", rule.to_smolstr())], 73 | }; 74 | 75 | self.events.push(TraceEvent { 76 | kind: TraceEventKind::RouteRuleMatched, 77 | phase: TracePhase::RouteResolution, 78 | at: Instant::now(), 79 | kv, 80 | }) 81 | } 82 | 83 | pub(crate) fn select_backend(&mut self, backend: &BackendId) { 84 | debug_assert!(matches!(self.phase, TracePhase::RouteResolution)); 85 | 86 | self.events.push(TraceEvent { 87 | phase: self.phase, 88 | kind: TraceEventKind::BackendSelected, 89 | at: Instant::now(), 90 | kv: vec![("name", backend.to_smolstr())], 91 | }); 92 | } 93 | 94 | pub(crate) fn start_endpoint_selection(&mut self) { 95 | let next_phase = match self.phase { 96 | TracePhase::RouteResolution => TracePhase::EndpointSelection(0), 97 | TracePhase::EndpointSelection(n) => TracePhase::EndpointSelection(n + 1), 98 | }; 99 | self.phase = next_phase; 100 | } 101 | 102 | pub(crate) fn lookup_backend(&mut self, backend: &BackendId) { 103 | debug_assert!(matches!(self.phase, TracePhase::EndpointSelection(_))); 104 | 105 | self.events.push(TraceEvent { 106 | kind: TraceEventKind::BackendLookup, 107 | phase: self.phase, 108 | at: Instant::now(), 109 | kv: vec![("backend-id", backend.to_smolstr())], 110 | }) 111 | } 112 | 113 | pub(crate) fn lookup_endpoints(&mut self, backend: &BackendId) { 114 | debug_assert!(matches!(self.phase, TracePhase::EndpointSelection(_))); 115 | 116 | self.events.push(TraceEvent { 117 | kind: TraceEventKind::EndpointsLookup, 118 | phase: self.phase, 119 | at: Instant::now(), 120 | kv: vec![("backend-id", backend.to_smolstr())], 121 | }) 122 | } 123 | 124 | pub(crate) fn load_balance( 125 | &mut self, 126 | lb_name: &'static str, 127 | addr: Option<&SocketAddr>, 128 | extra: Vec, 129 | ) { 130 | debug_assert!(matches!(self.phase, TracePhase::EndpointSelection(_))); 131 | 132 | let mut kv = Vec::with_capacity(extra.len() + 2); 133 | kv.push(("type", lb_name.to_smolstr())); 134 | kv.push(( 135 | "addr", 136 | addr.map(|a| a.to_smolstr()) 137 | .unwrap_or_else(|| "-".to_smolstr()), 138 | )); 139 | kv.extend(extra); 140 | 141 | self.events.push(TraceEvent { 142 | kind: TraceEventKind::SelectAddr, 143 | phase: self.phase, 144 | at: Instant::now(), 145 | kv, 146 | }); 147 | } 148 | } 149 | 150 | /// A `Result` alias where the `Err` case is `junction_core::Error`. 151 | pub type Result = std::result::Result; 152 | 153 | /// An error when using the Junction client. 154 | #[derive(Debug, thiserror::Error)] 155 | #[error("{inner}")] 156 | pub struct Error { 157 | // a trace of what's happened so far 158 | trace: Option, 159 | 160 | // boxed to keep the size of the error down. this apparently has a large 161 | // effect on the performance of calls to functions that return 162 | // Result<_, Error>. 163 | // 164 | // https://rust-lang.github.io/rust-clippy/master/index.html#result_large_err 165 | // https://docs.rs/serde_json/latest/src/serde_json/error.rs.html#15-20 166 | inner: Box, 167 | } 168 | 169 | impl Error { 170 | /// Returns `true` if this is a temporary error. 171 | /// 172 | /// Temporary errors may occur because of a network timeout or because of 173 | /// lag fetching a configuration from a Junction server. 174 | pub fn is_temporary(&self) -> bool { 175 | matches!(*self.inner, ErrorImpl::NoReachableEndpoints { .. }) 176 | } 177 | } 178 | 179 | impl Error { 180 | // timeouts 181 | 182 | pub(crate) fn timed_out(message: &'static str, trace: Trace) -> Self { 183 | let inner = ErrorImpl::TimedOut(Cow::from(message)); 184 | Self { 185 | trace: Some(trace), 186 | inner: Box::new(inner), 187 | } 188 | } 189 | 190 | // url problems 191 | // 192 | // TODO: should this be a separate type? thye don't need a Trace or anything 193 | 194 | pub(crate) fn into_invalid_url(message: String) -> Self { 195 | let inner = ErrorImpl::InvalidUrl(Cow::Owned(message)); 196 | Self { 197 | trace: None, 198 | inner: Box::new(inner), 199 | } 200 | } 201 | 202 | pub(crate) fn invalid_url(message: &'static str) -> Self { 203 | let inner = ErrorImpl::InvalidUrl(Cow::Borrowed(message)); 204 | Self { 205 | trace: None, 206 | inner: Box::new(inner), 207 | } 208 | } 209 | 210 | // route problems 211 | 212 | pub(crate) fn no_route_matched(authority: String, trace: Trace) -> Self { 213 | Self { 214 | trace: Some(trace), 215 | inner: Box::new(ErrorImpl::NoRouteMatched { authority }), 216 | } 217 | } 218 | 219 | pub(crate) fn no_rule_matched(route: Name, trace: Trace) -> Self { 220 | Self { 221 | trace: Some(trace), 222 | inner: Box::new(ErrorImpl::NoRuleMatched { route }), 223 | } 224 | } 225 | 226 | pub(crate) fn invalid_route( 227 | message: &'static str, 228 | id: Name, 229 | rule: usize, 230 | trace: Trace, 231 | ) -> Self { 232 | Self { 233 | trace: Some(trace), 234 | inner: Box::new(ErrorImpl::InvalidRoute { id, message, rule }), 235 | } 236 | } 237 | 238 | // backend problems 239 | 240 | pub(crate) fn no_backend(backend: BackendId, trace: Trace) -> Self { 241 | Self { 242 | trace: Some(trace), 243 | inner: Box::new(ErrorImpl::NoBackend { backend }), 244 | } 245 | } 246 | 247 | pub(crate) fn no_reachable_endpoints(backend: BackendId, trace: Trace) -> Self { 248 | Self { 249 | trace: Some(trace), 250 | inner: Box::new(ErrorImpl::NoReachableEndpoints { backend }), 251 | } 252 | } 253 | } 254 | 255 | #[derive(Debug, thiserror::Error)] 256 | enum ErrorImpl { 257 | #[error("timed out: {0}")] 258 | TimedOut(Cow<'static, str>), 259 | 260 | #[error("invalid url: {0}")] 261 | InvalidUrl(Cow<'static, str>), 262 | 263 | #[error("invalid route configuration")] 264 | InvalidRoute { 265 | message: &'static str, 266 | id: Name, 267 | rule: usize, 268 | }, 269 | 270 | #[error("no route matched: '{authority}'")] 271 | NoRouteMatched { authority: String }, 272 | 273 | #[error("{route}: no rules matched the request")] 274 | NoRuleMatched { route: Name }, 275 | 276 | #[error("{backend}: backend not found")] 277 | NoBackend { backend: BackendId }, 278 | 279 | #[error("{backend}: no reachable endpoints")] 280 | NoReachableEndpoints { backend: BackendId }, 281 | } 282 | -------------------------------------------------------------------------------- /crates/junction-core/src/hash.rs: -------------------------------------------------------------------------------- 1 | /// Convenience functions for doing things with a thread-local Xxhash hasher. 2 | pub(crate) mod thread_local_xxhash { 3 | use std::cell::RefCell; 4 | use xxhash_rust::xxh64::Xxh64; 5 | 6 | // gRPC and Envoy use a zero seed. gRPC does this by definition, Envoy by 7 | // default. 8 | // 9 | // https://github.com/grpc/proposal/blob/master/A42-xds-ring-hash-lb-policy.md#xdsconfigselector-changes 10 | // https://github.com/envoyproxy/envoy/blob/main/source/common/common/hash.h#L22-L30 11 | // https://github.com/envoyproxy/envoy/blob/main/source/common/http/hash_policy.cc#L69 12 | const SEED: u64 = 0; 13 | 14 | thread_local! { 15 | static HASHER: RefCell = const { RefCell::new(Xxh64::new(SEED)) }; 16 | } 17 | 18 | /// Hash a single item using a thread-local [xx64 Hasher][Xxh64]. 19 | pub(crate) fn hash(h: &H) -> u64 { 20 | HASHER.with_borrow_mut(|hasher| { 21 | hasher.reset(SEED); 22 | h.hash(hasher); 23 | hasher.digest() 24 | }) 25 | } 26 | 27 | /// Hash an iterable of hashable items using a thread-local 28 | /// [xx64 Hasher][Xxh64]. 29 | /// 30 | /// *Note*: Tuples implement [std::hash::Hash], so if you need to hash a 31 | /// sequence of items of different types, try passing a tuple to [hash]. 32 | pub(crate) fn hash_iter, H: std::hash::Hash>(iter: I) -> u64 { 33 | HASHER.with_borrow_mut(|hasher| { 34 | hasher.reset(SEED); 35 | for h in iter { 36 | h.hash(hasher) 37 | } 38 | hasher.digest() 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/junction-core/src/rand.rs: -------------------------------------------------------------------------------- 1 | //! Controlled randomness. 2 | //! 3 | //! This module implements PRNGs that can all be deterministically seeded from a 4 | //! the `JUNCTION_SEED` env variable, falling back to system entropy when 5 | //! ncessary. Using only PRNG values seeded from this package allows 6 | //! deterministic testing. 7 | //! 8 | //! This module is currently implemented using a two-stage strategy. A single 9 | //! global PRNG wrapped in a `Mutex`` is used to lazily initialize thread local 10 | //! PRNGs as necessary. After initialization, each thread has unfettered access 11 | //! to a PRNG a-la `rand::thread_rng()`. 12 | 13 | // NOTE: rand::ThreadRng is a thin wrapper on top of an Rc> and 14 | // liberally uses unsafe to take momentary access to a thread-local rng. We 15 | // could do the same if the performance of this strategy isn't good enough. 16 | // 17 | //As of writing, we're not touching randomess nearly enough to have to think 18 | //about that. 19 | 20 | use std::cell::RefCell; 21 | use std::sync::Mutex; 22 | 23 | use once_cell::sync::Lazy; 24 | use rand::rngs::StdRng; 25 | use rand::{Rng, SeedableRng}; 26 | 27 | /// Call a function with access to a thread-local PRNG. 28 | /// 29 | /// Prefer this to `rand::thread_rng()` from the `rand` crate - this fn is 30 | /// seeded globally to enable deterministic testing, unlike the `rand` 31 | /// crate's default thread-local Rng. 32 | pub fn with_thread_rng(f: F) -> T 33 | where 34 | F: FnMut(&mut StdRng) -> T, 35 | { 36 | thread_local! { 37 | static THREAD_RNG: RefCell = RefCell::new(seeded_std_rng()); 38 | } 39 | 40 | THREAD_RNG.with_borrow_mut(f) 41 | } 42 | 43 | pub fn random() -> T 44 | where 45 | rand::distributions::Standard: rand::distributions::Distribution, 46 | { 47 | with_thread_rng(|rng| rng.gen()) 48 | } 49 | 50 | fn seeded_std_rng() -> StdRng { 51 | let seed = { 52 | let mut rng = SEED_RNG.lock().unwrap(); 53 | rng.gen() 54 | }; 55 | StdRng::from_seed(seed) 56 | } 57 | 58 | static SEED_RNG: Lazy> = Lazy::new(|| { 59 | let env_seed: Option = { 60 | match std::env::var("JUNCTION_SEED") { 61 | Ok(seed_str) => seed_str.parse().ok(), 62 | _ => None, 63 | } 64 | }; 65 | 66 | let rng = match env_seed { 67 | Some(seed) => StdRng::seed_from_u64(seed), 68 | None => StdRng::from_entropy(), 69 | }; 70 | 71 | Mutex::new(rng) 72 | }); 73 | -------------------------------------------------------------------------------- /crates/junction-core/src/url.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, str::FromStr}; 2 | 3 | use crate::Error; 4 | 5 | /// An Uri with an `http` or `https` scheme and a non-empty `authority`. 6 | /// 7 | /// The `authority` section of a `Url` must contains a hostname and may contain 8 | /// a port, but must not contain a username or password. 9 | /// 10 | /// ```ascii 11 | /// https://example.com:123/path/data?key=value&key2=value2#fragid1 12 | /// ─┬─── ──────────┬──── ─────┬──── ───────┬───────────────────── 13 | /// │ │ │ │ 14 | /// └─scheme │ path─┘ │ 15 | /// │ │ 16 | /// authority─┘ query─┘ 17 | /// ``` 18 | /// 19 | /// There are no extra restrictions on the path or query components of a valid 20 | /// `Url`. 21 | #[derive(Clone, Debug, PartialEq, Eq)] 22 | pub struct Url { 23 | scheme: http::uri::Scheme, 24 | authority: http::uri::Authority, 25 | path_and_query: http::uri::PathAndQuery, 26 | } 27 | 28 | // TODO: own error type here? 29 | 30 | impl std::fmt::Display for Url { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | write!( 33 | f, 34 | "{scheme}://{authority}{path}", 35 | scheme = self.scheme, 36 | authority = self.authority, 37 | path = self.path(), 38 | )?; 39 | 40 | if let Some(query) = self.query() { 41 | write!(f, "?{query}")?; 42 | } 43 | 44 | Ok(()) 45 | } 46 | } 47 | 48 | impl Url { 49 | pub fn new(uri: http::Uri) -> crate::Result { 50 | let uri = uri.into_parts(); 51 | 52 | let Some(authority) = uri.authority else { 53 | return Err(Error::invalid_url("missing hostname")); 54 | }; 55 | if !authority.as_str().starts_with(authority.host()) { 56 | return Err(Error::invalid_url( 57 | "url must not contain a username or password", 58 | )); 59 | } 60 | 61 | let scheme = match uri.scheme.as_ref().map(|s| s.as_str()) { 62 | Some("http") | Some("https") => uri.scheme.unwrap(), 63 | Some(_) => return Err(Error::invalid_url("unknown scheme")), 64 | _ => return Err(Error::invalid_url("missing scheme")), 65 | }; 66 | let path_and_query = uri 67 | .path_and_query 68 | .unwrap_or_else(|| http::uri::PathAndQuery::from_static("/")); 69 | 70 | Ok(Self { 71 | scheme, 72 | authority, 73 | path_and_query, 74 | }) 75 | } 76 | } 77 | 78 | impl FromStr for Url { 79 | type Err = Error; 80 | 81 | fn from_str(s: &str) -> Result { 82 | let uri = http::Uri::from_str(s).map_err(|e| Error::into_invalid_url(e.to_string()))?; 83 | 84 | Self::new(uri) 85 | } 86 | } 87 | 88 | impl Url { 89 | pub fn scheme(&self) -> &str { 90 | self.scheme.as_str() 91 | } 92 | 93 | pub fn hostname(&self) -> &str { 94 | self.authority.host() 95 | } 96 | 97 | pub fn port(&self) -> Option { 98 | self.authority.port_u16() 99 | } 100 | 101 | pub fn default_port(&self) -> u16 { 102 | self.authority 103 | .port_u16() 104 | .unwrap_or_else(|| match self.scheme.as_ref() { 105 | "https" => 443, 106 | _ => 80, 107 | }) 108 | } 109 | 110 | pub fn path(&self) -> &str { 111 | self.path_and_query.path() 112 | } 113 | 114 | pub fn query(&self) -> Option<&str> { 115 | self.path_and_query.query() 116 | } 117 | 118 | pub fn request_uri(&self) -> &str { 119 | self.path_and_query.as_str() 120 | } 121 | 122 | pub(crate) fn with_hostname(&self, hostname: &str) -> Result { 123 | let authority: Result = 124 | match self.authority.port() { 125 | Some(port) => format!("{hostname}:{port}").parse(), 126 | None => hostname.parse(), 127 | }; 128 | 129 | let authority = authority.map_err(|e| Error::into_invalid_url(e.to_string()))?; 130 | 131 | Ok(Self { 132 | authority, 133 | scheme: self.scheme.clone(), 134 | path_and_query: self.path_and_query.clone(), 135 | }) 136 | } 137 | 138 | pub(crate) fn authority(&self) -> Cow<'_, str> { 139 | match self.authority.port() { 140 | Some(_) => Cow::Borrowed(self.authority.as_str()), 141 | None => Cow::Owned(format!( 142 | "{host}:{port}", 143 | host = self.authority.as_str(), 144 | port = self.default_port() 145 | )), 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /crates/junction-core/src/xds/csds.rs: -------------------------------------------------------------------------------- 1 | //! A server for [CSDS][csds]. 2 | //! 3 | //! [csds]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/status/v3/csds.proto 4 | 5 | use std::{ 6 | net::SocketAddr, 7 | net::{IpAddr, Ipv4Addr}, 8 | pin::Pin, 9 | }; 10 | 11 | use futures::Stream; 12 | use tonic::{Request, Response, Status, Streaming}; 13 | use xds_api::pb::envoy::{ 14 | admin::v3::ClientResourceStatus, 15 | service::status::v3::{ 16 | client_config::GenericXdsConfig, 17 | client_status_discovery_service_server::{ 18 | ClientStatusDiscoveryService, ClientStatusDiscoveryServiceServer, 19 | }, 20 | ClientConfig, ClientStatusRequest, ClientStatusResponse, 21 | }, 22 | }; 23 | 24 | use crate::xds::{cache::CacheReader, XdsConfig}; 25 | 26 | /// Run a CSDS server listening on `localhost` at the given port. 27 | pub async fn local_server(cache: CacheReader, port: u16) -> Result<(), tonic::transport::Error> { 28 | let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); 29 | 30 | let reflection = tonic_reflection::server::Builder::configure() 31 | .register_encoded_file_descriptor_set(xds_api::FILE_DESCRIPTOR_SET) 32 | .with_service_name("envoy.service.status.v3.ClientStatusDiscoveryService") 33 | .build_v1() 34 | .unwrap(); 35 | 36 | tonic::transport::Server::builder() 37 | .add_service(reflection) 38 | .add_service(ClientStatusDiscoveryServiceServer::new(Server { cache })) 39 | .serve(socket_addr) 40 | .await 41 | } 42 | 43 | /// A CSDS Server that exposes the current state of a client's cache. 44 | /// 45 | /// Unlike a standard CSDS server, this server only has a single node worth of 46 | /// config to expose. Because there is no reasonable way to interpret a node 47 | /// matcher, any request that sets `node_matchers` will return an error. 48 | struct Server { 49 | cache: CacheReader, 50 | } 51 | 52 | type ClientStatusResponseStream = 53 | Pin> + Send>>; 54 | 55 | #[tonic::async_trait] 56 | impl ClientStatusDiscoveryService for Server { 57 | type StreamClientStatusStream = ClientStatusResponseStream; 58 | 59 | async fn stream_client_status( 60 | &self, 61 | _request: Request>, 62 | ) -> Result, Status> { 63 | return Err(Status::unimplemented( 64 | "streaming client status is not supported", 65 | )); 66 | } 67 | 68 | async fn fetch_client_status( 69 | &self, 70 | request: Request, 71 | ) -> Result, Status> { 72 | let request = request.into_inner(); 73 | 74 | if !request.node_matchers.is_empty() { 75 | return Err(Status::invalid_argument( 76 | "node_matchers are unsupported for a single client CSDS endpoint", 77 | )); 78 | } 79 | 80 | let node = request.node; 81 | let generic_xds_configs: Vec<_> = self.cache.iter_xds().map(to_generic_config).collect(); 82 | 83 | Ok(Response::new(ClientStatusResponse { 84 | config: vec![ClientConfig { 85 | node, 86 | generic_xds_configs, 87 | ..Default::default() 88 | }], 89 | })) 90 | } 91 | } 92 | 93 | /// Convert a crate config to an xDS generic config 94 | /// 95 | /// There's no way on GenericXdsConfig to indicate that you've ACKed one version 96 | /// of a config but rejected another. Since xDS generally gets cranky when you 97 | /// specify a duplicate resource name, we're currently just only showing an error 98 | /// if there was any error at all. 99 | /// 100 | /// This is weird but so is xDS. There's a hidden field that describes the last 101 | /// error trying to apply this config that would do what we want, but it's hidden 102 | /// as not-implemented, so not in our protobufs. 103 | /// 104 | /// Either figure out how to use the hidden field, or return MULTIPLE statuses for 105 | /// resources that have a valid and an invalid resource. 106 | fn to_generic_config(config: XdsConfig) -> GenericXdsConfig { 107 | let client_status = match (&config.xds, &config.last_error) { 108 | (_, Some(_)) => ClientResourceStatus::Nacked, 109 | (Some(_), None) => ClientResourceStatus::Acked, 110 | _ => ClientResourceStatus::Unknown, 111 | }; 112 | let version_info = config.version.map(|v| v.to_string()).unwrap_or_default(); 113 | 114 | GenericXdsConfig { 115 | type_url: config.type_url, 116 | name: config.name, 117 | version_info, 118 | xds_config: config.xds, 119 | client_status: client_status.into(), 120 | ..Default::default() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /crates/junction-typeinfo-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-typeinfo-derive" 3 | version.workspace = true 4 | edition.workspace = true 5 | homepage.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | rust-version.workspace = true 9 | description = """ 10 | TypeInfo derive macro 11 | """ 12 | 13 | [lib] 14 | proc-macro = true 15 | 16 | [dependencies] 17 | proc-macro2 = "1" 18 | quote = "1" 19 | serde_derive_internals = "0.29.1" 20 | syn = "2" 21 | -------------------------------------------------------------------------------- /crates/junction-typeinfo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-typeinfo" 3 | version.workspace = true 4 | edition.workspace = true 5 | homepage.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | rust-version.workspace = true 9 | description = """ 10 | reflection for junction API generation 11 | """ 12 | 13 | [dependencies] 14 | junction-typeinfo-derive = { workspace = true } 15 | 16 | [dev-dependencies] 17 | serde = { version = "1.0", features = ["derive"] } 18 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # to get a live server 2 | cd docs 3 | pip install -r source/requirements.txt 4 | mkdocs serve -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | # https://www.mkdocs.org/user-guide/configuration/ 2 | 3 | site_name: Junction User Guide 4 | site_url: https://docs.junctionlabs.io/ 5 | repo_url: https://github.com/junction-labs/junction-client 6 | repo_name: junction-labs/junction-client 7 | docs_dir: source 8 | strict: true 9 | nav: 10 | - "Overview": 11 | - index.md 12 | - overview/core-concepts.md 13 | - "Getting Started": 14 | - getting-started/index.md 15 | - getting-started/ezbake.md 16 | - getting-started/node.md 17 | - getting-started/python.md 18 | - getting-started/rust.md 19 | - getting-started/configuring-junction.md 20 | - "Reference": 21 | - "reference/api.md" 22 | not_in_nav: | 23 | /_build/ 24 | validation: 25 | links: 26 | absolute_links: ignore 27 | 28 | theme: 29 | name: material 30 | locale: en 31 | custom_dir: source/_build/overrides 32 | palette: 33 | # Palette toggle for light mode 34 | - media: "(prefers-color-scheme: light)" 35 | primary: deep orange 36 | scheme: default 37 | toggle: 38 | icon: material/brightness-7 39 | name: Switch to dark mode 40 | # Palette toggle for dark mode 41 | - media: "(prefers-color-scheme: dark)" 42 | primary: deep orange 43 | scheme: slate 44 | toggle: 45 | icon: material/brightness-4 46 | name: Switch to light mode 47 | logo: _build/assets/logo.png 48 | features: 49 | - navigation.top 50 | - navigation.sections 51 | - navigation.tabs 52 | - content.code.copy 53 | icon: 54 | repo: fontawesome/brands/github 55 | 56 | markdown_extensions: 57 | - admonition 58 | - pymdownx.details 59 | - attr_list 60 | - pymdownx.emoji: 61 | emoji_index: !!python/name:material.extensions.emoji.twemoji 62 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 63 | - pymdownx.superfences 64 | - pymdownx.tabbed: 65 | alternate_style: true 66 | - pymdownx.snippets: 67 | base_path: [".", "source/src/"] 68 | check_paths: true 69 | dedent_subsections: true 70 | 71 | plugins: 72 | - search: 73 | lang: en 74 | - markdown-exec 75 | - macros: 76 | module_name: source/_build/scripts/macro 77 | -------------------------------------------------------------------------------- /docs/source/_build/API_REFERENCE_LINKS.yml: -------------------------------------------------------------------------------- 1 | python: 2 | DataFrame: https://docs.pola.rs/api/python/stable/reference/dataframe/index.html 3 | 4 | rust: 5 | DataFrame: https://docs.pola.rs/api/rust/dev/polars/frame/struct.DataFrame.html 6 | -------------------------------------------------------------------------------- /docs/source/_build/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junction-labs/junction-client/f97d3db9ea61f2590a1d1ca082bfca90ba2fd565/docs/source/_build/assets/logo.png -------------------------------------------------------------------------------- /docs/source/_build/scripts/macro.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import os 3 | from typing import List, Optional, Set 4 | import yaml 5 | import logging 6 | 7 | 8 | from mkdocs_macros.plugin import MacrosPlugin 9 | 10 | # Supported Languages and their metadata 11 | LANGUAGES = OrderedDict( 12 | python={ 13 | "extension": ".py", 14 | "display_name": "Python", 15 | "icon_name": "python", 16 | "code_name": "python", 17 | }, 18 | rust={ 19 | "extension": ".rs", 20 | "display_name": "Rust", 21 | "icon_name": "rust", 22 | "code_name": "rust", 23 | }, 24 | ) 25 | 26 | # Load all links to reference docs 27 | with open("source/_build/API_REFERENCE_LINKS.yml", "r") as f: 28 | API_REFERENCE_LINKS = yaml.load(f, Loader=yaml.CLoader) 29 | 30 | 31 | def create_feature_flag_link(feature_name: str) -> str: 32 | """Create a feature flag warning telling the user to activate a certain feature before running the code 33 | 34 | Args: 35 | feature_name (str): name of the feature 36 | 37 | Returns: 38 | str: Markdown formatted string with a link and the feature flag message 39 | """ 40 | return f'[:material-flag-plus: Available on feature {feature_name}](/user-guide/installation/#feature-flags "To use this functionality enable the feature flag {feature_name}"){{.feature-flag}}' 41 | 42 | 43 | def create_feature_flag_links(language: str, api_functions: List[str]) -> List[str]: 44 | """Generate markdown feature flags for the code tabs based on the api_functions. 45 | It checks for the key feature_flag in the configuration yaml for the function and if it exists print out markdown 46 | 47 | Args: 48 | language (str): programming languages 49 | api_functions (List[str]): Api functions that are called 50 | 51 | Returns: 52 | List[str]: Per unique feature flag a markdown formatted string for the feature flag 53 | """ 54 | api_functions_info = [ 55 | info 56 | for f in api_functions 57 | if (info := API_REFERENCE_LINKS.get(language).get(f)) 58 | ] 59 | feature_flags: Set[str] = { 60 | flag 61 | for info in api_functions_info 62 | if info is dict and info.get("feature_flags") 63 | for flag in info.get("feature_flags") 64 | } 65 | 66 | return [create_feature_flag_link(flag) for flag in feature_flags] 67 | 68 | 69 | def create_api_function_link(language: str, function_key: str) -> Optional[str]: 70 | """Create an API link in markdown with an icon of the YAML file 71 | 72 | Args: 73 | language (str): programming language 74 | function_key (str): Key to the specific function 75 | 76 | Returns: 77 | str: If the function is found than the link else None 78 | """ 79 | info = API_REFERENCE_LINKS.get(language, {}).get(function_key) 80 | 81 | if info is None: 82 | logging.warning(f"Could not find {function_key} for language {language}") 83 | return None 84 | else: 85 | # Either be a direct link 86 | if info is str: 87 | return f"[:material-api: `{function_key}`]({info})" 88 | else: 89 | function_name = info["name"] 90 | link = info["link"] 91 | return f"[:material-api: `{function_name}`]({link})" 92 | 93 | 94 | def code_tab( 95 | base_path: str, 96 | section: Optional[str], 97 | language_info: dict, 98 | api_functions: List[str], 99 | ) -> str: 100 | """Generate a single tab for the code block corresponding to a specific language. 101 | It gets the code at base_path and possible section and pretty prints markdown for it 102 | 103 | Args: 104 | base_path (str): path where the code is located 105 | section (str, optional): section in the code that should be displayed 106 | language_info (dict): Language specific information (icon name, display name, ...) 107 | api_functions (List[str]): List of api functions which should be linked 108 | 109 | Returns: 110 | str: A markdown formatted string represented a single tab 111 | """ 112 | language = language_info["code_name"] 113 | 114 | # Create feature flags 115 | feature_flags_links = create_feature_flag_links(language, api_functions) 116 | 117 | # Create API Links if they are defined in the YAML 118 | api_functions = [ 119 | link for f in api_functions if (link := create_api_function_link(language, f)) 120 | ] 121 | language_headers = " ·".join(api_functions + feature_flags_links) 122 | 123 | # Create path for Snippets extension 124 | snippets_file_name = f"{base_path}:{section}" if section else f"{base_path}" 125 | 126 | # See Content Tabs for details https://squidfunk.github.io/mkdocs-material/reference/content-tabs/ 127 | return f"""=== \":fontawesome-brands-{language_info['icon_name']}: {language_info['display_name']}\" 128 | {language_headers} 129 | ```{language} 130 | --8<-- \"{snippets_file_name}\" 131 | ``` 132 | """ 133 | 134 | 135 | def define_env(env: MacrosPlugin) -> None: 136 | @env.macro 137 | def code_header( 138 | language: str, section: str = [], api_functions: List[str] = [] 139 | ) -> str: 140 | language_info = LANGUAGES[language] 141 | 142 | language = language_info["code_name"] 143 | 144 | # Create feature flags 145 | feature_flags_links = create_feature_flag_links(language, api_functions) 146 | 147 | # Create API Links if they are defined in the YAML 148 | api_functions = [ 149 | link 150 | for f in api_functions 151 | if (link := create_api_function_link(language, f)) 152 | ] 153 | language_headers = " ·".join(api_functions + feature_flags_links) 154 | return f"""=== \":fontawesome-brands-{language_info['icon_name']}: {language_info['display_name']}\" 155 | {language_headers}""" 156 | 157 | @env.macro 158 | def code_block( 159 | path: str, 160 | section: str = None, 161 | api_functions: List[str] = None, 162 | python_api_functions: List[str] = None, 163 | rust_api_functions: List[str] = None, 164 | ) -> str: 165 | """Dynamically generate a code block for the code located under {language}/path 166 | 167 | Args: 168 | path (str): base_path for each language 169 | section (str, optional): Optional segment within the code file. Defaults to None. 170 | api_functions (List[str], optional): API functions that should be linked. Defaults to None. 171 | Returns: 172 | str: Markdown tabbed code block with possible links to api functions and feature flags 173 | """ 174 | result = [] 175 | 176 | for language, info in LANGUAGES.items(): 177 | base_path = f"{language}/{path}{info['extension']}" 178 | full_path = "docs/source/src/" + base_path 179 | if language == "python": 180 | extras = python_api_functions or [] 181 | else: 182 | extras = rust_api_functions or [] 183 | # Check if file exists for the language 184 | if os.path.exists(full_path): 185 | result.append( 186 | code_tab(base_path, section, info, api_functions + extras) 187 | ) 188 | 189 | return "\n".join(result) 190 | -------------------------------------------------------------------------------- /docs/source/_build/scripts/people.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from github import Github, Auth 3 | import os 4 | 5 | token = os.getenv("GITHUB_TOKEN") 6 | auth = Auth.Token(token) if token else None 7 | g = Github(auth=auth) 8 | 9 | ICON_TEMPLATE = '{login}' 10 | 11 | 12 | def get_people_md(): 13 | repo = g.get_repo("pola-rs/polars") 14 | contributors = repo.get_contributors() 15 | with open("./docs/assets/people.md", "w") as f: 16 | for c in itertools.islice(contributors, 50): 17 | # We love dependabot, but he doesn't need a spot on our website 18 | if c.login == "dependabot[bot]": 19 | continue 20 | 21 | f.write( 22 | ICON_TEMPLATE.format( 23 | login=c.login, 24 | avatar_url=c.avatar_url, 25 | html_url=c.html_url, 26 | ) 27 | + "\n" 28 | ) 29 | 30 | 31 | def on_startup(command, dirty): 32 | """Mkdocs hook to autogenerate docs/assets/people.md on startup""" 33 | try: 34 | get_people_md() 35 | except Exception as e: 36 | msg = f"WARNING:{__file__}: Could not generate docs/assets/people.md. Got error: {str(e)}" 37 | print(msg) 38 | 39 | 40 | if __name__ == "__main__": 41 | get_people_md() 42 | -------------------------------------------------------------------------------- /docs/source/getting-started/configuring-junction.md: -------------------------------------------------------------------------------- 1 | # Configuring Junction 2 | 3 | Junction enables testing to validate behavior with the same configuration that 4 | eventually gets used dynamically. In this last part of the getting started 5 | guide, we walk through how the same configuration can be used in three different 6 | ways. 7 | 8 | * Static configuration for unit testing 9 | * Static configuration with dynamic IP's for Integration Testing 10 | * Dynamic configuration with ezbake 11 | 12 | ## Defining our config 13 | 14 | To start, we need to define a configuration. Here we have a route on the 15 | hostname `jct-simple-app.default.svc.cluster.local`, which sends all requests 16 | matching the path `/v2/user` to a test service called `jct-simple-app-v2`, 17 | putting in place a retry policy as well: 18 | 19 | ```python 20 | import junction 21 | import junction.config 22 | import typing 23 | 24 | my_service = {"type": "kube", "name": "jct-simple-app", "namespace": "default"} 25 | my_test_service = {"type": "kube", "name": "jct-simple-app-v2", "namespace": "default"} 26 | 27 | retry_policy: junction.config.RouteRetry = { 28 | "attempts": 3, 29 | "backoff": 0.5, 30 | "codes": [500, 503], 31 | } 32 | 33 | routes: typing.List[junction.config.Route] = [ 34 | { 35 | "id": "jct-simple-app-routes", 36 | "hostnames": ["jct-simple-app.default.svc.cluster.local"], 37 | "rules": [ 38 | { 39 | "backends": [{**my_test_service, "port": 8008}], 40 | "retry": retry_policy, 41 | "matches": [{"path": {"value": "/v2/users"}}], 42 | }, 43 | { 44 | "backends": [{**my_service, "port": 8008}], 45 | "retry": retry_policy, 46 | }, 47 | ], 48 | }, 49 | ] 50 | ``` 51 | 52 | ## Static configuration for unit testing 53 | 54 | The first step in testing a configuration is unit tests. Junction provides the 55 | check_route method, which lets you test how a specific request will get 56 | processed by it's rule: 57 | 58 | ```python 59 | # assert that requests with no path go to the cool service like normal 60 | (_, _, matched_backend) = junction.check_route( 61 | routes, "GET", "http://jct-simple-app.default.svc.cluster.local", {} 62 | ) 63 | assert matched_backend["name"] == "jct-simple-app" 64 | 65 | # assert that requests to /v2/users go to the cool-test service 66 | (_, _, matched_backend) = junction.check_route( 67 | routes, "GET", "http://jct-simple-app.default.svc.cluster.local/v2/users", {} 68 | ) 69 | assert matched_backend["name"] == "jct-simple-app-v2" 70 | ``` 71 | 72 | ## Static configuration for pre-deployment testing 73 | 74 | Before we roll out the configuration dynamically, we probably want to see it 75 | work in a real HTTP client. To allow this mode, all clients can be configured 76 | with static routes and backends, and use those rather than what comes back from 77 | the control plane. 78 | 79 | Then: 80 | ```python 81 | import junction.requests as requests 82 | 83 | session = junction.requests.Session( 84 | static_routes=routes 85 | ) 86 | r1 = session.get("http://jct-simple-app.default.svc.cluster.local") 87 | r2 = session.get("http://jct-simple-app.default.svc.cluster.local/v2/users") 88 | # both go to expected service 89 | print(r1.text) 90 | print(r2.text) 91 | ``` 92 | 93 | ## Dynamic configuration with ezbake 94 | 95 | The final step is deploying the configuration to the control plane feeding all 96 | clients in the cluster. Junction provides integrations with EZBake to make this 97 | simple. EZBake uses the Gateway API [HTTPRoute] to specify routes, and so 98 | junction provides a method that allows configuration to be dumped to a file, 99 | that can either be directly executed with `kubectl apply -f`, or put in whatever 100 | GitOps mechanism you use to roll out configuration. 101 | 102 | [HTTPRoute]: https://gateway-api.sigs.k8s.io/api-types/httproute/ 103 | 104 | ```python 105 | for route in routes: 106 | print("---") 107 | print(junction.dump_kube_route(route=route, namespace="default")) 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/source/getting-started/ezbake.md: -------------------------------------------------------------------------------- 1 | # Setting Up Control Plane - EZBake 2 | 3 | Ezbake is a simple [xDS] control plane for 4 | Junction, which uses the [gateway_api] to support dynamic configuration. 5 | `ezbake` runs in a Kubernetes cluster, watches its running services, and runs 6 | as an xDS control plane to drive the Junction client. 7 | 8 | [ezbake]: https://github.com/junction-labs/ezbake 9 | [gateway_api]: https://gateway-api.sigs.k8s.io/ 10 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 11 | 12 | ## Simple Installation 13 | 14 | The simplest installation is as follows, which first sets up the Kubernetes 15 | Gateway API CRD, and then sets up ezbake as a 2 pod deployment in its own 16 | namespace (junction), with permissions to monitor all services, endpoints, and 17 | gateway API config in the cluster. 18 | 19 | ```bash 20 | kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 21 | kubectl apply -f https://github.com/junction-labs/ezbake/releases/latest/download/install-for-cluster.yml 22 | ``` 23 | 24 | Now, to communicate with ezbake, all clients will need the `JUNCTION_ADS_SERVER` environment 25 | variable set as follows: 26 | 27 | ```bash 28 | export JUNCTION_ADS_SERVER="grpc://ezbake.junction.svc.cluster.local:8008" 29 | ``` 30 | 31 | > [!NOTE] 32 | > 33 | > `ezbake` returns Pod IPs directly without any NAT, so if your cluster 34 | > isn't configured to allow talking directly to Pod IPs from outside the cluster, 35 | > any client you run outside the cluster **won't be able to connect to any 36 | > backends**. Notably, local clusters created with `k3d`, `kind`, and Docker 37 | > Desktop behave this way. 38 | 39 | ## Uninstalling 40 | 41 | To uninstall, run `kubectl delete` on the Gateway APIs and the objects that 42 | `ezbake` installed: 43 | 44 | ```bash 45 | kubectl delete -f https://github.com/junction-labs/ezbake/releases/latest/download/install-for-cluster.yml 46 | kubectl delete -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 47 | ``` 48 | 49 | ## More advanced installation 50 | 51 | ### Deploying to Kubernetes in a Single Namespace 52 | 53 | On a cluster where you only have access to a single namespace, you can still run 54 | `ezbake`. 55 | 56 | First you do need your cluster admin install the Gateway APIs by [following the official instructions][official-instructions]. 57 | 58 | ```bash 59 | kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 60 | ``` 61 | 62 | [official-instructions]: https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api 63 | 64 | Next, create a service account that has permissions to list and watch the API 65 | server. The `ServiceAccount`, `Role` and `RoleBinding` in 66 | `scripts/install-for-namespace-admin.yml` list all of the required privileges. 67 | Feel free to copy that template, replace `foo` with your namespace, and apply it 68 | to the cluster: 69 | 70 | ```bash 71 | # run this as a cluster admin 72 | sed 's/foo/$YOUR_NAMESPACE_HERE/' < scripts/install-for-namespace-admin.yml > ezbake-role.yml 73 | kubectl apply -f ezbake-role.yml 74 | ``` 75 | 76 | Deploy `ezbake` as you would any other Deployment, making sure to run it as the 77 | `ServiceAccount` created with permissions. The template in 78 | `install-for-namespace.yml` gives an example, and can be used as a template to 79 | get started. 80 | 81 | ```bash 82 | sed 's/foo/$YOUR_NAMESPACE_HERE/' < scripts/install-for-namespace.yml > ezbake.yml 83 | kubectl apply -f ezbake.yml 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/source/getting-started/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | There are 3 steps to getting Junction running. 4 | 5 | ## Step 1 - Set up a control plane 6 | 7 | Today the only xDS server the junction-client regression tests against is 8 | [ezbake]. Ezbake is a simple [xDS] control plane for 9 | Junction, which uses the [gateway_api] to support dynamic configuration. 10 | `ezbake` runs in a Kubernetes cluster, watches its running services, and runs 11 | as an xDS control plane to drive the Junction client. 12 | 13 | [ezbake]: https://github.com/junction-labs/ezbake 14 | [gateway_api]: https://gateway-api.sigs.k8s.io/ 15 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 16 | 17 | * [Set up ezbake](ezbake.md) 18 | 19 | ## Step 2 - Install the Junction client 20 | 21 | Here you just need to read the guide for the languages you are using. 22 | 23 | * [Node.js](node.md) 24 | * [Python](python.md) 25 | * [Rust](rust.md) 26 | 27 | ## Step 3 - Configure your client behavior 28 | 29 | Finally you must configure Junction to shape your clients behavior. 30 | 31 | * [Configuring Junction](configuring-junction.md) 32 | -------------------------------------------------------------------------------- /docs/source/getting-started/node.md: -------------------------------------------------------------------------------- 1 | # Installing Client - Node.js 2 | 3 | The Junction client is [on NPM](https://www.npmjs.com/package/@junction-labs/client). 4 | This means all you need to do is: 5 | 6 | ```bash 7 | npm install @junction-labs/client 8 | ``` 9 | 10 | If you are using Next.js, you will also have to add the following to `next.config.ts`: 11 | 12 | ```typescript 13 | const nextConfig: NextConfig = { 14 | serverExternalPackages: ['@junction-labs/client'], 15 | }; 16 | ``` 17 | 18 | 19 | ## [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) 20 | 21 | Junction provides a `fetch()` method, that's fully compatible with the Fetch 22 | standard, that uses Junction under the hood to route requests and handle 23 | retries. 24 | 25 | 26 | ```typescript 27 | const junction = require("@junction-labs/client"); 28 | 29 | var response = await junction.fetch("http://httpbin.default.svc.cluster.local:8008/status/418"); 30 | console.log(response.status); 31 | // 418 32 | console.log(await response.text()); 33 | // 34 | // -=[ teapot ]=- 35 | // 36 | // _...._ 37 | // .' _ _ `. 38 | // | ."` ^ `". _, 39 | // \_;`"---"`|// 40 | // | ;/ 41 | // \_ _/ 42 | // `"""` 43 | ``` 44 | 45 | ## Direct use 46 | 47 | To examine and debug configuration, you can instantiate a Junction client and 48 | use it to directly call `resolveHttp`. 49 | 50 | ```typescript 51 | const junction = require("@junction-labs/client"); 52 | 53 | const client = await junction.Client.build({ 54 | adsServer: "grpc://192.168.194.201:8008", 55 | }); 56 | 57 | console.log(await client.resolveHttp({"url": "https://httpbin.org")); 58 | // Endpoint { 59 | // scheme: 'https', 60 | // sockAddr: { address: '50.19.58.113', port: 443 }, 61 | // hostname: 'httpbin.org', 62 | // retry: undefined, 63 | // timeouts: undefined 64 | // } 65 | ``` 66 | 67 | APIs for dumping Route and Backend configuration are not yet available. 68 | 69 | For more, [see the full API reference](https://docs.junctionlabs.io/api/node/stable/modules/fetch.html). 70 | -------------------------------------------------------------------------------- /docs/source/getting-started/python.md: -------------------------------------------------------------------------------- 1 | # Installing Client - Python 2 | 3 | The Junction client is [on PyPi](https://pypi.org/project/junction-python/). 4 | This means all you need to do is: 5 | 6 | ``` bash 7 | pip install junction-python 8 | ``` 9 | 10 | 11 | ## [Requests](https://pypi.org/project/requests/) 12 | 13 | Junction is fully compatible with the Requests library, just with a different 14 | import: 15 | 16 | ```python 17 | import junction.requests as requests 18 | 19 | session = requests.Session() 20 | session.get("http://jct-simple-app.default.svc.cluster.local:8008") 21 | ``` 22 | 23 | The Junction client used by a session is also available on that session as a 24 | field, and can be used to inspect and debug configuration. 25 | 26 | ```python 27 | junction_client = session.junction 28 | junction_client.dump_routes() 29 | ``` 30 | 31 | For more, [see the full API reference](https://docs.junctionlabs.io/api/python/stable/reference/requests.html). 32 | 33 | ## [Urllib3](https://github.com/urllib3/urllib3) 34 | 35 | Junction is fully compatible with the Urllib3 library, just with a different 36 | import: 37 | 38 | ```python 39 | from junction.urllib3 import PoolManager 40 | http = PoolManager() 41 | http.urlopen("GET", "http://jct-simple-app.default.svc.cluster.local:8008") 42 | ``` 43 | 44 | The Junction client used by each PoolManager is also available as a field 45 | and can be used to inspect and debug configuration. 46 | 47 | ```python 48 | junction_client = http.junction 49 | junction_client.dump_routes() 50 | ``` 51 | 52 | For more, [see the full API reference](https://docs.junctionlabs.io/api/python/stable/reference/urllib3.html). 53 | 54 | ## Direct use 55 | 56 | Junction is generally intended to be used indirectly, though the interfaces 57 | that that match your HTTP client. However, using the Junction client directly 58 | can be useful to inspect and debug your configuration.. 59 | 60 | The `junction` module makes the default Junction client available for 61 | introspection, and individual Sessions and PoolManagers make their 62 | active clients available. 63 | 64 | ```python 65 | import junction 66 | 67 | client = junction.default_client() 68 | client.dump_routes() 69 | ``` 70 | 71 | For more, [see the full API reference](https://docs.junctionlabs.io/api/python/stable/reference/junction.html#junction.Junction). 72 | 73 | -------------------------------------------------------------------------------- /docs/source/getting-started/rust.md: -------------------------------------------------------------------------------- 1 | # Installing Client - Rust 2 | 3 | The core of Junction is written in Rust and is available in the 4 | [`junction-core`](https://github.com/junction-labs/junction-client/tree/main/crates/junction-core) 5 | crate. At the moment, we don't have an integration with an HTTP library 6 | available, but you can use the core client to dynamically fetch config and 7 | resolve addresses. 8 | 9 | See the `examples` directory for [an 10 | example](https://github.com/junction-labs/junction-client/blob/main/crates/junction-core/examples/get-endpoints.rs) 11 | of how to use junction to resolve an address. 12 | 13 | For more, [see the full API reference](https://docs.rs/junction-core/latest/junction_core/). 14 | -------------------------------------------------------------------------------- /docs/source/guides/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuring Routes and Backends 2 | 3 | ## Routes 4 | 5 | ### Weighting Backends 6 | 7 | ### Timeouts 8 | 9 | ### Retries 10 | 11 | ## Backends 12 | 13 | ### Kubernetes Backends 14 | 15 | ### DNS Backends 16 | 17 | ### Load Balancing: Round Robin 18 | 19 | ### Load Balancing: Ring Hash 20 | -------------------------------------------------------------------------------- /docs/source/guides/debugging.md: -------------------------------------------------------------------------------- 1 | # Interactively Debugging -------------------------------------------------------------------------------- /docs/source/guides/index.md: -------------------------------------------------------------------------------- 1 | # Index 2 | 3 | These are deeper dives on particular topics: 4 | 5 | * [Configuring Routes and Backends](configuration.md) 6 | * [Interactively Debugging](debugging.md) 7 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | An xDS dynamically-configurable API load-balancer library. 4 | 5 | ## What is it? 6 | 7 | Junction is a library that allows you to dynamically configure application 8 | level HTTP routing, load balancing, and resilience by writing a few lines of 9 | configuration and dynamically pushing it to your client. Imagine all of the 10 | features of a rich HTTP proxy that's as easy to work with as the HTTP library 11 | you're already using. 12 | 13 | Junction does that by pulling endpoints and configuration from an [xDS] 14 | control plane, as follows: 15 | 16 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 17 | 18 | 19 | ``` 20 | ┌─────────────────────┐ 21 | │ Client Service │ 22 | ├──────────┬──────────┤ ┌───────────┐ 23 | │ Existing │ Junction ◄────┤ xDS │ 24 | │ HTTP │ Client │ │ Control │ 25 | │ Library │ Library │ │ Plane │ 26 | └────┬─┬───┴──────────┘ └─────▲─────┘ 27 | │ │ │ 28 | │ └──────────┐ │ 29 | ┌────▼────┐ ┌────▼────┐ ┌─────┴─────┐ 30 | │ Your │ │ Your │ │ K8s API │ 31 | │ Service │ │ Service │ │ Server │ 32 | └─────────┘ └─────────┘ └───────────┘ 33 | ``` 34 | 35 | Junction is developed by [Junction Labs](https://www.junctionlabs.io/). 36 | 37 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 38 | 39 | ## Features 40 | 41 | Today, Junction allows you to dynamically configure: 42 | 43 | - Routing traffic based on HTTP method, path, headers, or query parameters 44 | - Timeouts 45 | - Retries 46 | - Weighted traffic splitting 47 | - Load balancing (Ring-Hash or WRR) 48 | 49 | On our roadmap are features like: 50 | 51 | - multi-cluster federation 52 | - zone-based load balancing 53 | - rate limiting 54 | - subsetting 55 | - circuit breaking 56 | 57 | ## Supported xDS Control Planes 58 | 59 | Today the only xDS server the junction-client regression tests against is 60 | [ezbake]. Ezbake is a simple [xDS] control plane for 61 | Junction, which uses the [gateway_api] to support dynamic configuration. 62 | `ezbake` runs in a Kubernetes cluster, watches its running services, and runs 63 | as an xDS control plane to drive the Junction client. 64 | 65 | [ezbake]: https://github.com/junction-labs/ezbake 66 | [gateway_api]: https://gateway-api.sigs.k8s.io/ 67 | [xDS]: https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol 68 | 69 | ## Supported languages and HTTP Libraries 70 | 71 | | Language | Integrated HTTP Libraries | 72 | |-------------|---------------------------| 73 | | [Rust] | None | 74 | | [Python] | [requests], [urllib3] | 75 | | [Node.js] | [fetch()] | 76 | 77 | [Rust]: getting-started/rust.md 78 | [Python]: getting-started/python.md 79 | [Node.js]: getting-started/node.md 80 | [requests]: https://pypi.org/project/requests/ 81 | [urllib3]: https://github.com/urllib3/urllib3 82 | [fetch()]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch 83 | 84 | ## Getting started 85 | 86 | [See here](getting-started/index.md) 87 | 88 | ## Project status 89 | 90 | Junction is alpha software, developed in the open. We're still iterating rapidly 91 | on our client facing API, and our integration into xDS. At this stage you should 92 | expect occasional breaking changes as the library evolves. 93 | 94 | ## License 95 | 96 | The Junction client is [Apache 2.0 licensed](https://github.com/junction-labs/junction-client/blob/main/LICENSE). 97 | 98 | ## Contact Us 99 | 100 | [info@junctionlabs.io](mailto:info@junctionlabs.io) 101 | -------------------------------------------------------------------------------- /docs/source/mlc-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | { 4 | "pattern": "^https://crates.io/" 5 | }, 6 | { 7 | "pattern": "^https://stackoverflow.com/" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /docs/source/overview/core-concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | ## Resolution 4 | 5 | At its heart Junction replaces DNS, except rather than go from hostname to an 6 | IP, it goes from hostname and header/query params to a list of IPs, and dynamic 7 | configuration such as retry policies, timeouts, rate limits, and mTLS certs. 8 | 9 | To implement this lookup Junction has two layers of indirection which allow for 10 | configuration: Routes and Backends. The lookup flow then looks like the 11 | following: 12 | 13 | ``` 14 | Request 15 | ?───► Lookup: Hostname + header/querystring params 16 | │ 17 | ▼ 18 | Route ──────► Also determines: timeouts, retry policy 19 | ?───► Lookup: Weighting, Mirroring 20 | │ 21 | ▼ 22 | Service Backend ─► Also determines: mTLS policy 23 | ?───► Lookup: load balancing algorithm 24 | │ 25 | ▼ 26 | IP address list 27 | ``` 28 | 29 | For those coming from the Kubernetes Gateway API, we choose the names routes and 30 | backends to be exactly the same concepts as it's routes and BackendRefs. However 31 | because Junction is very much targeted as service to service communication, some 32 | things like ParentRefs are not carried across. 33 | 34 | ## Routes 35 | 36 | A route is the client facing half of Junction, and contains most of the 37 | things you'd traditionally find in a hand-rolled HTTP client - timeouts, 38 | retries, URL rewriting and more. Routes match requests based on their 39 | hostname, method, URL, and headers. 40 | 41 | ## Hostnames 42 | 43 | Junction's main purpose is service discovery, and just like with DNS, the major 44 | input to a lookup is a hostname. However, unlike DNS which needs to handle the 45 | scale of the Internet, Junction is aimed at problems where the entire set of 46 | names can be kept in memory of a single server (at 1,000 bytes per record, 47 | 1,000,000 records is just 1 GiB). Thus in Junction there is no logic about 48 | subdomains. Rather, you set up a Route on any hostname you want, and a Junction 49 | will match it. 50 | 51 | One thing Junction does support is wildcard prefixes "*.mydomain.com", to allow 52 | a single route to pick up dynamically allocated hostnames. 53 | 54 | ## Services 55 | 56 | The Junction API is built around the idea that you're always routing requests to 57 | a Service, which is an abstract representation of a place you might want traffic 58 | to go. A Service can be anything, but to use one in Junction you need a way to 59 | uniquely specify it. That could be anything from a DNS name someone else has 60 | already set up to a Kubernetes Service in a cluster you've connected to 61 | Junction. 62 | 63 | ## Backends 64 | 65 | A Backend is a single port on a Service. Backend configuration gives you 66 | control over the things you'd normally configure in a reverse proxy or a 67 | traditional load balancer. 68 | -------------------------------------------------------------------------------- /docs/source/pyproject.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junction-labs/junction-client/f97d3db9ea61f2590a1d1ca082bfca90ba2fd565/docs/source/pyproject.toml -------------------------------------------------------------------------------- /docs/source/reference/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | # API reference 7 | 8 | The API reference contains detailed descriptions of all public functions and objects. 9 | It's the best place to look if you need information on a specific function. 10 | 11 | * [Node.js](https://docs.junctionlabs.io/api/node/stable/index.html) 12 | * [Python](https://docs.junctionlabs.io/api/python/stable/reference/index.html) 13 | * [Rust](https://docs.rs/junction-core/latest/junction_core/) 14 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material==9.5.27 2 | mkdocs-macros-plugin==1.2.0 3 | mkdocs-redirects==1.2.1 4 | mkdocs-literate-nav 5 | markdown-exec[ansi]==1.9.3 6 | pygithub==2.4.0 -------------------------------------------------------------------------------- /junction-node/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | index.node 3 | **/node_modules 4 | **/.DS_Store 5 | npm-debug.log* 6 | lib 7 | cargo.log 8 | cross.log 9 | dist/ 10 | docs/ 11 | -------------------------------------------------------------------------------- /junction-node/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-node" 3 | version = "0.3.2" 4 | edition = "2021" 5 | description = """ 6 | Dynamically configurable HTTP service discovery bindings for NodeJS 7 | """ 8 | homepage.workspace = true 9 | repository.workspace = true 10 | license.workspace = true 11 | categories = ["api-bindings", "network-programming"] 12 | rust-version.workspace = true 13 | 14 | exclude = ["index.node"] 15 | 16 | [lib] 17 | name = "client" 18 | crate-type = ["cdylib"] 19 | 20 | [dependencies] 21 | http = { workspace = true } 22 | neon = { version = "1.0", default-features = false, features = ["napi-8"] } 23 | once_cell = { workspace = true } 24 | tokio = { workspace = true, features = ["rt-multi-thread"] } 25 | 26 | junction-core = { workspace = true } 27 | junction-api = { workspace = true, features = ["kube_v1_29", "xds"] } 28 | -------------------------------------------------------------------------------- /junction-node/README.md: -------------------------------------------------------------------------------- 1 | An xDS dynamically-configurable API load-balancer library. 2 | 3 | * [Source](https://github.com/junction-labs/junction-client/tree/main/junction-node) 4 | * [Getting Started](https://docs.junctionlabs.io/getting-started/node) 5 | * [API reference](https://docs.junctionlabs.io/api/node/stable/index.html) 6 | * [Samples](https://github.com/junction-labs/junction-client/tree/main/junction-node/samples) 7 | -------------------------------------------------------------------------------- /junction-node/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["node_modules/", "lib/", "docs/"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true 23 | } 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "double" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /junction-node/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | // set BUILD_SHA as an env var 5 | let short_sha = Command::new("git") 6 | .args(["rev-parse", "--short", "HEAD"]) 7 | .output() 8 | .expect("failed to get build version"); 9 | let short_sha = std::str::from_utf8(short_sha.stdout.trim_ascii()).unwrap(); 10 | 11 | let status = Command::new("git") 12 | .args(["status", "--porcelain"]) 13 | .output() 14 | .expect("failed to get git status"); 15 | let dirty = if status.stdout.trim_ascii().is_empty() { 16 | "" 17 | } else { 18 | "-dirty" 19 | }; 20 | println!("cargo::rustc-env=BUILD_SHA={short_sha}{dirty}") 21 | } 22 | -------------------------------------------------------------------------------- /junction-node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client", 3 | "version": "0.3.2", 4 | "description": "", 5 | "main": "./lib/index.cjs", 6 | "scripts": { 7 | "doc": "npx typedoc", 8 | "test": "tsc && cargo test", 9 | "prepack": "tsc", 10 | "build": "tsc && cargo build --message-format=json-render-diagnostics > cargo.log", 11 | "postbuild": "neon dist < cargo.log", 12 | "build-release": "tsc && cargo build --release --message-format=json-render-diagnostics > cargo.log", 13 | "postbuild-release": "neon dist < cargo.log", 14 | "cross-release": "tsc && cross build --release --message-format=json-render-diagnostics > cargo.log", 15 | "postcross-release": "neon dist -m /target < cargo.log", 16 | "lint": "biome check", 17 | "fix": "biome check --write", 18 | "postinstall": "node scripts/postinstall.js" 19 | }, 20 | "author": "", 21 | "license": "Apache-2.0", 22 | "exports": { 23 | ".": { 24 | "import": { 25 | "types": "./lib/index.d.mts", 26 | "default": "./lib/index.mjs" 27 | }, 28 | "require": { 29 | "types": "./lib/index.d.cts", 30 | "default": "./lib/index.cjs" 31 | } 32 | } 33 | }, 34 | "types": "./lib/index.d.cts", 35 | "files": ["scripts/postinstall.js", "lib/**/*.?({c,m}){t,j}s"], 36 | "neon": { 37 | "type": "library", 38 | "org": "@junction-node/", 39 | "platforms": "common", 40 | "load": "./ts/load.cts" 41 | }, 42 | "devDependencies": { 43 | "@biomejs/biome": "1.9.4", 44 | "@neon-rs/cli": "^0.1.82", 45 | "@tsconfig/node20": "^20.1.4", 46 | "@types/node": "^20.11.16", 47 | "typedoc": "^0.27.3", 48 | "typescript": "^5.3.3" 49 | }, 50 | "dependencies": { 51 | "@neon-rs/load": "^0.1.82", 52 | "undici": "^7.2.0" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "https://github.com/junction-labs/junction-client" 57 | }, 58 | "optionalDependencies": { 59 | "@junction-labs/client-darwin-arm64": "0.3.2", 60 | "@junction-labs/client-darwin-x64": "0.3.2", 61 | "@junction-labs/client-linux-arm64-gnu": "0.3.2", 62 | "@junction-labs/client-linux-x64-gnu": "0.3.2", 63 | "@junction-labs/client-win32-x64-msvc": "0.3.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /junction-node/platforms/darwin-arm64/README.md: -------------------------------------------------------------------------------- 1 | # `@junction-node//darwin-arm64` 2 | 3 | Prebuilt binary package for `@junction-labs/client` on `darwin-arm64`. 4 | -------------------------------------------------------------------------------- /junction-node/platforms/darwin-arm64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client-darwin-arm64", 3 | "description": "Prebuilt binary package for `@junction-labs/client` on `darwin-arm64`.", 4 | "version": "0.3.2", 5 | "os": ["darwin"], 6 | "cpu": ["arm64"], 7 | "main": "index.node", 8 | "files": ["index.node"], 9 | "neon": { 10 | "type": "binary", 11 | "rust": "aarch64-apple-darwin", 12 | "node": "darwin-arm64", 13 | "os": "darwin", 14 | "arch": "arm64", 15 | "abi": null 16 | }, 17 | "author": "", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/junction-labs/junction-client" 21 | }, 22 | "license": "Apache-2.0" 23 | } 24 | -------------------------------------------------------------------------------- /junction-node/platforms/darwin-x64/README.md: -------------------------------------------------------------------------------- 1 | # `@junction-node//darwin-x64` 2 | 3 | Prebuilt binary package for `@junction-labs/client` on `darwin-x64`. 4 | -------------------------------------------------------------------------------- /junction-node/platforms/darwin-x64/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client-darwin-x64", 3 | "description": "Prebuilt binary package for `@junction-labs/client` on `darwin-x64`.", 4 | "version": "0.3.2", 5 | "os": ["darwin"], 6 | "cpu": ["x64"], 7 | "main": "index.node", 8 | "files": ["index.node"], 9 | "neon": { 10 | "type": "binary", 11 | "rust": "x86_64-apple-darwin", 12 | "node": "darwin-x64", 13 | "os": "darwin", 14 | "arch": "x64", 15 | "abi": null 16 | }, 17 | "author": "", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/junction-labs/junction-client" 21 | }, 22 | "license": "Apache-2.0" 23 | } 24 | -------------------------------------------------------------------------------- /junction-node/platforms/linux-arm64-gnu/README.md: -------------------------------------------------------------------------------- 1 | # `@junction-node//linux-arm64-gnu` 2 | 3 | Prebuilt binary package for `@junction-labs/client` on `linux-arm64-gnu`. 4 | -------------------------------------------------------------------------------- /junction-node/platforms/linux-arm64-gnu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client-linux-arm64-gnu", 3 | "description": "Prebuilt binary package for `@junction-labs/client` on `linux-arm64-gnu`.", 4 | "version": "0.3.2", 5 | "os": ["linux"], 6 | "cpu": ["arm64"], 7 | "main": "index.node", 8 | "files": ["index.node"], 9 | "neon": { 10 | "type": "binary", 11 | "rust": "aarch64-unknown-linux-gnu", 12 | "node": "linux-arm64-gnu", 13 | "os": "linux", 14 | "arch": "arm64", 15 | "abi": "gnu" 16 | }, 17 | "author": "", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/junction-labs/junction-client" 21 | }, 22 | "license": "Apache-2.0" 23 | } 24 | -------------------------------------------------------------------------------- /junction-node/platforms/linux-x64-gnu/README.md: -------------------------------------------------------------------------------- 1 | # `@junction-node//linux-x64-gnu` 2 | 3 | Prebuilt binary package for `@junction-labs/client` on `linux-x64-gnu`. 4 | -------------------------------------------------------------------------------- /junction-node/platforms/linux-x64-gnu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client-linux-x64-gnu", 3 | "description": "Prebuilt binary package for `@junction-labs/client` on `linux-x64-gnu`.", 4 | "version": "0.3.2", 5 | "os": ["linux"], 6 | "cpu": ["x64"], 7 | "main": "index.node", 8 | "files": ["index.node"], 9 | "neon": { 10 | "type": "binary", 11 | "rust": "x86_64-unknown-linux-gnu", 12 | "node": "linux-x64-gnu", 13 | "os": "linux", 14 | "arch": "x64", 15 | "abi": "gnu" 16 | }, 17 | "author": "", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/junction-labs/junction-client" 21 | }, 22 | "license": "Apache-2.0" 23 | } 24 | -------------------------------------------------------------------------------- /junction-node/platforms/win32-x64-msvc/README.md: -------------------------------------------------------------------------------- 1 | # `@junction-node//win32-x64-msvc` 2 | 3 | Prebuilt binary package for `@junction-labs/client` on `win32-x64-msvc`. 4 | -------------------------------------------------------------------------------- /junction-node/platforms/win32-x64-msvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@junction-labs/client-win32-x64-msvc", 3 | "description": "Prebuilt binary package for `@junction-labs/client` on `win32-x64-msvc`.", 4 | "version": "0.3.2", 5 | "os": ["win32"], 6 | "cpu": ["x64"], 7 | "main": "index.node", 8 | "files": ["index.node"], 9 | "neon": { 10 | "type": "binary", 11 | "rust": "x86_64-pc-windows-msvc", 12 | "node": "win32-x64-msvc", 13 | "os": "win32", 14 | "arch": "x64", 15 | "abi": "msvc" 16 | }, 17 | "author": "", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/junction-labs/junction-client" 21 | }, 22 | "license": "Apache-2.0" 23 | } 24 | -------------------------------------------------------------------------------- /junction-node/scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | // Run a post-install check to see that a) we actually support the current 2 | // platform and b) the native lib was installed. There's nothing fancy about 3 | // the check - we're just running `require` to make sure the library can be 4 | // loaded. 5 | // 6 | // This script assums its run from an install into `node_modules` where the 7 | // platform-specific binary has already been installed. In local dev, `npm 8 | // install` or `npm ci` will try to install the package but the platform 9 | // specific binaries won't be installed - there's no graceful way to avoid 10 | // failing this check so if you set JUNCTION_CLIENT_SKIP_POSTINSTALL we'll 11 | // just not run. 12 | if (process.env.JUNCTION_CLIENT_SKIP_POSTINSTALL) { 13 | process.exit(0); 14 | } 15 | 16 | const { platform, arch } = process; 17 | 18 | const PLATFORMS = { 19 | darwin: { 20 | arm64: "darwin-arm64", 21 | x64: "darwin-x64", 22 | }, 23 | linux: { 24 | arm64: "linux-arm64-gnu", 25 | x64: "linux-x64-gnu", 26 | }, 27 | win32: { 28 | x64: "win32-x64-msvc", 29 | }, 30 | }; 31 | 32 | const platformSuffix = PLATFORMS?.[platform]?.[arch]; 33 | 34 | if (!platform) { 35 | console.error( 36 | `No native library is available for your platform/cpu (${platform}/${arch}). Junction will not function!`, 37 | ); 38 | process.exit(2); 39 | } 40 | 41 | const libName = `@junction-labs/client-${platformSuffix}/index.node`; 42 | let path; 43 | try { 44 | path = require.resolve(libName); 45 | } catch { 46 | console.error( 47 | `Failed to install native lib ${libName} for @junction-labs/client. Junction will not function!`, 48 | "\n", 49 | "", 50 | ); 51 | process.exit(3); 52 | } 53 | -------------------------------------------------------------------------------- /junction-node/ts/core.cts: -------------------------------------------------------------------------------- 1 | // Types and wrappers for Junction FFI. 2 | // 3 | // The interfaces exposed from Rust are all fairly low level and 4 | // require keeping opaque pointers to FFI types around so we can 5 | // represent the Runtime etc. This module wraps them up into a 6 | // much more native JS/TS experience. 7 | 8 | import * as ffi from "./load.cjs"; 9 | 10 | declare module "./load.cjs" { 11 | interface Runtime {} 12 | interface Client {} 13 | interface EndpointHandle {} 14 | 15 | type EndpointResult = [EndpointHandle, EndpointProps]; 16 | 17 | type Headers = Array<[string, string]>; 18 | 19 | const version: string; 20 | const build: string; 21 | 22 | function newRuntime(): Runtime; 23 | 24 | function newClient( 25 | rt: Runtime, 26 | adsServer: string, 27 | node: string, 28 | cluster: string, 29 | ): Promise; 30 | 31 | function resolveHttp( 32 | rt: Runtime, 33 | client: Client, 34 | method: string, 35 | url: string, 36 | headers: Headers, 37 | ): Promise; 38 | 39 | function reportStatus( 40 | rt: Runtime, 41 | client: Client, 42 | endpoint: EndpointHandle, 43 | status?: number, 44 | error?: string, 45 | ): Promise; 46 | } 47 | 48 | const defaultRuntime: ffi.Runtime = ffi.newRuntime(); 49 | 50 | export const _VERSION = ffi.version; 51 | export const _BUILD = ffi.build; 52 | 53 | /** 54 | * An error in the Junction client. 55 | */ 56 | export class JunctionError extends Error { 57 | constructor(message?: string) { 58 | super(message); 59 | this.name = "JunctionError"; 60 | } 61 | } 62 | 63 | /** 64 | * Options for configuring a Junction client. 65 | */ 66 | export type ClientOpts = { 67 | /** 68 | * The URL of the Junction ADS server to connect to. 69 | */ 70 | adsServer: string; 71 | 72 | /** 73 | * The name of the individual process running Junction. Should be unique to 74 | * this process. 75 | */ 76 | nodeName?: string; 77 | 78 | /** 79 | * The cluster of nodes this client is part of. 80 | */ 81 | clusterName?: string; 82 | }; 83 | 84 | /** 85 | * Arguments to resolveHttp. 86 | */ 87 | export type ResolveHttpOpts = { 88 | /** 89 | * The HTTP method of the request. Defaults to `GET`. 90 | */ 91 | method?: string; 92 | 93 | /** 94 | * The URL of the request, including the full path and any query parameters. 95 | */ 96 | url: string; 97 | 98 | /** 99 | * The request headers. 100 | */ 101 | headers?: HeadersInit; 102 | }; 103 | 104 | /** 105 | * Arguments to `reportStatus`. Either `status` or `error` must be set. 106 | */ 107 | export type ReportStatusOpts = { 108 | /** 109 | * The status code of a complete HTTP request. 110 | */ 111 | status?: number; 112 | 113 | /** 114 | * An error returned in place of an incomplete HTTP request. 115 | */ 116 | error?: Error; 117 | }; 118 | 119 | /** 120 | * A Junction retry policy. 121 | */ 122 | // TODO: this should be generated from junction-api 123 | export interface RetryPolicy { 124 | attempts?: number; 125 | backoff?: number; 126 | codes?: [number]; 127 | } 128 | 129 | /** 130 | * Junction timeouts. 131 | */ 132 | // TODO: this should be generated from junction-api 133 | export interface Timeouts { 134 | request?: number; 135 | backendRequest?: number; 136 | } 137 | 138 | /** 139 | * A socket address. 140 | */ 141 | // TODO: this should be generated from junction-api 142 | export interface SockAddr { 143 | address: string; 144 | port: number; 145 | } 146 | 147 | /** 148 | * A Junction endpoint. 149 | */ 150 | // TODO: this should be generated from junction-api 151 | export interface EndpointProps { 152 | readonly scheme: string; 153 | readonly sockAddr: SockAddr; 154 | readonly hostname: string; 155 | readonly retry?: RetryPolicy; 156 | readonly timeouts?: Timeouts; 157 | } 158 | 159 | // symbols used as method names to hide the Endpoint inside Endpint. 160 | 161 | const _buildEndpoint: unique symbol = Symbol(); 162 | const _getHandle: unique symbol = Symbol(); 163 | 164 | export class Endpoint implements EndpointProps { 165 | #handle: ffi.EndpointHandle; 166 | 167 | readonly scheme: string; 168 | readonly sockAddr: SockAddr; 169 | readonly hostname: string; 170 | readonly retry?: RetryPolicy; 171 | readonly timeouts?: Timeouts; 172 | 173 | private constructor( 174 | handle: ffi.EndpointHandle, 175 | { scheme, sockAddr, hostname, retry, timeouts }: EndpointProps, 176 | ) { 177 | this.#handle = handle; 178 | this.scheme = scheme; 179 | this.sockAddr = sockAddr; 180 | this.hostname = hostname; 181 | this.retry = retry; 182 | this.timeouts = timeouts; 183 | } 184 | 185 | // module-private 186 | static [_buildEndpoint]( 187 | handle: ffi.EndpointHandle, 188 | props: EndpointProps, 189 | ): Endpoint { 190 | return new Endpoint(handle, props); 191 | } 192 | 193 | // module-private 194 | [_getHandle](): ffi.EndpointHandle { 195 | return this.#handle; 196 | } 197 | } 198 | 199 | /** 200 | * Convert an endpoint to a URL. 201 | */ 202 | export function endpointURL(e: Endpoint): URL { 203 | return new URL(`${e.scheme}://${e.sockAddr.address}:${e.sockAddr.port}`); 204 | } 205 | 206 | /** 207 | * A Junction client, used for resolving endpoint data and policy. 208 | */ 209 | export class Client { 210 | #runtime: ffi.Runtime; 211 | #client: ffi.Client; 212 | 213 | private constructor(rt: ffi.Runtime, client: ffi.Client) { 214 | this.#runtime = rt; 215 | this.#client = client; 216 | } 217 | 218 | /** 219 | * Build a new client from the given client opts. 220 | */ 221 | static async build(opts: ClientOpts): Promise { 222 | const client = await ffi.newClient( 223 | defaultRuntime, 224 | opts.adsServer, 225 | opts.nodeName || "nodejs", 226 | opts.clusterName || "junction-node", 227 | ); 228 | return new Client(defaultRuntime, client); 229 | } 230 | 231 | /** 232 | * Resolve an endpoint for an HTTP request. 233 | */ 234 | async resolveHttp(options: ResolveHttpOpts): Promise { 235 | try { 236 | const [handle, data] = await ffi.resolveHttp( 237 | this.#runtime, 238 | this.#client, 239 | options.method || "GET", 240 | options.url, 241 | toFfiHeaders(options.headers), 242 | ); 243 | 244 | return Endpoint[_buildEndpoint](handle, data); 245 | } catch (err) { 246 | if (err instanceof Error) { 247 | throw new JunctionError(err.message); 248 | } 249 | throw err; 250 | } 251 | } 252 | 253 | /** 254 | * Report the status of an HTTP request. 255 | */ 256 | async reportStatus( 257 | endpoint: Endpoint, 258 | opts: ReportStatusOpts, 259 | ): Promise { 260 | try { 261 | const [handle, data] = await ffi.reportStatus( 262 | this.#runtime, 263 | this.#client, 264 | endpoint[_getHandle](), 265 | opts.status, 266 | opts.error?.message, 267 | ); 268 | return Endpoint[_buildEndpoint](handle, data); 269 | } catch (err) { 270 | if (err instanceof Error) { 271 | throw new JunctionError(err.message); 272 | } 273 | throw err; 274 | } 275 | } 276 | } 277 | 278 | function toFfiHeaders(headers?: HeadersInit): ffi.Headers { 279 | if (Array.isArray(headers)) { 280 | return headers; 281 | } 282 | 283 | return [...new Headers(headers)]; 284 | } 285 | -------------------------------------------------------------------------------- /junction-node/ts/index.cts: -------------------------------------------------------------------------------- 1 | export { _BUILD, _VERSION, Client, type ClientOpts } from "./core.cjs"; 2 | export { fetch, setDefaultClient as setGlobalClient } from "./fetch.cjs"; 3 | -------------------------------------------------------------------------------- /junction-node/ts/index.mts: -------------------------------------------------------------------------------- 1 | export * from "./index.cjs"; 2 | -------------------------------------------------------------------------------- /junction-node/ts/load.cts: -------------------------------------------------------------------------------- 1 | // This module loads the platform-specific build of the addon on 2 | // the current system. The supported platforms are registered in 3 | // the `platforms` object below, whose entries can be managed by 4 | // by the Neon CLI: 5 | // 6 | // https://www.npmjs.com/package/@neon-rs/cli 7 | 8 | module.exports = require("@neon-rs/load").proxy({ 9 | platforms: { 10 | "win32-x64-msvc": () => require("@junction-labs/client-win32-x64-msvc"), 11 | "darwin-x64": () => require("@junction-labs/client-darwin-x64"), 12 | "darwin-arm64": () => require("@junction-labs/client-darwin-arm64"), 13 | "linux-x64-gnu": () => require("@junction-labs/client-linux-x64-gnu"), 14 | "linux-arm64-gnu": () => require("@junction-labs/client-linux-arm64-gnu"), 15 | }, 16 | debug: () => require("../index.node"), 17 | }); 18 | -------------------------------------------------------------------------------- /junction-node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "nodenext", 5 | "declaration": true, 6 | "removeComments": false, 7 | "outDir": "./lib", 8 | "rootDir": "./ts", 9 | "lib": ["es2022", "DOM", "DOM.Iterable"] 10 | }, 11 | "exclude": ["lib"], 12 | "typedocOptions": { 13 | "entryPointStrategy": "expand", 14 | "excludePrivate": true, 15 | "excludeProtected": true, 16 | "excludeExternals": true, 17 | "categorizeByGroup": false, 18 | "entryPoints": ["ts/core.cts", "ts/fetch.cts", "ts/load.cts"], 19 | "out": "docs", 20 | "sort": ["alphabetical"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /junction-python/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyCharm 66 | .idea/ 67 | 68 | # VSCode 69 | .vscode/ 70 | 71 | # Pyenv 72 | .python-version 73 | -------------------------------------------------------------------------------- /junction-python/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "junction-python" 3 | version = "0.3.3" 4 | edition = "2021" 5 | description = """ 6 | Dynamically configurable HTTP service discovery bindings for Python 7 | """ 8 | homepage.workspace = true 9 | repository.workspace = true 10 | license.workspace = true 11 | categories = ["api-bindings", "network-programming"] 12 | rust-version.workspace = true 13 | 14 | [lib] 15 | name = "junction" 16 | crate-type = ["cdylib"] 17 | 18 | [dependencies] 19 | http = { workspace = true } 20 | junction-core = { workspace = true } 21 | junction-api = { workspace = true, features = ["kube_v1_29", "xds"] } 22 | once_cell = { workspace = true } 23 | serde = { workspace = true, features = ["derive"] } 24 | serde_json = { workspace = true } 25 | serde_yml = { workspace = true } 26 | tokio = { workspace = true, features = ["rt-multi-thread"] } 27 | tracing-subscriber = { workspace = true } 28 | xds-api = { workspace = true, features = ["pbjson"] } 29 | pyo3 = { version = "0.21", features = [ 30 | "extension-module", 31 | "abi3-py38", 32 | "serde", 33 | ] } 34 | pythonize = "0.21" 35 | 36 | [build-dependencies] 37 | pyo3-build-config = "0.22" 38 | -------------------------------------------------------------------------------- /junction-python/LICENSE: -------------------------------------------------------------------------------- 1 | ../LICENSE -------------------------------------------------------------------------------- /junction-python/README.md: -------------------------------------------------------------------------------- 1 | # junction-python 2 | 3 | An xDS dynamically-configurable API load-balancer library. 4 | 5 | * [Source](https://github.com/junction-labs/junction-client/tree/main/junction-python) 6 | * [Getting Started](https://docs.junctionlabs.io/getting-started/python) 7 | * [API reference](https://docs.junctionlabs.io/api/python/stable/reference/index.html) 8 | * [Samples](https://github.com/junction-labs/junction-client/tree/main/junction-python/samples) 9 | -------------------------------------------------------------------------------- /junction-python/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | // pyo3 requires special linker args on macos. instead of setting .cargo/config.toml 5 | // we can do that here instead: 6 | // 7 | // https://pyo3.rs/v0.22.2/building-and-distribution#macos 8 | pyo3_build_config::add_extension_module_link_args(); 9 | 10 | // set BUILD_SHA as an env var 11 | let short_sha = Command::new("git") 12 | .args(["rev-parse", "--short", "HEAD"]) 13 | .output() 14 | .expect("failed to get build version"); 15 | let short_sha = std::str::from_utf8(short_sha.stdout.trim_ascii()).unwrap(); 16 | 17 | let status = Command::new("git") 18 | .args(["status", "--porcelain"]) 19 | .output() 20 | .expect("failed to get git status"); 21 | let dirty = if status.stdout.trim_ascii().is_empty() { 22 | "" 23 | } else { 24 | "-dirty" 25 | }; 26 | println!("cargo::rustc-env=BUILD_SHA={short_sha}{dirty}") 27 | } 28 | -------------------------------------------------------------------------------- /junction-python/docs/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | source/reference/**/api/ -------------------------------------------------------------------------------- /junction-python/docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.3.7 2 | 3 | # Third-party Sphinx extensions 4 | autodocsumm==0.2.12 5 | numpydoc==1.7.0 6 | pydata-sphinx-theme==0.15.4 7 | sphinx-autosummary-accessors==2023.4.0 8 | sphinx-copybutton==0.5.2 9 | sphinx-design==0.6.0 10 | sphinx-favicon==1.0.1 11 | sphinx-reredirects==0.1.4 12 | sphinx-toolbox==3.7.0 13 | 14 | livereload==2.7.0 15 | -------------------------------------------------------------------------------- /junction-python/docs/run_live_docs_server.py: -------------------------------------------------------------------------------- 1 | from livereload import Server, shell 2 | from source.conf import html_static_path, templates_path 3 | 4 | # ------------------------------------------------------------------------- 5 | # To use, just execute `python run_live_docs_server.py` in a terminal 6 | # and a local server will run the docs in your browser, automatically 7 | # refreshing/reloading the pages you're working on as they are modified. 8 | # Extremely helpful to see the real output before it gets uploaded, and 9 | # a much smoother experience than constantly running `make html` yourself. 10 | # ------------------------------------------------------------------------- 11 | 12 | if __name__ == "__main__": 13 | # establish a local docs server 14 | svr = Server() 15 | 16 | # command to rebuild the docs 17 | refresh_docs = shell("make html") 18 | 19 | # watch for source file changes and trigger rebuild/refresh 20 | svr.watch("*.rst", refresh_docs, delay=1) 21 | svr.watch("*.md", refresh_docs, delay=1) 22 | svr.watch("source/reference/*", refresh_docs, delay=1) 23 | for path in html_static_path + templates_path: 24 | svr.watch(f"source/{path}/*", refresh_docs, delay=1) 25 | 26 | # path from which to serve the docs 27 | svr.serve(root="build/html") 28 | -------------------------------------------------------------------------------- /junction-python/docs/source/_static/css/custom.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junction-labs/junction-client/f97d3db9ea61f2590a1d1ca082bfca90ba2fd565/junction-python/docs/source/_static/css/custom.css -------------------------------------------------------------------------------- /junction-python/docs/source/_static/version_switcher.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "dev", 4 | "version": "dev", 5 | "url": "https://docs.junctionlabs.io/api/python/dev/" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /junction-python/docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 3 | 4 | from __future__ import annotations 5 | 6 | import inspect 7 | import os 8 | import re 9 | import sys 10 | import warnings 11 | from pathlib import Path 12 | from typing import Any 13 | 14 | import sphinx_autosummary_accessors 15 | 16 | # -- Path setup -------------------------------------------------------------- 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. 20 | 21 | # Add py-polars directory 22 | sys.path.insert(0, str(Path("../..").resolve())) 23 | 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = "Junction" 28 | author = "Junction Labs" 29 | copyright = f"2024, {author}" 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | extensions = [ 35 | # Sphinx extensions 36 | "sphinx.ext.napoleon", 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.autosummary", 39 | "sphinx.ext.githubpages", 40 | "sphinx.ext.intersphinx", 41 | "sphinx.ext.linkcode", 42 | "sphinx.ext.mathjax", 43 | # Third-party extensions 44 | "autodocsumm", 45 | "sphinx_autosummary_accessors", 46 | "sphinx_copybutton", 47 | "sphinx_design", 48 | "sphinx_favicon", 49 | "sphinx_reredirects", 50 | "sphinx_toolbox.more_autodoc.overloads", 51 | ] 52 | 53 | # Render docstring text in `single backticks` as code. 54 | default_role = "code" 55 | 56 | maximum_signature_line_length = 88 57 | 58 | # Below setting is used by 59 | # sphinx-autosummary-accessors - build docs for namespace accessors like `Series.str` 60 | # https://sphinx-autosummary-accessors.readthedocs.io/en/stable/ 61 | templates_path = ["_templates", sphinx_autosummary_accessors.templates_path] 62 | 63 | # List of patterns, relative to source directory, that match files and 64 | # directories to ignore when looking for source files. 65 | # This pattern also affects html_static_path and html_extra_path. 66 | exclude_patterns = ["Thumbs.db", ".DS_Store"] 67 | 68 | # Hide overload type signatures 69 | # sphinx_toolbox - Box of handy tools for Sphinx 70 | # https://sphinx-toolbox.readthedocs.io/en/latest/ 71 | overloads_location = ["bottom"] 72 | 73 | # -- Extension settings ------------------------------------------------------ 74 | 75 | # sphinx.ext.intersphinx - link to other projects' documentation 76 | # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html 77 | intersphinx_mapping = { 78 | "python": ("https://docs.python.org/3", None), 79 | } 80 | 81 | # Sphinx-copybutton - add copy button to code blocks 82 | # https://sphinx-copybutton.readthedocs.io/en/latest/index.html 83 | # strip the '>>>' and '...' prompt/continuation prefixes. 84 | copybutton_prompt_text = r">>> |\.\.\. " 85 | copybutton_prompt_is_regexp = True 86 | 87 | # redirect empty root to the actual landing page 88 | redirects = {"index": "reference/index.html"} 89 | 90 | 91 | # -- Options for HTML output ------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. 94 | html_theme = "pydata_sphinx_theme" 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ["_static"] 100 | html_css_files = ["css/custom.css"] # relative to html_static_path 101 | html_show_sourcelink = False 102 | 103 | # key site root paths 104 | static_assets_root = "https://cdn.prod.website-files.com/6707f6a426fd2aa62b6ff292" 105 | github_root = "https://github.com/junction-labs/junction-client" 106 | web_root = "https://docs.junctionlabs.io" 107 | 108 | # Specify version for version switcher dropdown menu 109 | junction_version = os.environ.get("JUNCTION_VERSION", "main") 110 | version_match = re.fullmatch(r"\d+\.\d+\.\d+.*", junction_version) 111 | switcher_version = junction_version if version_match is not None else "dev" 112 | 113 | html_js_files = [ 114 | ( 115 | "https://plausible.io/js/script.js", 116 | {"data-domain": "docs.junctionlabs", "defer": "defer"}, 117 | ), 118 | ] 119 | 120 | html_theme_options = { 121 | "external_links": [ 122 | { 123 | "name": "User guide", 124 | "url": f"{web_root}/", 125 | }, 126 | ], 127 | "icon_links": [ 128 | { 129 | "name": "GitHub", 130 | "url": github_root, 131 | "icon": "fa-brands fa-github", 132 | }, 133 | { 134 | "name": "Discord", 135 | "url": "https://discord.gg/9Uq9FwnW3y", 136 | "icon": "fa-brands fa-discord", 137 | }, 138 | ], 139 | "logo": { 140 | "image_light": f"{static_assets_root}/67099720085278dbb34eccb2_logo-closed-orange-grey.svg", 141 | "image_dark": f"{static_assets_root}/67099720085278dbb34eccb2_logo-closed-orange-grey.svg", 142 | }, 143 | "switcher": { 144 | "json_url": f"{web_root}/api/python/dev/_static/version_switcher.json", 145 | "version_match": switcher_version, 146 | }, 147 | "show_version_warning_banner": False, 148 | "navbar_end": ["theme-switcher", "version-switcher", "navbar-icon-links"], 149 | "check_switcher": False, 150 | } 151 | 152 | # sphinx-favicon - Add support for custom favicons 153 | # https://github.com/tcmetzger/sphinx-favicon 154 | favicons = [ 155 | { 156 | "rel": "icon", 157 | "sizes": "32x32", 158 | "href": f"{static_assets_root}/icons/favicon-32x32.png", 159 | }, 160 | { 161 | "rel": "apple-touch-icon", 162 | "sizes": "180x180", 163 | "href": f"{static_assets_root}/icons/touchicon-180x180.png", 164 | }, 165 | ] 166 | 167 | 168 | # sphinx-ext-linkcode - Add external links to source code 169 | # https://www.sphinx-doc.org/en/master/usage/extensions/linkcode.html 170 | def linkcode_resolve(domain: str, info: dict[str, Any]) -> str | None: 171 | """ 172 | Determine the URL corresponding to Python object. 173 | 174 | Based on pandas equivalent: 175 | https://github.com/pandas-dev/pandas/blob/main/doc/source/conf.py#L629-L686 176 | """ 177 | if domain != "py": 178 | return None 179 | 180 | modname = info["module"] 181 | fullname = info["fullname"] 182 | 183 | submod = sys.modules.get(modname) 184 | if submod is None: 185 | return None 186 | 187 | obj = submod 188 | for part in fullname.split("."): 189 | try: 190 | with warnings.catch_warnings(): 191 | # Accessing deprecated objects will generate noisy warnings 192 | warnings.simplefilter("ignore", FutureWarning) 193 | obj = getattr(obj, part) 194 | except AttributeError: # noqa: PERF203 195 | return None 196 | 197 | try: 198 | fn = inspect.getsourcefile(inspect.unwrap(obj)) 199 | except TypeError: 200 | try: # property 201 | fn = inspect.getsourcefile(inspect.unwrap(obj.fget)) 202 | except (AttributeError, TypeError): 203 | fn = None 204 | if not fn: 205 | return None 206 | 207 | try: 208 | source, lineno = inspect.getsourcelines(obj) 209 | except TypeError: 210 | try: # property 211 | source, lineno = inspect.getsourcelines(obj.fget) 212 | except (AttributeError, TypeError): 213 | lineno = None 214 | except OSError: 215 | lineno = None 216 | 217 | linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" 218 | 219 | conf_dir_path = Path(__file__).absolute().parent 220 | junction_client_root = (conf_dir_path.parent.parent / "junction").absolute() 221 | 222 | fn = os.path.relpath(fn, start=junction_client_root) 223 | return ( 224 | f"{github_root}/blob/{junction_version}/junction-python/junction/{fn}{linespec}" 225 | ) 226 | 227 | 228 | def process_signature( # noqa: D103 229 | app: object, 230 | what: object, 231 | name: object, 232 | obj: object, 233 | opts: object, 234 | sig: str, 235 | ret: str, 236 | ) -> tuple[str, str]: 237 | return (sig, ret) 238 | 239 | 240 | def setup(app: Any) -> None: # noqa: D103 241 | # TODO: a handful of methods do not seem to trigger the event for 242 | # some reason (possibly @overloads?) - investigate further... 243 | app.connect("autodoc-process-signature", process_signature) 244 | -------------------------------------------------------------------------------- /junction-python/docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 |
4 | 5 | ===== 6 | Index 7 | ===== 8 | .. raw:: html 9 | 10 |
11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | reference/index -------------------------------------------------------------------------------- /junction-python/docs/source/reference/config.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Config 3 | ====== 4 | 5 | .. automodule:: junction.config 6 | :members: 7 | :autosummary: -------------------------------------------------------------------------------- /junction-python/docs/source/reference/index.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Python API reference 3 | ==================== 4 | 5 | This page gives a high-level overview of all public Junction objects, functions and 6 | methods. All classes and functions exposed in the ``junction.*`` namespace are public. 7 | 8 | 9 | .. grid:: 10 | 11 | .. grid-item-card:: 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | config 17 | 18 | .. grid-item-card:: 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | junction 24 | requests 25 | urllib3 26 | -------------------------------------------------------------------------------- /junction-python/docs/source/reference/junction.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Junction 3 | ======== 4 | 5 | .. autoclass:: junction.Junction 6 | :autosummary: 7 | :members: 8 | -------------------------------------------------------------------------------- /junction-python/docs/source/reference/requests.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Requests 3 | ======== 4 | 5 | .. automodule:: junction.requests 6 | :members: 7 | :autosummary: 8 | 9 | -------------------------------------------------------------------------------- /junction-python/docs/source/reference/urllib3.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Urllib3 3 | ======= 4 | 5 | .. automodule:: junction.urllib3 6 | :members: 7 | :autosummary: 8 | 9 | -------------------------------------------------------------------------------- /junction-python/junction/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from junction.junction import ( 4 | _version, 5 | _build, 6 | Junction, 7 | Endpoint, 8 | RetryPolicy, 9 | default_client, 10 | check_route, 11 | dump_kube_backend, 12 | dump_kube_route, 13 | enable_tracing, 14 | ) 15 | 16 | from . import config, requests, urllib3 17 | 18 | __version__ = _version 19 | __build__ = _build 20 | 21 | 22 | def _default_client( 23 | static_routes: typing.Optional[typing.List[config.Route]], 24 | static_backends: typing.Optional[typing.List[config.Backend]], 25 | ) -> Junction: 26 | """ 27 | Return a Junction client with default Routes and Backends. 28 | 29 | Uses the passed defaults to create a new client with default Routes 30 | and Backends. 31 | """ 32 | client_kwargs = {} 33 | if static_routes: 34 | # This check is just in case the user does something dumb as otherwise 35 | # the error on the return line is pretty ambiguous 36 | if not isinstance(static_routes, typing.List): 37 | raise ValueError("static_routes must be a list of routes") 38 | client_kwargs["static_routes"] = static_routes 39 | if static_backends: 40 | # This check is just in case the user does something dumb as otherwise 41 | # the error on the return line is pretty ambiguous 42 | if not isinstance(static_backends, typing.List): 43 | raise ValueError("static_backends must be a list of backends") 44 | client_kwargs["static_backends"] = static_backends 45 | return default_client(**client_kwargs) 46 | 47 | 48 | __all__ = ( 49 | Junction, 50 | Endpoint, 51 | RetryPolicy, 52 | config, 53 | urllib3, 54 | requests, 55 | check_route, 56 | default_client, 57 | dump_kube_backend, 58 | dump_kube_route, 59 | enable_tracing, 60 | ) 61 | -------------------------------------------------------------------------------- /junction-python/junction/requests.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Mapping, Union, Optional 3 | 4 | import requests 5 | 6 | import urllib3 7 | from urllib3.util import Timeout 8 | from urllib3.exceptions import HTTPError as _HTTPError 9 | from urllib3.exceptions import InvalidHeader as _InvalidHeader 10 | from urllib3.exceptions import ProxyError as _ProxyError 11 | from urllib3.exceptions import SSLError as _SSLError 12 | from urllib3.exceptions import ( 13 | MaxRetryError, 14 | NewConnectionError, 15 | ProtocolError, 16 | ReadTimeoutError, 17 | ResponseError, 18 | ClosedPoolError, 19 | ConnectTimeoutError, 20 | ) 21 | from .urllib3 import PoolManager 22 | from requests.exceptions import ( 23 | ConnectionError, 24 | ConnectTimeout, 25 | InvalidHeader, 26 | ProxyError, 27 | ReadTimeout, 28 | RetryError, 29 | SSLError, 30 | ) 31 | 32 | import junction 33 | 34 | 35 | class HTTPAdapter(requests.adapters.HTTPAdapter): 36 | """ 37 | An HTTPAdapter subclass customized to use Junction for endpoint discovery 38 | and load-balancing. 39 | 40 | You should almost never need to use this class directly, use a Session 41 | instead. 42 | """ 43 | 44 | def __init__(self, junction_client: junction.Junction, **kwargs): 45 | self.junction = junction_client 46 | super().__init__(**kwargs) 47 | 48 | def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): 49 | self.poolmanager: urllib3.PoolManager = PoolManager( 50 | num_pools=connections, 51 | maxsize=maxsize, 52 | block=block, 53 | junction_client=self.junction, 54 | **pool_kwargs, 55 | ) 56 | 57 | def send( 58 | self, 59 | request: requests.PreparedRequest, 60 | stream: bool = False, 61 | timeout: Union[None, float, tuple[float, float], tuple[float, None]] = None, 62 | verify: Union[bool, str] = True, 63 | cert: Union[None, bytes, str, tuple[bytes, str, Union[bytes, str]]] = None, 64 | proxies: Optional[Mapping[str, str]] = None, 65 | ) -> requests.Response: 66 | """Sends PreparedRequest object. Returns Response object. 67 | 68 | :param request: The :class:`PreparedRequest ` being sent. 69 | :param stream: (optional) Whether to stream the request content. 70 | :param timeout: (optional) How long to wait for the server to send data before giving up. 71 | :type timeout: float or tuple or urllib3 Timeout object 72 | :param verify: (optional) Either a boolean, in which case it controls whether 73 | we verify the server's TLS certificate, or a string, in which case it 74 | must be a path to a CA bundle to use 75 | :param cert: (optional) Any user-provided SSL certificate to be trusted. 76 | :param proxies: (optional) The proxies dictionary to apply to the request. 77 | :rtype: requests.Response 78 | """ 79 | # This is overridden instead of the smaller hooks that requests provides 80 | # because it's where the actual load balancing takes place - for some 81 | # reason requests pulls connections from a PoolManager itself instead of 82 | # calling PoolManager.urlopen. 83 | # 84 | # The code in the original send method does: 85 | # - Some very basic TLS cert validation (literally just checking 86 | # os.path.exists) 87 | # - Formats the request url so it's always relative, with a twist for 88 | # proxy usage 89 | # - Grabs a single connection and then uses it 90 | # - Munges timeouts for urllib3 91 | # - Translates exceptions 92 | # 93 | # We're interested in fixing that middle step where the code grabs a 94 | # single connection and use it. Instead of doing that, grab the junction 95 | # PoolManager we've installed instead and delegate. 96 | 97 | self.add_headers( 98 | request, 99 | stream=stream, 100 | timeout=timeout, 101 | verify=verify, 102 | cert=cert, 103 | proxies=proxies, 104 | ) 105 | chunked = not (request.body is None or "Content-Length" in request.headers) 106 | 107 | # duplicate requests' timeout handling. it's gotta eventually be a 108 | # urllib3.util.Timeout so make it one. 109 | if isinstance(timeout, tuple): 110 | try: 111 | connect, read = timeout 112 | timeout = Timeout(connect=connect, read=read) 113 | except ValueError: 114 | raise ValueError( 115 | f"Invalid timeout {timeout}. Pass a (connect, read) timeout to " 116 | f"configure both timeouts individually or a single float to set " 117 | f"both timeouts to the same value" 118 | ) from None 119 | elif isinstance(timeout, Timeout): 120 | pass 121 | elif timeout: 122 | timeout = Timeout(connect=timeout, read=timeout) 123 | 124 | # in requests.HTTPAdapter, TLS settings get configured on individual 125 | # connections fetched from the pool. since there's no connection 126 | # fetching going on here, make sure to pass tls args on every request. 127 | # 128 | # for now this involves smuggling it through urlopen's kwargs, but there 129 | # may be a way to do this by resetting the existing poolmanager whenever 130 | # session.verify is set with some cursed # setattr nonsense. requests 131 | # itself does some tls context building, so sneak this through for now 132 | # and we can figure this out later. 133 | tls_args = { 134 | "cert_reqs": "CERT_REQUIRED", 135 | } 136 | if verify is False: 137 | tls_args["cert_reqs"] = "CERT_NONE" 138 | elif isinstance(verify, str): 139 | if os.path.isdir(verify): 140 | tls_args["ca_cert_dir"] = verify 141 | else: 142 | tls_args["ca_certs"] = verify 143 | 144 | try: 145 | resp = self.poolmanager.urlopen( 146 | method=request.method, 147 | url=request.url, 148 | redirect=False, 149 | body=request.body, 150 | headers=request.headers, 151 | assert_same_host=False, 152 | preload_content=False, 153 | decode_content=False, 154 | retries=self.max_retries, 155 | timeout=timeout, 156 | chunked=chunked, 157 | jct_tls_args=tls_args, 158 | ) 159 | except (ProtocolError, OSError) as err: 160 | raise ConnectionError(err, request=request) 161 | 162 | except MaxRetryError as e: 163 | if isinstance(e.reason, ConnectTimeoutError): 164 | # TODO: Remove this in 3.0.0: see #2811 165 | if not isinstance(e.reason, NewConnectionError): 166 | raise ConnectTimeout(e, request=request) from e 167 | 168 | if isinstance(e.reason, ResponseError): 169 | raise RetryError(e, request=request) from e 170 | 171 | if isinstance(e.reason, _ProxyError): 172 | raise ProxyError(e, request=request) from e 173 | 174 | if isinstance(e.reason, _SSLError): 175 | # This branch is for urllib3 v1.22 and later. 176 | raise SSLError(e, request=request) from e 177 | 178 | raise ConnectionError(e, request=request) from e 179 | 180 | except ClosedPoolError as e: 181 | raise ConnectionError(e, request=request) from e 182 | 183 | except _ProxyError as e: 184 | raise ProxyError(e) from e 185 | 186 | except (_SSLError, _HTTPError) as e: 187 | if isinstance(e, _SSLError): 188 | # This branch is for urllib3 versions earlier than v1.22 189 | raise SSLError(e, request=request) from e 190 | elif isinstance(e, ReadTimeoutError): 191 | raise ReadTimeout(e, request=request) from e 192 | elif isinstance(e, _InvalidHeader): 193 | raise InvalidHeader(e, request=request) from e 194 | else: 195 | raise 196 | 197 | return self.build_response(request, resp) 198 | 199 | 200 | class Session(requests.Session): 201 | """ 202 | A drop-in replacement for a requests.Session that uses Junction for 203 | discovery and load-balancing. 204 | """ 205 | 206 | def __init__( 207 | self, 208 | static_routes: Optional[List["junction.config.Route"]] = None, 209 | static_backends: Optional[List["junction.config.Backend"]] = None, 210 | junction_client: Optional[junction.Junction] = None, 211 | ) -> None: 212 | super().__init__() 213 | 214 | if junction_client: 215 | self.junction = junction_client 216 | else: 217 | self.junction = junction._default_client( 218 | static_routes=static_routes, static_backends=static_backends 219 | ) 220 | 221 | self.mount("https://", HTTPAdapter(junction_client=self.junction)) 222 | self.mount("http://", HTTPAdapter(junction_client=self.junction)) 223 | -------------------------------------------------------------------------------- /junction-python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "junction-python" 3 | readme = "README.md" 4 | authors = [{ name = "Ben Linsay", email = "blinsay@gmail.com" }] 5 | dependencies = ["requests>2.0", "urllib3>2.0"] 6 | description = "An embeddable, dynamically configurable HTTP library" 7 | requires-python = ">=3.9" 8 | keywords = ["service discovery", "http"] 9 | classifiers = [ 10 | "Development Status :: 3 - Alpha", 11 | "Programming Language :: Python", 12 | "Programming Language :: Python :: 3", 13 | "Programming Language :: Python :: 3 :: Only", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Rust", 19 | ] 20 | dynamic = ["version"] 21 | 22 | [project.urls] 23 | Homepage = "https://junctionlabs.io" 24 | Repository = "https://github.com/junction-labs/junction-client" 25 | 26 | [project.optional-dependencies] 27 | test = ["pytest"] 28 | 29 | [build-system] 30 | requires = ["maturin>=1.0,<2.0"] 31 | build-backend = "maturin" 32 | 33 | [tool.ruff] 34 | exclude = ["*pb2.py", "*pb2_grpc.py"] 35 | 36 | [tool.maturin] 37 | include = [{ path = "LICENSE", format = "sdist" }] 38 | exclude = [{ path = "junction-python/samples", format = "sdist" }] 39 | -------------------------------------------------------------------------------- /junction-python/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # tools for development 2 | # 3 | # tools shouldn't be pinned by default. leave a comment with a reason for 4 | # pinning something. 5 | 6 | maturin 7 | pip 8 | pyyaml # only needed for the smoke tests 9 | 10 | # pinned so upgrades don't interrupt development with formatting changes 11 | ruff==0.6.3 12 | -------------------------------------------------------------------------------- /junction-python/samples/smoke-test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | # Optional arg is the name of the junction python package to install with pip 4 | # this can be either a pypi package name, a wheel URL, or a local path to a 5 | # wheel. 6 | # 7 | # The magic [x] in the default allows the COPY to succeed even if the arg is not set. 8 | ARG junction_wheel=default[x] 9 | 10 | WORKDIR /app 11 | 12 | COPY ${junction_wheel} . 13 | 14 | COPY junction-python/requirements-dev.txt requirements-dev.txt 15 | RUN pip install --upgrade uv 16 | RUN uv pip install --system -r requirements-dev.txt 17 | ADD junction-python/samples/smoke-test/server.py . 18 | 19 | # extra stuff just for the client as having it in the same docker image is convenient 20 | RUN if [ "$junction_wheel" = "default[x]" ] ; then uv pip install --system junction-python ; else uv pip install --system *.whl ; fi 21 | 22 | RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl 23 | RUN chmod +x ./kubectl 24 | RUN mv ./kubectl /usr/local/bin 25 | ADD junction-python/samples/smoke-test/client.py . 26 | -------------------------------------------------------------------------------- /junction-python/samples/smoke-test/README.md: -------------------------------------------------------------------------------- 1 | # smoke-test 2 | 3 | A test case of junction-client that works with `ezbake` and shows off routing, load balancing and 4 | dynamic configuration capabilities. 5 | 6 | *All paths assume you are running from the top level junction-client directory* 7 | 8 | ## Build the junction python client and Set up the environment 9 | ```bash 10 | cargo xtask python-build 11 | source .venv/bin/activate 12 | ``` 13 | 14 | ## Set up `ezbake` 15 | ```bash 16 | kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 17 | kubectl apply -f https://github.com/junction-labs/ezbake/releases/latest/download/install-for-cluster.yml 18 | export JUNCTION_ADS_SERVER="grpc://ezbake.junction.svc.cluster.local:8008" 19 | ``` 20 | 21 | ## Build docker image and deploy it 22 | ```bash 23 | docker build --tag jct_simple_app --file junction-python/samples/smoke-test/Dockerfile --load . 24 | kubectl apply -f junction-python/samples/smoke-test/deploy/jct-simple-app.yml 25 | ``` 26 | 27 | ## Run client with static config (requires something like Orbstack to forward to k8s) 28 | ```bash 29 | python junction-python/samples/smoke-test/client.py 30 | ``` 31 | 32 | ## Run client with dynamic config 33 | ```bash 34 | python junction-python/samples/smoke-test/client.py --use-gateway-api 35 | ``` 36 | 37 | ## Run the client with dynamic config from within kube 38 | Note the first line here is just a one off to let the client call kubectl. Further, 39 | this uses the global junction-python, rather than the local build. for that you need 40 | to rebuild the docker image. 41 | ```bash 42 | kubectl apply -f junction-python/samples/smoke-test/deploy/client-cluster-role-binding.yml 43 | kubectl run jct-client --rm --image=jct_simple_app:latest --image-pull-policy=IfNotPresent --env="JUNCTION_ADS_SERVER=grpc://ezbake.junction.svc.cluster.local:8008" --restart=Never --attach -- python /app/client.py --use-gateway-api 44 | ``` 45 | 46 | ## Clean up 47 | ```bash 48 | kubectl delete -f junction-python/samples/smoke-test/deploy/jct-simple-app.yml 49 | kubectl delete -f https://github.com/junction-labs/ezbake/releases/latest/download/install-for-cluster.yml 50 | kubectl delete -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml 51 | ``` 52 | -------------------------------------------------------------------------------- /junction-python/samples/smoke-test/deploy/client-cluster-role-binding.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: RoleBinding 4 | metadata: 5 | namespace: default 6 | name: httproute-rolebinding 7 | subjects: 8 | - kind: ServiceAccount 9 | name: default 10 | namespace: default 11 | roleRef: 12 | kind: ClusterRole 13 | name: cluster-admin 14 | apiGroup: rbac.authorization.k8s.io 15 | -------------------------------------------------------------------------------- /junction-python/samples/smoke-test/deploy/jct-simple-app.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: jct-simple-app 6 | labels: 7 | app: jct-simple-app 8 | spec: 9 | replicas: 3 10 | selector: 11 | matchLabels: 12 | app: jct-simple-app 13 | template: 14 | metadata: 15 | labels: 16 | app: jct-simple-app 17 | spec: 18 | containers: 19 | - name: jct-simple-app 20 | image: jct_simple_app:latest 21 | imagePullPolicy: IfNotPresent 22 | command: ["python", "/app/server.py"] 23 | env: 24 | - name: TARGET 25 | value: "jct-simple-app" 26 | --- 27 | apiVersion: v1 28 | kind: Service 29 | metadata: 30 | name: jct-simple-app 31 | spec: 32 | type: ClusterIP 33 | selector: 34 | app: jct-simple-app 35 | ports: 36 | - port: 8008 37 | targetPort: 8008 38 | --- 39 | apiVersion: apps/v1 40 | kind: Deployment 41 | metadata: 42 | name: jct-simple-app-v2 43 | labels: 44 | app: jct-simple-app-v2 45 | spec: 46 | replicas: 1 47 | selector: 48 | matchLabels: 49 | app: jct-simple-app-v2 50 | template: 51 | metadata: 52 | labels: 53 | app: jct-simple-app-v2 54 | spec: 55 | containers: 56 | - name: jct-simple-app-v2 57 | image: jct_simple_app:latest 58 | imagePullPolicy: IfNotPresent 59 | command: ["python", "/app/server.py"] 60 | env: 61 | - name: TARGET 62 | value: "jct-simple-app-v2" 63 | --- 64 | apiVersion: v1 65 | kind: Service 66 | metadata: 67 | name: jct-simple-app-v2 68 | spec: 69 | type: ClusterIP 70 | selector: 71 | app: jct-simple-app-v2 72 | ports: 73 | - port: 8008 74 | targetPort: 8008 75 | -------------------------------------------------------------------------------- /junction-python/samples/smoke-test/server.py: -------------------------------------------------------------------------------- 1 | from http.server import HTTPServer, BaseHTTPRequestHandler 2 | import os 3 | import logging 4 | from random import randint 5 | from time import sleep 6 | 7 | target = os.environ.get("TARGET", "none") 8 | server_id = randint(1, 100) 9 | fail_counters = {} 10 | 11 | 12 | ## some ugliness to avoid pulling in a dependency 13 | def get_query_params(path): 14 | index = path.find("?") 15 | if index != -1: 16 | query_string = path[index + 1 :] 17 | return dict(item.split("=") for item in query_string.split("&")) 18 | else: 19 | return {} 20 | 21 | 22 | class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): 23 | def _set_headers(self, code): 24 | self.send_response(code) 25 | self.send_header("Content-type", "text/plain") 26 | self.end_headers() 27 | 28 | def do_HEAD(self): 29 | logging.warning("header request received") 30 | self._set_headers(200) 31 | 32 | def do_GET(self): 33 | logging.warning(f"get request received to {self.path}") 34 | query_params = get_query_params(self.path) 35 | code = 200 36 | if "fail_match" in query_params: 37 | fail_code = int(query_params["fail_match"]) 38 | if "fail_match_set_counter" in query_params: 39 | fail_counters[fail_code] = int(query_params["fail_match_set_counter"]) 40 | elif fail_code in fail_counters: 41 | fail_counters[fail_code] = fail_counters[fail_code] - 1 42 | if fail_counters[fail_code] != 0: 43 | code = fail_code 44 | else: 45 | logging.warning("got fail_match request but counter was never set") 46 | elif "sleep_ms" in query_params: 47 | sleep(float(query_params["sleep_ms"]) / 1000) 48 | 49 | logging.warning(f"returning code {code}") 50 | self._set_headers(code) 51 | self.wfile.write(str.encode(f"target:{target},server_id:{server_id}")) 52 | 53 | 54 | address = ("", 8008) 55 | httpd = HTTPServer(address, SimpleHTTPRequestHandler) 56 | logging.warning( 57 | f"Starting, target {target}, server_id {server_id}, binding to {address}" 58 | ) 59 | httpd.serve_forever() 60 | -------------------------------------------------------------------------------- /junction-python/src/runtime.rs: -------------------------------------------------------------------------------- 1 | use std::{future::Future, time::Duration}; 2 | 3 | use once_cell::sync::Lazy; 4 | use pyo3::{exceptions::PyRuntimeError, PyErr, PyResult, Python}; 5 | 6 | static RUNTIME: Lazy = Lazy::new(|| { 7 | let rt = tokio::runtime::Builder::new_multi_thread() 8 | .worker_threads(2) 9 | .enable_all() 10 | .thread_name("junction") 11 | .build() 12 | .expect("Junction failed to initialize its async runtime. this is a bug in Junction"); 13 | 14 | rt 15 | }); 16 | 17 | /// Spawn a task on a static/lazy tokio runtime. 18 | /// 19 | /// Python and Bound are !Send. Do not try to work around this - holding the GIL 20 | /// in a background task WILL cause deadlocks. 21 | pub(crate) fn spawn(fut: F) -> tokio::task::JoinHandle 22 | where 23 | F: Future + Send + 'static, 24 | T: Send + 'static, 25 | { 26 | RUNTIME.spawn(fut) 27 | } 28 | 29 | /// Acquire a static/lazy tokio Runtime and `block_on` a future while 30 | /// occasionally allowing the interpreter to check for signals. 31 | /// 32 | /// This fn always converts the error type of the future to a PyRuntimeError by 33 | /// calling `PyRuntimeError::new_err(e.to_string())` 34 | /// 35 | /// # You're on main 36 | /// 37 | /// Checking for signals is done on the current thread, as guaranteed by 38 | /// [tokio::runtime::Runtime::block_on], HOWEVER, checking for signals from any 39 | /// thread but the main thread will do nothing. Calling this fn on any thread 40 | /// but the main fn will do nothing but incur the overhead of running an 41 | /// unnecessary future. 42 | /// 43 | /// Checking signals requires holding the GIL, which would be a bad thing to do 44 | /// across an await point - instead of taking a `Python<'_>` token, it 45 | /// periodically calls [Python::with_gil] and briefly holds the GIL to check on 46 | /// signals. See the [Python] docs for more about the GIL and deadlocks. 47 | /// 48 | /// # (Not) Holding the GIL 49 | /// 50 | /// In addition to the signal check not holding the GIL, you should ALSO not be 51 | /// holding the GIL while calling this fn. The future passed to this fn and its 52 | /// outputs must be Send, which means the compiler will keep you from passing a 53 | /// Python or a Bound here. 54 | /// 55 | /// HOWEVER, the caller might implicitly have a Python or a Bound in scope, so 56 | /// before calling block_on this function (re)acquires the GIL so that it can 57 | /// temporarily suspend it while `block_on` runs. 58 | /// 59 | /// The Pyo3 authors recommend a slightly different, finer-grained which will 60 | /// appears to release the GIL while a future is being Polled but not while it 61 | /// is suspended waiting for its next poll. 62 | /// 63 | /// https://pyo3.rs/v0.23.3/async-await.html#release-the-gil-across-await 64 | pub(crate) fn block_and_check_signals(fut: F) -> PyResult 65 | where 66 | F: Future> + Send, 67 | T: Send, 68 | E: Send + std::fmt::Display, 69 | { 70 | async fn check_signals() -> PyErr { 71 | loop { 72 | if let Err(e) = Python::with_gil(|py| py.check_signals()) { 73 | return e; 74 | } 75 | tokio::time::sleep(Duration::from_millis(5)).await; 76 | } 77 | } 78 | 79 | Python::with_gil(|py| { 80 | py.allow_threads(|| { 81 | RUNTIME.block_on(async { 82 | tokio::select! { 83 | biased; 84 | res = fut => res.map_err(|e| PyRuntimeError::new_err(e.to_string())), 85 | e = check_signals() => Err(e), 86 | } 87 | }) 88 | }) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /junction-python/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junction-labs/junction-client/f97d3db9ea61f2590a1d1ca082bfca90ba2fd565/junction-python/tests/__init__.py -------------------------------------------------------------------------------- /junction-python/tests/test_urllib3.py: -------------------------------------------------------------------------------- 1 | from urllib3 import Retry 2 | 3 | from junction.urllib3 import _configure_retries 4 | from junction import RetryPolicy 5 | 6 | 7 | def retry_equals(r1: Retry, r2: Retry): 8 | return (r1 is r2) or (r1.__dict__ == r2.__dict__) 9 | 10 | 11 | def test_retry_policy_only(): 12 | expected = Retry(total=1, status_forcelist=[501, 503], backoff_factor=0.5) 13 | (actual, redirect_retries) = _configure_retries( 14 | retries=None, policy=RetryPolicy(codes=[501, 503], attempts=2, backoff=0.5) 15 | ) 16 | 17 | assert retry_equals(expected, actual) 18 | assert redirect_retries == 10 19 | 20 | 21 | def test_retries_only(): 22 | expected = Retry(total=123, redirect=789, connect=123) 23 | (actual, redirect_retries) = _configure_retries( 24 | retries=expected, 25 | policy=None, 26 | ) 27 | 28 | assert retry_equals(expected, actual) 29 | assert redirect_retries == 789 30 | 31 | 32 | def test_merge_retries_and_policy(): 33 | expected = Retry(total=123, redirect=789, connect=123) 34 | policy = RetryPolicy(codes=[501, 503], attempts=2, backoff=0.5) 35 | 36 | (actual, redirect_retries) = _configure_retries( 37 | retries=expected, 38 | policy=policy, 39 | ) 40 | 41 | assert retry_equals(expected, actual) 42 | assert redirect_retries == 789 43 | 44 | 45 | def test_merge_default_retries_and_policy(): 46 | default_retry = Retry.from_int(0) 47 | policy = RetryPolicy(codes=[501, 503], attempts=2, backoff=0.5) 48 | 49 | (actual, redirect_retries) = _configure_retries( 50 | retries=default_retry, 51 | policy=policy, 52 | ) 53 | 54 | assert retry_equals( 55 | Retry(total=1, status_forcelist=[501, 503], backoff_factor=0.5), 56 | actual, 57 | ) 58 | assert redirect_retries == 10 59 | -------------------------------------------------------------------------------- /junction-python/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | env_list = py{38, 310, 312} 5 | 6 | [testenv] 7 | description = run tests 8 | deps = 9 | pytest 10 | commands = 11 | pytest {postargs:tests} 12 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | edition = "2021" 4 | version = "0.1.0" 5 | rust-version.workspace = true 6 | 7 | [dependencies] 8 | anyhow = "1.0" 9 | clap = { version = "4.5", features = ["derive"] } 10 | semver = { version = "1.0", features = ["serde"] } 11 | serde = { version = "1.0", features = ["derive"] } 12 | serde_json = { version = "1.0", features = ["preserve_order"] } 13 | toml = "0.8" 14 | xshell = "0.2" 15 | -------------------------------------------------------------------------------- /xtask/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | cargo xtask precommit 4 | --------------------------------------------------------------------------------