diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 6fbc9065..d93732c1 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -5,9 +5,11 @@ on: push: branches: - main + - rust-next pull_request: branches: - main + - rust-next types: [opened, synchronize, reopened, labeled] env: E2E: "1" @@ -31,6 +33,7 @@ jobs: apisix: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: version: - 3.2.2 @@ -79,10 +82,22 @@ jobs: # Run E2E tests - name: Run E2E tests run: npx nx run backend-apisix:test + + # Run the Rust port's E2E tests against the same live apisix instance + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: rust -> target + - name: Run Rust E2E tests + working-directory: ./rust + run: | + rustup update stable + rustup default stable + cargo test -p adc-backend-apisix -- --ignored --test-threads=1 apisix-standalone: runs-on: ubuntu-latest if: contains(github.event.pull_request.labels.*.name, 'test/apisix-standalone') || github.event_name == 'push' strategy: + fail-fast: false matrix: version: - 3.13.0 diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index fc0ad647..60770ad6 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -3,9 +3,11 @@ on: push: branches: - main + - rust-next pull_request: branches: - main + - rust-next jobs: test: runs-on: ubuntu-latest @@ -35,3 +37,28 @@ jobs: run: npx nx run backend-api7:test - name: Run OpenAPI Converter unit tests run: npx nx run converter-openapi:test + rust: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup Rust toolchain + run: | + rustup update stable + rustup default stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: rust -> target + + - name: Build + working-directory: ./rust + run: cargo build --workspace + - name: Clippy + working-directory: ./rust + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Run unit tests + working-directory: ./rust + run: cargo test --workspace diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.cer b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.cer index 70a23a31..54a8c928 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.cer +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.cer @@ -1,17 +1,19 @@ -----BEGIN CERTIFICATE----- -MIICqzCCAZMCFHJ1qdQ562AtZhnNb2g5RP964zFtMA0GCSqGSIb3DQEBCwUAMBEx -DzANBgNVBAMMBlJPT1RDQTAgFw0yNDA2MDYwNzExMzhaGA8yMTI0MDUxMzA3MTEz -OFowETEPMA0GA1UEAwwGUk9PVENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAhWEhjd+3TF6tuGdEL1GD9VgaeEemXSYpjI2iaYs2MqtS6ge0QmUYmjgS -hikCkA+hOIwhLFB8Cy23BcsEIQFDGs53HThOBt+harW4+M1bz+rQb+DQNhS4LmqI -VbriMxR5ReLn76rJnc1SXVgjyuLQQrVDPESGpnqhvkPIDVR1xcjklE3iNVH7Dy7o -XH/xRO0h1RqW4JmBrW3z9IjeP80oTjZFpa/Vse3V64CBsR5VEB6Y0qs97PlTTsoM -u7/AIcvb6y4t+qMksKT3HUBsu+QGkRpbjFCcd3zacezRk2FcRU1njTuQaxwBQNBw -cQ25nMO9vbo1uKjrTen+68oQp0wR7QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAY -LYET3babzbqhw8OtrhdyON7GT1DqBeB+2PS1s+9glUCuk2Ov4lFwdXDklxVhYpFt -XNVlGqbYhI0ypTF/us2jKgrJGpusJt+xQ+03so1ysp49TfptfZPF3vaJsdzePn2V -yCK2Yj0O8JZcHWQEPQR+qbP6l3k+WkAQ2iCJhbXzafemNmixRxS/DpgYK6pe7XhF -emlTdRF4J0YMJPfkpaoSNCpp1gIYt+xbnDxOA+72sOJJN1BH2w5oAA+JLYFkAZSC -pMG8eWGt/+9PcF2LFN+D1vW7K8nshmsoqS6OrjFWEP7C68Mjj6XFfL0vzODWntph -1wIUTc09QrjQzMnTwxpw +MIIDFTCCAf2gAwIBAgIUDyTDepWtyOWY9e913mEUi9YaQnAwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGUk9PVENBMCAXDTI2MDgwMTIwMTcwNFoYDzIxMjYwNzA4 +MjAxNzA0WjARMQ8wDQYDVQQDDAZST09UQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCwT1VIXE1wRIdkHbpR1jAjI2o9Jfbrj5el9j9hDfMO1Jy75wM3 +tyf6ECDCsUdQOa+TrMzP7ihPK+6ynlm3rngLXi9rEV1iJiq+FhrxiUvZV8gtH0g9 +WpfpPr05Gv2PRM+iZimOyeFHMpj6vScyvC9B4fnIEj+Y7TBE/jZ8OGNrKO0d9+93 +FJsRaGJH58pDeonVt/Btez88qu93oM1vNlXiBS/g88t+H7WjMvAtuwgm6AKmaVLw +de5P7LTWLANKdhbqUd62dqVfPE+XFA0UpHywz1praOe6rvtJK58kK5TUrRzzLLrA +byNTAmUX3CWYzIXUZ/3IZSyzQVUmQe6sf7S9AgMBAAGjYzBhMB0GA1UdDgQWBBSi +pEuDXSP6nxr4THoHpAHVfnBimTAfBgNVHSMEGDAWgBSipEuDXSP6nxr4THoHpAHV +fnBimTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B +AQsFAAOCAQEAr6q2dixAl+S/pDCQrvOgfysJ+M2LuPXca1n6MKhC7tkLY0hpI/lO +xeHz384Zz0MMMCHWLJDd2oOaZGbQ8cATJwlw5pF5WNP89X4LC7RWFu1E3paZuHD6 +dTQDqGQkH7fx2lLO5H7XICFsksQ5O8++JAkMrQCV4eCg+VP7XPd2/9XA8aoRuK89 +eKAIiTKmynrSx8I+9Nt7fERnuR+GpIi9lOe9Mr4t8WVlxUarjUhdF6qwa1baTCOs +IIovKANSEyVWDo1/mgwYkUOrf425q/t9Ze+fN801M70GmWCB/hFwyqhtx/HRgkCD +pIzp0f15SSQt6DDjFCv3qpK5O/9zPN8RGg== -----END CERTIFICATE----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.csr b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.csr deleted file mode 100755 index 1586ce87..00000000 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.csr +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICVjCCAT4CAQAwETEPMA0GA1UEAwwGUk9PVENBMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAhWEhjd+3TF6tuGdEL1GD9VgaeEemXSYpjI2iaYs2MqtS -6ge0QmUYmjgShikCkA+hOIwhLFB8Cy23BcsEIQFDGs53HThOBt+harW4+M1bz+rQ -b+DQNhS4LmqIVbriMxR5ReLn76rJnc1SXVgjyuLQQrVDPESGpnqhvkPIDVR1xcjk -lE3iNVH7Dy7oXH/xRO0h1RqW4JmBrW3z9IjeP80oTjZFpa/Vse3V64CBsR5VEB6Y -0qs97PlTTsoMu7/AIcvb6y4t+qMksKT3HUBsu+QGkRpbjFCcd3zacezRk2FcRU1n -jTuQaxwBQNBwcQ25nMO9vbo1uKjrTen+68oQp0wR7QIDAQABoAAwDQYJKoZIhvcN -AQELBQADggEBABXzWtLjaZuxW/WweDb+HCwb5bYBrYfPqqjby1acjl68eYlo0ZFM -kZYPKHmxp+NVbtGORAXvFDmNt9Kfguk0IfZse9n1jsfTozpHM6hGTQJGjPIxA9Sh -l91PNPCODnFy/bg9J9BEQ6HKK7+pUn3cY6BA3JWQ9hJyV1kP8Dir/zhoinhs7aUK -+f+FS7YNWwezqQacBFkZKybomikxkhREWMyWaRk+aflDvGRvGLZ8Wa0ykruyzJV7 -3Hpl+d3RSP7xq+r0ETBsJQ4HYwjRc1glJv7+BVyxZoIbpuPK0oyKafezHSThn+Ro -tRIWwZAjRGHvqpcortiiNR3sL8cXMFCUw1w= ------END CERTIFICATE REQUEST----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.key b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.key index e0d13983..5ef418f2 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.key +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/ca.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCFYSGN37dMXq24 -Z0QvUYP1WBp4R6ZdJimMjaJpizYyq1LqB7RCZRiaOBKGKQKQD6E4jCEsUHwLLbcF -ywQhAUMazncdOE4G36Fqtbj4zVvP6tBv4NA2FLguaohVuuIzFHlF4ufvqsmdzVJd -WCPK4tBCtUM8RIameqG+Q8gNVHXFyOSUTeI1UfsPLuhcf/FE7SHVGpbgmYGtbfP0 -iN4/zShONkWlr9Wx7dXrgIGxHlUQHpjSqz3s+VNOygy7v8Ahy9vrLi36oySwpPcd -QGy75AaRGluMUJx3fNpx7NGTYVxFTWeNO5BrHAFA0HBxDbmcw729ujW4qOtN6f7r -yhCnTBHtAgMBAAECggEACyvir2s3WJOuMZL2AI/vdrC4QtkezjrV4bpeiere5r6N -ICH2D1ZACca8MnV5NqTAfafVrXcDn2dm/CVlb/hBl8YG7wZvbp7zelMZ2Oj7vCia -6GFS6dIGx93PaSgmgwhqIe2Kkfs4ONng1gWoYHlqinPRMQPnp/D/NBxMQ2WOxGUw -tPo0NMwj7pKxNkXvACfbx3699DDnF9sLia2ZOn71MY2A7FHMN744eY6lOvzvJ/KT -m1Y0QPaiRUzJmY8w+jXTG/3Pcg/SPu+MiObgzeHkHBzNPLovFaMj1CJTJcuih/Od -4epcAliFtbHyrb/N1sl6X6z53bVEWOWXaGWC76efdwKBgQC5mqleNKzd24kEubp7 -DIvA7WuRmgKji7BHjBRE7ZRr85Fq+JkEH58S/4oY8uXpFC523s+t6CzVn7tgVGvV -ed6LcFeon6MUeEvNqpYow9ku/MbzPn+dOrasS9yEzI7ROoIpuHgdXd2s/Y7ukC4Z -gvVv4SGO3j1TeBO2pfUV6uXRjwKBgQC3964lPEUpot1L7R62fXTyJ+7wfoGibIps -jRcqV+EeZJt9LzBtthrMGqYOINfGWXVThjjfMX2H5Ig1CuUE5t2TLgW0jPWuE0/x -09KM77dclr9PVA3OKPNZoA1NTq/kVUJQeA9+vYfzitY5XR28Ak0qav6rQHjZ37Yi -jy5ARa5uwwKBgB79AZYm+U25X7EYSVhOCe9WNIWEzzf7FJ19d8ziVcuISRkxFGsp -1GdZnvb3Zwd5RSC3prkEcKfiGWjF75Me29cwFJKkxJegVheqiZOYz2QW9CicoLXh -nao6qEDL3nR0blME55kPmPlPBFQ0Yl4EDXJ2hiHSXS7Yd4IhR0A3jdNlAoGBAIcs -C978K+1t56BUOE7qW7VaNiyrJ2FK02LzQGQycgy866rNs43JUmNJ6V1UMHdjX9vh -MYR9frkM6C/hM2mooIH2PObu883WDtWnSHuZ32a+tQ76ubITUMs32M5G1OK26qTp -sqjzZiNCjilUC/cK0dwrBbibBLFBuTgncFp1WLe1AoGActRr0ocNGwchOLFcTwJ2 -hTVHlK0F7eoOgt+hBb7lPSW3jRJFxmJq65BUPTnYU2j+o34kHQnIt1reb5s5Lstu -e2h4xLO4vE8WMDp1WYwV6BgcEsVkbxAX5J/hsDzRPnGq49oVEz1uIup7uGZq9Bgr -xJ0fTr/SVy7uXF3T+zLZlAk= +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwT1VIXE1wRIdk +HbpR1jAjI2o9Jfbrj5el9j9hDfMO1Jy75wM3tyf6ECDCsUdQOa+TrMzP7ihPK+6y +nlm3rngLXi9rEV1iJiq+FhrxiUvZV8gtH0g9WpfpPr05Gv2PRM+iZimOyeFHMpj6 +vScyvC9B4fnIEj+Y7TBE/jZ8OGNrKO0d9+93FJsRaGJH58pDeonVt/Btez88qu93 +oM1vNlXiBS/g88t+H7WjMvAtuwgm6AKmaVLwde5P7LTWLANKdhbqUd62dqVfPE+X +FA0UpHywz1praOe6rvtJK58kK5TUrRzzLLrAbyNTAmUX3CWYzIXUZ/3IZSyzQVUm +Qe6sf7S9AgMBAAECggEAGH1oj/TMR+Io+Unc0dXB2Xunm+dMWVOYgbPHAXT11FNJ +kxsnWgOdsLExT8HesUDWB9n6mA5ReI/WZ+axeR9pqtCSHxnqIgJBsSYyZ2cGC2JT +WpssBmOxUrOFhqteWZp6CemNb7lQirp3P+rqyTQVD15zeuw3+8bEi+zQxifx/VYW +n/WqOvEP5ok+H/xNob3JuZxOI0yy9N/q1+ePC71dYWeXRy/0SNT1uCRQguRzHMlV +EMmY4EUx/XHOQbwYCr/SX3g0swAX9JLR2nTifsSA4IQR6jxzfFMyLWz+gybRyUFz +wWTAEaK3Om3Jmqk5eykoTpxGwUatcaZooQIEkjkx0QKBgQDgV8acx+n94fw9GhZg +nFanwgb11gyIcmrsPCZ331k1hW6gLiG218GZhxnlxsV2fJmJ3exu299EGOG1tYy8 +B9OprtH9n/YQCAS+FhwOw4tsHqYvSY1v3j7N2bB9Tn87fSEuRqf5wo4nqMeKZTiu +j7laRMx6fy0T6BxjFwcNd50psQKBgQDJMGaQ+mXvNPHBKEQXF840MgxIkDlL488u +hhvhpkPsH/VR2AFVSXBvz78BGiN4gO8Qzm5Y7F2Dl8CsBruzpW4m3Qk+GiD4bFmT +pbed7MmTBwz4pRzWkxzKB0cbyMxVELqp19GpPS/nXSz33JDi8vs2FcY70eg7UWNT +cp2MyPzyzQKBgEuBH0Za/kTFTyidZBSl6YBSjBcnMFan85aIdAX6NMMPonjkrdIk +8XKjhrLP4FuNefttpop1E8KOgUCv1qreE33Tabdro9wnZrk3JcBOTdlYw2O85I5X +BVowvvadebLi3Uz+2L/1m/R7GJValeYiVyarawp7TQebIhqzgFsm+80xAoGBAJgb +tOAVwK26/LDKDbjb8bX6xNiz2e3rnb+R+1B+UUob23D2fAOMU6rliYZ+yaSY7qZp +PHqUshsEx9IIAS/Qelh9szg1rwnDeXIsz4dh+ADmSsNOql/8t75zBCsYhIwn6sjw +d/6ZWTd2dcIsC4rapF1+1vJxQEBgx1TXChDgAIZdAoGBAI0eZrv1hIP51XUmgmo9 +SmCBSKJmfGbZJR7HklS2Lg4pgT4/nVUEfNhqi4aq6ZBKWPrfVTUiJaPeevcX3W9z +azGtD8f87HfdngFguYQ7g9hjTKJeG28IFwDrE4DB/ABBPO0330PlET+DHZQN4J0Q +koVjW68iPZiEouw5w5kcw4Cy -----END PRIVATE KEY----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.cer b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.cer index a7198822..84736b93 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.cer +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.cer @@ -1,17 +1,19 @@ -----BEGIN CERTIFICATE----- -MIICqzCCAZMCFGtUTWJsSz7Li0uWX+lN/rHcDmg4MA0GCSqGSIb3DQEBCwUAMBEx -DzANBgNVBAMMBlJPT1RDQTAgFw0yNDA2MDYwNzExMzhaGA8yMTI0MDUxMzA3MTEz -OFowETEPMA0GA1UEAwwGQ0xJRU5UMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAtRnzgCX/VsqbTqbDuVlzq2E30O01lzBMUpT6JAOao8nDs0euovUNh2QM -6CdElhNjeF3yWtVHgl4kAWRMbvdr9LarqiiVvHk6B5eO+tIhT8Nw7fuTbjpAtzjk -5Zk4yMjiy4voNzLKY6LuhCO2KtSF7Uo6krBb8RtSgW1kElkYrqBoX6q8Z0PmH+5w -jyjRpsOFVGYDVfIjiJyy0dAu1ifwSFvfgvikGLzkFg+QnUTtiGZ6jDSqHBgN+U4B -WDkIClzHlT3jJKZW/KSDMc68q8NTNTN2gKDU5Lo0wvQpNtmczEFVRImn6KXFn0fP -Ok4AI+NmSWwtAfs+2dm3gl/lHUoakQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB/ -RgRCBM21IqfhyCmIf0E0KzTAuRFSo4wgk0MTXddl0w8XDOdLAP4T1IXwPYKkwAtA -6pEhNpYep6kAy32k8NcbirlLp/URzwI7C8Z8+3l1xyl8GV0ivDfE1WPVWk9uwDwn -qJfuqi+BZF+9P0wtzCvov5bXlbaK0Gf65S12dKKw5GRERFU52YcQXUexd+Gnrr9x -Xgoa7qJ+7MzVB8QBx7Jsdbks7fFa371S/450cp4vYTYVF0MAOPOxzXg1BBtp8I+/ -TryUg/G/u/7dnals+GmucCgJ4AqNFLqOPsVwpZBXErdqJBDgxFSRQUGJ1DRHR9lP -8cx9TgbbU7wYLWP+rjbL +MIIDJDCCAgygAwIBAgIUKwrGWAbV3/Ek5PATGrGnqOmwspkwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGUk9PVENBMCAXDTI2MDgwMTIwMTcwNFoYDzIxMjYwNzA4 +MjAxNzA0WjARMQ8wDQYDVQQDDAZDTElFTlQwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCjBj/zeUj6W5wOPod9Lf4SqiUELXUBd1lwpC+MCNUqoc0oosy1 +jgP0IhjQtzLRJmw1uK2BNvjH0VZY5sXWNFJYyeTs/8s1/5Akq/77ZT0F82bVHDrL +oq8iLSOSGzUQYqtBq7E67LeBkCnkp40A86v7EAbS5lFaK8GZTU/Cs/SatEPe5XdV +7fsgllawZD32YqWalN64Av56TKu2GPMCPXAsI9LLt9VxabZt8dW6vMdEfs9bwoAS +eMTbbKZNZUzO1s3atkJuyxjvvJf7MyV4LHgYmbnJUarV1vlhxzPNCLgINXtOVCDC +gP7rTYgXqMO0yRnoWh7i2VhvsJovAotSSMOrAgMBAAGjcjBwMAwGA1UdEwEB/wQC +MAAwCwYDVR0PBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBQh +hX+uw/G4bsqh1jtDjh0dIz0rKjAfBgNVHSMEGDAWgBSipEuDXSP6nxr4THoHpAHV +fnBimTANBgkqhkiG9w0BAQsFAAOCAQEAqKkIiaTFPcwqHO73R7DVj0M4PxZ7+SLq +OemJmP+Gdc5oQtf4v9IlakN2z4v9lAAD6Bx54MOQNIuailGXyxgmI08yiFBdiLak +ocw5KTbi97YbeDZ9dXxPFJiGdJdyf1rk5RG3GHczmgGXa4ifU92LHR+aewoOILuB +TuoYPCbD8LOgNm6MI4j1P+P8IDH3BMCeQ4jsP3Rejkao21i6hvnhE+GlqbMB5sZB +K92Gyu1QIxUyQvA5cW99YH2EgA+wFOzcOaU05MOOHtJJIuoSLaEUaTbV+xM/NU0/ +xN0OsZoOtQXh1y/n99u1RtQnQRic6+ZyA3STquC8SQ16dNOJFnPhHA== -----END CERTIFICATE----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.csr b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.csr index 034bc7a7..0f76a406 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.csr +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.csr @@ -1,15 +1,15 @@ -----BEGIN CERTIFICATE REQUEST----- MIICVjCCAT4CAQAwETEPMA0GA1UEAwwGQ0xJRU5UMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAtRnzgCX/VsqbTqbDuVlzq2E30O01lzBMUpT6JAOao8nD -s0euovUNh2QM6CdElhNjeF3yWtVHgl4kAWRMbvdr9LarqiiVvHk6B5eO+tIhT8Nw -7fuTbjpAtzjk5Zk4yMjiy4voNzLKY6LuhCO2KtSF7Uo6krBb8RtSgW1kElkYrqBo -X6q8Z0PmH+5wjyjRpsOFVGYDVfIjiJyy0dAu1ifwSFvfgvikGLzkFg+QnUTtiGZ6 -jDSqHBgN+U4BWDkIClzHlT3jJKZW/KSDMc68q8NTNTN2gKDU5Lo0wvQpNtmczEFV -RImn6KXFn0fPOk4AI+NmSWwtAfs+2dm3gl/lHUoakQIDAQABoAAwDQYJKoZIhvcN -AQELBQADggEBAFWS8ymjbYN2dQU4KwlCvXwVqWgCLhP8T9TsUQwigmmbeX9ylv+j -Y8UkTitNmOSI+KxSFHP85rRS28pWDUU7FERljUxQSZcBZX6Ibgj9VaVZnIX6sGi+ -rzIWuaZEe5303MiGjxzlFdISfIGJP1zdsmpSg94EvuMLT7Wx329wU9XpY8/QXzZx -pDvsNlpEXkRErTjuxJrP8izdCM59ANXQ8FO5ADQOrtOcJzg3T9CN4deQ6erSF5lV -8NBcxPJM91pdXCwwWZsANMr1Hzb5nJYI25e5vn1DiFunrhtUDI6OCuz+TAkDwqID -W/37OxZ9SO22kDKP4KN7Lgax1i+Wym2t3P8= +AAOCAQ8AMIIBCgKCAQEAowY/83lI+lucDj6HfS3+EqolBC11AXdZcKQvjAjVKqHN +KKLMtY4D9CIY0Lcy0SZsNbitgTb4x9FWWObF1jRSWMnk7P/LNf+QJKv++2U9BfNm +1Rw6y6KvIi0jkhs1EGKrQauxOuy3gZAp5KeNAPOr+xAG0uZRWivBmU1PwrP0mrRD +3uV3Ve37IJZWsGQ99mKlmpTeuAL+ekyrthjzAj1wLCPSy7fVcWm2bfHVurzHRH7P +W8KAEnjE22ymTWVMztbN2rZCbssY77yX+zMleCx4GJm5yVGq1db5YcczzQi4CDV7 +TlQgwoD+602IF6jDtMkZ6Foe4tlYb7CaLwKLUkjDqwIDAQABoAAwDQYJKoZIhvcN +AQELBQADggEBADu9fRsjFYfCO37Br0bJCKWXTPg3oyW1tRJqxjDqsAZrVEOyUSi7 +/S9+IEUfXp14l1LbKQukiScD/nWIayMO3cZnPe1247tnDIrDLfnolmLy/h1fDAVi +rDQn8FuaYMAghYpBgf/uth6Tn8ACREi3ij7mUs2zoLhTYtbAUe6YbUiFvLnTBFtW +ADV8V6d2j1D8KcpTtQCyEmo26BUVdisD5iV1zaTNWMM/iIr1l3AA8u4Le7MnC/5w +2TENR0adLoR96BFp+/b0nwzSCnUUMGx12yzpnRwwD3BVTMTbMrWxGadI9kgDjy2t +prreJGjBeAB5aUrDTdTUTHl1t0ZQa/nmlCE= -----END CERTIFICATE REQUEST----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.key b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.key index 8395a0b2..072854db 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.key +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/client.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1GfOAJf9WyptO -psO5WXOrYTfQ7TWXMExSlPokA5qjycOzR66i9Q2HZAzoJ0SWE2N4XfJa1UeCXiQB -ZExu92v0tquqKJW8eToHl4760iFPw3Dt+5NuOkC3OOTlmTjIyOLLi+g3Mspjou6E -I7Yq1IXtSjqSsFvxG1KBbWQSWRiuoGhfqrxnQ+Yf7nCPKNGmw4VUZgNV8iOInLLR -0C7WJ/BIW9+C+KQYvOQWD5CdRO2IZnqMNKocGA35TgFYOQgKXMeVPeMkplb8pIMx -zryrw1M1M3aAoNTkujTC9Ck22ZzMQVVEiafopcWfR886TgAj42ZJbC0B+z7Z2beC -X+UdShqRAgMBAAECggEABPODboC7cSRDaDxTdA0PxkL3a3Ecyrghkg1sJFFr6Cfz -P4LJcb+Y4dd4qd0u+RppGQiagGS92oDX46jaFRuTGkLnQRTweRFoZn48hLt7eSqK -+xqqbnRNwiSeT2+nt4eUecOmsuGi2mQBOOAgEh4y1ii2Hr4PGXGpiQvUVVMVw2pn -8ult6mu701EiuvoG4nQ+tHwa0d4x6oY+PHx6CNIcw9wbAwG1NJRVzjU6Nr3LZ2GT -IjfkRbf/VNey/gnQ3lQkAZL1BUMOYoPwyUwtyRfX9Q4YDEV67hj6eQ3k4S9eQdVA -F6dkD+U7J4VPNer1G0EIQgLFuxl6MWpW0IebilpIxQKBgQDCxw2aKKq5s0xS6QEt -gO1ixl6YWFfSkNvPn5Q5M3kVSR7PYdTnELyJHjcBlVN0hZQSOitcj+PLyru0loXf -L7y8Dvbk6WLUBObfGK5Ya1Mkz20luv2TCjV/VhC23uFqGWruFakJTaqsXBta1xQb -hV1pmET2S23v3VRChdyTKIo9TQKBgQDuBm6m5D4ydCPOQ8hu9usmoTOhbkJe3fD/ -9AQ8CAiIxnagBr9WQvfVuqd0tdvy7ncJFjPz+9LzusPm2fHlXBIMfnXFlc0UzJ3q -N34ttIsI8UltHdw8hTK5EGUCh/DZHyMjgj+jPmPteGaJbfQhPYaXtR0bOkFBkkB8 -ZiVeUhLAVQKBgQCJYb7tWfu5SYbu/9vNzZ8iSUqVZ9Jh+bygwCza/7xK0C1EFvwF -Ep/4kvA3VKzthf4/sPm+qtsAdflZUcYQ+unDN0bbhOYpJ1/0QcNPKDwXvZp9v1t1 -qAC91OWrJp+Vp2HtlieJIVfdhIXi18sd8j5F6ZnSordjNRhmZ2aCAVkoDQKBgH4E -R8bx5f/+PwqwFkixT3PnOpeH8XmaVUKdZTSHKEWJlTpJ+DjqQZ4VMRz4P1eRatIT -wDO3KktBoP/yeT/uC9wzg5S1J5uQbTaYZKQ8BrQYUsCDY5tjBaS3ClNZt2isNIVT -Ku/5Uxxhdx/hZ7PFiCF/kMZehDf7/0odJkGWgDj1AoGBAIya8y+vrRJk0XeSlpFv -99tPPg9hQJThsc3JM+1XBXT0XkbiJAR4lKihuiJBm+CpsbAkLqfnz/EdE3YYC3UH -XYxu11VjQnEN1uhXMCqB35LHmnRHuiFxOaes/w889CGF2Ns4mvsLOq48QER0f3jH -1+Q6NOuao6xu5N9jypPt3ZIR +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCjBj/zeUj6W5wO +Pod9Lf4SqiUELXUBd1lwpC+MCNUqoc0oosy1jgP0IhjQtzLRJmw1uK2BNvjH0VZY +5sXWNFJYyeTs/8s1/5Akq/77ZT0F82bVHDrLoq8iLSOSGzUQYqtBq7E67LeBkCnk +p40A86v7EAbS5lFaK8GZTU/Cs/SatEPe5XdV7fsgllawZD32YqWalN64Av56TKu2 +GPMCPXAsI9LLt9VxabZt8dW6vMdEfs9bwoASeMTbbKZNZUzO1s3atkJuyxjvvJf7 +MyV4LHgYmbnJUarV1vlhxzPNCLgINXtOVCDCgP7rTYgXqMO0yRnoWh7i2VhvsJov +AotSSMOrAgMBAAECggEAS+RD7yc+FvaArozjbB32EmBMvSYgOx+BbpOwYsNV4PPT +2x/2pmpHYuZgoQE30e/OHjAr7WRW6kaEqPw9ixtuXlEWllH6GtgvFTNQj12wy81k +yL7MjDHez80tqv2pdUhJq1evONwsDU+8gOqTFKdbH5R1aMa5hn2qMaCxCdXWgBG+ +AZR1WONuK0pMCoCXG5pSnYE/xDtyh+jYMjmMY9k7oPTvMeGuXvj2GG/2JiBDj1S4 +WvAvvToH+IP92Vchnjp62f5mnJ713ZZSzbocInorl99+F1CrLvqP9aue3zs/Cykl +ysk0WbB/hqyXDFEX1LJeWmgx71X0L6ij5wYcSEidkQKBgQDhz+TV0C8ypJ6Vo7FN +2pMAplaK7xeBUEsBzGwcbw661+QhxqK3SpfxGfUrdLhNwZmHllF4MTgi4c4S3jGs +SU0SU3BVDEwdoJPxL/aHVMlVcndy+50AannRKQ0TzBnzPXYeGfMXCW8R/KYGsegL +WhBtfGGiN8S3DEnc1tBBlpkkswKBgQC40YjkeJcPUJL+Gp1CScPLYtq1G41lBTEy +md7tVhdLI8tsy2D8nahraDm6y2nh0qQaGiKpb6QFpZNpsebRBLXvezaVYU7fR17S +AcdfNZ5GN8iYHzv/2cyulY0riLkDjZE/as+wQmOzusWhoMHEjFgIyplwjBohaZqM +lDvTc2YRKQKBgQDUWoxJomjwSC02beTsYRODNeoG58eD/T08aMB2kN4aSbKpLMKS +ehyfSJjuD+rOfLTj9DGTRT4UV6NSzQ+LhLds93iZEmJ2Li9STpeZNtvLdkTspcBE +xtzeHKvGpVb2DyOINUEMtft0v9JmU2HzYvQ18hVi1u0wcub3PjBErdpAzwKBgDuX +9NgWuj7J8FvXIRYayvNt8K+tLq6AOhji3KsSs3gbFqECA0fxXjkj5lmA9adHUHrK +KpsP0IexmBLgtlh3eGK8DCGmutPsI3r779mXZG2n1xqWF7ar9//jYR3EfEPVBhy4 +yDdBcPynIMq5F0HVLx3GlitgQZy8fIUP1yqbbzHhAoGASF+v0KBEt0q34lshYWNy +X7ailP+GNP0SpRai0BeNKAiVyFbAyW+wRHmglMxmrm1jDBe1blLVHTPBRSn15y9u +nHYEaz8NUKW+tkuFFkXUa8t5PcW7HHPTgauH4UDqU36vVKiKL6YoJxgVhonV60IE +79xgBYQYZRlM7o3dU/CB+bs= -----END PRIVATE KEY----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/generate-mtls.sh b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/generate-mtls.sh index 939cf217..546ae3f3 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/generate-mtls.sh +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/generate-mtls.sh @@ -1,18 +1,33 @@ #!/bin/bash +set -e + +# Uses `-addext` (OpenSSL 1.1.1+) to set v3 extensions inline instead of +# `-extensions v3_ca`/`v3_req`, which only work if the system's default +# openssl.cnf happens to define matching [v3_ca]/[v3_req] sections — it +# often doesn't, silently producing a v1 certificate with no extensions at +# all. rustls's webpki validator (used by the Rust e2e suite's HTTP client) +# rejects v1 trust anchors outright, unlike Node's OpenSSL-backed TLS stack +# which accepts them; -addext keeps this reproducible regardless of the +# host's config. # For ROOT CA -openssl genrsa -out ca.key 2048 -openssl req -new -sha256 -key ca.key -out ca.csr -subj "/CN=ROOTCA" -openssl x509 -req -days 36500 -sha256 -extensions v3_ca -signkey ca.key -in ca.csr -out ca.cer +openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 36500 \ + -keyout ca.key -out ca.cer -subj "/CN=ROOTCA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" # For server certificate openssl genrsa -out server.key 2048 openssl req -new -sha256 -key server.key -out server.csr -subj "/CN=localhost" -openssl x509 -req -days 36500 -sha256 -extensions v3_req -CA ca.cer -CAkey ca.key -CAserial ca.srl -CAcreateserial -in server.csr -out server.cer +openssl x509 -req -sha256 -days 36500 -in server.csr -CA ca.cer -CAkey ca.key -CAserial ca.srl -CAcreateserial \ + -out server.cer \ + -extfile <(printf "basicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost") # For client certificate openssl genrsa -out client.key 2048 -openssl req -new -sha256 -key client.key -out client.csr -subj "/CN=CLIENT" -openssl x509 -req -days 36500 -sha256 -extensions v3_req -CA ca.cer -CAkey ca.key -CAserial ca.srl -CAcreateserial -in client.csr -out client.cer +openssl req -new -sha256 -key client.key -out client.csr -subj "/CN=CLIENT" +openssl x509 -req -sha256 -days 36500 -in client.csr -CA ca.cer -CAkey ca.key -CAserial ca.srl -CAcreateserial \ + -out client.cer \ + -extfile <(printf "basicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=clientAuth") chmod -R 777 . diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.cer b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.cer index 5b5d3d24..763854d2 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.cer +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.cer @@ -1,17 +1,20 @@ -----BEGIN CERTIFICATE----- -MIICrjCCAZYCFDvgCOozSi7reDVf+3IBVoi1pxg1MA0GCSqGSIb3DQEBCwUAMBEx -DzANBgNVBAMMBlJPT1RDQTAgFw0yNDA2MDYwNzExMzhaGA8yMTI0MDUxMzA3MTEz -OFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEA8TTjrcxP1n+/Rh7f7S0WIG589Ne+toBi2MXO1maJ3/6iZGGfhAlu -ofOg59OpEfoz6WgCOiVdwQvPAEHs6XXw+mS5B3oEIB9/WMrU6P6Ew/zYNjVyxhT0 -dGQg1ug7SnqOdpieGRk7f+2qRDXt+j7TybeEGZ81lUXrDLqPnkC5sqJAQCbj1X2s -tEWMquROH56kp99lL/PF1swVwNa+pmoKnc+S3vhcMK2PvkfGKQ/HJAoVMvyF9rwR -Mr9cuEmXm0irqIMd/cy7y4Sfvk5snPfKzJ40HUl0tHkmxkQV2SvSO9soJH/4XmIm -YRDN2HvA2zZfGTxz4mzbnIMcI+xieleFKQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB -AQBQVw/bIaFZx8JbdNqRwGy0ryZT7U/D5fdqKel1kWwjdTFMldtH1jX4LMuBR/LE -Iscsv2pXypGjuSPmPtHl9+fcg31d5VFFpg+SvLkifyP7iPZoe/cr5erK/ytoJD35 -64APQuYzz3+BzgLLHhOyv5wAr+Bb9cSweXGyyKHPB+zVp8LthYMOnmlIxXrKCnmi -skRtEmI3RPhMF/Vrkm44mkYB19P7cgzawuvxn3ooYHBjI0pLsoxRXyj5QJurJGsq -6Qkt2omsqNCZuz2r6m8jHXdtGMKjQm+KUww5ntDo68r2xHpftCMPgX5EKeUxvlAD -BT6Rk9y/p4Gz2jsFsJLfKjwu +MIIDPzCCAiegAwIBAgIUKwrGWAbV3/Ek5PATGrGnqOmwspgwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGUk9PVENBMCAXDTI2MDgwMTIwMTcwNFoYDzIxMjYwNzA4 +MjAxNzA0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQD2+D35BLaVPV+T3sXFNMOVq9kSsNwt+Ae/F4a2ufmtW0U6 +oXWLJ+jXdYfRqVvi+lkonKR+DNz14XqANW6T38nfJKOOwBuxwiGlu3zbptpAtkFO +gionbAxSjXPHiTrbvBapDM8HtHT3D8ZKImC9XOX5vMkpvvCENCw7sfBz2Q1gIN8u +mPsgjvXNjMJit3ybGIs36VERqDVnElGDK8Z1coQEV1Qc1Y22MOcKQvSMTlqgsVGs +8qAo45Jp8XCXxxhJFQRVF8Og4YLm+xBRvzHNIsveMYih7lj5RQQLwQyZNS9vaPEf +AXbH+VLXLZdPDGR7IOeIy96CAAyi2CiBeAXMmXrRAgMBAAGjgYkwgYYwDAYDVR0T +AQH/BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0R +BA0wC4IJbG9jYWxob3N0MB0GA1UdDgQWBBQO4Vc7tqqkrO784K5T6l92QN+zlDAf +BgNVHSMEGDAWgBSipEuDXSP6nxr4THoHpAHVfnBimTANBgkqhkiG9w0BAQsFAAOC +AQEAoPCzPLpXxOS2CBRjfQtgiAbXuIhjWHRWQN9ihFn6/lmg1gp4ZFiqLzVAe04U +PUG04oW22xHd6jMSy3m8pvbAWZ1uCqcoZ1VT8g13RokQ6tP67IiZOCePcmR800eN +ZWzBXyK8o+ov1UrklkDvAAC2AerOWf80JBx1BqzmqTJnbwEC+zVRMH0k+luuAxTV +0nXCeh4t5jtY39Fc7vmZqi7n0egPQVxt3RAswpLq7HJGF+VA81Sm1oRcP3Xwbkv8 +NryB8B8bC7TgJNsQ8vJrnPxMz8qDrfnAsF+xr9iJlWjMKSPRZHztK3gnIe9xnBVi +2o9uIu3IfjsUPRXF7FtVGXAfbA== -----END CERTIFICATE----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.csr b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.csr index 701dee4f..8b427bc3 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.csr +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.csr @@ -1,15 +1,15 @@ -----BEGIN CERTIFICATE REQUEST----- MIICWTCCAUECAQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B -AQEFAAOCAQ8AMIIBCgKCAQEA8TTjrcxP1n+/Rh7f7S0WIG589Ne+toBi2MXO1maJ -3/6iZGGfhAluofOg59OpEfoz6WgCOiVdwQvPAEHs6XXw+mS5B3oEIB9/WMrU6P6E -w/zYNjVyxhT0dGQg1ug7SnqOdpieGRk7f+2qRDXt+j7TybeEGZ81lUXrDLqPnkC5 -sqJAQCbj1X2stEWMquROH56kp99lL/PF1swVwNa+pmoKnc+S3vhcMK2PvkfGKQ/H -JAoVMvyF9rwRMr9cuEmXm0irqIMd/cy7y4Sfvk5snPfKzJ40HUl0tHkmxkQV2SvS -O9soJH/4XmImYRDN2HvA2zZfGTxz4mzbnIMcI+xieleFKQIDAQABoAAwDQYJKoZI -hvcNAQELBQADggEBABtrExq53/Cq/gcowGU+eIlamO+JH5vpZDL1l80PxQ3qE2R2 -UZwN8ocldAvcvYs0YPcBGOkNhBdqrcHaHx28el1Xu3NbUwg+YK47sbj/zcOsQEYd -MvCXtmd1r7MGyvfrsqir3fAFHry5ppIlGj47/sr39/Nv+rNGFU3lYBkmukhvOLpz -JhC6sUwmjN+nn68feYrAOtr7EnCf/D0Y711IWmdw18D/KxCm2Cl8DHVH60Tzdj+2 -0vXm9sKrN+Xzg/l7849wEUIXTCQROHdQCLuJ8bkk3T5RsnTqePUJzwnnC0V6HfMN -cJNVmV1zuf5w/gxEGEilAmytlYbPYCSJPNTrSC4= +AQEFAAOCAQ8AMIIBCgKCAQEA9vg9+QS2lT1fk97FxTTDlavZErDcLfgHvxeGtrn5 +rVtFOqF1iyfo13WH0alb4vpZKJykfgzc9eF6gDVuk9/J3ySjjsAbscIhpbt826ba +QLZBToIqJ2wMUo1zx4k627wWqQzPB7R09w/GSiJgvVzl+bzJKb7whDQsO7Hwc9kN +YCDfLpj7II71zYzCYrd8mxiLN+lREag1ZxJRgyvGdXKEBFdUHNWNtjDnCkL0jE5a +oLFRrPKgKOOSafFwl8cYSRUEVRfDoOGC5vsQUb8xzSLL3jGIoe5Y+UUEC8EMmTUv +b2jxHwF2x/lS1y2XTwxkeyDniMveggAMotgogXgFzJl60QIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAB8trRVLHzFdj080OfIpbvTGczXg1KbxyM/Kki5YKbjsQz+Q +Lm46KmkMHGQawUrJQ8cNkapLyKgvDVKdzw6Qeg3ExQml/Z7OjV6CvmTQpCuLBHBn +5P2L5KVnvkanGfUMp362dYJp6pKRqUC5a98h3e3P+Gf75CbyQ2IXPdTVgEL5gHRH +tAfFRbFHXlJRRBuZaMtVh+s8lAEtFl1/D63hLR6hhHjCycivZlFF8Dpezao8G3dG +ETPhghq656aCGSLcaJkaY2HXEn2jfz0maF20hNUubDRzR4L3sTNcng2ucyp3n5AX +4aBghoJW7Xgl+nyJZ1kUbZtF+BBEfdo+IOSP5wo= -----END CERTIFICATE REQUEST----- diff --git a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.key b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.key index 032a3674..0d7b692e 100755 --- a/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.key +++ b/libs/backend-apisix/e2e/assets/apisix_conf/mtls/server.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDxNOOtzE/Wf79G -Ht/tLRYgbnz01762gGLYxc7WZonf/qJkYZ+ECW6h86Dn06kR+jPpaAI6JV3BC88A -QezpdfD6ZLkHegQgH39YytTo/oTD/Ng2NXLGFPR0ZCDW6DtKeo52mJ4ZGTt/7apE -Ne36PtPJt4QZnzWVResMuo+eQLmyokBAJuPVfay0RYyq5E4fnqSn32Uv88XWzBXA -1r6magqdz5Le+FwwrY++R8YpD8ckChUy/IX2vBEyv1y4SZebSKuogx39zLvLhJ++ -Tmyc98rMnjQdSXS0eSbGRBXZK9I72ygkf/heYiZhEM3Ye8DbNl8ZPHPibNucgxwj -7GJ6V4UpAgMBAAECggEAALjT+PhfFs5xaqAmCHDgRj7pFdcy0Btd8GCAiCjCFI8P -umAxGN/pr7IUfbfRo5Gi/SRdZAIqSoJ4Cfv93bcHfnW1uL2B7Kdbe2RkCGyBRjro -BMWFL0LvIQ1kVxsBLm25JnFk1mtlFQgc6zEEIix0+CoZ9Z+sio1X25plWqe+m+Jv -SkfRQQ1GtQenjzc+WF+TZT0gP33c0eQwbY0AZSSE/FJtmczfRe19+GkZfHZfukIg -raLIp3HZFq7eDeZrO/lCsDdiwl8H/dA1gtMC1bwku+qVES4YtskL1c46x7Id8FdL -m1nWjUf/21NGEJns+OoTA3lZ2KlpCMHJKr/PGSHrWQKBgQD7PDGnIW9ir45Yf6Y9 -9fNVUNZLM8ecvKaMicesPsA/GmmSBGJFiY7QmyCPc2/eI1PXFNHCVlAoykaz07Nr -Y+nWf+QD5QciDSdCos9MYnbpa/whVnIlLeGCXJKM09kkaDQ4vN6ReEjpm96n3+xX -R9X99SXiUtKmnPKw4mLnwOlUzQKBgQD1yAEnjwp4mXM5/kJVZLcjpYaeEqX9fOF5 -5O9jIKbMzsRIVNuX5ItpyTvLLr0GaRWXYoMga1kov4eTOojygD2PlpSxk+8a7N2z -oTtGZ8WmypQswImVR9Ris/6jADzAzeiwWxZT0gV7bBE7O1iBfgAevWLtw+cqH1Y6 -RbNwXFERzQKBgQCK1GV/vJsnhml7f/ZmcN3pPEVewxtAAoNqT8y14usrM7Y4yRFg -6bWwkrh4bMrZjt4KkWekIzwifjx5rLeN1WVncb6XZFz/tRMH4J360MJzFIf8CCAF -aYgfGHanOX3Zf3e0DrJS4owwA0ETtUqNpJWcw3YOzcO37Cy0EDWlaVXG1QKBgQCk -HY3vzUrPpp9TXR0MCjlj2xZdnNQrxGSG0UCr71SRs4tLRSZwcVJKK+36SVY83pRl -RomKb0PUurebrt1dGBaDN6hIPyDM2NddJ879vzMyoVh53YLBJHqEAe6JBxKKJ7Q1 -dk0dYUL52/pRk9oQdYM9A3b4jvRfoxcfyAT+hRY5DQKBgHtMa6mn0vxUFd77INOP -DGG3Ostf6MwA7tS/TnVCnFNZoZnUb00y3fq3rOqBWA75Aqf3pCSl8kk1dHPeh1GU -zbGEtWFo20wendCJGim+vVs4GSp0RUV9rkT/eB3D4lPjLzVQA8AlGew9T2ZMqomo -jv5ta8/TiGiqpVLP62L2TKt2 +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD2+D35BLaVPV+T +3sXFNMOVq9kSsNwt+Ae/F4a2ufmtW0U6oXWLJ+jXdYfRqVvi+lkonKR+DNz14XqA +NW6T38nfJKOOwBuxwiGlu3zbptpAtkFOgionbAxSjXPHiTrbvBapDM8HtHT3D8ZK +ImC9XOX5vMkpvvCENCw7sfBz2Q1gIN8umPsgjvXNjMJit3ybGIs36VERqDVnElGD +K8Z1coQEV1Qc1Y22MOcKQvSMTlqgsVGs8qAo45Jp8XCXxxhJFQRVF8Og4YLm+xBR +vzHNIsveMYih7lj5RQQLwQyZNS9vaPEfAXbH+VLXLZdPDGR7IOeIy96CAAyi2CiB +eAXMmXrRAgMBAAECggEADttfKwhQDYlMIyKfJh5m962HWPr/u9o0aRf6uCpW7UlU +0FKfNhhYBwIgggncp7pku680rAqNDlOaoXTVaprnU3+qQS+oWtixWn19ZN9UONld +82GcNJPo59xvjxzfyBE4EcJd//oooSY3SQVpaWrZwNzNAzBj2qgi0xKAww9XISWq +1r1k2yHNU/79fpNkfCGUBLUKYLYQFv6x7dage67Bji6rWbnFmDxDUHJAZ7Wdstr2 +4tvqV37gqkZfELd6H0tKNgIhdjssZXENGJen4S80yAzBOZeJO3MfBoxYJnwy5wlR +O3PFxjYqKwtH+SLulTLCXvUWwMo9AjAFHLXCWA+2AQKBgQD7nuSxWThchUoicVSL +nn/znrAUdg5QB0DrM8TKQCcxBMhFCsG4qcCH/Y2FQbTWzdW0iul+YBvFDa+0/ZZB +T4Djl3/W+xiPjlaiZPyXq+IZj5Y493Eu2I5JcQDeMTDWxIzQ1myb+uLl87Mzk3DX +EVelL88EtrkJDrB7zY0vP681wQKBgQD7RKBIUOOzoDlv8+0doHvhxDSnRu6MtDaB +g91nIifjOCqKMPIDebsr3leM9/AkX5cohiS3M63Z0VRQbIoy1o2BHWm56T3zyxdC +/pm6cdRbeXXb9u5a3HpwmhPNeMeTcwkK5yYH9p3Rv8Sbe8jDqBmcGp8rrtZNek9D +uRaAlxspEQKBgQDzOVarMClRFe7heFdXVvEsFzrxrGSNa1WPhc02C+lWct78DjfQ +PxbC3Y+NPkSGVxW6nYSzCkgNpPsxouc5XpaiaXHD55iIO/j2pVOdcSLvvmJh0p5v +wBlaWIeUQEyLbhcIUB1QcaYezgFtW5p56mOkY6BjWBVo8fAa3EkbYR4GgQKBgQDY +LPK5RN6ia/b+mp4mHyx10Jh8UkpqQ6GhTBHv6TLoCiqPOgz9cH3YADLS1X2Fzbf+ +5WoxNAUeoToo9zjvBCg6LVQI3uKuHMymluUrw0MwcA82b/whsA6nUeg3nvIVqaLd +/oB52XNuQc6k9pqDutxNsZm/u2wkvFX1rgfuGhMEAQKBgDvWFVwz9736ki5LAlUF +0gwcruYEKoD41473rkdzREcz1RiqYOdI49T/qhQ9ztyKU5jTFnLBY42fL9fOx5aj +cFzrb4R1brz+kxVpA7RrmpGv10D9A1Iok4hlJ5x+xCS0fIBaBwzbtLtwjFsPmJne +eJpx8eQLCNg5XneisgMnf1B5 -----END PRIVATE KEY----- diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9ec3fe08..6bc93daf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,13 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adc-backend-apisix" +version = "0.29.0" +dependencies = [ + "adc-backend-apisix", + "adc-backend-core", + "adc-sdk", + "async-trait", + "axum", + "indexmap", + "semver", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "adc-backend-core" -version = "0.1.0" +version = "0.29.0" dependencies = [ "adc-sdk", "axum", "futures", + "percent-encoding", "reqwest", "serde_json", "tokio", @@ -16,7 +33,7 @@ dependencies = [ [[package]] name = "adc-differ" -version = "0.1.0" +version = "0.29.0" dependencies = [ "adc-sdk", "criterion", @@ -25,7 +42,7 @@ dependencies = [ [[package]] name = "adc-mock-server" -version = "0.1.0" +version = "0.29.0" dependencies = [ "axum", "tokio", @@ -33,7 +50,7 @@ dependencies = [ [[package]] name = "adc-sdk" -version = "0.1.0" +version = "0.29.0" dependencies = [ "async-trait", "semver", @@ -45,7 +62,7 @@ dependencies = [ [[package]] name = "adc-sync-bench" -version = "0.1.0" +version = "0.29.0" dependencies = [ "adc-differ", "adc-sdk", @@ -166,11 +183,11 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -220,7 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "rand_core", ] @@ -276,6 +293,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpu-time" version = "1.0.0" @@ -286,15 +309,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -370,21 +384,21 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", ] @@ -524,16 +538,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -623,6 +627,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1295,12 +1308,12 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -1596,12 +1609,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0f3d35e4..0065ff2a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -4,11 +4,13 @@ members = [ "crates/adc-sdk", "crates/adc-differ", "crates/adc-backend-core", + "crates/adc-backend-apisix", "crates/adc-sync-bench", "crates/adc-mock-server", ] [workspace.package] +version = "0.29.0" edition = "2024" publish = false rust-version = "1.95" @@ -16,10 +18,11 @@ rust-version = "1.95" [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -sha1 = "0.10" +sha1 = "0.11" async-trait = "0.1" semver = "1" thiserror = "2" +tokio = "1" [profile.release] lto = "fat" diff --git a/rust/crates/adc-backend-apisix/Cargo.toml b/rust/crates/adc-backend-apisix/Cargo.toml new file mode 100644 index 00000000..5a9a9082 --- /dev/null +++ b/rust/crates/adc-backend-apisix/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "adc-backend-apisix" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[features] +# Exposes `adc_backend_apisix::tests`, the internal building blocks this +# crate's own `tests/*.rs` integration tests reach into — never meant to be +# enabled by a real consumer. Off by default so it doesn't leak into the +# crate's normal public API surface; the dev-dependency below turns it back +# on for the crate's own test builds. +test-utils = [] + +[dependencies] +adc-sdk = { path = "../adc-sdk" } +adc-backend-core = { path = "../adc-backend-core" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } +tokio = { workspace = true, features = ["macros", "sync"] } +indexmap = "2" + +[dev-dependencies] +adc-backend-apisix = { path = ".", features = ["test-utils"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] } +axum = "0.8" diff --git a/rust/crates/adc-backend-apisix/src/backend.rs b/rust/crates/adc-backend-apisix/src/backend.rs new file mode 100644 index 00000000..8c5771bf --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/backend.rs @@ -0,0 +1,100 @@ +//! Ties the fetcher, operator, and validator together behind +//! `adc_sdk::Backend` — the interface the CLI actually dispatches through. + +use adc_backend_core::{HttpClient, Method}; +use adc_sdk::resources::Configuration; +use adc_sdk::{ + BackendError, BackendMetadata, BackendSyncOptions, BackendSyncResult, BackendValidateResult, + DefaultValue, Event, +}; +use async_trait::async_trait; +use semver::Version; +use tokio::sync::OnceCell; + +use crate::fetcher::Fetcher; +use crate::operator::Operator; +use crate::validator::Validator; + +pub struct Backend { + client: HttpClient, + version: OnceCell, +} + +impl Backend { + pub fn new(client: HttpClient) -> Self { + Self { + client, + version: OnceCell::new(), + } + } + + /// APISIX has no dedicated "get version" endpoint; every admin API + /// response carries it in the `Server` response header instead + /// (`APISIX/3.9.0`, confirmed strict `MAJOR.MINOR.PATCH` against a real + /// instance). Falls back to a version high enough to unlock every + /// version-gated feature when the header is missing or unparseable, + /// rather than failing outright — matches the TS backend's own + /// fallback. Fetched once and cached for the lifetime of this `Backend`. + async fn resolved_version(&self) -> Result { + let version = self + .version + .get_or_try_init(|| async { + let request = self.client.request(Method::GET, "/apisix/admin/routes")?; + let response = self.client.send(request).await?; + + let header = response + .headers() + .get("server") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("APISIX/")); + Ok::<_, BackendError>(match header.map(Version::parse) { + Some(Ok(version)) => version, + _ => Version::new(999, 999, 999), + }) + }) + .await?; + Ok(version.clone()) + } +} + +#[async_trait] +impl adc_sdk::Backend for Backend { + fn metadata(&self) -> BackendMetadata { + BackendMetadata { + log_scope: vec!["APISIX".to_string()], + } + } + + async fn ping(&self) -> Result<(), BackendError> { + // Bounds the response to a handful of routes rather than the + // server's entire route table — a lighter probe on route-heavy + // deployments. Unrecognized query params are ignored by APISIX + // versions that predate pagination support, so this is safe across + // the whole supported version range. + let request = self.client.request(Method::GET, "/apisix/admin/routes?page=1&page_size=10")?; + self.client.send(request).await?; + Ok(()) + } + + async fn version(&self) -> Result { + self.resolved_version().await + } + + async fn default_value(&self) -> Result { + Ok(DefaultValue::default()) + } + + async fn dump(&self) -> Result { + let version = self.resolved_version().await?; + Fetcher::new(self.client.clone(), version).dump().await + } + + async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Result, BackendError> { + let version = self.resolved_version().await?; + Operator::new(self.client.clone(), version).sync(events, opts).await + } + + async fn validate(&self, events: &[Event]) -> Result { + Validator::new(self.client.clone()).validate(events).await + } +} diff --git a/rust/crates/adc-backend-apisix/src/fetcher.rs b/rust/crates/adc-backend-apisix/src/fetcher.rs new file mode 100644 index 00000000..b91876f9 --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/fetcher.rs @@ -0,0 +1,355 @@ +use std::collections::HashMap; + +use adc_backend_core::{HttpClient, Method, concurrent_map_until_err}; +use adc_sdk::resources::{self as adc, Configuration, LabelValue, Plugins}; +use adc_sdk::{BackendError, ResourceType}; +use indexmap::IndexMap; +use semver::Version; +use serde::de::DeserializeOwned; + +use crate::typing; +use crate::utils::resource_type_to_api_name; + +/// Bounds how many consumers' credentials `list_consumers` fetches at once, +/// so a large consumer list doesn't fan out unboundedly against the admin +/// API. +const CREDENTIAL_FETCH_CONCURRENCY: usize = 16; + +/// Fetches an ADC-managed APISIX instance's full resource state, one +/// resource type at a time, in APISIX's own wire shape (`crate::typing`) — +/// converting that into ADC's model (`adc_sdk::resources`) and assembling it +/// into a single `Configuration` is a separate concern, layered on top of +/// this. +pub struct Fetcher { + client: HttpClient, + version: Version, +} + +impl Fetcher { + pub fn new(client: HttpClient, version: Version) -> Self { + Self { client, version } + } + + async fn list( + &self, + resource_type: ResourceType, + ) -> Result, BackendError> { + let api_name = resource_type_to_api_name(resource_type) + .ok_or_else(|| BackendError::Unsupported(format!("{resource_type:?} has no top-level admin API collection")))?; + let path = format!("/apisix/admin/{api_name}"); + let builder = self.client.request(Method::GET, &path)?; + let response = self.client.send(builder).await?; + let body: typing::ListResponse = response.json().await.map_err(|e| { + BackendError::Serialization(format!("decoding response from {path}: {e}")) + })?; + Ok(body.list.into_iter().map(|item| item.value).collect()) + } + + pub async fn list_services(&self) -> Result, BackendError> { + self.list(ResourceType::Service).await + } + + pub async fn list_routes(&self) -> Result, BackendError> { + self.list(ResourceType::Route).await + } + + pub async fn list_upstreams(&self) -> Result, BackendError> { + self.list(ResourceType::Upstream).await + } + + pub async fn list_ssls(&self) -> Result, BackendError> { + self.list(ResourceType::Ssl).await + } + + pub async fn list_plugin_configs(&self) -> Result, BackendError> { + self.list(ResourceType::PluginConfig).await + } + + /// A backend may define several `global_rules` entries; their `plugins` + /// maps are merged into one (later entries win on key collision), + /// matching how they're consumed — as a single flat set of gateway-wide + /// plugins, not as separate rules. + pub async fn list_global_rules(&self) -> Result { + let rules: Vec = self.list(ResourceType::GlobalRule).await?; + let mut merged = Plugins::new(); + for rule in rules { + merged.extend(rule.plugins); + } + Ok(merged) + } + + /// Plugin metadata isn't returned as a `name` field on each item — the + /// name only appears as the last segment of the etcd key + /// (`/apisix/plugin_metadata/http-logger`), so it's extracted from + /// `ListItem::key` rather than `ListItem::value`. + pub async fn list_plugin_metadata(&self) -> Result { + let path = format!( + "/apisix/admin/{}", + resource_type_to_api_name(ResourceType::PluginMetadata).expect("PluginMetadata always has an api name") + ); + let builder = self.client.request(Method::GET, &path)?; + let response = self.client.send(builder).await?; + let body: typing::ListResponse = response.json().await.map_err(|e| { + BackendError::Serialization(format!("decoding response from {path}: {e}")) + })?; + + let mut merged = Plugins::new(); + for item in body.list { + if let Some(name) = item.key.rsplit('/').next() { + merged.insert(name.to_string(), item.value.into()); + } + } + Ok(merged) + } + + /// Stream routes are an optional APISIX feature: on a version/build + /// that doesn't support them, the endpoint itself may not exist. Only a + /// 404 here is treated as "no stream routes" — anything else + /// (authentication/authorization failure, a 5xx) is a real error, same + /// as [`Fetcher::list`]. + pub async fn list_stream_routes(&self) -> Result, BackendError> { + let builder = self + .client + .request(Method::GET, "/apisix/admin/stream_routes")?; + let response = self.client.execute(builder).await?; + if response.status().as_u16() == 404 { + return Ok(Vec::new()); + } + let response = HttpClient::require_success(response).await?; + let body: typing::ListResponse = + response.json().await.map_err(|e| { + BackendError::Serialization(format!( + "decoding response from /apisix/admin/stream_routes: {e}" + )) + })?; + Ok(body.list.into_iter().map(|item| item.value).collect()) + } + + /// Consumers, plus (from APISIX 3.11.0 onward, where the credentials + /// API exists) each consumer's credentials, fetched concurrently. A 404 + /// on a specific consumer's credentials endpoint means that consumer + /// simply has none — anything else (network failure, 5xx) is a real + /// error and aborts the whole call, same as any other resource type. + pub async fn list_consumers(&self) -> Result, BackendError> { + let consumers: Vec = self.list(ResourceType::Consumer).await?; + + if self.version < Version::new(3, 11, 0) { + return Ok(consumers); + } + + concurrent_map_until_err(consumers, Some(CREDENTIAL_FETCH_CONCURRENCY), |consumer| self.with_credentials(consumer)).await + } + + async fn with_credentials( + &self, + consumer: typing::Consumer, + ) -> Result { + let path = format!("/apisix/admin/consumers/{}/credentials", consumer.username); + let builder = self.client.request(Method::GET, &path)?; + let response = self.client.execute(builder).await?; + if response.status().as_u16() == 404 { + return Ok(consumer); + } + let response = HttpClient::require_success(response).await?; + let body: typing::ListResponse = + response.json().await.map_err(|e| { + BackendError::Serialization(format!("decoding response from {path}: {e}")) + })?; + Ok(typing::Consumer { + credentials: Some(body.list.into_iter().map(|item| item.value).collect()), + ..consumer + }) + } + + /// Fetches every resource type (concurrently) and assembles them into a + /// single ADC `Configuration`: routes and stream routes nested under + /// their owning service, a service's default upstream inlined onto it, + /// its named upstreams (matched via + /// [`typing::ADC_UPSTREAM_SERVICE_ID_LABEL`]) collected into + /// `Service.upstreams`, and a route's `plugin_config_id` resolved into + /// its `plugins` — APISIX stores each of those as a link between two + /// separate admin-API resources; ADC's model has them nested instead. + pub async fn dump(&self) -> Result { + // Step 1: fetch every resource type concurrently, in APISIX's own wire shape. + let ( + services, + mut routes, + upstreams, + ssls, + consumers, + plugin_configs, + global_rules, + plugin_metadata, + stream_routes, + ) = tokio::try_join!( + self.list_services(), + self.list_routes(), + self.list_upstreams(), + self.list_ssls(), + self.list_consumers(), + self.list_plugin_configs(), + self.list_global_rules(), + self.list_plugin_metadata(), + self.list_stream_routes(), + )?; + + // Step 2: resolve the two APISIX-only cross-references that don't + // survive as-is in ADC's model — a route's `plugin_config_id` + // becomes its resolved `plugins`, and the flat upstream list splits + // into "this service's default upstream" and "this service's named + // upstreams". + resolve_plugin_config_refs(&mut routes, &plugin_configs); + let (default_upstream_by_id, named_upstreams_by_service) = index_upstreams(upstreams)?; + + // Step 3: convert each service to ADC's model and attach its + // upstream(s) from step 2. + let mut services: IndexMap = services + .into_iter() + .map(|service| { + let id = service.id.clone(); + let mut service: adc::Service = + service.try_into().map_err(BackendError::Serialization)?; + if let Some(upstream) = default_upstream_by_id.get(&id) { + let mut upstream = upstream.clone(); + upstream.id = None; + upstream.name = None; + service.upstream = Some(upstream); + } + if let Some(named) = named_upstreams_by_service.get(&id) { + service.upstreams = Some(named.clone()); + } + Ok((id, service)) + }) + .collect::>()?; + + // Step 4: bucket routes and stream routes by their owning service — + // an orphaned one (referencing a service id that wasn't in this + // dump, which shouldn't normally happen) is dropped rather than + // surfaced as an error. + let mut routes_by_service: HashMap> = HashMap::new(); + for route in routes { + let Some(service_id) = route.service_id.clone() else { + continue; + }; + if !services.contains_key(&service_id) { + continue; + } + routes_by_service + .entry(service_id) + .or_default() + .push(route.try_into().map_err(BackendError::Serialization)?); + } + let mut stream_routes_by_service: HashMap> = HashMap::new(); + for stream_route in stream_routes { + let Some(service_id) = stream_route.service_id.clone() else { + continue; + }; + if !services.contains_key(&service_id) { + continue; + } + stream_routes_by_service + .entry(service_id) + .or_default() + .push(stream_route.into()); + } + + // Step 5: attach each service's bucketed routes/stream routes from + // step 4. A service is either HTTP or stream, never both — mirrors + // `ServiceRoutes`'s own invariant, and matches how APISIX data is + // actually shaped (a route and a stream_route never share a + // `service_id`). + for (id, service) in services.iter_mut() { + if let Some(routes) = routes_by_service.remove(id) { + service.routes = Some(adc::ServiceRoutes::Http { routes }); + } else if let Some(stream_routes) = stream_routes_by_service.remove(id) { + service.routes = Some(adc::ServiceRoutes::Stream { stream_routes }); + } + } + + // Step 6: assemble the final Configuration — everything not nested + // under a service converts independently. + Ok(Configuration { + services: (!services.is_empty()).then(|| services.into_values().collect()), + ssls: (!ssls.is_empty()) + .then(|| { + ssls.into_iter() + .map(adc::SSL::try_from) + .collect::, _>>() + }) + .transpose() + .map_err(BackendError::Serialization)?, + consumers: (!consumers.is_empty()) + .then(|| consumers.into_iter().map(Into::into).collect()), + consumer_groups: None, // apisix's fetcher doesn't fetch consumer groups at all — see `crate::transformer`'s doc comment. + global_rules: (!global_rules.is_empty()).then_some(global_rules), + plugin_metadata: (!plugin_metadata.is_empty()).then_some(plugin_metadata), + }) + } +} + +/// Resolves each route's `plugin_config_id` reference into its `plugins`, +/// in place — APISIX stores a plugin config as its own admin-API resource +/// and a route merely points at one by id; ADC's `Route` has no equivalent +/// reference field, only the resolved `plugins`. A route can carry its own +/// inline `plugins` alongside a `plugin_config_id` at the same time; APISIX +/// merges the two at request-serving time with the route's own entries +/// winning on a name collision, so the reconstructed `plugins` here does +/// the same rather than letting the plugin config clobber the route's own. +fn resolve_plugin_config_refs( + routes: &mut [typing::Route], + plugin_configs: &[typing::PluginConfig], +) { + let by_id: HashMap<&str, &typing::PluginConfig> = plugin_configs + .iter() + .map(|pc| (pc.id.as_str(), pc)) + .collect(); + for route in routes { + if let Some(plugin_config_id) = &route.plugin_config_id + && let Some(plugin_config) = by_id.get(plugin_config_id.as_str()) + { + let mut plugins = plugin_config.plugins.clone(); + plugins.extend(route.plugins.clone().unwrap_or_default()); + route.plugins = Some(plugins); + } + } +} + +type DefaultUpstreamById = HashMap; +type NamedUpstreamsByService = HashMap>; + +/// Splits APISIX's flat upstream list into: the default upstream for each +/// service that has one (keyed by upstream id, which for a service's +/// default upstream is always the service's own id), and each service's +/// *named* upstreams (matched via the association label ADC writes on +/// `Backend::sync` — see `typing::ADC_UPSTREAM_SERVICE_ID_LABEL`). +fn index_upstreams( + upstreams: Vec, +) -> Result<(DefaultUpstreamById, NamedUpstreamsByService), BackendError> { + let mut by_id = HashMap::new(); + let mut named_by_service: HashMap> = HashMap::new(); + + for upstream in upstreams { + let id = upstream.id.clone(); + let service_label = upstream + .labels + .as_ref() + .and_then(|labels| labels.get(typing::ADC_UPSTREAM_SERVICE_ID_LABEL)) + .and_then(|value| match value { + LabelValue::Single(name) => Some(name.clone()), + LabelValue::Multiple(_) => None, + }); + let upstream: adc::Upstream = upstream.try_into().map_err(BackendError::Serialization)?; + + if let Some(service_id) = service_label { + named_by_service + .entry(service_id) + .or_default() + .push(upstream.clone()); + } + if let Some(id) = id { + by_id.insert(id, upstream); + } + } + + Ok((by_id, named_by_service)) +} diff --git a/rust/crates/adc-backend-apisix/src/lib.rs b/rust/crates/adc-backend-apisix/src/lib.rs new file mode 100644 index 00000000..284d624d --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/lib.rs @@ -0,0 +1,36 @@ +//! The Apache APISIX gateway integration. The supported public API is just +//! [`Backend`] — the fetcher, operator, and validator it's built from are +//! internal orchestration pieces, not things a real consumer should reach +//! for directly (call `Backend::dump`/`sync`/`validate` instead). They're +//! still reachable via [`tests`] for this crate's own test suite and for +//! other crates' e2e tests that want to exercise one piece in isolation. + +mod backend; +mod fetcher; +mod operator; +mod transformer; +mod typing; +mod utils; +mod validator; + +pub use backend::Backend; + +/// Internal building blocks, exposed only for tests — see the crate-level +/// doc comment. Not part of the supported API: gated behind the +/// `test-utils` feature (on by default only via this crate's own +/// self-referencing dev-dependency), so it doesn't leak into a normal +/// build's public surface. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +pub mod tests { + pub use crate::fetcher::Fetcher; + pub use crate::operator::Operator; + pub use crate::validator::Validator; + + pub mod transformer { + pub use crate::transformer::*; + } + pub mod typing { + pub use crate::typing::*; + } +} diff --git a/rust/crates/adc-backend-apisix/src/operator.rs b/rust/crates/adc-backend-apisix/src/operator.rs new file mode 100644 index 00000000..93dc58b4 --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/operator.rs @@ -0,0 +1,449 @@ +//! Applying a differ's `Event`s to a live APISIX instance: `sync`. +//! +//! Two pieces of behavior are worth calling out because they're +//! APISIX-specific rather than generic "call the admin API" plumbing: +//! +//! - A service's default upstream is a *separate* admin-API resource +//! (`/apisix/admin/upstreams/{id}`, same id as the service), so a single +//! `SERVICE` event can turn into up to two requests, and their order +//! matters: creating/updating writes the upstream first (routes/services +//! referencing it must find it already there), deleting removes the +//! service first (nothing should reference the upstream by the time it's +//! removed). An update where only the upstream actually changed skips the +//! service request entirely. +//! - Requests within one event are applied *sequentially* (a service's +//! upstream write must complete before its own write starts); events are +//! grouped by `(resource_type, event_type)` and applied *sequentially +//! across groups* but *concurrently within a group* (bounded by +//! `BackendSyncOptions::concurrent`), then retried individually on +//! failure. Grouping preserves each resource type's relative event order +//! while still letting e.g. all SSL creates happen before any upstream +//! creates, matching the differ's own topological ordering. + +use adc_backend_core::{HttpClient, Method, RetryPolicy, concurrent_map, concurrent_map_until_err, encode_path_segment}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{BackendError, BackendSyncOptions, BackendSyncResult, Event, EventType, PathSegment, ResourceType, ValueDiff}; +use semver::Version; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::transformer; +use crate::typing; +use crate::utils::resource_type_to_api_name; + +pub struct Operator { + client: HttpClient, + version: Version, + retry_policy: RetryPolicy, +} + +impl Operator { + pub fn new(client: HttpClient, version: Version) -> Self { + Self { client, version, retry_policy: RetryPolicy::default() } + } + + /// Applies `events`. Matches the TS implementation: a version-gate + /// rejection (`check_version_support`) is always a normal + /// `success: false` result, never an abort, since it's produced before + /// the point in the pipeline TS's `catchError`/`exitOnFailure` logic + /// applies. An actual `operate` (HTTP) failure, on the other hand, + /// aborts the whole call as an `Err` when `exit_on_failure` is set (the + /// default) — mirroring RxJS `mergeMap` unsubscribing on error: + /// events already dispatched within that group still run to completion + /// (there's no cheap way to cancel an in-flight request), but any event + /// still queued behind `opts.concurrent`'s limit is dropped and never + /// dispatched at all, and every result — from this group and any + /// accumulated from earlier ones — is discarded in favor of the single + /// `Err`. + pub async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Result, BackendError> { + let exit_on_failure = opts.exit_on_failure.unwrap_or(true); + + let mut results = Vec::new(); + for group in group_events(events) { + if exit_on_failure { + let group_results = concurrent_map_until_err(group, opts.concurrent, |event| self.apply(event)).await.map_err(|(_, error)| error)?; + results.extend(group_results); + } else { + let group_results = concurrent_map(group, opts.concurrent, |event| self.apply(event)).await; + for outcome in group_results { + match outcome { + Ok(result) => results.push(result), + Err((event, error)) => results.push(BackendSyncResult { success: false, event, error: Some(error), server: None }), + } + } + } + } + Ok(results) + } + + /// `Ok` is always a normal outcome (including a version-gate + /// rejection); `Err` is specifically an `operate` (HTTP) failure, which + /// `sync` treats differently depending on `exit_on_failure`. + async fn apply(&self, event: Event) -> Result { + if let Err(error) = self.check_version_support(&event) { + return Ok(BackendSyncResult { success: false, event, error: Some(error), server: None }); + } + + match self.operate(&event).await { + Ok(()) => Ok(BackendSyncResult { success: true, event, error: None, server: None }), + Err(error) => Err((event, error)), + } + } + + fn check_version_support(&self, event: &Event) -> Result<(), BackendError> { + if event.resource_type == ResourceType::StreamRoute && self.version < Version::new(3, 7, 0) { + return Err(BackendError::Unsupported( + "stream routes are not supported on apisix versions below 3.7.0".to_string(), + )); + } + if event.resource_type == ResourceType::ConsumerCredential && self.version < Version::new(3, 11, 0) { + return Err(BackendError::Unsupported( + "consumer credentials are not supported on apisix versions below 3.11.0".to_string(), + )); + } + Ok(()) + } + + /// Runs one event's requests sequentially, each with a retry policy. + /// + /// A `DELETE` on the upstream sub-resource specifically tolerates a + /// 404: a service's default upstream only exists when the service was + /// actually created with one (see `build_requests`'s `Create` branch), + /// so deleting a service that never had one would otherwise fail this + /// whole event on a request for a resource that was never there to + /// begin with. Depending on `event.kind.old_value()` accurately + /// recording that instead would work for the differ's own real Delete + /// events (which do carry the full prior state) but not for a + /// hand-built one with an incomplete `old_value` — treating "already + /// gone" as success here doesn't depend on that being reliable. + async fn operate(&self, event: &Event) -> Result<(), BackendError> { + for (method, path, body, kind) in build_requests(event, &self.version)? { + let tolerate_missing = method == Method::DELETE && kind == RequestKind::Upstream; + self.retry_policy + .run(|| async { + let mut builder = self.client.request(method.clone(), &path)?; + if let Some(body) = &body { + builder = builder.json(body); + } + let response = self.client.execute(builder).await?; + if tolerate_missing && response.status().as_u16() == 404 { + return Ok(()); + } + HttpClient::require_success(response).await.map(|_| ()) + }) + .await?; + } + Ok(()) + } +} + +/// Buckets events by `(resource_type, event_type)`, preserving each +/// bucket's internal relative order and ordering buckets themselves by +/// first appearance — the same "group, don't just chunk consecutive runs" +/// semantics as the TS operator's `reduce`-into-buckets step. +fn group_events(events: Vec) -> Vec> { + let mut groups: Vec<(ResourceType, EventType, Vec)> = Vec::new(); + 'events: for event in events { + let key = (event.resource_type, event.event_type()); + for group in &mut groups { + if (group.0, group.1) == key { + group.2.push(event); + continue 'events; + } + } + groups.push((key.0, key.1, vec![event])); + } + groups.into_iter().map(|(_, _, events)| events).collect() +} + +fn missing_parent(event: &Event) -> BackendError { + BackendError::Other(format!("{:?} event for resource {:?} is missing a parent_id", event.resource_type, event.resource_id).into()) +} + +fn deserialize_event_value(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| BackendError::Serialization(format!("decoding event payload: {e}"))) +} + +fn to_request_body(value: T) -> Result { + serde_json::to_value(value).map_err(|e| BackendError::Serialization(format!("encoding request body: {e}"))) +} + +fn main_path(event: &Event) -> Result { + let resource_id = encode_path_segment(&event.resource_id)?; + if event.resource_type == ResourceType::ConsumerCredential { + let parent_id = event.parent_id.as_deref().ok_or_else(|| missing_parent(event))?; + let parent_id = encode_path_segment(parent_id)?; + return Ok(format!("/apisix/admin/consumers/{parent_id}/credentials/{resource_id}")); + } + let api_name = resource_type_to_api_name(event.resource_type) + .expect("ConsumerCredential is the only resource type with no api name, and it's handled above"); + Ok(format!("/apisix/admin/{api_name}/{resource_id}")) +} + +fn diff_path_is_upstream(diff: &ValueDiff) -> bool { + let path = match diff { + ValueDiff::New { path, .. } | ValueDiff::Deleted { path, .. } | ValueDiff::Edit { path, .. } | ValueDiff::Array { path, .. } => path, + }; + matches!(path.first(), Some(PathSegment::Key(key)) if key == "upstream") +} + +/// Which of a `SERVICE` event's (up to two) request paths a given +/// (method, path, body) triple is for — replaces sniffing the path string +/// for `"/upstreams/"` with an explicit tag threaded alongside it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RequestKind { + Main, + Upstream, +} + +/// One request `build_requests` produced: method, path, body (`None` for a +/// `DELETE`), and which of a `SERVICE` event's (up to two) requests it is. +type BuiltRequest = (Method, String, Option, RequestKind); + +/// Builds the ordered requests for one event. A non-`SERVICE` event always +/// produces exactly one; a `SERVICE` event produces one or two (see the +/// module doc comment). The `RequestKind` on each is carried through to +/// [`Operator::operate`], which needs it to know whether a `DELETE` is for +/// the upstream sub-resource specifically (see its doc comment for why +/// that one tolerates a 404). +fn build_requests(event: &Event, version: &Version) -> Result, BackendError> { + let is_delete = event.event_type() == EventType::Delete; + let mut paths = vec![(main_path(event)?, RequestKind::Main)]; + + if event.resource_type == ResourceType::Service { + let upstream_path = (format!("/apisix/admin/upstreams/{}", encode_path_segment(&event.resource_id)?), RequestKind::Upstream); + match event.event_type() { + EventType::Delete => paths.push(upstream_path), + EventType::Create => { + // A service create with no `upstream` field at all has + // nothing to write there — `transform_service` would + // return `None` for it, which `request_body` would + // otherwise have to reject as an error for a case that + // isn't actually one. + let has_upstream = event.kind.new_value().and_then(|v| v.get("upstream")).is_some_and(|v| !v.is_null()); + if has_upstream { + paths.insert(0, upstream_path); + } + } + EventType::Update => { + let diff = event.kind.diff().filter(|d| !d.is_empty()).ok_or_else(|| { + BackendError::Other(format!("service {:?} update event is missing diff info", event.resource_id).into()) + })?; + let touches_non_upstream = diff.iter().any(|d| !diff_path_is_upstream(d)); + let touches_upstream = diff.iter().any(diff_path_is_upstream); + if !touches_non_upstream { + paths.pop(); + } + if touches_upstream { + paths.insert(0, upstream_path); + } + } + EventType::OnlySubEvents => {} + } + } + + paths + .into_iter() + .map(|(path, kind)| { + let method = if is_delete { Method::DELETE } else { Method::PUT }; + let body = if is_delete { None } else { Some(request_body(event, kind, version)?) }; + Ok((method, path, body, kind)) + }) + .collect() +} + +/// Builds the JSON body for one request. For a `SERVICE` event this is +/// called once per request path, and `kind` says which of the (up to two) +/// it's building for. +fn request_body(event: &Event, kind: RequestKind, version: &Version) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| BackendError::Other("create/update event is missing new_value".into()))?; + + match event.resource_type { + ResourceType::Consumer => to_request_body(typing::Consumer::from(deserialize_event_value::(new_value)?)), + ResourceType::ConsumerGroup => { + let group: adc::ConsumerGroup = deserialize_event_value(new_value)?; + let (mut wire, _consumers) = transformer::transform_consumer_group(group); + // `transform_consumer_group` derives its id from the group's + // name, but `main_path` builds the request URL from + // `event.resource_id` — APISIX rejects a PUT whose body id + // doesn't match the URL, so they must match exactly, including + // when the differ assigned an explicit id independent of the + // name (`ConsumerGroup` supports a user-specified `id`). + wire.id = event.resource_id.clone(); + to_request_body(wire) + } + ResourceType::ConsumerCredential => { + let mut credential: adc::ConsumerCredential = deserialize_event_value(new_value)?; + credential.id = Some(event.resource_id.clone()); + to_request_body(typing::ConsumerCredential::from(credential)) + } + ResourceType::GlobalRule => Ok(serde_json::json!({ "plugins": { event.resource_id.clone(): new_value.clone() } })), + ResourceType::PluginMetadata => Ok(new_value.clone()), + ResourceType::Route => { + // The differ's event carries the authoritative id + // (`event.resource_id`); `new_value` itself was never required + // to have one, so it must be stamped on before transforming — + // APISIX's admin API rejects a PUT whose body id doesn't match + // the URL, including when the body's id is empty. + let mut route: adc::Route = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event.parent_id.clone().ok_or_else(|| missing_parent(event))?; + to_request_body(transformer::transform_route(route, parent_id)) + } + ResourceType::Service => { + let mut service: adc::Service = deserialize_event_value(new_value)?; + service.id = Some(event.resource_id.clone()); + let (wire_service, wire_upstream) = transformer::transform_service(service); + match kind { + RequestKind::Upstream => { + let upstream = wire_upstream.ok_or_else(|| { + BackendError::Other(format!("service {:?} has no default upstream to write", event.resource_id).into()) + })?; + to_request_body(upstream) + } + RequestKind::Main => to_request_body(wire_service), + } + } + ResourceType::Ssl => { + let mut ssl: adc::SSL = deserialize_event_value(new_value)?; + ssl.id = Some(event.resource_id.clone()); + to_request_body(typing::Ssl::from(ssl)) + } + ResourceType::StreamRoute => { + let route: adc::StreamRoute = deserialize_event_value(new_value)?; + let parent_id = event.parent_id.clone().ok_or_else(|| missing_parent(event))?; + let inject_name = *version >= Version::new(3, 8, 0); + to_request_body(transformer::transform_stream_route(route, parent_id, inject_name)) + } + ResourceType::Upstream => { + let upstream: adc::Upstream = deserialize_event_value(new_value)?; + let mut wire = typing::Upstream::from(upstream); + if let Some(parent_id) = &event.parent_id { + let mut labels = wire.labels.unwrap_or_default(); + labels.insert(typing::ADC_UPSTREAM_SERVICE_ID_LABEL.to_string(), adc::LabelValue::Single(parent_id.clone())); + wire.labels = Some(labels); + } + to_request_body(wire) + } + ResourceType::PluginConfig | ResourceType::InternalStreamService => { + Err(BackendError::Unsupported(format!("{:?} is not directly syncable by the apisix backend", event.resource_type))) + } + } +} + +#[cfg(test)] +mod tests { + use adc_sdk::EventKind; + use serde_json::json; + + use super::*; + + fn event(rt: ResourceType, kind: EventKind, id: &str) -> Event { + Event::new(rt, kind, id, id) + } + + fn create(rt: ResourceType, id: &str) -> Event { + event(rt, EventKind::Create { new_value: json!({}) }, id) + } + + #[test] + fn groups_by_resource_and_event_type_preserving_first_seen_order() { + let route1 = create(ResourceType::Route, "r1"); + let consumer = create(ResourceType::Consumer, "c1"); + let route2 = create(ResourceType::Route, "r2"); + let ssl_delete = event(ResourceType::Ssl, EventKind::Delete { old_value: json!({}) }, "s1"); + + let groups = group_events(vec![route1, consumer, route2, ssl_delete]); + + // Buckets ordered by first appearance: (Route, Create) seen first, + // then (Consumer, Create), then (Ssl, Delete) — even though the + // second Route event arrives later in the input, it joins the + // first bucket rather than starting a new one. + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].len(), 2); + assert_eq!(groups[0][0].resource_id, "r1"); + assert_eq!(groups[0][1].resource_id, "r2"); + assert_eq!(groups[1].len(), 1); + assert_eq!(groups[1][0].resource_type, ResourceType::Consumer); + assert_eq!(groups[2].len(), 1); + assert_eq!(groups[2][0].event_type(), EventType::Delete); + } + + #[test] + fn same_resource_type_but_different_event_type_gets_its_own_group() { + let create_route = create(ResourceType::Route, "r1"); + let delete_route = event(ResourceType::Route, EventKind::Delete { old_value: json!({}) }, "r2"); + + let groups = group_events(vec![create_route, delete_route]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0][0].event_type(), EventType::Create); + assert_eq!(groups[1][0].event_type(), EventType::Delete); + } + + #[test] + fn empty_input_produces_no_groups() { + assert!(group_events(vec![]).is_empty()); + } + + #[test] + fn consumer_group_request_body_id_matches_the_event_resource_id_not_the_name() { + // The differ can assign a ConsumerGroup an explicit, stable id + // independent of its name; `transform_consumer_group` derives its + // own id from the name alone, so the request body must be + // overridden back to the event's id or it'll mismatch the URL + // path (built from `event.resource_id`) and APISIX will reject it. + let group_event = Event::new( + ResourceType::ConsumerGroup, + EventKind::Create { new_value: json!({ "name": "renamed-group" }) }, + "stable-id", + "renamed-group", + ); + let requests = build_requests(&group_event, &Version::new(3, 17, 0)).unwrap(); + assert_eq!(requests.len(), 1); + let (_, path, body, _) = &requests[0]; + assert!(path.ends_with("/stable-id"), "{path}"); + assert_eq!(body.as_ref().unwrap()["id"], "stable-id"); + } + + #[test] + fn service_create_without_an_upstream_produces_only_the_main_request() { + let service_event = event( + ResourceType::Service, + EventKind::Create { new_value: json!({ "name": "svc-no-upstream" }) }, + "svc-no-upstream", + ); + let requests = build_requests(&service_event, &Version::new(3, 17, 0)).unwrap(); + assert_eq!(requests.len(), 1, "no upstream request should be generated for a service with no upstream"); + assert!(requests[0].1.ends_with("/services/svc-no-upstream"), "{}", requests[0].1); + } + + #[test] + fn service_update_with_no_diff_is_rejected_instead_of_silently_sending_nothing() { + let service_event = Event::new( + ResourceType::Service, + EventKind::Update { old_value: json!({}), new_value: json!({}), diff: None }, + "svc1", + "svc1", + ); + assert!(build_requests(&service_event, &Version::new(3, 17, 0)).is_err()); + } + + #[test] + fn resource_id_containing_a_path_separator_is_percent_encoded_not_split() { + let route_event = { + let mut e = event( + ResourceType::Route, + EventKind::Create { new_value: json!({ "name": "r1", "uris": ["/x"] }) }, + "a/../b", + ); + e.parent_id = Some("svc1".to_string()); + e + }; + let requests = build_requests(&route_event, &Version::new(3, 17, 0)).unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].1.starts_with("/apisix/admin/routes/"), "{}", requests[0].1); + assert!(!requests[0].1.contains("/../"), "{}", requests[0].1); + } +} diff --git a/rust/crates/adc-backend-apisix/src/transformer.rs b/rust/crates/adc-backend-apisix/src/transformer.rs new file mode 100644 index 00000000..a84e421a --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/transformer.rs @@ -0,0 +1,665 @@ +//! Converting between APISIX's wire shapes (`crate::typing`) and ADC's +//! resource model (`adc_sdk::resources`). +//! +//! **Read direction** (APISIX -> ADC, used by the fetcher): a `TryFrom`/ +//! `From` impl *on the ADC type*, with the wire type as the source (e.g. +//! `TryFrom for adc::Route`); `Into` comes for free from the +//! standard library's blanket impl, so callers can write either +//! `adc::Route::try_from(route)` or `route.try_into()`. +//! +//! **Write direction** (ADC -> APISIX, used by the operator): the reverse +//! `From` impls, on the wire type this time (`From for +//! typing::Consumer`) — plain `From` throughout, since nothing here can fail +//! the way parsing a live server's response can. Two conversions need more +//! than the resource itself (a route needs its parent service's id; a +//! service needs its own id split out into a matching upstream) and are +//! free functions instead, since `From` only takes one argument. +//! +//! `TryFrom` is used on the read direction wherever a conversion can +//! genuinely fail or elect not to apply (HTTP method strings APISIX didn't +//! validate, a discovery-map node with an unparsable port, a credential +//! plugin ADC doesn't support); `From` is used where it can't. +//! +//! Resource types with no ToADC conversion here (`PluginConfig`, +//! `GlobalRule`, `PluginMetadata`) either never go through a dedicated +//! per-item transform on the read path (`global_rules`/`plugin_metadata` +//! already come out of the fetcher shaped as ADC's flat `Plugins` map; +//! `plugin_configs` gets merged into a route's `plugins` rather than +//! transformed on its own), or (`ConsumerGroup`) aren't reachable from what +//! the fetcher currently fetches — APISIX's fetcher doesn't fetch consumer +//! groups at all, though sync can still write them, so `ConsumerGroup` gets +//! a write-direction conversion despite having no read-direction one. + +use std::collections::HashMap; + +use adc_sdk::resources::{self as adc, LabelValue}; +use serde_json::Value; + +use crate::typing; + +const ALLOWED_CREDENTIAL_PLUGINS: &[&str] = &["key-auth", "basic-auth", "jwt-auth", "hmac-auth"]; + +fn parse_http_method(method: String) -> Result { + serde_json::from_value(Value::String(method.clone())) + .map_err(|_| format!("unrecognized HTTP method {method:?}")) +} + +impl TryFrom for adc::Route { + type Error = String; + + fn try_from(route: typing::Route) -> Result { + let methods = route + .methods + .map(|methods| { + methods + .into_iter() + .map(parse_http_method) + .collect::, _>>() + }) + .transpose()?; + + Ok(adc::Route { + id: Some(route.id.clone()), + name: route.name.unwrap_or(route.id), + description: route.desc, + labels: route.labels.map(|labels| { + labels + .into_iter() + .map(|(k, v)| (k, LabelValue::Single(v))) + .collect() + }), + + hosts: route.host.map(|h| vec![h]).or(route.hosts), + uris: route + .uri + .map(|u| vec![u]) + .or(route.uris) + .unwrap_or_default(), + priority: route.priority, + timeout: route.timeout, + vars: route.vars, + methods, + enable_websocket: route.enable_websocket, + remote_addrs: route.remote_addr.map(|a| vec![a]).or(route.remote_addrs), + plugins: route.plugins, + filter_func: route.filter_func, + }) + } +} + +impl TryFrom for adc::Service { + type Error = String; + + fn try_from(service: typing::Service) -> Result { + let upstream = service.upstream.map(adc::Upstream::try_from).transpose()?; + let upstreams = service + .upstreams + .map(|list| { + list.into_iter() + .map(adc::Upstream::try_from) + .collect::, _>>() + }) + .transpose()?; + + Ok(adc::Service { + id: Some(service.id.clone()), + name: service.name.unwrap_or(service.id), + description: service.desc, + labels: service.labels, + + upstream, + upstreams, + plugins: service.plugins, + // Not an APISIX concept: these only exist on ADC-authored config. + path_prefix: None, + strip_path_prefix: None, + hosts: service.hosts, + + // Attached later, once route/stream_route fetch results are + // available to nest under their parent service — out of scope + // for a single resource's conversion. + routes: None, + }) + } +} + +fn default_upstream_port(scheme: Option) -> u32 { + match scheme { + Some(adc::UpstreamScheme::Http) | Some(adc::UpstreamScheme::Grpc) => 80, + Some(adc::UpstreamScheme::Https) | Some(adc::UpstreamScheme::Grpcs) => 443, + _ => 80, + } +} + +/// Parses APISIX's legacy `"host:port": weight` node map into ADC's node +/// list. The TS transformer's own naive `host.split(':')` mishandles any +/// host with more than one colon, including a *bracketed* IPv6 address +/// (`"[::1]:9000"`) — unlike TS, that specific case is recognized and +/// parsed correctly here (see the `strip_prefix('[')` branch below). A +/// bare, bracket-less IPv6 host is inherently ambiguous with a +/// `host:port` pair and still falls through to the "no port" branch. +fn parse_discovery_map_nodes( + map: HashMap, + scheme: Option, +) -> Result, String> { + map.into_iter() + .map(|(node, weight)| { + let (host, port) = if let Some(rest) = node.strip_prefix('[') { + // Bracketed IPv6, e.g. "[::1]:9000" or "[::1]" — the host + // itself contains colons, so unlike an IPv4/hostname:port + // pair it can't be split on ':' directly. + let (host, after_bracket) = rest + .split_once(']') + .ok_or_else(|| format!("unterminated \"[\" in upstream node {node:?}"))?; + let port = match after_bracket.strip_prefix(':') { + Some(port) => port + .parse::() + .map_err(|_| format!("invalid upstream node port in {node:?}"))?, + None => default_upstream_port(scheme), + }; + (host.to_string(), port) + } else { + let parts: Vec<&str> = node.split(':').collect(); + if parts.len() == 2 { + let port = parts[1] + .parse::() + .map_err(|_| format!("invalid upstream node port in {node:?}"))?; + (parts[0].to_string(), port) + } else { + (parts[0].to_string(), default_upstream_port(scheme)) + } + }; + Ok(adc::UpstreamNode { + host, + port, + weight, + priority: 0.0, + metadata: None, + }) + }) + .collect() +} + +impl TryFrom for adc::Upstream { + type Error = String; + + fn try_from(upstream: typing::Upstream) -> Result { + let nodes = match upstream.nodes { + None => None, + Some(typing::UpstreamNodes::List(nodes)) => Some(nodes), + Some(typing::UpstreamNodes::Map(map)) => { + Some(parse_discovery_map_nodes(map, upstream.scheme)?) + } + }; + + // The service-association label is fetcher-internal bookkeeping + // (see `typing::ADC_UPSTREAM_SERVICE_ID_LABEL`), not something a + // consumer of the ADC model should see. + let labels = upstream + .labels + .map(|mut labels| { + labels.remove(typing::ADC_UPSTREAM_SERVICE_ID_LABEL); + labels + }) + .filter(|labels| !labels.is_empty()); + + Ok(adc::Upstream { + id: upstream.id, + name: upstream.name, + description: upstream.desc, + labels, + + r#type: upstream.ty.unwrap_or_default(), + hash_on: upstream.hash_on, + key: upstream.key, + checks: upstream.checks, + nodes, + scheme: upstream.scheme.unwrap_or_default(), + retries: upstream.retries, + retry_timeout: upstream.retry_timeout, + timeout: upstream.timeout, + tls: upstream.tls, + keepalive_pool: upstream.keepalive_pool, + pass_host: upstream.pass_host.unwrap_or_default(), + upstream_host: upstream.upstream_host, + + service_name: upstream.service_name, + discovery_type: upstream.discovery_type, + discovery_args: upstream.discovery_args.and_then(|v| v.as_object().cloned()), + }) + } +} + +impl TryFrom for adc::SSL { + type Error = String; + + fn try_from(ssl: typing::Ssl) -> Result { + let id = ssl.id.clone(); + let cert = ssl + .cert + .ok_or_else(|| format!("ssl {id:?} is missing a certificate"))?; + + // APISIX never echoes a private key back on read, on any admin API + // shape (list or single-resource GET) — confirmed against a real + // instance, not just this fixture. A missing key here means + // "redacted by the server", not "this SSL resource is broken", so + // it degrades to an empty placeholder rather than failing the + // conversion (and with it, the whole dump — one SSL resource + // shouldn't be able to take down `dump()` for everything else). + let mut keys = ssl.keys.unwrap_or_default().into_iter(); + let mut certificates = vec![adc::SSLCertificate { + certificate: cert, + key: ssl.key.unwrap_or_default(), + }]; + if let Some(certs) = ssl.certs { + certificates.extend(certs.into_iter().map(|certificate| adc::SSLCertificate { + certificate, + key: keys.next().unwrap_or_default(), + })); + } + + Ok(adc::SSL { + id: Some(ssl.id), + labels: ssl.labels, + + r#type: ssl.ty.unwrap_or_default(), + snis: ssl.sni.map(|s| vec![s]).or(ssl.snis).unwrap_or_default(), + certificates, + client: ssl.client, + ssl_protocols: ssl.ssl_protocols, + }) + } +} + +/// A credential's `type`/`config` come from its single plugin entry (APISIX +/// models a credential as a one-plugin `Plugins` map); credentials configured +/// with a plugin outside ADC's supported credential types, or with none at +/// all, don't convert — callers filter these out with `.ok()` rather than +/// treating them as fatal, matching the TS transformer silently skipping them. +impl TryFrom for adc::ConsumerCredential { + type Error = String; + + fn try_from(credential: typing::ConsumerCredential) -> Result { + let plugins = credential + .plugins + .filter(|p| !p.is_empty()) + .ok_or("credential has no plugin configured")?; + let (plugin_name, config) = plugins.into_iter().next().expect("checked non-empty above"); + if !ALLOWED_CREDENTIAL_PLUGINS.contains(&plugin_name.as_str()) { + return Err(format!("unsupported credential plugin {plugin_name:?}")); + } + let Value::Object(config) = config else { + return Err(format!( + "credential plugin {plugin_name:?} config is not an object" + )); + }; + + Ok(adc::ConsumerCredential { + id: credential.id, + name: credential.name, + description: credential.desc, + labels: credential.labels, + r#type: plugin_name, + config, + }) + } +} + +impl From for adc::Consumer { + fn from(consumer: typing::Consumer) -> Self { + // Present-but-empty stays present-but-empty (matches APISIX having + // returned a credentials array, even if none of its entries + // converted); absent (pre-3.11 APISIX, no credentials fetched at + // all) stays absent — the two are different facts. + let credentials = consumer.credentials.map(|creds| { + creds + .into_iter() + .filter_map(|c| adc::ConsumerCredential::try_from(c).ok()) + .collect() + }); + + adc::Consumer { + username: consumer.username, + description: consumer.desc, + labels: consumer.labels, + plugins: consumer.plugins, + credentials, + } + } +} + +fn extract_name_label(labels: &Option, key: &str) -> Option { + labels + .as_ref() + .and_then(|labels| labels.get(key)) + .and_then(|value| match value { + LabelValue::Single(name) => Some(name.clone()), + LabelValue::Multiple(_) => None, + }) +} + +impl From for adc::StreamRoute { + fn from(route: typing::StreamRoute) -> Self { + // APISIX's stream routes have no `name` field at all; ADC smuggles + // one through a magic label when writing, and recovers it here when + // reading back, falling back to `id` if it was never set that way. + let name = extract_name_label(&route.labels, typing::ADC_NAME_LABEL) + .unwrap_or_else(|| route.id.clone().unwrap_or_default()); + let labels = route + .labels + .map(|mut labels| { + labels.remove(typing::ADC_NAME_LABEL); + labels + }) + .filter(|labels| !labels.is_empty()); + + adc::StreamRoute { + id: route.id, + name, + description: route.desc, + labels, + plugins: route.plugins, + remote_addr: route.remote_addr, + server_addr: route.server_addr, + server_port: route.server_port.map(|port| port as u32), + sni: route.sni, + } + } +} + +// --- Write direction: ADC -> APISIX --- + +/// A label value that's already a string is written as-is; anything else +/// (ADC labels can be string-or-array, APISIX labels are always plain +/// strings) is JSON-stringified — APISIX's admin API only ever accepts +/// plain string label values on write, regardless of how permissive a given +/// resource's read-side type is. +fn stringify_label_value(value: LabelValue) -> String { + match value { + LabelValue::Single(s) => s, + LabelValue::Multiple(items) => serde_json::to_string(&items).unwrap_or_default(), + } +} + +/// For every resource whose wire `labels` field is typed `Labels` (all of +/// them except `Route` — see `typing::Route`'s doc comment): stringify each +/// value, then re-wrap as `Labels` to fit the field's declared shape. +fn transform_labels_to_apisix(labels: Option) -> Option { + labels.map(|labels| { + labels + .into_iter() + .map(|(key, value)| (key, LabelValue::Single(stringify_label_value(value)))) + .collect() + }) +} + +/// `Route.labels` is the one wire field genuinely typed `Record` rather than `Labels`, so its stringified labels don't get +/// re-wrapped. +fn transform_route_labels_to_apisix( + labels: Option, +) -> Option> { + labels.map(|labels| { + labels + .into_iter() + .map(|(key, value)| (key, stringify_label_value(value))) + .collect() + }) +} + +/// Derived from `adc::HttpMethod`'s own `#[serde(rename = ...)]` names +/// rather than a hand-written match, so this can't drift from +/// [`parse_http_method`]'s (the read-direction counterpart) idea of what +/// each variant's wire string is. +fn http_method_to_string(method: adc::HttpMethod) -> String { + match serde_json::to_value(method).expect("HttpMethod serialization is infallible") { + Value::String(s) => s, + other => unreachable!("HttpMethod must serialize to a JSON string, got {other:?}"), + } +} + +/// Builds a route's wire body. Takes `parent_id` (the owning service's id) +/// separately since ADC's `Route` doesn't carry it — a route only knows its +/// parent by virtue of being nested under `Service.routes` in the model. +pub fn transform_route(route: adc::Route, parent_id: String) -> typing::Route { + typing::Route { + id: route.id.unwrap_or_default(), + name: Some(route.name), + desc: route.description, + labels: transform_route_labels_to_apisix(route.labels), + + uri: None, + uris: Some(route.uris), + host: None, + hosts: route.hosts, + methods: route + .methods + .map(|methods| methods.into_iter().map(http_method_to_string).collect()), + remote_addr: None, + remote_addrs: route.remote_addrs, + vars: route.vars, + filter_func: route.filter_func, + + script: None, + script_id: None, + plugins: route.plugins, + plugin_config_id: None, + upstream: None, + upstream_id: None, + service_id: Some(parent_id), + timeout: route.timeout, + + enable_websocket: route.enable_websocket, + priority: route.priority, + // Always written active; ADC's model has no notion of a disabled route. + status: Some(1), + } +} + +impl From for typing::Upstream { + fn from(upstream: adc::Upstream) -> Self { + typing::Upstream { + // Left unset: a standalone upstream is always addressed by the + // URL path it's PUT to, and an inlined one (under a service) + // gets its id set separately by the caller. + id: None, + name: upstream.name, + desc: upstream.description, + labels: transform_labels_to_apisix(upstream.labels), + + nodes: upstream.nodes.map(typing::UpstreamNodes::List), + scheme: Some(upstream.scheme), + ty: Some(upstream.r#type), + hash_on: upstream.hash_on, + key: upstream.key, + checks: upstream.checks, + + discovery_type: upstream.discovery_type, + service_name: upstream.service_name, + discovery_args: upstream.discovery_args.map(Value::Object), + + pass_host: Some(upstream.pass_host), + upstream_host: upstream.upstream_host, + retries: upstream.retries, + retry_timeout: upstream.retry_timeout, + timeout: upstream.timeout, + tls: upstream.tls, + keepalive_pool: upstream.keepalive_pool, + } + } +} + +/// Builds a service's wire body and, if it has a default upstream, that +/// upstream's own wire body — APISIX stores a service's default upstream as +/// a *separate* `/apisix/admin/upstreams/{id}` resource sharing the +/// service's id, not embedded inline, which is why this returns two bodies +/// rather than one with a nested `upstream` field. (A service's *named* +/// upstreams, `Service.upstreams`, don't go through this at all: ADC's +/// flattened config representation surfaces each as its own top-level +/// `Upstream` resource with a `parent_id`, so they arrive at the operator as +/// independent `Event`s, handled by [`transform_route`]'s sibling for +/// upstreams — see the operator's `UPSTREAM` dispatch.) +pub fn transform_service(service: adc::Service) -> (typing::Service, Option) { + let id = service.id.unwrap_or_default(); + + let upstream = service.upstream.map(|upstream| { + let mut wire = typing::Upstream::from(upstream); + wire.id = Some(id.clone()); + wire.name = Some(service.name.clone()); + wire + }); + + let wire_service = typing::Service { + id: id.clone(), + name: Some(service.name), + desc: service.description, + labels: transform_labels_to_apisix(service.labels), + + hosts: service.hosts, + upstream: None, + // Only reference an upstream_id when there's actually an upstream + // resource to reference — APISIX validates this at write time and + // rejects a service pointing at a nonexistent upstream (confirmed + // against a real instance), which a service with no `upstream` + // field at all would otherwise always hit. + upstream_id: upstream.as_ref().map(|_| id.clone()), + plugins: service.plugins, + script: None, + enable_websocket: None, + upstreams: None, + }; + + (wire_service, upstream) +} + +impl From for typing::Consumer { + fn from(consumer: adc::Consumer) -> Self { + typing::Consumer { + username: consumer.username, + desc: consumer.description, + labels: transform_labels_to_apisix(consumer.labels), + group_id: None, + plugins: consumer.plugins, + // Credentials are synced as their own independent Events, not + // nested in the consumer body. + credentials: None, + } + } +} + +impl From for typing::ConsumerCredential { + fn from(credential: adc::ConsumerCredential) -> Self { + let mut plugins = adc::Plugins::new(); + plugins.insert(credential.r#type, Value::Object(credential.config)); + + typing::ConsumerCredential { + id: credential.id, + name: credential.name, + desc: credential.description, + labels: transform_labels_to_apisix(credential.labels), + plugins: Some(plugins), + } + } +} + +impl From for typing::Ssl { + fn from(ssl: adc::SSL) -> Self { + let mut certificates = ssl.certificates.into_iter(); + let first = certificates.next().unwrap_or(adc::SSLCertificate { + certificate: String::new(), + key: String::new(), + }); + let (certs, keys): (Vec, Vec) = + certificates.map(|c| (c.certificate, c.key)).unzip(); + + typing::Ssl { + id: ssl.id.unwrap_or_default(), + labels: transform_labels_to_apisix(ssl.labels), + + ty: Some(ssl.r#type), + sni: None, + snis: Some(ssl.snis), + cert: Some(first.certificate), + certs: (!certs.is_empty()).then_some(certs), + key: Some(first.key), + keys: (!keys.is_empty()).then_some(keys), + client: ssl.client, + ssl_protocols: ssl.ssl_protocols, + + status: 1, + } + } +} + +/// Builds a stream route's wire body. `inject_name` gates whether ADC's name +/// gets smuggled through the `__ADC_NAME` label (older APISIX versions +/// don't support labels on stream routes at all, so the caller only sets +/// this once it's confirmed the target version does — APISIX >= 3.8.0). +pub fn transform_stream_route( + route: adc::StreamRoute, + parent_id: String, + inject_name: bool, +) -> typing::StreamRoute { + let mut labels = transform_labels_to_apisix(route.labels).unwrap_or_default(); + if inject_name { + labels.insert( + typing::ADC_NAME_LABEL.to_string(), + LabelValue::Single(route.name), + ); + } + + typing::StreamRoute { + id: None, + desc: route.description, + labels: (!labels.is_empty()).then_some(labels), + + remote_addr: route.remote_addr, + server_addr: route.server_addr, + server_port: route.server_port.map(i64::from), + sni: route.sni, + upstream: None, + upstream_id: None, + service_id: Some(parent_id), + + plugins: route.plugins, + protocol: None, + } +} + +/// Builds a consumer group's wire body, plus its member consumers' own +/// bodies (each stamped with the group's id) — mirrors the TS transformer's +/// return shape, though the operator currently only writes the group itself +/// (member consumers reach it as their own separate `Event`s already). +/// Unlike every other write-direction conversion, the id here is *derived* +/// (`generate_id(name)`), not taken from the caller — APISIX's consumer +/// group has no natural identity of its own beyond its plugin set, so ADC +/// manufactures one from the name and recovers the name back from a label +/// on read, the same trick used for stream routes. +pub fn transform_consumer_group( + group: adc::ConsumerGroup, +) -> (typing::ConsumerGroup, Vec) { + let id = adc_sdk::utils::generate_id(&group.name); + + let consumers = group + .consumers + .unwrap_or_default() + .into_iter() + .map(|consumer| { + let mut wire = typing::Consumer::from(consumer); + wire.group_id = Some(id.clone()); + wire + }) + .collect(); + + let mut labels = transform_labels_to_apisix(group.labels).unwrap_or_default(); + labels.insert("ADC_NAME".to_string(), LabelValue::Single(group.name)); + + let wire_group = typing::ConsumerGroup { + id, + desc: group.description, + labels: Some(labels), + plugins: group.plugins.unwrap_or_default(), + }; + + (wire_group, consumers) +} diff --git a/rust/crates/adc-backend-apisix/src/typing.rs b/rust/crates/adc-backend-apisix/src/typing.rs new file mode 100644 index 00000000..5b2dbe0a --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/typing.rs @@ -0,0 +1,339 @@ +//! APISIX admin API wire shapes — what actually comes back from (and gets +//! sent to) `/apisix/admin/*`, as opposed to `adc_sdk::resources::*` (ADC's +//! own resource model). The two are close but not identical: this side +//! carries APISIX-only linkage fields (`upstream_id`, `service_id`, ...). +//! +//! `Deserialize` stays permissive (no `deny_unknown_fields`) since it's +//! decoding a live, evolving third-party API rather than validating +//! user-authored config — an unrecognized field from a newer APISIX release +//! should be ignored, not rejected. `Serialize` (for building sync request +//! bodies) omits `None` fields via `skip_serializing_if` rather than sending +//! explicit `null`s, matching APISIX's own admin API examples and avoiding +//! any risk of an explicit `null` being interpreted differently than an +//! absent key. +//! +//! Nested shapes that are structurally identical between APISIX's wire +//! format and ADC's model (health checks, node lists, timeouts, plugin +//! maps, labels) are reused directly from `adc_sdk::resources` rather than +//! duplicated. + +use std::collections::HashMap; + +use adc_sdk::resources::{ + Expr, Labels, Plugins, SslClient, SslProtocol, SslType, Timeout, UpstreamBalancer, UpstreamHealthCheck, + UpstreamKeepalivePool, UpstreamNode, UpstreamPassHost, UpstreamScheme, UpstreamTls, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const ADC_UPSTREAM_SERVICE_ID_LABEL: &str = "__ADC_UPSTREAM_SERVICE_ID"; + +/// Label a stream route's ADC name is smuggled through, since APISIX +/// stream routes have no native `name` field — see `transformer:: +/// transform_stream_route`'s doc comment. +pub const ADC_NAME_LABEL: &str = "__ADC_NAME"; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Route { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + /// Narrower than every other resource's `labels` field (`Labels`, + /// string-or-array): APISIX's admin API schema for routes only accepts + /// plain string label values. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uris: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_addr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_addrs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vars: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter_func: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_config_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_websocket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Service { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_websocket: Option, + + /// Populated by [`crate::Fetcher`], not by APISIX itself: named + /// upstreams associated with this service via + /// [`ADC_UPSTREAM_SERVICE_ID_LABEL`], indexed back onto the owning + /// service once all upstreams are fetched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstreams: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ConsumerCredential { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Consumer { + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Ssl { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keys: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssl_protocols: Option>, + + pub status: i64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PluginConfig { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + pub plugins: Plugins, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConsumerGroup { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + pub plugins: Plugins, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GlobalRule { + pub id: String, + #[serde(default)] + pub plugins: Plugins, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamRouteProtocolLogger { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conf: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamRouteProtocol { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superior_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conf: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logger: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct StreamRoute { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_addr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_addr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sni: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +/// APISIX accepts (and returns) upstream targets either as a list of node +/// objects, or as a legacy `"host:port": weight` map — both shapes are live +/// on real instances, so both need to parse. Sync always writes the list +/// form. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum UpstreamNodes { + List(Vec), + Map(HashMap), +} + +/// Shared between the top-level `/apisix/admin/upstreams` list entry and an +/// upstream inlined directly into a route/service/stream_route body (APISIX +/// calls the latter shape `InlineUpstream`, i.e. `Omit`) — +/// modeled here as one type with an optional `id`, matching how +/// `adc_sdk::resources::Upstream` already treats `id` as position-dependent +/// rather than duplicating near-identical fields across two structs. On +/// write, `id` is intentionally left unset for a standalone upstream PUT — +/// the URL path already carries it. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Upstream { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nodes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hash_on: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checks: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_args: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pass_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retries: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keepalive_pool: Option, +} + +/// The etcd-derived list envelope every APISIX admin API list endpoint +/// returns. Only `key` and `value` are modeled — the response also carries +/// `createdIndex`/`modifiedIndex`/`total`, but nothing in this crate reads +/// them. +#[derive(Debug, Clone, Deserialize)] +pub struct ListResponse { + pub list: Vec>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListItem { + pub key: String, + pub value: T, +} diff --git a/rust/crates/adc-backend-apisix/src/utils.rs b/rust/crates/adc-backend-apisix/src/utils.rs new file mode 100644 index 00000000..5c672964 --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/utils.rs @@ -0,0 +1,16 @@ +use adc_sdk::ResourceType; + +/// Maps a resource type onto its admin API collection path segment (e.g. +/// `Service` -> `services`). Plugin metadata's collection isn't pluralized. +/// `ConsumerCredential` has no such collection at all — it lives nested +/// under a specific consumer's own path, which callers build themselves +/// (see `operator::main_path`'s dedicated branch) — so it's `None` here +/// rather than a made-up path fragment a new caller could accidentally use +/// as-is. +pub fn resource_type_to_api_name(resource_type: ResourceType) -> Option { + match resource_type { + ResourceType::PluginMetadata => Some(resource_type.as_str().to_string()), + ResourceType::ConsumerCredential => None, + _ => Some(format!("{}s", resource_type.as_str())), + } +} diff --git a/rust/crates/adc-backend-apisix/src/validator.rs b/rust/crates/adc-backend-apisix/src/validator.rs new file mode 100644 index 00000000..1c0aeb41 --- /dev/null +++ b/rust/crates/adc-backend-apisix/src/validator.rs @@ -0,0 +1,294 @@ +//! Pre-flight validation against APISIX's `/apisix/admin/configs/validate` +//! endpoint: batches every create/update event's wire body by resource type +//! and asks APISIX to check it without actually applying anything, then maps +//! any reported errors back onto the `Event`s that produced them. + +use std::collections::HashMap; + +use adc_backend_core::{HttpClient, Method}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{ + BackendError, BackendValidateResult, BackendValidationError, Event, EventType, ResourceType, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::transformer; +use crate::typing; + +pub struct Validator { + client: HttpClient, +} + +/// One entry per group APISIX's validate endpoint recognizes +/// (`routes`/`services`/`consumers`/`ssls`/`global_rules`/`stream_routes`/ +/// `plugin_metadata`/`upstreams`) — deliberately not every `ResourceType`: +/// consumer credentials, consumer groups, plugin configs and standalone +/// upstream events never appear in this payload, matching the TS +/// validator's own `switch` (no case for them, nothing pushed). +#[derive(Debug, Default, Serialize)] +struct ValidateRequestBody { + routes: Vec, + services: Vec, + consumers: Vec, + ssls: Vec, + global_rules: Vec, + stream_routes: Vec, + plugin_metadata: Vec, + upstreams: Vec, +} + +/// Per group, the `(resource_name, Event)` that produced each entry, in the +/// same order they were pushed — APISIX's validate response reports errors +/// by `(resource_type, index)`, and this is what turns that back into a +/// name and an `Event` for `BackendValidationError`. +type ValidateIndex = HashMap<&'static str, Vec<(String, Event)>>; + +#[derive(Debug, Deserialize)] +struct ValidateErrorResponse { + error_msg: Option, + #[serde(default)] + errors: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawValidationError { + resource_type: String, + resource_id: Option, + index: usize, + error: String, +} + +impl Validator { + pub fn new(client: HttpClient) -> Self { + Self { client } + } + + pub async fn validate(&self, events: &[Event]) -> Result { + let (body, index) = build_request(events)?; + + let request = self + .client + .request(Method::POST, "/apisix/admin/configs/validate")? + .json(&body); + let response = self.client.execute(request).await?; + + match response.status().as_u16() { + 200..=299 => Ok(BackendValidateResult { success: true, error_message: None, errors: vec![] }), + 404 => Err(BackendError::Unsupported( + "validate is not supported by this APISIX version; please upgrade to a newer version".to_string(), + )), + 400 => { + let payload: ValidateErrorResponse = response + .json() + .await + .map_err(|e| BackendError::Serialization(format!("decoding validate error response: {e}")))?; + let errors = payload.errors.into_iter().map(|raw| enrich(raw, &index)).collect(); + Ok(BackendValidateResult { success: false, error_message: payload.error_msg, errors }) + } + _ => Err(HttpClient::require_success(response).await.unwrap_err()), + } + } +} + +fn enrich(raw: RawValidationError, index: &ValidateIndex) -> BackendValidationError { + let matched = index + .get(raw.resource_type.as_str()) + .and_then(|group| group.get(raw.index)); + BackendValidationError { + resource_type: raw.resource_type, + resource_id: raw.resource_id, + resource_name: matched.map(|(name, _)| name.clone()), + index: raw.index, + error: raw.error, + event: matched.map(|(_, event)| event.clone()), + } +} + +fn missing_parent(event: &Event) -> BackendError { + BackendError::Other( + format!( + "{:?} event for resource {:?} is missing a parent_id", + event.resource_type, event.resource_id + ) + .into(), + ) +} + +fn deserialize_event_value( + value: &Value, +) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| BackendError::Serialization(format!("decoding event payload: {e}"))) +} + +fn build_request(events: &[Event]) -> Result<(ValidateRequestBody, ValidateIndex), BackendError> { + let mut body = ValidateRequestBody::default(); + let mut index: ValidateIndex = HashMap::new(); + + for event in events { + if !matches!(event.event_type(), EventType::Create | EventType::Update) { + continue; + } + let new_value = event + .kind + .new_value() + .ok_or_else(|| BackendError::Other("create/update event missing new_value".into()))?; + let track = |index: &mut ValidateIndex, group: &'static str| { + index + .entry(group) + .or_default() + .push((event.resource_name.clone(), event.clone())); + }; + + match event.resource_type { + ResourceType::Service => { + let mut service: adc::Service = deserialize_event_value(new_value)?; + service.id = Some(event.resource_id.clone()); + let (wire_service, wire_upstream) = transformer::transform_service(service); + body.services.push(wire_service); + track(&mut index, "services"); + if let Some(upstream) = wire_upstream { + body.upstreams.push(upstream); + track(&mut index, "upstreams"); + } + } + ResourceType::Route => { + let mut route: adc::Route = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + body.routes + .push(transformer::transform_route(route, parent_id)); + track(&mut index, "routes"); + } + ResourceType::StreamRoute => { + let mut route: adc::StreamRoute = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + body.stream_routes + .push(transformer::transform_stream_route(route, parent_id, true)); + track(&mut index, "stream_routes"); + } + ResourceType::Consumer => { + let consumer: adc::Consumer = deserialize_event_value(new_value)?; + body.consumers.push(typing::Consumer::from(consumer)); + track(&mut index, "consumers"); + } + ResourceType::Ssl => { + let mut ssl: adc::SSL = deserialize_event_value(new_value)?; + ssl.id = Some(event.resource_id.clone()); + body.ssls.push(typing::Ssl::from(ssl)); + track(&mut index, "ssls"); + } + ResourceType::GlobalRule => { + let mut plugins = adc::Plugins::new(); + plugins.insert(event.resource_id.clone(), new_value.clone()); + body.global_rules.push(typing::GlobalRule { + id: event.resource_id.clone(), + plugins, + }); + track(&mut index, "global_rules"); + } + ResourceType::PluginMetadata => { + let mut value = new_value.clone(); + if let Value::Object(map) = &mut value { + map.insert("id".to_string(), Value::String(event.resource_id.clone())); + } + body.plugin_metadata.push(value); + track(&mut index, "plugin_metadata"); + } + ResourceType::ConsumerCredential + | ResourceType::ConsumerGroup + | ResourceType::PluginConfig + | ResourceType::Upstream + | ResourceType::InternalStreamService => { + // Not part of APISIX's validate payload — matches the TS + // validator's `switch`, which has no case for these either. + } + } + } + + Ok((body, index)) +} + +#[cfg(test)] +mod tests { + use adc_sdk::EventKind; + use serde_json::json; + + use super::*; + + #[test] + fn enrich_matches_a_known_resource_type_and_index() { + let event = Event::new( + ResourceType::Route, + EventKind::Create { + new_value: json!({}), + }, + "route-1", + "route-1", + ); + let mut index: ValidateIndex = HashMap::new(); + index.insert("routes", vec![("get-anything".to_string(), event.clone())]); + + let raw = RawValidationError { + resource_type: "routes".to_string(), + resource_id: None, + index: 0, + error: "bad route".to_string(), + }; + let result = enrich(raw, &index); + + assert_eq!(result.resource_type, "routes"); + assert_eq!(result.resource_name.as_deref(), Some("get-anything")); + assert_eq!(result.event, Some(event)); + } + + #[test] + fn enrich_handles_an_unrecognized_resource_type_without_panicking() { + let index: ValidateIndex = HashMap::new(); + let raw = RawValidationError { + resource_type: "unknown_type".to_string(), + resource_id: None, + index: 0, + error: "some error".to_string(), + }; + + let result = enrich(raw, &index); + + assert_eq!(result.resource_type, "unknown_type"); + assert!(result.resource_name.is_none()); + assert!(result.event.is_none()); + } + + #[test] + fn enrich_handles_an_out_of_range_index_without_panicking() { + let event = Event::new( + ResourceType::Route, + EventKind::Create { + new_value: json!({}), + }, + "route-1", + "route-1", + ); + let mut index: ValidateIndex = HashMap::new(); + index.insert("routes", vec![("get-anything".to_string(), event)]); + + let raw = RawValidationError { + resource_type: "routes".to_string(), + resource_id: None, + index: 5, + error: "bad route".to_string(), + }; + let result = enrich(raw, &index); + + assert!(result.resource_name.is_none()); + assert!(result.event.is_none()); + } +} diff --git a/rust/crates/adc-backend-apisix/tests/common/mod.rs b/rust/crates/adc-backend-apisix/tests/common/mod.rs new file mode 100644 index 00000000..ef051065 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/common/mod.rs @@ -0,0 +1,34 @@ +//! Shared scaffolding for this crate's real-e2e test files: a live APISIX +//! admin API at `SERVER`, reachable with `TOKEN`. See `e2e_apisix.rs`'s +//! module doc for how to bring one up and run these tests. Not every test +//! file uses every item here, so dead-code warnings are suppressed at the +//! module level rather than per item. +#![allow(dead_code)] + +use adc_backend_apisix::Backend as ApisixBackend; +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; + +pub const SERVER: &str = "http://localhost:19180"; +pub const TOKEN: &str = "edd1c9f034335f136f87ad84b625c8f1"; + +pub fn client() -> HttpClient { + HttpClient::new(HttpClientConfig { server: SERVER.to_string(), token: TOKEN.to_string(), timeout: None, tls: TlsConfig::default() }).unwrap() +} + +pub fn backend() -> ApisixBackend { + ApisixBackend::new(client()) +} + +/// The CI matrix runs this suite against every supported APISIX release +/// (`BACKEND_APISIX_VERSION`, same env var the TS e2e suite reads) — falls +/// back to a version high enough to exercise every version-gated code path +/// when unset, for local runs against whatever's in the compose file. A +/// value that's *present* but doesn't parse as a semver is almost +/// certainly a CI misconfiguration, so that panics loudly instead of +/// silently falling back the same way "unset" does. +pub fn apisix_version() -> semver::Version { + match std::env::var("BACKEND_APISIX_VERSION") { + Ok(v) => semver::Version::parse(&v).unwrap_or_else(|e| panic!("BACKEND_APISIX_VERSION={v:?} is not a valid semver: {e}")), + Err(_) => semver::Version::new(999, 999, 999), + } +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs new file mode 100644 index 00000000..e704ce83 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs @@ -0,0 +1,273 @@ +//! Real end-to-end tests against a live APISIX instance, not a mock. +//! Requires `docker compose up -d` in `libs/backend-apisix/e2e/assets` +//! (the same stack the TS `backend-apisix` e2e suite uses) — admin API at +//! `http://localhost:19180`, admin key `edd1c9f034335f136f87ad84b625c8f1`. +//! +//! Ignored by default (`cargo test` never touches the network); run with +//! `cargo test -p adc-backend-apisix --test e2e_apisix -- --ignored --test-threads=1`. +//! Single-threaded because tests share one APISIX/etcd instance and each +//! cleans up its own resources rather than sandboxing into a namespace. + +use adc_backend_apisix::tests::{Fetcher, Operator}; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use semver::Version; +use serde_json::json; + +mod common; +use common::{apisix_version, client}; + +fn read_asset(name: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets").join(name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +fn operator() -> Operator { + Operator::new(client(), apisix_version()) +} + +fn fetcher() -> Fetcher { + Fetcher::new(client(), apisix_version()) +} + +fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +fn delete(rt: ResourceType, id: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) +} + +fn delete_child(rt: ResourceType, id: &str, parent_id: &str) -> Event { + let mut event = delete(rt, id); + event.parent_id = Some(parent_id.to_string()); + event +} + +async fn sync_ok(events: Vec) { + let results = operator().sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "sync failed for {:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + } +} + +/// Deletes whatever's been `push`ed onto it when dropped, so a panicking +/// assertion partway through a test still cleans up the server instead of +/// leaving orphaned resources for a later test run to trip over. Push in +/// delete order (children before parents); call `disarm` once a test's own +/// explicit cleanup at the end has already succeeded, so a passing test +/// doesn't also pay for a redundant (if harmless) delete here. +/// +/// Runs the actual delete on a throwaway OS thread with its own fresh +/// runtime, since `Drop` can't `.await` and `#[tokio::test]` defaults to +/// the current-thread flavor, which can't be re-entered from within itself +/// during a panic unwind. +#[derive(Default)] +struct Cleanup(Vec); + +impl Cleanup { + fn push(&mut self, event: Event) { + self.0.push(event); + } + + fn disarm(&mut self) { + self.0.clear(); + } +} + +impl Drop for Cleanup { + fn drop(&mut self) { + if self.0.is_empty() { + return; + } + let events = std::mem::take(&mut self.0); + let outcome = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("building a cleanup runtime") + .block_on(operator().sync(events, BackendSyncOptions::default())) + }) + .join(); + match outcome { + Ok(Ok(results)) => { + for result in &results { + if !result.success { + eprintln!("cleanup failed for {:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + } + } + } + Ok(Err(error)) => eprintln!("cleanup sync failed: {error:?}"), + Err(_) => eprintln!("cleanup thread panicked"), + } + } +} + +#[tokio::test] +#[ignore] +async fn syncs_a_service_with_upstream_and_route_then_reads_them_back() { + let mut cleanup = Cleanup::default(); + let service_id = "e2e-svc-1"; + let route_id = "e2e-route-1"; + + let mut route_event = create(ResourceType::Route, route_id, json!({ "name": "e2e route", "uris": ["/e2e-1"] })); + route_event.parent_id = Some(service_id.to_string()); + + sync_ok(vec![ + create(ResourceType::Service, service_id, json!({ "name": "e2e service", "upstream": { "nodes": [{ "host": "127.0.0.1", "port": 1980, "weight": 1 }] } })), + route_event, + ]) + .await; + cleanup.push(delete(ResourceType::Route, route_id)); + cleanup.push(delete(ResourceType::Service, service_id)); + + let services = fetcher().list_services().await.unwrap(); + let service = services.iter().find(|s| s.id == service_id).expect("service was not written"); + assert_eq!(service.name.as_deref(), Some("e2e service")); + assert_eq!(service.upstream_id.as_deref(), Some(service_id)); + + let upstreams = fetcher().list_upstreams().await.unwrap(); + let upstream = upstreams.iter().find(|u| u.id.as_deref() == Some(service_id)).expect("upstream was not written"); + let adc_upstream: adc_sdk::resources::Upstream = upstream.clone().try_into().unwrap(); + let nodes = adc_upstream.nodes.unwrap(); + assert_eq!(nodes[0].host, "127.0.0.1"); + assert_eq!(nodes[0].port, 1980); + + let routes = fetcher().list_routes().await.unwrap(); + let route = routes.iter().find(|r| r.id == route_id).expect("route was not written"); + assert_eq!(route.uris, Some(vec!["/e2e-1".to_string()])); + assert_eq!(route.service_id.as_deref(), Some(service_id)); + + sync_ok(vec![delete(ResourceType::Route, route_id), delete(ResourceType::Service, service_id)]).await; + cleanup.disarm(); + + let routes = fetcher().list_routes().await.unwrap(); + assert!(routes.iter().all(|r| r.id != route_id), "route should have been deleted"); + let upstreams = fetcher().list_upstreams().await.unwrap(); + assert!(upstreams.iter().all(|u| u.id.as_deref() != Some(service_id)), "upstream should have been deleted alongside its service"); +} + +#[tokio::test] +#[ignore] +async fn syncs_an_ssl_certificate_then_reads_it_back() { + let mut cleanup = Cleanup::default(); + let cert = read_asset("test-ssl.cer"); + let key = read_asset("test-ssl.key"); + let ssl_id = "e2e-ssl-1"; + + sync_ok(vec![create( + ResourceType::Ssl, + ssl_id, + json!({ "snis": ["e2e.example.com"], "certificates": [{ "certificate": cert, "key": key }] }), + )]) + .await; + cleanup.push(delete(ResourceType::Ssl, ssl_id)); + + let ssls = fetcher().list_ssls().await.unwrap(); + let ssl = ssls.iter().find(|s| s.id == ssl_id).expect("ssl was not written"); + assert_eq!(ssl.snis.as_deref(), Some(&["e2e.example.com".to_string()][..])); + assert!(ssl.cert.is_some()); + + sync_ok(vec![delete(ResourceType::Ssl, ssl_id)]).await; + cleanup.disarm(); + let ssls = fetcher().list_ssls().await.unwrap(); + assert!(ssls.iter().all(|s| s.id != ssl_id)); +} + +#[tokio::test] +#[ignore] +async fn syncs_a_consumer_with_a_key_auth_credential_then_reads_it_back() { + if apisix_version() < Version::new(3, 11, 0) { + eprintln!("skipping: consumer credentials require apisix >= 3.11.0"); + return; + } + + let mut cleanup = Cleanup::default(); + // APISIX's consumer `username` pattern is stricter than most other id + // fields on older versions (`^[a-zA-Z0-9_]+$`, no hyphens) — 3.17.0 + // happens to accept hyphens too, but keep this hyphen-free so the test + // passes across the whole version matrix. + let username = "e2e_consumer_1"; + let credential_id = "e2e-cred-1"; + + let mut credential_event = + create(ResourceType::ConsumerCredential, credential_id, json!({ "name": credential_id, "type": "key-auth", "config": { "key": "e2e-secret" } })); + credential_event.parent_id = Some(username.to_string()); + + sync_ok(vec![create(ResourceType::Consumer, username, json!({ "username": username })), credential_event]).await; + cleanup.push(delete_child(ResourceType::ConsumerCredential, credential_id, username)); + cleanup.push(delete(ResourceType::Consumer, username)); + + let consumers = fetcher().list_consumers().await.unwrap(); + let consumer = consumers.iter().find(|c| c.username == username).expect("consumer was not written"); + let credentials = consumer.credentials.as_ref().expect("credentials should have been fetched (version-gated above)"); + assert!(credentials.iter().any(|c| c.id.as_deref() == Some(credential_id))); + + sync_ok(vec![delete_child(ResourceType::ConsumerCredential, credential_id, username), delete(ResourceType::Consumer, username)]).await; + cleanup.disarm(); + let consumers = fetcher().list_consumers().await.unwrap(); + assert!(consumers.iter().all(|c| c.username != username)); +} + +#[tokio::test] +#[ignore] +async fn syncs_a_stream_route_then_reads_it_back() { + if apisix_version() < Version::new(3, 7, 0) { + eprintln!("skipping: stream routes require apisix >= 3.7.0"); + return; + } + + let mut cleanup = Cleanup::default(); + let service_id = "e2e-svc-stream-1"; + let stream_route_id = "e2e-stream-route-1"; + + let mut stream_route_event = + create(ResourceType::StreamRoute, stream_route_id, json!({ "name": "e2e-stream-route", "server_port": 33061 })); + stream_route_event.parent_id = Some(service_id.to_string()); + + sync_ok(vec![ + create(ResourceType::Service, service_id, json!({ "name": "e2e stream service", "upstream": { "nodes": [{ "host": "127.0.0.1", "port": 1980, "weight": 1 }] } })), + stream_route_event, + ]) + .await; + cleanup.push(delete(ResourceType::StreamRoute, stream_route_id)); + cleanup.push(delete(ResourceType::Service, service_id)); + + let stream_routes = fetcher().list_stream_routes().await.unwrap(); + let route = stream_routes.iter().find(|r| r.id.as_deref() == Some(stream_route_id)).expect("stream route was not written"); + assert_eq!(route.server_port, Some(33061)); + let adc_route: adc_sdk::resources::StreamRoute = route.clone().into(); + if apisix_version() >= Version::new(3, 8, 0) { + // Recovered from the __ADC_NAME label injected on write (APISIX + // stream routes have no native `name` field, and that label is only + // written from 3.8.0 on) — proves the read/write round trip for + // that trick actually works against a real server, not just our + // own mock. Matches the TS suite's own `Dump (>=3.8.0)` case. + assert_eq!(adc_route.name, "e2e-stream-route"); + } else { + // Below 3.8.0 no label is ever written, so recovery falls back to + // the route's own id — matches the TS suite's `Dump (<3.8.0)` case. + assert_eq!(adc_route.name, stream_route_id); + } + + sync_ok(vec![delete(ResourceType::StreamRoute, stream_route_id), delete(ResourceType::Service, service_id)]).await; + cleanup.disarm(); + let stream_routes = fetcher().list_stream_routes().await.unwrap(); + assert!(stream_routes.iter().all(|r| r.id.as_deref() != Some(stream_route_id))); +} + +#[tokio::test] +#[ignore] +async fn deleting_a_service_that_never_had_an_upstream_still_succeeds() { + // A service with no `upstream` field never gets a + // `/apisix/admin/upstreams/{id}` resource created for it (see + // `operator.rs`'s `build_requests`), so deleting it must not depend on + // that resource existing — `operate` tolerates a 404 specifically for + // this delete rather than failing the whole event. + let service_id = "e2e-svc-no-upstream"; + sync_ok(vec![create(ResourceType::Service, service_id, json!({ "name": "e2e service with no upstream" }))]).await; + + sync_ok(vec![delete(ResourceType::Service, service_id)]).await; + + let services = fetcher().list_services().await.unwrap(); + assert!(services.iter().all(|s| s.id != service_id)); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_misc.rs b/rust/crates/adc-backend-apisix/tests/e2e_misc.rs new file mode 100644 index 00000000..7b9b3375 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_misc.rs @@ -0,0 +1,71 @@ +//! Ported from `libs/backend-apisix/e2e/misc.e2e-spec.ts`. Real network +//! calls against a live APISIX — see `e2e_apisix.rs`'s module doc for how +//! to bring one up and run this file. Exercises `Backend::dump`'s +//! assembly/nesting logic specifically (a route ending up nested under its +//! service in the dumped `Configuration`), which none of the other e2e +//! files cover — everything else so far only checks the flat, unassembled +//! `Fetcher::list_*` results. + +use adc_sdk::Backend as _; +use adc_sdk::{Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::backend; + +fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +fn delete(rt: ResourceType, id: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) +} + +#[tokio::test] +#[ignore] +async fn syncs_resources_with_custom_ids_and_dump_nests_the_route_under_its_service() { + let service_id = "custom-service"; + let route_id = "custom-route"; + let backend = backend(); + + let service = create( + ResourceType::Service, + service_id, + json!({ + "name": "Test Service", + "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + }), + ); + let mut route = create(ResourceType::Route, route_id, json!({ "name": "Test Route", "uris": ["/test"] })); + route.parent_id = Some(service_id.to_string()); + + let results = backend.sync(vec![service, route], adc_sdk::BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}", result.error); + } + + let config = backend.dump().await.unwrap(); + let services = config.services.expect("dump should have returned services"); + assert_eq!(services.len(), 1); + let service = &services[0]; + assert_eq!(service.id.as_deref(), Some(service_id)); + assert_eq!(service.name, "Test Service"); + + let routes = service.routes.as_ref().expect("service should have its route nested under it").http().expect("an HTTP route list"); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].id.as_deref(), Some(route_id)); + assert_eq!(routes[0].uris, vec!["/test".to_string()]); + + let delete_route = { + let mut e = delete(ResourceType::Route, route_id); + e.parent_id = Some(service_id.to_string()); + e + }; + let results = backend.sync(vec![delete_route, delete(ResourceType::Service, service_id)], adc_sdk::BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}", result.error); + } + + let config = backend.dump().await.unwrap(); + assert!(config.services.is_none() || config.services.unwrap().is_empty()); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_operator.rs b/rust/crates/adc-backend-apisix/tests/e2e_operator.rs new file mode 100644 index 00000000..4327313a --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_operator.rs @@ -0,0 +1,245 @@ +//! Real-server replacement for the old mock-based `tests/operator.rs`. +//! Real network calls against a live apisix — see `e2e_apisix.rs`'s module +//! doc for how to bring one up and run this file. +//! +//! What's *not* here, and why: +//! - Create/delete ordering for a service's default upstream (upstream +//! before service on create, service before upstream on delete) isn't +//! re-tested here: apisix itself enforces the referential integrity that +//! ordering exists for — PUTting a service that references a nonexistent +//! upstream id, or DELETEing an upstream a service still references, both +//! get rejected with a 400 (confirmed against a real instance). Every +//! other e2e test that successfully creates/deletes a service with an +//! inline upstream (`e2e_resource_service_upstream.rs`, +//! `e2e_sync_and_dump.rs`, ...) is already, necessarily, proof the +//! ordering is correct — if it weren't, those creates/deletes would fail +//! outright, not silently pass. +//! - Retry-on-failure (`RetryPolicy`'s own behavior is unit-tested in +//! `adc-backend-core`; the operator's use of it is a single `.run(...)` +//! call, not worth re-proving against a live server that would need to be +//! made to misbehave on purpose to exercise it). +//! - Event grouping (`group_events`) is a pure function with no HTTP +//! involved at all — see the inline `#[cfg(test)]` unit tests in +//! `operator.rs` instead. + +use std::time::Duration; + +use adc_backend_apisix::Backend as ApisixBackend; +use adc_backend_apisix::tests::Operator; +use adc_backend_core::Method; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, Event, EventKind, PathSegment, ResourceType, ValueDiff}; +use serde_json::{Value, json}; + +mod common; +use common::{backend, client}; + +/// A PUT bumps `update_time` unconditionally, even for byte-identical +/// content (confirmed against a real instance) — so comparing it before and +/// after an operation directly answers "was this resource actually +/// written to", without needing to record wire traffic. +/// +/// `None` specifically means "the resource doesn't currently exist / has no +/// `update_time`" (a non-success status, or an unparsable body) — a real +/// transport failure panics with context instead of silently becoming +/// `None` too, so a broken docker-compose stack fails loudly here rather +/// than surfacing as a confusing `unwrap()` panic at the call site. +async fn update_time(path: &str) -> Option { + let client = client(); + let request = client.request(Method::GET, path).expect("building the update_time request should never fail for a well-formed path"); + let response = client.execute(request).await.expect("transport failure while polling update_time"); + if !response.status().is_success() { + return None; + } + let body: Value = response.json().await.ok()?; + body["value"]["update_time"].as_i64() +} + +fn create(rt: ResourceType, id: &str, new_value: Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +fn delete(rt: ResourceType, id: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) +} + +fn upstream_diff() -> ValueDiff { + ValueDiff::Edit { path: vec![PathSegment::Key("upstream".into())], lhs: json!({}), rhs: json!({}) } +} + +fn plugins_diff() -> ValueDiff { + ValueDiff::Edit { path: vec![PathSegment::Key("plugins".into())], lhs: json!({}), rhs: json!({}) } +} + +async fn sync_ok(backend: &ApisixBackend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + } +} + +#[tokio::test] +#[ignore] +async fn update_touching_only_upstream_does_not_rewrite_the_service_record() { + let backend = backend(); + let service_id = "e2e-op-svc1"; + sync_ok( + &backend, + vec![create( + ResourceType::Service, + service_id, + json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + )], + ) + .await; + let service_path = format!("/apisix/admin/services/{service_id}"); + let before = update_time(&service_path).await.unwrap(); + + tokio::time::sleep(Duration::from_secs(1)).await; + let event = Event::new( + ResourceType::Service, + EventKind::Update { + old_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + new_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "2.2.2.2", "port": 80, "weight": 1 }] } }), + diff: Some(vec![upstream_diff()]), + }, + service_id, + service_id, + ); + sync_ok(&backend, vec![event]).await; + + let after = update_time(&service_path).await.unwrap(); + assert_eq!(before, after, "the service record itself must not be re-written when only its upstream changed"); + + sync_ok(&backend, vec![delete(ResourceType::Service, service_id)]).await; +} + +#[tokio::test] +#[ignore] +async fn update_touching_both_writes_both_records() { + let backend = backend(); + let service_id = "e2e-op-svc2"; + sync_ok( + &backend, + vec![create( + ResourceType::Service, + service_id, + json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + )], + ) + .await; + let service_path = format!("/apisix/admin/services/{service_id}"); + let upstream_path = format!("/apisix/admin/upstreams/{service_id}"); + let service_before = update_time(&service_path).await.unwrap(); + let upstream_before = update_time(&upstream_path).await.unwrap(); + + tokio::time::sleep(Duration::from_secs(1)).await; + let event = Event::new( + ResourceType::Service, + EventKind::Update { + old_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + new_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "2.2.2.2", "port": 80, "weight": 1 }] }, "plugins": { "key-auth": {} } }), + diff: Some(vec![upstream_diff(), plugins_diff()]), + }, + service_id, + service_id, + ); + sync_ok(&backend, vec![event]).await; + + let service_after = update_time(&service_path).await.unwrap(); + let upstream_after = update_time(&upstream_path).await.unwrap(); + assert!(service_after > service_before, "service record must be rewritten when a non-upstream field changed too"); + assert!(upstream_after > upstream_before, "upstream record must be rewritten when the upstream changed"); + + sync_ok(&backend, vec![delete(ResourceType::Service, service_id)]).await; +} + +#[tokio::test] +#[ignore] +async fn update_not_touching_upstream_does_not_rewrite_the_upstream_record() { + let backend = backend(); + let service_id = "e2e-op-svc3"; + sync_ok( + &backend, + vec![create( + ResourceType::Service, + service_id, + json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + )], + ) + .await; + let upstream_path = format!("/apisix/admin/upstreams/{service_id}"); + let before = update_time(&upstream_path).await.unwrap(); + + tokio::time::sleep(Duration::from_secs(1)).await; + let event = Event::new( + ResourceType::Service, + EventKind::Update { + old_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] } }), + new_value: json!({ "name": service_id, "upstream": { "nodes": [{ "host": "1.1.1.1", "port": 80, "weight": 1 }] }, "plugins": { "key-auth": {} } }), + diff: Some(vec![plugins_diff()]), + }, + service_id, + service_id, + ); + sync_ok(&backend, vec![event]).await; + + let after = update_time(&upstream_path).await.unwrap(); + assert_eq!(before, after, "the upstream record must not be re-written when only non-upstream fields changed"); + + sync_ok(&backend, vec![delete(ResourceType::Service, service_id)]).await; +} + +#[tokio::test] +#[ignore] +async fn stream_route_below_minimum_version_is_rejected_without_a_request() { + // A real client pointed at a real (supporting) server, but the + // `Operator` is deliberately told it's talking to an old one — the + // version check is entirely client-side, so this doesn't need an + // actual old apisix to prove it never sends the request. + let operator = Operator::new(client(), semver::Version::new(3, 5, 0)); + let stream_route_id = "e2e-op-sr1"; + let mut event = create(ResourceType::StreamRoute, stream_route_id, json!({ "name": stream_route_id, "server_port": 34000 })); + event.parent_id = Some("nonexistent-service".to_string()); + + let results = operator.sync(vec![event], BackendSyncOptions::default()).await.unwrap(); + assert!(!results[0].success); + assert!(matches!(results[0].error, Some(adc_sdk::BackendError::Unsupported(_))), "{:?}", results[0].error); +} + +#[tokio::test] +#[ignore] +async fn consumer_credential_below_minimum_version_is_rejected_without_a_request() { + let operator = Operator::new(client(), semver::Version::new(3, 10, 0)); + let credential_id = "e2e-op-cred1"; + let mut event = create(ResourceType::ConsumerCredential, credential_id, json!({ "name": credential_id, "type": "key-auth", "config": {} })); + event.parent_id = Some("nonexistent-consumer".to_string()); + + let results = operator.sync(vec![event], BackendSyncOptions::default()).await.unwrap(); + assert!(!results[0].success); + assert!(matches!(results[0].error, Some(adc_sdk::BackendError::Unsupported(_))), "{:?}", results[0].error); +} + +#[tokio::test] +#[ignore] +async fn stops_starting_new_groups_after_a_failure_by_default() { + // The failure here is entirely client-side (a route event with no + // `parent_id` fails apisix-side path construction before any request + // is sent), so this doesn't depend on the server misbehaving either. + // It's a real `operate` failure (not a version-gate rejection), so with + // the default `exit_on_failure: true` it aborts the whole call as an + // `Err` rather than a partial result list — matching the TS + // implementation's `Observable` erroring out instead of completing. + let backend = backend(); + let bad_route_id = "e2e-op-bad-route"; + let consumer_username = "e2e_op_should_not_run"; + let bad_route = create(ResourceType::Route, bad_route_id, json!({ "name": bad_route_id, "uris": ["/x"] })); + let consumer = create(ResourceType::Consumer, consumer_username, json!({ "username": consumer_username })); + + let result = backend.sync(vec![bad_route, consumer], BackendSyncOptions::default()).await; + assert!(result.is_err(), "{result:?}"); + + let config = backend.dump().await.unwrap(); + let consumers = config.consumers.unwrap_or_default(); + assert!(consumers.iter().all(|c| c.username != consumer_username), "the consumer from the second group must not have been created"); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_ping.rs b/rust/crates/adc-backend-apisix/tests/e2e_ping.rs new file mode 100644 index 00000000..7231641a --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_ping.rs @@ -0,0 +1,76 @@ +//! Ported from `libs/backend-apisix/e2e/ping.e2e-spec.ts`. Real network +//! calls against a live APISIX (plain HTTP on :19180, mTLS on :29180) — see +//! `e2e_apisix.rs`'s module doc for how to bring one up and run this file. +//! +//! Exact error text isn't asserted where TS checks for one: Node's TLS +//! stack (OpenSSL) and Rust's (rustls) report connection/certificate +//! failures in their own wording — what's portable is *that* the call +//! fails and, where meaningful, which `BackendError` variant it fails as. + +use adc_backend_apisix::Backend as ApisixBackend; +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; +// Trait-only import: brings `.ping()`/`.dump()`/etc. into scope without +// binding the name `Backend`, which would collide with the APISIX crate's +// own `Backend` type (its concrete implementation of this trait). +use adc_sdk::Backend as _; + +mod common; +use common::TOKEN; + +fn read_asset(name: &str) -> Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets/apisix_conf/mtls").join(name); + std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +fn backend(server: &str, tls: TlsConfig) -> ApisixBackend { + let client = HttpClient::new(HttpClientConfig { server: server.to_string(), token: TOKEN.to_string(), timeout: None, tls }).unwrap(); + ApisixBackend::new(client) +} + +#[tokio::test] +#[ignore] +async fn succeeds_over_plain_http() { + let backend = backend("http://localhost:19180", TlsConfig::default()); + backend.ping().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn succeeds_over_mtls() { + let tls = TlsConfig { + ca_cert_pem: Some(read_asset("ca.cer")), + client_cert_pem: Some(read_asset("client.cer")), + client_key_pem: Some(read_asset("client.key")), + skip_verify: false, + }; + let backend = backend("https://localhost:29180", tls); + backend.ping().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn fails_against_an_unreachable_server() { + let backend = backend("http://0.0.0.0:1", TlsConfig::default()); + let err = backend.ping().await.unwrap_err(); + assert!(matches!(err, adc_sdk::BackendError::Transport(_)), "got {err:?}"); +} + +#[tokio::test] +#[ignore] +async fn fails_when_the_server_certificate_is_not_trusted() { + // mTLS endpoint's cert is self-signed for this test fixture; not + // supplying the CA to trust it must fail the TLS handshake. + let backend = backend("https://localhost:29180", TlsConfig::default()); + let err = backend.ping().await.unwrap_err(); + assert!(matches!(err, adc_sdk::BackendError::Transport(_)), "got {err:?}"); +} + +#[tokio::test] +#[ignore] +async fn fails_when_the_client_certificate_is_missing() { + let tls = TlsConfig { ca_cert_pem: Some(read_asset("ca.cer")), client_cert_pem: None, client_key_pem: None, skip_verify: false }; + let backend = backend("https://localhost:29180", tls); + // APISIX's mTLS listener requires a client cert; without one the TLS + // handshake itself is refused before any HTTP response comes back. + assert!(backend.ping().await.is_err()); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs new file mode 100644 index 00000000..84a14985 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs @@ -0,0 +1,141 @@ +//! Ported from `libs/backend-apisix/e2e/resources/consumer.e2e-spec.ts`. +//! Real network calls against a live APISIX (>= 3.11.0, same as the TS +//! suite's own version gate) — see `e2e_apisix.rs`'s module doc for how to +//! bring one up and run this file. + +use adc_backend_apisix::tests::Fetcher; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::{apisix_version, backend, client}; + +fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +fn update(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + // Consumer credential updates aren't SERVICE events, so the operator + // doesn't need diff info to decide what to touch — an empty diff is + // fine here (contrast `e2e_sync_and_dump.rs`'s `update()` helper). + Event::new(rt, EventKind::Update { old_value: json!({}), new_value, diff: None }, id, id) +} + +fn delete(rt: ResourceType, id: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { + if apisix_version() < semver::Version::new(3, 11, 0) { + eprintln!("skipping: consumer credentials require apisix >= 3.11.0"); + return; + } + + let consumer_username = "consumer1"; + let credential_id = "consumer1-key"; + let backend = backend(); + + let mut credential = create( + ResourceType::ConsumerCredential, + credential_id, + json!({ "name": credential_id, "type": "key-auth", "config": { "key": credential_id } }), + ); + // APISIX keys a credential's parent consumer by literal username in + // the URL path, not a content hash — matches `main_path`'s special + // case for `ConsumerCredential` in `operator.rs`. + credential.parent_id = Some(consumer_username.to_string()); + + let results = backend + .sync( + vec![create(ResourceType::Consumer, consumer_username, json!({ "username": consumer_username })), credential], + BackendSyncOptions::default(), + ) + .await + .unwrap(); + for result in &results { + assert!(result.success, "{:?}", result.error); + } + + let config = backend.dump().await.unwrap(); + let consumers = config.consumers.unwrap(); + assert_eq!(consumers.len(), 1); + let credentials = consumers[0].credentials.as_ref().expect("consumer should have its credential"); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].config.get("key"), Some(&json!(credential_id))); + + let mut updated_credential = + update(ResourceType::ConsumerCredential, credential_id, json!({ "name": credential_id, "type": "key-auth", "config": { "key": "new-key" } })); + updated_credential.parent_id = Some(consumer_username.to_string()); + let results = backend.sync(vec![updated_credential], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let config = backend.dump().await.unwrap(); + let credentials = config.consumers.unwrap()[0].credentials.clone().unwrap(); + assert_eq!(credentials[0].config.get("key"), Some(&json!("new-key"))); + + let mut delete_credential = delete(ResourceType::ConsumerCredential, credential_id); + delete_credential.parent_id = Some(consumer_username.to_string()); + let results = backend.sync(vec![delete_credential], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let config = backend.dump().await.unwrap(); + let consumers = config.consumers.unwrap(); + assert_eq!(consumers.len(), 1); + assert!(consumers[0].credentials.is_none()); + + let results = backend.sync(vec![delete(ResourceType::Consumer, consumer_username)], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let config = backend.dump().await.unwrap(); + assert!(config.consumers.is_none() || config.consumers.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn consumer_credentials_are_never_fetched_below_apisix_3_11_0() { + if apisix_version() < semver::Version::new(3, 11, 0) { + eprintln!("skipping: needs a real >= 3.11.0 server to prove the client-side gate is what's skipping the fetch, not the server lacking the feature"); + return; + } + + let consumer_username = "gated_consumer"; + let credential_id = "gated-key"; + let backend = backend(); + let mut credential = create( + ResourceType::ConsumerCredential, + credential_id, + json!({ "name": credential_id, "type": "key-auth", "config": { "key": credential_id } }), + ); + credential.parent_id = Some(consumer_username.to_string()); + let results = backend + .sync( + vec![create(ResourceType::Consumer, consumer_username, json!({ "username": consumer_username })), credential], + BackendSyncOptions::default(), + ) + .await + .unwrap(); + for result in &results { + assert!(result.success, "{:?}", result.error); + } + + // Same real server (which genuinely has this consumer's credential — + // proven above), but the `Fetcher` is told it's talking to a + // pre-3.11.0 apisix. `list_consumers` must not even attempt the + // credentials sub-fetch in that case, regardless of what the server + // could actually return. + let old_fetcher = Fetcher::new(client(), semver::Version::new(3, 10, 0)); + let consumers = old_fetcher.list_consumers().await.unwrap(); + let consumer = consumers.iter().find(|c| c.username == consumer_username).expect("consumer was not found"); + assert!(consumer.credentials.is_none(), "credentials must not be fetched when the fetcher believes the server predates 3.11.0"); + + let mut delete_credential = delete(ResourceType::ConsumerCredential, credential_id); + delete_credential.parent_id = Some(consumer_username.to_string()); + let results = + backend.sync(vec![delete_credential, delete(ResourceType::Consumer, consumer_username)], BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}", result.error); + } +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs new file mode 100644 index 00000000..6f5b6c98 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs @@ -0,0 +1,39 @@ +//! Ported from `libs/backend-apisix/e2e/resources/service.e2e-spec.ts`. +//! Real network calls against a live APISIX — see `e2e_apisix.rs`'s module +//! doc for how to bring one up and run this file. + +use adc_backend_apisix::tests::Fetcher; +use adc_sdk::Backend as _; +use adc_sdk::utils::generate_id; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::{backend, client}; + +#[tokio::test] +#[ignore] +async fn creating_a_service_with_an_inline_upstream_splits_it_into_a_separate_resource() { + let service_name = "test"; + let backend = backend(); + let service_id = generate_id(service_name); + + let service = Event::new( + ResourceType::Service, + EventKind::Create { new_value: json!({ "name": service_name, "upstream": { "type": "roundrobin", "nodes": [{ "host": "127.0.0.1", "port": 8080, "weight": 1 }] } }) }, + service_id.clone(), + service_name, + ); + let results = backend.sync(vec![service], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let fetcher = Fetcher::new(client(), semver::Version::new(3, 17, 0)); + let services = fetcher.list_services().await.unwrap(); + let wire_service = services.iter().find(|s| s.id == service_id).expect("service was not created"); + assert!(wire_service.upstream_id.is_some(), "service should reference a separate upstream resource by id"); + assert!(wire_service.upstream.is_none(), "the upstream must not be inlined into the service's own wire body"); + + let delete = Event::new(ResourceType::Service, EventKind::Delete { old_value: json!({}) }, service_id, service_name); + let results = backend.sync(vec![delete], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs new file mode 100644 index 00000000..22840a45 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs @@ -0,0 +1,156 @@ +//! Ported from +//! `libs/backend-apisix/e2e/resources/service-upstream.e2e-spec.ts`. Real +//! network calls against a live APISIX — see `e2e_apisix.rs`'s module doc +//! for how to bring one up and run this file. + +use adc_backend_apisix::Backend as ApisixBackend; +use adc_sdk::Backend as _; +use adc_sdk::utils::generate_id; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::backend; + +fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +fn create_child(rt: ResourceType, name: &str, new_value: serde_json::Value, parent_name: &str) -> Event { + let mut event = Event::new(rt, EventKind::Create { new_value }, generate_id(&format!("{parent_name}.{name}")), name); + event.parent_id = Some(generate_id(parent_name)); + event +} + +fn update(rt: ResourceType, id: &str, old_value: serde_json::Value, new_value: serde_json::Value) -> Event { + let diff = adc_sdk::diff_value(&old_value, &new_value); + Event::new(rt, EventKind::Update { old_value, new_value, diff }, id, id) +} + +fn update_child(rt: ResourceType, name: &str, new_value: serde_json::Value, parent_name: &str) -> Event { + // Only used for named-upstream updates below, which aren't SERVICE + // events — the operator doesn't need real diff info to decide what to + // touch for any type other than SERVICE (see `operator.rs`). + let mut event = Event::new(rt, EventKind::Update { old_value: json!({}), new_value, diff: None }, generate_id(&format!("{parent_name}.{name}")), name); + event.parent_id = Some(generate_id(parent_name)); + event +} + +fn delete(rt: ResourceType, id: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) +} + +fn delete_child(rt: ResourceType, name: &str, parent_name: &str) -> Event { + let mut event = Event::new(rt, EventKind::Delete { old_value: json!({}) }, generate_id(&format!("{parent_name}.{name}")), name); + event.parent_id = Some(generate_id(parent_name)); + event +} + +async fn sync_ok(backend: &ApisixBackend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + } +} + +#[tokio::test] +#[ignore] +async fn service_inline_upstream_lifecycle() { + let service_name = "test-inline-upstream"; + let backend = backend(); + let service_id = generate_id(service_name); + let upstream_v1 = json!({ "type": "roundrobin", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }); + + sync_ok(&backend, vec![create(ResourceType::Service, &service_id, json!({ "name": service_name, "upstream": upstream_v1 }))]).await; + + let config = backend.dump().await.unwrap(); + let service = config.services.unwrap().into_iter().find(|s| s.id.as_deref() == Some(&service_id)).unwrap(); + let upstream = service.upstream.as_ref().unwrap(); + assert_eq!(upstream.nodes.as_ref().unwrap().len(), 1); + assert!(upstream.id.is_none(), "an inlined default upstream must not carry its own id"); + assert!(upstream.name.is_none(), "an inlined default upstream must not carry its own name"); + + let upstream_v2 = json!({ + "type": "roundrobin", + "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 50 }, { "host": "example.com", "port": 80, "weight": 50 }], + }); + sync_ok( + &backend, + vec![update( + ResourceType::Service, + &service_id, + json!({ "name": service_name, "upstream": upstream_v1 }), + json!({ "name": service_name, "upstream": upstream_v2 }), + )], + ) + .await; + + let config = backend.dump().await.unwrap(); + let service = config.services.unwrap().into_iter().find(|s| s.id.as_deref() == Some(&service_id)).unwrap(); + let upstream = service.upstream.as_ref().unwrap(); + assert_eq!(upstream.nodes.as_ref().unwrap().len(), 2); + assert!(upstream.id.is_none()); + assert!(upstream.name.is_none()); + + sync_ok(&backend, vec![delete(ResourceType::Service, &service_id)]).await; + let config = backend.dump().await.unwrap(); + assert!(config.services.unwrap_or_default().iter().all(|s| s.id.as_deref() != Some(&service_id))); +} + +#[tokio::test] +#[ignore] +async fn service_named_upstreams_lifecycle() { + // A distinct name, not the bare "test" several other e2e files also use + // — those all currently run as separate sequential test binaries + // against the one shared APISIX instance, so this doesn't collide + // today, but a differently-named service here avoids relying on that. + let service_name = "test-named-upstreams"; + let upstream1_name = "nd-upstream1"; + let upstream2_name = "nd-upstream2"; + let backend = backend(); + let service_id = generate_id(service_name); + + sync_ok( + &backend, + vec![ + create(ResourceType::Service, &service_id, json!({ "name": service_name, "upstream": { "type": "roundrobin", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] } })), + create_child(ResourceType::Upstream, upstream1_name, json!({ "name": upstream1_name, "type": "roundrobin", "scheme": "https", "nodes": [{ "host": "1.1.1.1", "port": 443, "weight": 100 }] }), service_name), + create_child(ResourceType::Upstream, upstream2_name, json!({ "name": upstream2_name, "type": "roundrobin", "scheme": "https", "nodes": [{ "host": "1.0.0.1", "port": 443, "weight": 100 }] }), service_name), + ], + ) + .await; + + let config = backend.dump().await.unwrap(); + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + let upstreams = services[0].upstreams.as_ref().expect("service should have its named upstreams"); + assert_eq!(upstreams.len(), 2); + assert!(upstreams.iter().any(|u| u.name.as_deref() == Some(upstream1_name))); + assert!(upstreams.iter().any(|u| u.name.as_deref() == Some(upstream2_name))); + + sync_ok( + &backend, + vec![update_child( + ResourceType::Upstream, + upstream1_name, + json!({ "name": upstream1_name, "type": "roundrobin", "scheme": "https", "nodes": [{ "host": "1.1.1.1", "port": 443, "weight": 100 }], "retry_timeout": 100 }), + service_name, + )], + ) + .await; + + let config = backend.dump().await.unwrap(); + let upstreams = config.services.unwrap()[0].upstreams.clone().unwrap(); + let updated = upstreams.iter().find(|u| u.name.as_deref() == Some(upstream1_name)).unwrap(); + assert_eq!(updated.retry_timeout, Some(100.0)); + + sync_ok(&backend, vec![delete_child(ResourceType::Upstream, upstream2_name, service_name)]).await; + let config = backend.dump().await.unwrap(); + let upstreams = config.services.unwrap()[0].upstreams.clone().unwrap(); + assert_eq!(upstreams.len(), 1); + assert_eq!(upstreams[0].name.as_deref(), Some(upstream1_name)); + + sync_ok(&backend, vec![delete(ResourceType::Service, &service_id)]).await; + let config = backend.dump().await.unwrap(); + assert!(config.services.unwrap_or_default().iter().all(|s| s.id.as_deref() != Some(&service_id))); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_upstream.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_upstream.rs new file mode 100644 index 00000000..4ecda9a9 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_upstream.rs @@ -0,0 +1,49 @@ +//! Ported from `libs/backend-apisix/e2e/resources/upstream.e2e-spec.ts`. +//! Real network calls against a live APISIX — see `e2e_apisix.rs`'s module +//! doc for how to bring one up and run this file. + +use adc_sdk::Backend as _; +use adc_sdk::utils::generate_id; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::backend; + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_discovery_based_upstream_with_no_nodes() { + let service_name = "service1"; + let discovery_service_name = "test"; + let backend = backend(); + let service_id = generate_id(service_name); + + let service = Event::new( + ResourceType::Service, + EventKind::Create { + new_value: json!({ + "name": service_name, + "upstream": { "scheme": "https", "discovery_type": "kubernetes", "service_name": discovery_service_name }, + }), + }, + service_id.clone(), + service_name, + ); + let results = backend.sync(vec![service], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let config = backend.dump().await.unwrap(); + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + let upstream = services[0].upstream.as_ref().expect("service should have its (nodeless) default upstream"); + assert!(upstream.nodes.is_none()); + assert_eq!(upstream.discovery_type.as_deref(), Some("kubernetes")); + assert_eq!(upstream.service_name.as_deref(), Some(discovery_service_name)); + + let delete = Event::new(ResourceType::Service, EventKind::Delete { old_value: json!({}) }, service_id, service_name); + let results = backend.sync(vec![delete], BackendSyncOptions::default()).await.unwrap(); + assert!(results[0].success, "{:?}", results[0].error); + + let config = backend.dump().await.unwrap(); + assert!(config.services.is_none() || config.services.unwrap().is_empty()); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs b/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs new file mode 100644 index 00000000..ca003460 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs @@ -0,0 +1,436 @@ +//! Ported from `libs/backend-apisix/e2e/sync-and-dump-1.e2e-spec.ts`. Real +//! network calls against a live APISIX — see `e2e_apisix.rs`'s module doc +//! for how to bring one up and run this file. +//! +//! Each TS `describe` block (a sequence of dependent `it`s sharing mutable +//! state) becomes one `#[tokio::test]` function running the same +//! create/dump/update/dump/delete/dump sequence linearly. Assertions that +//! depended on etcd's incidental list-return order (`services[0]` / +//! `services[1]`) are rewritten to find by id instead — that ordering was +//! never a property of *our* code, just an accident of how etcd happens to +//! range-scan keys, so asserting on it would be testing the wrong thing. + +use std::time::Duration; + +use adc_backend_apisix::Backend as ApisixBackend; +use adc_sdk::resources::Configuration; +use adc_sdk::utils::generate_id; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::{apisix_version, backend}; + +/// Mirrors `createEvent`'s `resourceId` derivation in +/// `libs/backend-apisix/e2e/support/utils.ts`: APISIX keys consumers, +/// global rules and plugin metadata by their literal name (not a content +/// hash), everything else by `generate_id(name)` — or, when nested under a +/// parent, `generate_id("parent.name")`. +fn resource_id(rt: ResourceType, name: &str, parent_name: Option<&str>) -> String { + match rt { + ResourceType::Consumer | ResourceType::GlobalRule | ResourceType::PluginMetadata => name.to_string(), + _ => match parent_name { + Some(parent) => generate_id(&format!("{parent}.{name}")), + None => generate_id(name), + }, + } +} + +fn create(rt: ResourceType, name: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, resource_id(rt, name, None), name) +} + +fn create_child(rt: ResourceType, name: &str, new_value: serde_json::Value, parent_name: &str) -> Event { + let mut event = Event::new(rt, EventKind::Create { new_value }, resource_id(rt, name, Some(parent_name)), name); + event.parent_id = Some(resource_id(ResourceType::Service, parent_name, None)); + event +} + +/// Computes a real diff (via `adc_sdk::diff_value`, the same function the +/// differ itself uses) rather than leaving it `None` — for `SERVICE` +/// updates specifically, the operator inspects `diff` to decide whether the +/// service's own admin-API resource needs touching at all versus only its +/// upstream (see `operator.rs`'s module doc comment); a `None`/empty diff +/// reads as "nothing outside upstream changed" and skips the main request +/// entirely, which would silently no-op a hand-built event with no diff. +fn update(rt: ResourceType, name: &str, old_value: serde_json::Value, new_value: serde_json::Value) -> Event { + let diff = adc_sdk::diff_value(&old_value, &new_value); + Event::new(rt, EventKind::Update { old_value, new_value, diff }, resource_id(rt, name, None), name) +} + +fn delete(rt: ResourceType, name: &str) -> Event { + Event::new(rt, EventKind::Delete { old_value: json!({}) }, resource_id(rt, name, None), name) +} + +fn delete_child(rt: ResourceType, name: &str, parent_name: &str) -> Event { + let mut event = Event::new(rt, EventKind::Delete { old_value: json!({}) }, resource_id(rt, name, Some(parent_name)), name); + event.parent_id = Some(resource_id(ResourceType::Service, parent_name, None)); + event +} + +async fn sync_ok(backend: &ApisixBackend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + } +} + +async fn dump(backend: &ApisixBackend) -> Configuration { + backend.dump().await.unwrap() +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_services_lifecycle() { + // Prefixed to stay unique across the whole e2e suite — several other + // files pick their own short, generic resource names too, and every + // test file shares one live APISIX/etcd instance within a CI job. + let service1_name = "sync-dump-service1"; + let service2_name = "sync-dump-service2"; + let backend = backend(); + let upstream = json!({ "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }); + + sync_ok( + &backend, + vec![ + create(ResourceType::Service, service1_name, json!({ "name": service1_name, "upstream": upstream, "hosts": ["example1.com", "example2.com"] })), + create(ResourceType::Service, service2_name, json!({ "name": service2_name, "upstream": upstream })), + ], + ) + .await; + + let config = dump(&backend).await; + let services = config.services.unwrap(); + assert_eq!(services.len(), 2); + let service1 = services.iter().find(|s| s.name == service1_name).expect("service1 missing"); + assert_eq!(service1.hosts.as_deref(), Some(&["example1.com".to_string(), "example2.com".to_string()][..])); + assert!(services.iter().any(|s| s.name == service2_name)); + + sync_ok( + &backend, + vec![update( + ResourceType::Service, + service1_name, + json!({ "name": service1_name, "upstream": upstream, "hosts": ["example1.com", "example2.com"] }), + json!({ "name": service1_name, "upstream": upstream, "hosts": ["example1.com", "example2.com"], "description": "desc" }), + )], + ) + .await; + + let config = dump(&backend).await; + let service1 = config.services.unwrap().into_iter().find(|s| s.name == service1_name).expect("service1 missing"); + assert_eq!(service1.description.as_deref(), Some("desc")); + + sync_ok(&backend, vec![delete(ResourceType::Service, service1_name)]).await; + let config = dump(&backend).await; + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services[0].name, service2_name); + + sync_ok(&backend, vec![delete(ResourceType::Service, service2_name)]).await; + let config = dump(&backend).await; + assert!(config.services.is_none() || config.services.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_service_with_routes_lifecycle() { + // Not the bare "test" a couple of other e2e files also use as a + // service name — see the note in `syncs_and_dumps_services_lifecycle`. + let service_name = "sync-dump-routes-svc"; + let route1_name = "route1"; + let route2_name = "route2"; + let backend = backend(); + let upstream = json!({ "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }); + + sync_ok( + &backend, + vec![ + create(ResourceType::Service, service_name, json!({ "name": service_name, "upstream": upstream })), + create_child(ResourceType::Route, route1_name, json!({ "name": route1_name, "uris": ["/route1"] }), service_name), + create_child(ResourceType::Route, route2_name, json!({ "name": route2_name, "uris": ["/route2"], "plugins": { "key-auth": {} } }), service_name), + ], + ) + .await; + + let config = dump(&backend).await; + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + let routes = services[0].routes.as_ref().unwrap().http().unwrap(); + assert_eq!(routes.len(), 2); + assert!(routes.iter().any(|r| r.name == route1_name && r.uris == vec!["/route1".to_string()])); + assert!(routes.iter().any(|r| r.name == route2_name && r.plugins.is_some())); + + sync_ok(&backend, vec![delete_child(ResourceType::Route, route1_name, service_name)]).await; + let config = dump(&backend).await; + let services = config.services.unwrap(); + let routes = services[0].routes.as_ref().unwrap().http().unwrap(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].name, route2_name); + + sync_ok(&backend, vec![delete_child(ResourceType::Route, route2_name, service_name)]).await; + // See the TS spec's comment: APISIX checks referential integrity + // against its own etcd-watch-derived in-memory cache, which lags + // slightly behind the admin API write that created the delete event — + // deleting a service right after its last route needs a short pause or + // the delete is flaky. + tokio::time::sleep(Duration::from_millis(200)).await; + sync_ok(&backend, vec![delete(ResourceType::Service, service_name)]).await; + + let config = dump(&backend).await; + assert!(config.services.is_none() || config.services.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_service_with_stream_route_lifecycle() { + if apisix_version() < semver::Version::new(3, 7, 0) { + eprintln!("skipping: stream routes require apisix >= 3.7.0"); + return; + } + + let service_name = "sync-dump-stream-svc"; + let stream_route_name = "postgres"; + let backend = backend(); + let upstream = json!({ "scheme": "tcp", "nodes": [{ "host": "1.1.1.1", "port": 5432, "weight": 100 }] }); + + sync_ok( + &backend, + vec![ + create(ResourceType::Service, service_name, json!({ "name": service_name, "upstream": upstream })), + create_child(ResourceType::StreamRoute, stream_route_name, json!({ "name": stream_route_name, "server_port": 54320 }), service_name), + ], + ) + .await; + + let config = dump(&backend).await; + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + let stream_routes = services[0].routes.as_ref().unwrap().stream().unwrap(); + assert_eq!(stream_routes.len(), 1); + assert_eq!(stream_routes[0].server_port, Some(54320)); + if apisix_version() >= semver::Version::new(3, 8, 0) { + assert_eq!(stream_routes[0].name, stream_route_name); + } else { + // Below 3.8.0 the `__ADC_NAME` label is never written (see + // `transformer.rs`), so recovery falls back to the route's own id + // — `generate_id("{parent}.{name}")` per this file's `resource_id` + // helper, *not* the literal name (confirmed the hard way: this + // fixture's id and name aren't the same string, unlike some other + // e2e files' fixtures) — matches the TS suite's `Dump (<3.8.0)` + // case. + assert_eq!(stream_routes[0].name, generate_id(&format!("{service_name}.{stream_route_name}"))); + } + + sync_ok(&backend, vec![delete_child(ResourceType::StreamRoute, stream_route_name, service_name)]).await; + let config = dump(&backend).await; + let services = config.services.unwrap(); + assert!(services[0].routes.is_none()); + + tokio::time::sleep(Duration::from_millis(200)).await; + sync_ok(&backend, vec![delete(ResourceType::Service, service_name)]).await; + let config = dump(&backend).await; + assert!(config.services.is_none() || config.services.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_consumers_lifecycle() { + // Underscored, not hyphenated: APISIX's `username` pattern is + // `^[a-zA-Z0-9_]+$` on older versions. Not the bare "consumer1" another + // e2e file also uses — see the note in `syncs_and_dumps_services_lifecycle`. + let consumer1_name = "sync_dump_consumer1"; + let consumer2_name = "sync_dump_consumer2"; + let backend = backend(); + + sync_ok( + &backend, + vec![ + create(ResourceType::Consumer, consumer1_name, json!({ "username": consumer1_name, "plugins": { "key-auth": { "key": consumer1_name } } })), + create(ResourceType::Consumer, consumer2_name, json!({ "username": consumer2_name, "plugins": { "key-auth": { "key": consumer2_name } } })), + ], + ) + .await; + + let config = dump(&backend).await; + let consumers = config.consumers.unwrap(); + assert_eq!(consumers.len(), 2); + assert!(consumers.iter().any(|c| c.username == consumer1_name)); + assert!(consumers.iter().any(|c| c.username == consumer2_name)); + + sync_ok( + &backend, + vec![update( + ResourceType::Consumer, + consumer1_name, + json!({ "username": consumer1_name, "plugins": { "key-auth": { "key": consumer1_name } } }), + json!({ "username": consumer1_name, "plugins": { "key-auth": { "key": consumer1_name } }, "description": "desc" }), + )], + ) + .await; + let config = dump(&backend).await; + let consumer1 = config.consumers.unwrap().into_iter().find(|c| c.username == consumer1_name).unwrap(); + assert_eq!(consumer1.description.as_deref(), Some("desc")); + + sync_ok(&backend, vec![delete(ResourceType::Consumer, consumer1_name)]).await; + let config = dump(&backend).await; + let consumers = config.consumers.unwrap(); + assert_eq!(consumers.len(), 1); + assert_eq!(consumers[0].username, consumer2_name); + + sync_ok(&backend, vec![delete(ResourceType::Consumer, consumer2_name)]).await; + let config = dump(&backend).await; + assert!(config.consumers.is_none() || config.consumers.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_ssls_lifecycle() { + let backend = backend(); + let cert = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets/test-ssl.cer"), + ) + .unwrap(); + let key = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets/test-ssl.key"), + ) + .unwrap(); + + let ssl1_snis = ["ssl1-1.com", "ssl1-2.com"]; + let ssl2_snis = ["ssl2-1.com", "ssl2-2.com"]; + let ssl1_name = ssl1_snis.join(","); + let ssl2_name = ssl2_snis.join(","); + + sync_ok( + &backend, + vec![ + create(ResourceType::Ssl, &ssl1_name, json!({ "snis": ssl1_snis, "certificates": [{ "certificate": cert, "key": key }] })), + create(ResourceType::Ssl, &ssl2_name, json!({ "snis": ssl2_snis, "certificates": [{ "certificate": cert, "key": key }] })), + ], + ) + .await; + + let config = dump(&backend).await; + let ssls = config.ssls.unwrap(); + assert_eq!(ssls.len(), 2); + assert!(ssls.iter().any(|s| s.snis == ssl1_snis)); + assert!(ssls.iter().any(|s| s.snis == ssl2_snis)); + + sync_ok( + &backend, + vec![update( + ResourceType::Ssl, + &ssl1_name, + json!({ "snis": ssl1_snis, "certificates": [{ "certificate": cert, "key": key }] }), + json!({ "snis": ssl1_snis, "certificates": [{ "certificate": cert, "key": key }], "labels": { "test": "test" } }), + )], + ) + .await; + let config = dump(&backend).await; + let ssl1 = config.ssls.unwrap().into_iter().find(|s| s.snis == ssl1_snis).unwrap(); + assert_eq!(ssl1.labels.unwrap().get("test"), Some(&adc_sdk::resources::LabelValue::Single("test".to_string()))); + + sync_ok(&backend, vec![delete(ResourceType::Ssl, &ssl1_name)]).await; + let config = dump(&backend).await; + let ssls = config.ssls.unwrap(); + assert_eq!(ssls.len(), 1); + assert_eq!(ssls[0].snis, ssl2_snis); + + sync_ok(&backend, vec![delete(ResourceType::Ssl, &ssl2_name)]).await; + let config = dump(&backend).await; + assert!(config.ssls.is_none() || config.ssls.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_global_rules_lifecycle() { + // `GlobalRule`'s id must be a real, registered APISIX plugin name (not + // an arbitrary string — confirmed the hard way: APISIX rejects an + // unrecognized one with 400 "unknown plugin"), so unlike the other + // resource names in this file this can't just be prefixed to + // dodge cross-file collisions. `e2e_validate.rs` also uses + // "prometheus", but only through `Validator::validate`, which is a + // dry run that never actually writes to the server — no real + // collision. + let rule1_name = "prometheus"; + let rule2_name = "file-logger"; + let backend = backend(); + + sync_ok( + &backend, + vec![ + create(ResourceType::GlobalRule, rule1_name, json!({ "prefer_name": true })), + create(ResourceType::GlobalRule, rule2_name, json!({ "path": "logs/file.log" })), + ], + ) + .await; + + let config = dump(&backend).await; + let rules = config.global_rules.unwrap(); + assert_eq!(rules.len(), 2); + assert_eq!(rules.get(rule1_name).and_then(|v| v.get("prefer_name")), Some(&json!(true))); + + sync_ok( + &backend, + vec![update(ResourceType::GlobalRule, rule1_name, json!({ "prefer_name": true }), json!({ "prefer_name": true, "test": "test" }))], + ) + .await; + let config = dump(&backend).await; + assert_eq!(config.global_rules.unwrap().get(rule1_name).and_then(|v| v.get("test")), Some(&json!("test"))); + + sync_ok(&backend, vec![delete(ResourceType::GlobalRule, rule1_name)]).await; + let config = dump(&backend).await; + let rules = config.global_rules.unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules.contains_key(rule2_name)); + + sync_ok(&backend, vec![delete(ResourceType::GlobalRule, rule2_name)]).await; + let config = dump(&backend).await; + assert!(config.global_rules.is_none() || config.global_rules.unwrap().is_empty()); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_plugin_metadata_lifecycle() { + let metadata1_name = "http-logger"; + let metadata2_name = "tcp-logger"; + let backend = backend(); + + sync_ok( + &backend, + vec![ + create(ResourceType::PluginMetadata, metadata1_name, json!({ "log_format": { "test": "test", "test1": "test1" } })), + create(ResourceType::PluginMetadata, metadata2_name, json!({ "log_format": { "test": "test", "test1": "test1" } })), + ], + ) + .await; + + let config = dump(&backend).await; + let metadata = config.plugin_metadata.unwrap(); + assert_eq!(metadata.len(), 2); + assert_eq!(metadata.get(metadata1_name).and_then(|v| v.get("log_format")).and_then(|v| v.get("test")), Some(&json!("test"))); + + sync_ok( + &backend, + vec![update( + ResourceType::PluginMetadata, + metadata1_name, + json!({ "log_format": { "test": "test", "test1": "test1" } }), + json!({ "log_format": { "test": "test", "test1": "test1" }, "test": { "value": "test" } }), + )], + ) + .await; + let config = dump(&backend).await; + assert_eq!(config.plugin_metadata.unwrap().get(metadata1_name).and_then(|v| v.get("test")), Some(&json!({ "value": "test" }))); + + sync_ok(&backend, vec![delete(ResourceType::PluginMetadata, metadata1_name)]).await; + let config = dump(&backend).await; + let metadata = config.plugin_metadata.unwrap(); + assert_eq!(metadata.len(), 1); + assert!(metadata.contains_key(metadata2_name)); + + sync_ok(&backend, vec![delete(ResourceType::PluginMetadata, metadata2_name)]).await; + let config = dump(&backend).await; + assert!(config.plugin_metadata.is_none() || config.plugin_metadata.unwrap().is_empty()); +} diff --git a/rust/crates/adc-backend-apisix/tests/e2e_validate.rs b/rust/crates/adc-backend-apisix/tests/e2e_validate.rs new file mode 100644 index 00000000..7cf050c2 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/e2e_validate.rs @@ -0,0 +1,200 @@ +//! Ported from `libs/backend-apisix/e2e/validate.e2e-spec.ts`. Real network +//! calls against a live APISIX (>= 3.17.0, same as the TS suite's own +//! version gate) — see `e2e_apisix.rs`'s module doc for how to bring one up +//! and run this file. +//! +//! Not ported: the TS "bad uri type" case (`uris: [123 as unknown as +//! string]`) — it relies on bypassing TypeScript's compile-time check to +//! smuggle a non-string into the array. `adc_sdk::resources::Route.uris` is +//! `Vec`, so that specific malformed shape can't be constructed at +//! all; there's nothing to send. + +use adc_backend_apisix::tests::Validator; +use adc_sdk::{Event, EventKind, ResourceType}; +use serde_json::json; + +mod common; +use common::{apisix_version, client}; + +/// `/apisix/admin/configs/validate` doesn't exist before 3.17.0 (a request +/// gets a 404, surfaced as `BackendError::Unsupported`) — matches the TS +/// suite's own `conditionalDescribe(semverCondition(gte, '3.17.0'))` gate. +/// A macro rather than a plain function since it needs to `return` out of +/// whichever `#[tokio::test]` function calls it. +macro_rules! skip_below_3_17_0 { + () => { + if apisix_version() < semver::Version::new(3, 17, 0) { + eprintln!("skipping: validate requires apisix >= 3.17.0"); + return; + } + }; +} + +fn validator() -> Validator { + Validator::new(client()) +} + +fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { + Event::new(rt, EventKind::Create { new_value }, id, id) +} + +/// A service create event plus its route's create event, linked via +/// `parent_id` the way the differ would produce them. +fn service_with_route(service_id: &str, route_id: &str, route: serde_json::Value) -> Vec { + let service = create( + ResourceType::Service, + service_id, + json!({ "name": service_id, "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] } }), + ); + let mut route_event = create(ResourceType::Route, route_id, route); + route_event.parent_id = Some(service_id.to_string()); + vec![service, route_event] +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_an_empty_configuration() { + skip_below_3_17_0!(); + + let result = validator().validate(&[]).await.unwrap(); + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_a_valid_service_and_route() { + skip_below_3_17_0!(); + + let events = service_with_route( + "e2e-validate-svc1", + "e2e-validate-route1", + json!({ "name": "validate-test-route", "uris": ["/validate-test"], "methods": ["GET"] }), + ); + + let result = validator().validate(&events).await.unwrap(); + assert!(result.success, "{:?}", result.errors); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_a_valid_consumer() { + skip_below_3_17_0!(); + + let consumer_username = "validate_test_consumer"; + let events = vec![create( + ResourceType::Consumer, + consumer_username, + json!({ "username": consumer_username, "plugins": { "key-auth": { "key": "test-key-123" } } }), + )]; + + let result = validator().validate(&events).await.unwrap(); + assert!(result.success, "{:?}", result.errors); +} + +#[tokio::test] +#[ignore] +async fn fails_with_an_invalid_plugin_configuration() { + skip_below_3_17_0!(); + + let service_id = "e2e-validate-svc2"; + let route_id = "e2e-validate-route2"; + // limit-count requires `count`/`time_window`; both are missing. + let events = service_with_route( + service_id, + route_id, + json!({ "name": "validate-bad-plugin-route", "uris": ["/bad-plugin"], "plugins": { "limit-count": {} } }), + ); + + let result = validator().validate(&events).await.unwrap(); + assert!(!result.success); + assert!(!result.errors.is_empty()); + assert_eq!(result.errors[0].resource_type, "routes"); + // The error is mapped back to the specific Event that produced it, not + // just its position in apisix's response. + assert_eq!(result.errors[0].resource_name.as_deref(), Some(route_id)); + let matched_event = result.errors[0].event.as_ref().expect("event should have been matched from the request index"); + assert_eq!(matched_event.resource_type, ResourceType::Route); + assert_eq!(matched_event.resource_id, route_id); + assert_eq!(matched_event.parent_id.as_deref(), Some(service_id)); +} + +#[tokio::test] +#[ignore] +async fn collects_multiple_errors() { + skip_below_3_17_0!(); + + let service_id = "e2e-validate-svc3"; + let route1_id = "e2e-validate-route3a"; + let route2_id = "e2e-validate-route3b"; + let service = create( + ResourceType::Service, + service_id, + json!({ "name": "validate-multi-err-svc", "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] } }), + ); + let mut route1 = create( + ResourceType::Route, + route1_id, + json!({ "name": "validate-multi-err-route1", "uris": ["/multi-err-1"], "plugins": { "limit-count": {} } }), + ); + route1.parent_id = Some(service_id.to_string()); + let mut route2 = create( + ResourceType::Route, + route2_id, + json!({ "name": "validate-multi-err-route2", "uris": ["/multi-err-2"], "plugins": { "limit-count": {} } }), + ); + route2.parent_id = Some(service_id.to_string()); + + let result = validator().validate(&[service, route1, route2]).await.unwrap(); + assert!(!result.success); + assert!(result.errors.len() >= 2, "{:?}", result.errors); + // Each route's error maps back to *its own* name, not a mix-up between + // the two routes sharing a parent service. + let names: Vec<&str> = result.errors.iter().filter_map(|e| e.resource_name.as_deref()).collect(); + assert!(names.contains(&route1_id), "{names:?}"); + assert!(names.contains(&route2_id), "{names:?}"); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_mixed_resource_types() { + skip_below_3_17_0!(); + + let mut events = service_with_route( + "e2e-validate-svc4", + "e2e-validate-route4", + json!({ "name": "validate-mixed-route", "uris": ["/mixed-test"], "methods": ["GET", "POST"] }), + ); + let consumer_username = "validate_mixed_consumer"; + events.push(create( + ResourceType::Consumer, + consumer_username, + json!({ "username": consumer_username, "plugins": { "key-auth": { "key": "mixed-key-456" } } }), + )); + events.push(create(ResourceType::GlobalRule, "prometheus", json!({ "prefer_name": false }))); + + let result = validator().validate(&events).await.unwrap(); + assert!(result.success, "{:?}", result.errors); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn is_a_dry_run_with_no_side_effects_on_the_server() { + skip_below_3_17_0!(); + + let service_id = "e2e-validate-dryrun-svc"; + let events = service_with_route( + service_id, + "e2e-validate-dryrun-route", + json!({ "name": "validate-dryrun-route", "uris": ["/dryrun-test"] }), + ); + + let result = validator().validate(&events).await.unwrap(); + assert!(result.success, "{:?}", result.errors); + + let fetcher = adc_backend_apisix::tests::Fetcher::new(client(), semver::Version::new(3, 17, 0)); + let services = fetcher.list_services().await.unwrap(); + assert!(services.iter().all(|s| s.id != service_id), "validate must not have created anything on the server"); +} diff --git a/rust/crates/adc-backend-apisix/tests/transformer.rs b/rust/crates/adc-backend-apisix/tests/transformer.rs new file mode 100644 index 00000000..2df4e077 --- /dev/null +++ b/rust/crates/adc-backend-apisix/tests/transformer.rs @@ -0,0 +1,526 @@ +use std::collections::HashMap; + +use adc_backend_apisix::tests::transformer::{transform_consumer_group, transform_route, transform_service, transform_stream_route}; +use adc_backend_apisix::tests::typing; +use adc_sdk::resources::{self as adc, LabelValue}; +use serde_json::json; + +fn adc_route(name: &str) -> adc::Route { + adc::Route { + id: None, + name: name.to_string(), + description: None, + labels: None, + hosts: None, + uris: vec![], + priority: None, + timeout: None, + vars: None, + methods: None, + enable_websocket: None, + remote_addrs: None, + plugins: None, + filter_func: None, + } +} + +fn adc_service(name: &str) -> adc::Service { + adc::Service { + id: None, + name: name.to_string(), + description: None, + labels: None, + upstream: None, + upstreams: None, + plugins: None, + path_prefix: None, + strip_path_prefix: None, + hosts: None, + routes: None, + } +} + +fn adc_upstream() -> adc::Upstream { + adc::Upstream { + id: None, + name: None, + description: None, + labels: None, + r#type: adc::UpstreamBalancer::default(), + hash_on: None, + key: None, + checks: None, + nodes: None, + scheme: adc::UpstreamScheme::default(), + retries: None, + retry_timeout: None, + timeout: None, + tls: None, + keepalive_pool: None, + pass_host: adc::UpstreamPassHost::default(), + upstream_host: None, + service_name: None, + discovery_type: None, + discovery_args: None, + } +} + +fn adc_ssl() -> adc::SSL { + adc::SSL { id: None, labels: None, r#type: adc::SslType::default(), snis: vec![], certificates: vec![], client: None, ssl_protocols: None } +} + +fn adc_credential(name: &str, ty: &str) -> adc::ConsumerCredential { + adc::ConsumerCredential { id: None, name: name.to_string(), description: None, labels: None, r#type: ty.to_string(), config: serde_json::Map::new() } +} + +fn adc_stream_route(name: &str) -> adc::StreamRoute { + adc::StreamRoute { id: None, name: name.to_string(), description: None, labels: None, plugins: None, remote_addr: None, server_addr: None, server_port: None, sni: None } +} + +fn adc_consumer_group(name: &str) -> adc::ConsumerGroup { + adc::ConsumerGroup { id: None, name: name.to_string(), description: None, labels: None, plugins: None, consumers: None } +} + +fn route(id: &str) -> typing::Route { + typing::Route { id: id.to_string(), ..Default::default() } +} + +fn service(id: &str) -> typing::Service { + typing::Service { id: id.to_string(), ..Default::default() } +} + +fn upstream() -> typing::Upstream { + typing::Upstream::default() +} + +#[test] +fn route_falls_back_to_id_when_name_is_absent() { + let route = route("r1"); + let adc_route: adc::Route = route.try_into().unwrap(); + assert_eq!(adc_route.name, "r1"); +} + +#[test] +fn route_prefers_singular_uri_over_plural() { + let mut route = route("r1"); + route.uri = Some("/single".into()); + route.uris = Some(vec!["/a".into(), "/b".into()]); + let adc_route: adc::Route = route.try_into().unwrap(); + assert_eq!(adc_route.uris, vec!["/single".to_string()]); +} + +#[test] +fn route_with_no_uri_at_all_gets_an_empty_list_not_missing() { + let route = route("r1"); + let adc_route: adc::Route = route.try_into().unwrap(); + assert_eq!(adc_route.uris, Vec::::new()); +} + +#[test] +fn route_rejects_unrecognized_http_methods() { + let mut route = route("r1"); + route.methods = Some(vec!["GET".into(), "MAGIC".into()]); + let err = adc::Route::try_from(route).unwrap_err(); + assert!(err.contains("MAGIC"), "{err}"); +} + +#[test] +fn route_parses_recognized_http_methods() { + let mut route = route("r1"); + route.methods = Some(vec!["GET".into(), "POST".into()]); + let adc_route: adc::Route = route.try_into().unwrap(); + assert_eq!(adc_route.methods, Some(vec![adc::HttpMethod::Get, adc::HttpMethod::Post])); +} + +#[test] +fn upstream_list_nodes_pass_through_unchanged() { + let mut upstream = upstream(); + upstream.nodes = + Some(typing::UpstreamNodes::List(vec![adc::UpstreamNode { host: "10.0.0.1".into(), port: 8080, weight: 1, priority: 0.0, metadata: None }])); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + let nodes = adc_upstream.nodes.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].host, "10.0.0.1"); + assert_eq!(nodes[0].port, 8080); +} + +#[test] +fn upstream_discovery_map_nodes_parse_host_and_port() { + let mut upstream = upstream(); + upstream.nodes = Some(typing::UpstreamNodes::Map(HashMap::from([("10.0.0.1:9000".to_string(), 5)]))); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + let nodes = adc_upstream.nodes.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].host, "10.0.0.1"); + assert_eq!(nodes[0].port, 9000); + assert_eq!(nodes[0].weight, 5); +} + +#[test] +fn upstream_discovery_map_nodes_without_a_port_fall_back_to_scheme_default() { + let mut upstream = upstream(); + upstream.scheme = Some(adc::UpstreamScheme::Https); + upstream.nodes = Some(typing::UpstreamNodes::Map(HashMap::from([("10.0.0.1".to_string(), 1)]))); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + assert_eq!(adc_upstream.nodes.unwrap()[0].port, 443); +} + +#[test] +fn upstream_discovery_map_nodes_parse_bracketed_ipv6_host_and_port() { + let mut upstream = upstream(); + upstream.nodes = Some(typing::UpstreamNodes::Map(HashMap::from([("[::1]:9000".to_string(), 5)]))); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + let nodes = adc_upstream.nodes.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].host, "::1"); + assert_eq!(nodes[0].port, 9000); + assert_eq!(nodes[0].weight, 5); +} + +#[test] +fn upstream_discovery_map_nodes_bracketed_ipv6_without_a_port_fall_back_to_scheme_default() { + let mut upstream = upstream(); + upstream.scheme = Some(adc::UpstreamScheme::Https); + upstream.nodes = Some(typing::UpstreamNodes::Map(HashMap::from([("[::1]".to_string(), 1)]))); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + let nodes = adc_upstream.nodes.unwrap(); + assert_eq!(nodes[0].host, "::1"); + assert_eq!(nodes[0].port, 443); +} + +#[test] +fn upstream_strips_the_service_association_label_but_keeps_others() { + let mut upstream = upstream(); + upstream.labels = Some(HashMap::from([ + (typing::ADC_UPSTREAM_SERVICE_ID_LABEL.to_string(), LabelValue::Single("svc1".into())), + ("env".to_string(), LabelValue::Single("prod".into())), + ])); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + let labels = adc_upstream.labels.unwrap(); + assert!(!labels.contains_key(typing::ADC_UPSTREAM_SERVICE_ID_LABEL)); + assert_eq!(labels.get("env"), Some(&LabelValue::Single("prod".into()))); +} + +#[test] +fn upstream_with_only_the_service_association_label_ends_up_with_no_labels() { + let mut upstream = upstream(); + upstream.labels = Some(HashMap::from([( + typing::ADC_UPSTREAM_SERVICE_ID_LABEL.to_string(), + LabelValue::Single("svc1".into()), + )])); + let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); + assert!(adc_upstream.labels.is_none()); +} + +#[test] +fn service_falls_back_to_id_when_name_is_absent_and_converts_its_upstream() { + let mut service = service("svc1"); + let mut inline_upstream = upstream(); + inline_upstream.scheme = Some(adc::UpstreamScheme::Http); + service.upstream = Some(inline_upstream); + + let adc_service: adc::Service = service.try_into().unwrap(); + assert_eq!(adc_service.name, "svc1"); + assert!(adc_service.upstream.is_some()); + assert!(adc_service.routes.is_none()); +} + +#[test] +fn ssl_pairs_the_primary_and_additional_certificates() { + let ssl = typing::Ssl { + id: "ssl1".into(), + labels: None, + ty: None, + sni: Some("a.example.com".into()), + snis: None, + cert: Some("CERT_A".into()), + certs: Some(vec!["CERT_B".into()]), + key: Some("KEY_A".into()), + keys: Some(vec!["KEY_B".into()]), + client: None, + ssl_protocols: None, + status: 1, + }; + let adc_ssl: adc::SSL = ssl.try_into().unwrap(); + assert_eq!(adc_ssl.snis, vec!["a.example.com".to_string()]); + assert_eq!( + adc_ssl.certificates, + vec![ + adc::SSLCertificate { certificate: "CERT_A".into(), key: "KEY_A".into() }, + adc::SSLCertificate { certificate: "CERT_B".into(), key: "KEY_B".into() }, + ] + ); +} + +#[test] +fn ssl_missing_a_certificate_is_rejected() { + let ssl = typing::Ssl { + id: "ssl1".into(), + labels: None, + ty: None, + sni: None, + snis: None, + cert: None, + certs: None, + key: Some("KEY_A".into()), + keys: None, + client: None, + ssl_protocols: None, + status: 1, + }; + let err = adc::SSL::try_from(ssl).unwrap_err(); + assert!(err.contains("ssl1"), "{err}"); +} + +#[test] +fn ssl_with_a_redacted_key_degrades_to_an_empty_placeholder_instead_of_failing() { + // APISIX never echoes a private key back on any read (list or + // single-resource GET), confirmed against a real instance — a missing + // `key` here means "redacted by the server", not "broken resource". + let ssl = typing::Ssl { + id: "ssl1".into(), + labels: None, + ty: None, + sni: None, + snis: Some(vec!["example.com".into()]), + cert: Some("CERT_A".into()), + certs: None, + key: None, + keys: None, + client: None, + ssl_protocols: None, + status: 1, + }; + let adc_ssl: adc::SSL = ssl.try_into().unwrap(); + assert_eq!(adc_ssl.certificates, vec![adc::SSLCertificate { certificate: "CERT_A".into(), key: String::new() }]); +} + +#[test] +fn consumer_credential_only_converts_recognized_plugins() { + let mut plugins = adc::Plugins::new(); + plugins.insert("key-auth".into(), json!({ "key": "secret" })); + let credential = typing::ConsumerCredential { id: Some("c1".into()), name: "c1".into(), desc: None, labels: None, plugins: Some(plugins) }; + + let adc_credential: adc::ConsumerCredential = credential.try_into().unwrap(); + assert_eq!(adc_credential.r#type, "key-auth"); + assert_eq!(adc_credential.config.get("key"), Some(&json!("secret"))); +} + +#[test] +fn consumer_credential_rejects_unsupported_plugins() { + let mut plugins = adc::Plugins::new(); + plugins.insert("proxy-rewrite".into(), json!({})); + let credential = typing::ConsumerCredential { id: None, name: "c1".into(), desc: None, labels: None, plugins: Some(plugins) }; + + assert!(adc::ConsumerCredential::try_from(credential).is_err()); +} + +#[test] +fn consumer_drops_credentials_that_fail_to_convert_but_keeps_the_rest() { + let mut good = adc::Plugins::new(); + good.insert("key-auth".into(), json!({})); + let mut bad = adc::Plugins::new(); + bad.insert("proxy-rewrite".into(), json!({})); + + let consumer = typing::Consumer { + username: "alice".into(), + desc: None, + labels: None, + group_id: None, + plugins: None, + credentials: Some(vec![ + typing::ConsumerCredential { id: Some("good".into()), name: "good".into(), desc: None, labels: None, plugins: Some(good) }, + typing::ConsumerCredential { id: Some("bad".into()), name: "bad".into(), desc: None, labels: None, plugins: Some(bad) }, + ]), + }; + + let adc_consumer: adc::Consumer = consumer.into(); + let credentials = adc_consumer.credentials.unwrap(); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].id.as_deref(), Some("good")); +} + +#[test] +fn consumer_with_credentials_never_fetched_stays_none_not_empty() { + let consumer = typing::Consumer { username: "alice".into(), desc: None, labels: None, group_id: None, plugins: None, credentials: None }; + let adc_consumer: adc::Consumer = consumer.into(); + assert!(adc_consumer.credentials.is_none()); +} + +#[test] +fn stream_route_recovers_its_name_from_the_magic_label_and_strips_it() { + let route = typing::StreamRoute { + id: Some("sr1".into()), + desc: None, + labels: Some(HashMap::from([ + ("__ADC_NAME".to_string(), LabelValue::Single("my-stream-route".into())), + ("env".to_string(), LabelValue::Single("prod".into())), + ])), + remote_addr: None, + server_addr: None, + server_port: Some(9000), + sni: None, + upstream: None, + upstream_id: None, + service_id: None, + plugins: None, + protocol: None, + }; + + let adc_route: adc::StreamRoute = route.into(); + assert_eq!(adc_route.name, "my-stream-route"); + let labels = adc_route.labels.unwrap(); + assert!(!labels.contains_key("__ADC_NAME")); + assert_eq!(labels.get("env"), Some(&LabelValue::Single("prod".into()))); +} + +#[test] +fn stream_route_without_the_magic_label_falls_back_to_id() { + let route = typing::StreamRoute { + id: Some("sr1".into()), + desc: None, + labels: None, + remote_addr: None, + server_addr: None, + server_port: None, + sni: None, + upstream: None, + upstream_id: None, + service_id: None, + plugins: None, + protocol: None, + }; + let adc_route: adc::StreamRoute = route.into(); + assert_eq!(adc_route.name, "sr1"); +} + +#[test] +fn write_route_carries_parent_id_and_stringifies_methods() { + let mut route = adc_route("r1"); + route.uris = vec!["/foo".into()]; + route.methods = Some(vec![adc::HttpMethod::Get, adc::HttpMethod::Post]); + + let wire = transform_route(route, "svc1".into()); + assert_eq!(wire.service_id.as_deref(), Some("svc1")); + assert_eq!(wire.uris, Some(vec!["/foo".to_string()])); + assert_eq!(wire.methods, Some(vec!["GET".to_string(), "POST".to_string()])); + assert_eq!(wire.status, Some(1)); +} + +#[test] +fn write_route_labels_are_plain_strings_arrays_get_json_stringified() { + let mut route = adc_route("r1"); + route.labels = Some(HashMap::from([ + ("env".to_string(), LabelValue::Single("prod".into())), + ("team".to_string(), LabelValue::Multiple(vec!["a".into(), "b".into()])), + ])); + + let wire = transform_route(route, "svc1".into()); + let labels = wire.labels.unwrap(); + assert_eq!(labels.get("env"), Some(&"prod".to_string())); + assert_eq!(labels.get("team"), Some(&"[\"a\",\"b\"]".to_string())); +} + +#[test] +fn write_service_splits_into_service_and_matching_upstream() { + let mut service = adc_service("svc1"); + service.id = Some("svc1".into()); + service.upstream = Some(adc_upstream()); + + let (wire_service, wire_upstream) = transform_service(service); + assert!(wire_service.upstream.is_none(), "upstream must not be inlined into the service body"); + assert_eq!(wire_service.upstream_id.as_deref(), Some("svc1")); + + let wire_upstream = wire_upstream.expect("service had a default upstream"); + assert_eq!(wire_upstream.id.as_deref(), Some("svc1")); + assert_eq!(wire_upstream.name.as_deref(), Some("svc1")); +} + +#[test] +fn write_service_without_a_default_upstream_returns_none() { + let service = adc_service("svc1"); + let (wire_service, wire_upstream) = transform_service(service); + assert!(wire_upstream.is_none()); + // Must not reference an upstream_id that doesn't exist — APISIX + // rejects a service PUT whose upstream_id points at a nonexistent + // upstream (confirmed against a real instance). + assert!(wire_service.upstream_id.is_none()); +} + +#[test] +fn write_upstream_never_carries_its_own_id() { + let upstream = adc_upstream(); + let wire: typing::Upstream = upstream.into(); + assert!(wire.id.is_none()); +} + +#[test] +fn write_ssl_splits_certificates_into_primary_and_additional() { + let mut ssl = adc_ssl(); + ssl.certificates = vec![ + adc::SSLCertificate { certificate: "CERT_A".into(), key: "KEY_A".into() }, + adc::SSLCertificate { certificate: "CERT_B".into(), key: "KEY_B".into() }, + ]; + + let wire: typing::Ssl = ssl.into(); + assert_eq!(wire.cert.as_deref(), Some("CERT_A")); + assert_eq!(wire.key.as_deref(), Some("KEY_A")); + assert_eq!(wire.certs, Some(vec!["CERT_B".to_string()])); + assert_eq!(wire.keys, Some(vec!["KEY_B".to_string()])); + assert_eq!(wire.status, 1); +} + +#[test] +fn write_ssl_with_a_single_certificate_omits_certs_and_keys() { + let mut ssl = adc_ssl(); + ssl.certificates = vec![adc::SSLCertificate { certificate: "CERT_A".into(), key: "KEY_A".into() }]; + + let wire: typing::Ssl = ssl.into(); + assert!(wire.certs.is_none()); + assert!(wire.keys.is_none()); +} + +#[test] +fn write_consumer_credential_wraps_type_and_config_into_a_plugins_map() { + let mut credential = adc_credential("c1", "key-auth"); + credential.config.insert("key".into(), json!("secret")); + + let wire: typing::ConsumerCredential = credential.into(); + let plugins = wire.plugins.unwrap(); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins.get("key-auth").and_then(|v| v.get("key")), Some(&json!("secret"))); +} + +#[test] +fn write_stream_route_injects_the_name_label_when_requested() { + let route = adc_stream_route("my-stream-route"); + let wire = transform_stream_route(route, "svc1".into(), true); + let labels = wire.labels.unwrap(); + assert_eq!(labels.get("__ADC_NAME"), Some(&LabelValue::Single("my-stream-route".to_string()))); + assert_eq!(wire.service_id.as_deref(), Some("svc1")); +} + +#[test] +fn write_stream_route_omits_the_name_label_when_not_requested() { + let route = adc_stream_route("my-stream-route"); + let wire = transform_stream_route(route, "svc1".into(), false); + assert!(wire.labels.is_none()); +} + +#[test] +fn write_consumer_group_derives_its_id_from_the_name_and_injects_adc_name() { + let group = adc_consumer_group("my-group"); + let (wire, _consumers) = transform_consumer_group(group); + assert_eq!(wire.id, adc_sdk::utils::generate_id("my-group")); + assert_eq!(wire.labels.unwrap().get("ADC_NAME"), Some(&LabelValue::Single("my-group".to_string()))); +} + +#[test] +fn write_consumer_group_stamps_its_id_onto_member_consumers() { + let mut group = adc_consumer_group("my-group"); + group.consumers = Some(vec![adc::Consumer { username: "alice".into(), description: None, labels: None, plugins: None, credentials: None }]); + + let (wire_group, consumers) = transform_consumer_group(group); + assert_eq!(consumers.len(), 1); + assert_eq!(consumers[0].group_id, Some(wire_group.id)); +} diff --git a/rust/crates/adc-backend-core/Cargo.toml b/rust/crates/adc-backend-core/Cargo.toml index 98bf7352..a254497b 100644 --- a/rust/crates/adc-backend-core/Cargo.toml +++ b/rust/crates/adc-backend-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adc-backend-core" -version = "0.1.0" +version.workspace = true edition.workspace = true publish.workspace = true rust-version.workspace = true @@ -9,9 +9,10 @@ rust-version.workspace = true adc-sdk = { path = "../adc-sdk" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } futures = "0.3" -tokio = { version = "1", features = ["time"] } +tokio = { workspace = true, features = ["time"] } +percent-encoding = "2" [dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time"] } axum = "0.8" serde_json = { workspace = true } diff --git a/rust/crates/adc-backend-core/src/client.rs b/rust/crates/adc-backend-core/src/client.rs index 153642a6..fd3140ca 100644 --- a/rust/crates/adc-backend-core/src/client.rs +++ b/rust/crates/adc-backend-core/src/client.rs @@ -1,11 +1,36 @@ use std::time::Duration; use adc_sdk::BackendError; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; use reqwest::{Method, RequestBuilder, Response, Url}; use crate::tls::TlsConfig; +/// RFC 3986's "unreserved" characters left unescaped, matching how path +/// segments are conventionally percent-encoded (e.g. `encodeURIComponent`); +/// everything else, including `/`, gets encoded. +const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); + +/// Percent-encodes one URL path segment, for backends building an +/// admin-API path from a user-controlled id (a `Consumer.username`, an +/// explicit resource `id`, ...) — without this, such a value could inject +/// a `/`, a query string, or a fragment into the request `Url::parse` +/// eventually builds from it. `PATH_SEGMENT` leaves `.` unescaped (it's an +/// RFC 3986 unreserved character), so a segment that's *exactly* `.` or +/// `..` is rejected outright here instead: `url`'s parser normalizes +/// dot-segments in *any* path it parses, not just during relative +/// reference resolution, so an unescaped `.`/`..` segment can still +/// traverse to a different admin-API path than the one requested even +/// though [`HttpClient::request`] itself no longer resolves paths via +/// `Url::join`. +pub fn encode_path_segment(segment: &str) -> Result { + if segment == "." || segment == ".." { + return Err(BackendError::Other(format!("{segment:?} is not a valid resource id").into())); + } + Ok(utf8_percent_encode(segment, PATH_SEGMENT).to_string()) +} + /// Everything needed to stand up a backend's HTTP client: where it lives, /// how to authenticate, and how to connect. Doesn't include backend-specific /// concerns like a gateway group name — those live in each backend crate. @@ -18,11 +43,18 @@ pub struct HttpClientConfig { } /// A backend's HTTP client: a `reqwest::Client` pre-configured with the -/// `X-API-KEY` auth header apisix/api7 both expect, TLS settings, and a base +/// `X-API-KEY` auth header APISIX/API7 both expect, TLS settings, and a base /// URL, plus request/response handling that classifies failures into /// `BackendError` uniformly. Connection pooling (the Node `agentkeepalive` /// equivalent) comes for free from `reqwest::Client`'s own pool — nothing to /// configure for that. +/// +/// Cheap to clone: `reqwest::Client` is internally `Arc`-backed and shares +/// its connection pool across clones, so handing each of a backend's +/// fetcher/operator/validator its own owned `HttpClient` (rather than a +/// borrow with a lifetime to thread through) costs nothing beyond a couple +/// of atomic increments. +#[derive(Clone)] pub struct HttpClient { inner: reqwest::Client, base_url: Url, @@ -30,8 +62,9 @@ pub struct HttpClient { impl HttpClient { pub fn new(config: HttpClientConfig) -> Result { - let base_url = Url::parse(&config.server) - .map_err(|e| BackendError::Other(format!("invalid server URL {:?}: {e}", config.server).into()))?; + let base_url = Url::parse(&config.server).map_err(|e| { + BackendError::Other(format!("invalid server URL {:?}: {e}", config.server).into()) + })?; let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -46,45 +79,81 @@ impl HttpClient { } builder = config.tls.apply(builder)?; - let inner = builder - .build() - .map_err(|e| BackendError::Other(format!("failed to build HTTP client: {e}").into()))?; + let inner = builder.build().map_err(|e| { + BackendError::Other(format!("failed to build HTTP client: {}", with_source(&e)).into()) + })?; Ok(Self { inner, base_url }) } - /// Starts a request against `path`, resolved relative to the configured - /// server URL (e.g. `/apisix/admin/routes`). + /// Starts a request against `path` (e.g. `/apisix/admin/routes`), + /// appended onto the configured server URL. Plain string concatenation + /// (trimming exactly one `/` at the join point) rather than + /// `Url::join`: a root-anchored `path` handed to `Url::join` replaces + /// the base URL's path outright per RFC 3986, which would silently + /// drop any path prefix the server URL carries (e.g. APISIX's admin + /// API exposed behind a reverse-proxy prefix like + /// `https://host/gateway/`) — matching the TS backend's own + /// axios-based client, which combines `baseURL` and a request path the + /// same simple way. pub fn request(&self, method: Method, path: &str) -> Result { - let url = self - .base_url - .join(path) - .map_err(|e| BackendError::Other(format!("invalid request path {path:?}: {e}").into()))?; + let base = self.base_url.as_str().trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let combined = format!("{base}/{path}"); + let url = Url::parse(&combined).map_err(|e| { + BackendError::Other(format!("invalid request path {path:?}: {e}").into()) + })?; Ok(self.inner.request(method, url)) } + /// Sends a request built via [`HttpClient::request`], classifying only + /// transport-level failures (timeout, connection refused, ...) into + /// `BackendError::Transport`. Unlike [`HttpClient::send`], any response + /// that actually comes back — including a non-2xx one — is returned as + /// `Ok`, for the handful of endpoints where a 404 (or worse) is a + /// meaningful result rather than a failure (e.g. probing whether a + /// resource or an admin-API path exists at all on this backend version). + pub async fn execute(&self, builder: RequestBuilder) -> Result { + let (client, request) = builder.build_split(); + let request = request + .map_err(|e| BackendError::Other(format!("failed to build request: {e}").into()))?; + let method = request.method().clone(); + let url = request.url().clone(); + + client + .execute(request) + .await + .map_err(|e| classify_transport_error(&e, &method, &url)) + } + /// Sends a request built via [`HttpClient::request`], classifying /// transport failures and non-2xx responses into `BackendError`. Callers /// decode the body themselves (`.json()`, `.text()`, or just read /// headers) since the right shape depends on the endpoint. pub async fn send(&self, builder: RequestBuilder) -> Result { - let (client, request) = builder.build_split(); - let request = request.map_err(|e| BackendError::Other(format!("failed to build request: {e}").into()))?; - let method = request.method().clone(); - let url = request.url().clone(); - - let response = client.execute(request).await.map_err(|e| classify_transport_error(&e, &method, &url))?; + let response = self.execute(builder).await?; + Self::require_success(response).await + } + /// Applies [`HttpClient::send`]'s non-2xx-to-`BackendError` mapping to a + /// response obtained via [`HttpClient::execute`], for callers that need + /// to inspect the status themselves before deciding whether to treat it + /// as an error. + pub async fn require_success(response: Response) -> Result { let status = response.status(); if status.is_success() { return Ok(response); } + let url = response.url().clone(); let message = response.text().await.unwrap_or_default(); Err(match status.as_u16() { 401 | 403 => BackendError::Auth(message), - 404 => BackendError::NotFound(format!("{method} {url}")), - _ => BackendError::Api { status: status.as_u16(), message }, + 404 => BackendError::NotFound(url.to_string()), + _ => BackendError::Api { + status: status.as_u16(), + message, + }, }) } } @@ -95,6 +164,60 @@ fn classify_transport_error(error: &reqwest::Error, method: &Method, url: &Url) "request \"{method} {url}\" timed out. Consider increasing the timeout." )) } else { - BackendError::Transport(format!("request \"{method} {url}\" failed: {error}")) + BackendError::Transport(format!( + "request \"{method} {url}\" failed: {}", + with_source(error) + )) + } +} + +/// `reqwest::Error`'s own `Display` is often terse ("builder error", +/// "error sending request") with the actually useful detail (a TLS +/// validation failure's specific reason, say) only available via +/// `.source()`. This walks the chain and appends it. +fn with_source(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_unreserved_characters_alone() { + assert_eq!(encode_path_segment("my-consumer_1.local~x").unwrap(), "my-consumer_1.local~x"); + } + + #[test] + fn encodes_a_path_separator_so_it_cant_split_the_url() { + assert_eq!(encode_path_segment("a/b").unwrap(), "a%2Fb"); + } + + #[test] + fn encodes_query_and_fragment_delimiters() { + assert_eq!(encode_path_segment("a?b=c#d").unwrap(), "a%3Fb%3Dc%23d"); + } + + #[test] + fn rejects_a_single_dot_segment() { + assert!(encode_path_segment(".").is_err()); + } + + #[test] + fn rejects_a_double_dot_segment() { + assert!(encode_path_segment("..").is_err()); + } + + #[test] + fn a_dot_elsewhere_in_the_segment_is_fine() { + assert_eq!(encode_path_segment("..a").unwrap(), "..a"); + assert_eq!(encode_path_segment("a..").unwrap(), "a.."); } } diff --git a/rust/crates/adc-backend-core/src/concurrency.rs b/rust/crates/adc-backend-core/src/concurrency.rs index 6926dd23..923cf9bb 100644 --- a/rust/crates/adc-backend-core/src/concurrency.rs +++ b/rust/crates/adc-backend-core/src/concurrency.rs @@ -1,6 +1,6 @@ use std::future::Future; -use futures::stream::{self, StreamExt}; +use futures::stream::{self, FuturesUnordered, StreamExt}; /// Runs `f` over `items` with at most `concurrency` in flight at once. /// `None` means unbounded — every item starts immediately, matching RxJS @@ -18,3 +18,50 @@ where let concurrency = concurrency.unwrap_or(items.len()).max(1); stream::iter(items).map(f).buffer_unordered(concurrency).collect().await } + +/// Like `concurrent_map`, but as soon as one item's future resolves to +/// `Err`, stops starting any new ones and returns that error, discarding +/// every `Ok` collected so far (from this batch and, per the caller's own +/// choice, typically from earlier batches too). Items already in flight +/// when that happens still run to completion — there's no way to cancel +/// them once started — but their outcomes are discarded either way. +/// +/// Mirrors RxJS `mergeMap(project, concurrency)`'s behavior when a +/// projected Observable errors: the merged subscription tears down +/// immediately, which cascades an `unsubscribe()` to every currently active +/// inner subscription (their underlying work, if it's a `Promise`, can't +/// actually be cancelled — it keeps running, just unobserved) while +/// anything still queued behind the concurrency limit is dropped without +/// ever being subscribed to, i.e. `f` is never even called for it. +pub async fn concurrent_map_until_err(items: Vec, concurrency: Option, mut f: F) -> Result, E> +where + F: FnMut(T) -> Fut, + Fut: Future>, +{ + let concurrency = concurrency.unwrap_or(items.len()).max(1); + let mut items = items.into_iter(); + let mut in_flight = FuturesUnordered::new(); + for item in items.by_ref().take(concurrency) { + in_flight.push(f(item)); + } + + let mut results = Vec::new(); + let mut failure = None; + while let Some(outcome) = in_flight.next().await { + match outcome { + Ok(value) => results.push(value), + Err(error) => { + failure.get_or_insert(error); + } + } + if failure.is_none() + && let Some(item) = items.next() + { + in_flight.push(f(item)); + } + } + match failure { + Some(error) => Err(error), + None => Ok(results), + } +} diff --git a/rust/crates/adc-backend-core/src/lib.rs b/rust/crates/adc-backend-core/src/lib.rs index 213e4b91..094df3c8 100644 --- a/rust/crates/adc-backend-core/src/lib.rs +++ b/rust/crates/adc-backend-core/src/lib.rs @@ -10,8 +10,8 @@ mod concurrency; mod retry; mod tls; -pub use client::{HttpClient, HttpClientConfig}; -pub use concurrency::concurrent_map; +pub use client::{HttpClient, HttpClientConfig, encode_path_segment}; +pub use concurrency::{concurrent_map, concurrent_map_until_err}; pub use retry::RetryPolicy; pub use tls::TlsConfig; diff --git a/rust/crates/adc-backend-core/src/retry.rs b/rust/crates/adc-backend-core/src/retry.rs index 5b81dff5..de2d5c76 100644 --- a/rust/crates/adc-backend-core/src/retry.rs +++ b/rust/crates/adc-backend-core/src/retry.rs @@ -10,7 +10,7 @@ pub struct RetryPolicy { } impl Default for RetryPolicy { - /// Matches the apisix operator's hardcoded `retry({ count: 3, delay: 100 })` + /// Matches the APISIX operator's hardcoded `retry({ count: 3, delay: 100 })` /// for mutating requests (PUT/DELETE against `/apisix/admin/*`). fn default() -> Self { Self { retries: 3, delay: Duration::from_millis(100) } diff --git a/rust/crates/adc-backend-core/tests/concurrency.rs b/rust/crates/adc-backend-core/tests/concurrency.rs index 98f2c96c..9970acc0 100644 --- a/rust/crates/adc-backend-core/tests/concurrency.rs +++ b/rust/crates/adc-backend-core/tests/concurrency.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use adc_backend_core::concurrent_map; +use adc_backend_core::{concurrent_map, concurrent_map_until_err}; #[tokio::test] async fn respects_the_concurrency_bound() { @@ -52,3 +52,39 @@ async fn unbounded_when_concurrency_is_none() { assert_eq!(results.len(), items.len()); assert_eq!(peak.load(Ordering::SeqCst), items.len()); } + +#[tokio::test] +async fn until_err_stops_pulling_new_work_after_the_first_failure_but_finishes_in_flight_items() { + let started = AtomicUsize::new(0); + let items: Vec = (0..20).collect(); + + // Item 0 fails immediately; everything else in flight takes long enough + // that item 0's failure is guaranteed to be observed first. + let result = concurrent_map_until_err(items, Some(4), |item| { + let started = &started; + async move { + started.fetch_add(1, Ordering::SeqCst); + if item == 0 { + return Err("boom"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(item) + } + }) + .await; + + assert_eq!(result, Err("boom")); + // Only the initial batch (bounded by concurrency) was ever started — + // nothing queued behind the limit was pulled once item 0 failed. + assert_eq!(started.load(Ordering::SeqCst), 4, "no new work should start once a failure is observed"); +} + +#[tokio::test] +async fn until_err_returns_all_results_when_nothing_fails() { + let items: Vec = (0..10).collect(); + let result = concurrent_map_until_err(items.clone(), Some(3), |item| async move { Ok::(item) }).await; + + let mut results = result.unwrap(); + results.sort(); + assert_eq!(results, items); +} diff --git a/rust/crates/adc-backend-core/tests/http_client.rs b/rust/crates/adc-backend-core/tests/http_client.rs index 4b42f442..13a26c94 100644 --- a/rust/crates/adc-backend-core/tests/http_client.rs +++ b/rust/crates/adc-backend-core/tests/http_client.rs @@ -25,6 +25,10 @@ async fn spawn_server() -> String { }, ), ) + .route( + "/gateway/apisix/admin/routes", + get(|| async { Json(json!({ "list": [], "prefixed": true })) }), + ) .route( "/slow", get(|| async { @@ -63,6 +67,21 @@ async fn injects_auth_header_and_decodes_success_response() { assert_eq!(body, json!({ "list": [] })); } +#[tokio::test] +async fn preserves_a_path_prefix_on_the_configured_server_url() { + let server = spawn_server().await; + // A trailing slash on the server URL, matching how a reverse-proxied + // admin API would typically be configured — the request path itself is + // still root-anchored (`/apisix/admin/routes`), which is exactly the + // case `Url::join` would otherwise resolve by discarding `/gateway`. + let client = client(format!("{server}/gateway/"), None); + + let req = client.request(Method::GET, "/apisix/admin/routes").unwrap(); + let resp = client.send(req).await.unwrap(); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body, json!({ "list": [], "prefixed": true })); +} + #[tokio::test] async fn classifies_404_as_not_found() { let server = spawn_server().await; diff --git a/rust/crates/adc-differ/Cargo.toml b/rust/crates/adc-differ/Cargo.toml index bfadbe2a..c5ce526e 100644 --- a/rust/crates/adc-differ/Cargo.toml +++ b/rust/crates/adc-differ/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adc-differ" -version = "0.1.0" +version.workspace = true edition.workspace = true publish.workspace = true rust-version.workspace = true diff --git a/rust/crates/adc-mock-server/Cargo.toml b/rust/crates/adc-mock-server/Cargo.toml index 9000c3a3..30069c37 100644 --- a/rust/crates/adc-mock-server/Cargo.toml +++ b/rust/crates/adc-mock-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adc-mock-server" -version = "0.1.0" +version.workspace = true edition.workspace = true publish.workspace = true rust-version.workspace = true @@ -10,5 +10,5 @@ name = "adc-mock-server" path = "src/main.rs" [dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal"] } axum = "0.8" diff --git a/rust/crates/adc-sdk/Cargo.toml b/rust/crates/adc-sdk/Cargo.toml index 38f4e9ad..650a76a0 100644 --- a/rust/crates/adc-sdk/Cargo.toml +++ b/rust/crates/adc-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adc-sdk" -version = "0.1.0" +version.workspace = true edition.workspace = true publish.workspace = true rust-version.workspace = true diff --git a/rust/crates/adc-sdk/src/backend/mod.rs b/rust/crates/adc-sdk/src/backend/mod.rs index 9a9857b0..b8896bb5 100644 --- a/rust/crates/adc-sdk/src/backend/mod.rs +++ b/rust/crates/adc-sdk/src/backend/mod.rs @@ -1,4 +1,4 @@ -//! The `Backend` trait: the interface every gateway integration (apisix, +//! The `Backend` trait: the interface every gateway integration (APISIX, //! api7, apisix-standalone) implements, and the shared result/error types //! that flow across it. `adc-sdk` only defines the contract — concrete //! implementations live in their own crates and depend on this one. @@ -10,10 +10,10 @@ pub use error::BackendError; use async_trait::async_trait; use semver::Version; -use crate::{DefaultValue, Event, ResourceType, resources::Configuration}; +use crate::{DefaultValue, Event, resources::Configuration}; /// Static, non-behavioral facts about a `Backend` implementation, used by the -/// CLI to scope log output (e.g. `[apisix]`) without the trait needing a +/// CLI to scope log output (e.g. `[APISIX]`) without the trait needing a /// `name()`-shaped method per concern. #[derive(Debug, Clone, Default)] pub struct BackendMetadata { @@ -34,9 +34,14 @@ pub struct BackendSyncResult { pub server: Option, } +/// `resource_type` stays a raw string, not `ResourceType`: it's whatever the +/// backend's own validation response echoes back, which isn't guaranteed to +/// map onto one of our known resource types (an unrecognized value must +/// degrade gracefully — carry the string through, leave `event`/ +/// `resource_name` unset — rather than fail the whole validate call). #[derive(Debug, Clone)] pub struct BackendValidationError { - pub resource_type: ResourceType, + pub resource_type: String, pub resource_id: Option, pub resource_name: Option, pub index: usize, @@ -81,13 +86,16 @@ pub trait Backend: Send + Sync { async fn dump(&self) -> Result; - /// Applies `events` and reports one result per event. The overall call - /// doesn't fail as a whole — a partial or total failure is expressed as - /// individual `BackendSyncResult`s with `success: false`, mirroring how - /// sync is inherently a batch of independent operations rather than one - /// atomic unit. Concurrency (per `opts.concurrent`) is an implementation - /// detail of each backend, not something the trait signature encodes. - async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Vec; + /// Applies `events`. Per-event failures are captured as individual + /// `BackendSyncResult`s with `success: false` rather than failing the + /// whole call — *unless* `opts.exit_on_failure` is set (the default): + /// then the first failure aborts the whole call and is returned as + /// `Err`, discarding any results accumulated so far, mirroring the TS + /// implementation's `Observable` erroring out (via `throwError`) instead + /// of completing with a partial list. Concurrency (per + /// `opts.concurrent`) is an implementation detail of each backend, not + /// something the trait signature encodes. + async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Result, BackendError>; /// Not every backend can pre-validate events against the remote server /// before applying them; the default rejects with `Unsupported`, diff --git a/rust/crates/adc-sdk/src/utils.rs b/rust/crates/adc-sdk/src/utils.rs index a79277a5..5c58a6ea 100644 --- a/rust/crates/adc-sdk/src/utils.rs +++ b/rust/crates/adc-sdk/src/utils.rs @@ -4,7 +4,10 @@ use sha1::{Digest, Sha1}; pub fn generate_id(name: &str) -> String { let mut hasher = Sha1::new(); hasher.update(name.as_bytes()); - format!("{:x}", hasher.finalize()) + // Manual hex encoding rather than `format!("{:x}", ...)`: sha1 0.11's + // `finalize()` output type (`hybrid_array::Array`) doesn't implement + // `LowerHex`, unlike the `generic_array::GenericArray` it replaced. + hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect() } #[cfg(test)] diff --git a/rust/crates/adc-sync-bench/Cargo.toml b/rust/crates/adc-sync-bench/Cargo.toml index f602895b..5a4c90cf 100644 --- a/rust/crates/adc-sync-bench/Cargo.toml +++ b/rust/crates/adc-sync-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adc-sync-bench" -version = "0.1.0" +version.workspace = true edition.workspace = true publish.workspace = true rust-version.workspace = true @@ -14,6 +14,6 @@ adc-sdk = { path = "../adc-sdk" } adc-differ = { path = "../adc-differ" } serde_json = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "time"] } futures = "0.3" cpu-time = "1"