├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── crossplane-ci.yml ├── .gitignore ├── AUTHORS.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── NOTICE ├── README.md ├── crossplane ├── __init__.py ├── __main__.py ├── analyzer.py ├── builder.py ├── compat.py ├── errors.py ├── ext │ ├── __init__.py │ ├── abstract.py │ └── lua.py ├── formatter.py ├── lexer.py └── parser.py ├── ext └── crossplane-logo.png ├── setup.py ├── tests ├── __init__.py ├── configs │ ├── bad-args │ │ └── nginx.conf │ ├── comments-between-args │ │ └── nginx.conf │ ├── directive-with-space │ │ └── nginx.conf │ ├── empty-value-map │ │ └── nginx.conf │ ├── includes-globbed │ │ ├── http.conf │ │ ├── locations │ │ │ ├── location1.conf │ │ │ └── location2.conf │ │ ├── nginx.conf │ │ └── servers │ │ │ ├── locations │ │ │ └── not-included.conf │ │ │ ├── server1.conf │ │ │ └── server2.conf │ ├── includes-regular │ │ ├── conf.d │ │ │ ├── bar.conf │ │ │ ├── foo.conf │ │ │ └── server.conf │ │ ├── foo.conf │ │ └── nginx.conf │ ├── lua-block-larger │ │ └── nginx.conf │ ├── lua-block-simple │ │ └── nginx.conf │ ├── lua-block-tricky │ │ └── nginx.conf │ ├── messy │ │ └── nginx.conf │ ├── missing-semicolon │ │ ├── broken-above.conf │ │ └── broken-below.conf │ ├── non-unicode │ │ └── nginx.conf │ ├── quote-behavior │ │ └── nginx.conf │ ├── quoted-right-brace │ │ └── nginx.conf │ ├── russian-text │ │ └── nginx.conf │ ├── simple │ │ └── nginx.conf │ ├── spelling-mistake │ │ └── nginx.conf │ └── with-comments │ │ └── nginx.conf ├── ext │ ├── __init__.py │ └── test_lua.py ├── test_analyze.py ├── test_build.py ├── test_format.py ├── test_lex.py └── test_parse.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Create an nginx config file with '...' 16 | 2. Run `crossplane parse` on nginx config 17 | 3. See traceback 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Your environment** 23 | * Operating System (e.g. Ubuntu 18.04) 24 | * Version of crossplane (e.g. crossplane 0.5.0) 25 | 26 | **Additional context** 27 | Add any other context about the problem here. Any log files you want to share. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Proposed changes 2 | Describe the use case and detail of the change. If this PR addresses an issue on GitHub, make sure to include a link to that issue here in this description (not in the title of the PR). 3 | 4 | ### Checklist 5 | Before creating a PR, run through this checklist and mark each as complete. 6 | 7 | - [ ] I have read the [CONTRIBUTING](https://github.com/nginxinc/crossplane/blob/master/CONTRIBUTING.md) doc 8 | - [ ] I have added tests that prove my fix is effective or that my feature works 9 | - [ ] I have checked that all unit tests pass after adding my changes 10 | - [ ] I have updated necessary documentation 11 | - [ ] I have rebased my branch onto master 12 | - [ ] I will ensure my PR is targeting the master branch and pulling from my branch from my own fork 13 | -------------------------------------------------------------------------------- /.github/workflows/crossplane-ci.yml: -------------------------------------------------------------------------------- 1 | name: Crossplane CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths-ignore: 7 | - '**.md' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | python-version: ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", pypy-3.6, pypy-3.7, pypy-3.8, pypy-3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Run tox under Python ${{ matrix.python-version }} 24 | run: | 25 | pip install tox 26 | tox -e py 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python 2 | __pycache__/ 3 | .pytest_cache/ 4 | *.py[cod] 5 | *$py.class 6 | *.so 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | *.manifest 25 | *.spec 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | htmlcov/ 29 | .tox/ 30 | .coverage 31 | .coverage.* 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | *.cover 36 | .hypothesis/ 37 | *.mo 38 | *.pot 39 | *.log 40 | local_settings.py 41 | instance/ 42 | .webassets-cache 43 | .scrapy 44 | docs/_build/ 45 | target/ 46 | .ipynb_checkpoints 47 | .python-version 48 | celerybeat-schedule 49 | *.sage.py 50 | .env 51 | .venv 52 | env/ 53 | venv/ 54 | ENV/ 55 | env.bak/ 56 | venv.bak/ 57 | .spyderproject 58 | .spyproject 59 | .ropeproject 60 | /site 61 | .mypy_cache/ 62 | 63 | 64 | ### Operating System Files 65 | 66 | ## Linux 67 | .fuse_hidden* 68 | .directory 69 | .Trash-* 70 | .nfs* 71 | 72 | ## macOS 73 | # General 74 | .DS_Store 75 | .AppleDouble 76 | .LSOverride 77 | Icon 78 | ._* 79 | .DocumentRevisions-V100 80 | .fseventsd 81 | .Spotlight-V100 82 | .TemporaryItems 83 | .Trashes 84 | .VolumeIcon.icns 85 | .com.apple.timemachine.donotpresent 86 | .AppleDB 87 | .AppleDesktop 88 | Network Trash Folder 89 | Temporary Items 90 | .apdisk 91 | 92 | ## Windows 93 | Thumbs.db 94 | ehthumbs.db 95 | ehthumbs_vista.db 96 | *.stackdump 97 | Desktop.ini 98 | $RECYCLE.BIN/ 99 | *.cab 100 | *.msi 101 | *.msm 102 | *.msp 103 | *.lnk 104 | 105 | 106 | ### Code Editor Files 107 | 108 | ## Emacs 109 | \#*\# 110 | /.emacs.desktop 111 | /.emacs.desktop.lock 112 | *.elc 113 | auto-save-list 114 | tramp 115 | .\#* 116 | .org-id-locations 117 | *_archive 118 | *_flymake.* 119 | /eshell/history 120 | /eshell/lastdir 121 | /elpa/ 122 | *.rel 123 | /auto/ 124 | .cask/ 125 | flycheck_*.el 126 | /server/ 127 | .projectile 128 | .dir-locals.el 129 | 130 | ## SublimeText 131 | *.tmlanguage.cache 132 | *.tmPreferences.cache 133 | *.stTheme.cache 134 | *.sublime-workspace 135 | *.sublime-project 136 | sftp-config.json 137 | Package Control.* 138 | oscrypto-ca-bundle.crt 139 | bh_unicode_properties.cache 140 | GitHub.sublime-settings 141 | 142 | ## TextMate 143 | *.tmproj 144 | *.tmproject 145 | tmtags 146 | 147 | ## JetBrains 148 | .idea 149 | *.iml 150 | out/ 151 | gen 152 | cmake-build-debug/ 153 | *.iws 154 | .idea_modules/ 155 | atlassian-ide-plugin.xml 156 | .idea/replstate.xml 157 | com_crashlytics_export_strings.xml 158 | crashlytics.properties 159 | crashlytics-build.properties 160 | fabric.properties 161 | 162 | ## Vim 163 | [._]*.s[a-v][a-z] 164 | [._]*.sw[a-p] 165 | [._]s[a-v][a-z] 166 | [._]sw[a-p] 167 | Session.vim 168 | .netrwhist 169 | *~ 170 | tags 171 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Arie van Luttikhuizen `@aluttik `_ 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Grant Hulegaard `@gshulegaard `_ (`GitLab `__) 14 | * Ivan Poluyanov `@poluyanov `_ 15 | * Raymond Lau `@Raymond26 `_ 16 | * Luca Comellini `@lucacome `_ 17 | * Ron Vider `@RonVider `_ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project and everyone participating in it is governed by this code. 4 | 5 | ## Our Pledge 6 | 7 | In the interest of fostering an open and welcoming environment, we as 8 | contributors and maintainers pledge to making participation in our project and 9 | our community a harassment-free experience for everyone, regardless of age, body 10 | size, disability, ethnicity, sex characteristics, gender identity and expression, 11 | level of experience, education, socio-economic status, nationality, personal 12 | appearance, race, religion, or sexual identity and orientation. 13 | 14 | ## Our Standards 15 | 16 | Examples of behavior that contributes to creating a positive environment 17 | include: 18 | 19 | * Using welcoming and inclusive language 20 | * Being respectful of differing viewpoints and experiences 21 | * Gracefully accepting constructive criticism 22 | * Focusing on what is best for the community 23 | * Showing empathy towards other community members 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | * The use of sexualized language or imagery and unwelcome sexual attention or 28 | advances 29 | * Trolling, insulting/derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or electronic 32 | address, without explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying the standards of acceptable 39 | behavior and are expected to take appropriate and fair corrective action in 40 | response to any instances of unacceptable behavior. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or 43 | reject comments, commits, code, wiki edits, issues, and other contributions 44 | that are not aligned to this Code of Conduct, or to ban temporarily or 45 | permanently any contributor for other behaviors that they deem inappropriate, 46 | threatening, offensive, or harmful. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies both within project spaces and in public spaces 51 | when an individual is representing the project or its community. Examples of 52 | representing a project or community include using an official project e-mail 53 | address, posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. Representation of a project may be 55 | further defined and clarified by project maintainers. 56 | 57 | ## Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 60 | reported by contacting the project team at [mailto:nginx@nginx.org]. All 61 | complaints will be reviewed and investigated and will result in a response that 62 | is deemed necessary and appropriate to the circumstances. The project team is 63 | obligated to maintain confidentiality with regard to the reporter of an incident. 64 | Further details of specific enforcement policies may be posted separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ## Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 73 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 74 | 75 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions are welcome, and they are greatly appreciated\! Every 4 | little bit helps, and credit will always be given. 5 | 6 | ## Table of Contents 7 | 8 | - [Types of Contributions](#types-of-contributions) 9 | - [Get Started](#get-started) 10 | - [Pull Request Guidelines](#pull-request-guidelines) 11 | - [Tips](#tips) 12 | 13 | ### Types of Contributions 14 | 15 | #### Report Bugs 16 | 17 | Report bugs at . 18 | 19 | If you are reporting a bug, please include: 20 | 21 | - Your operating system name and version. 22 | - Any details about your local setup that might be helpful in 23 | troubleshooting. 24 | - Detailed steps to reproduce the bug. 25 | 26 | #### Fix Bugs 27 | 28 | Look through the GitHub issues for bugs. Anything tagged with "bug" is 29 | open to whoever wants to implement it. 30 | 31 | #### Implement Features 32 | 33 | Look through the GitHub issues for features. Anything tagged with 34 | "feature" is open to whoever wants to implement it. 35 | 36 | #### Write Documentation 37 | 38 | crossplane could always use more documentation, whether as part of the 39 | official crossplane docs, in docstrings, or even on the web in blog 40 | posts, articles, and such. 41 | 42 | #### Submit Feedback 43 | 44 | The best way to send feedback is to file an issue at 45 | . 46 | 47 | If you are proposing a feature: 48 | 49 | - Explain in detail how it would work. 50 | - Keep the scope as narrow as possible, to make it easier to 51 | implement. 52 | - Remember that this is a volunteer-driven project, and that 53 | contributions are welcome :) 54 | 55 | ### Get Started 56 | 57 | Ready to contribute? Here's how to set up crossplane for local 58 | development. 59 | 60 | 1. [Fork](https://github.com/nginxinc/crossplane/fork) the crossplane 61 | repo on GitHub. 62 | 63 | 2. Clone your fork locally: 64 | 65 | git clone git@github.com:your_name_here/crossplane.git 66 | 67 | 3. Create a branch for local development: 68 | 69 | git checkout -b name-of-your-bugfix-or-feature 70 | 71 | Now you can make your changes locally. 72 | 73 | 4. When you're done making changes, check that your changes pass style 74 | and unit tests, including testing other Python versions with tox: 75 | 76 | tox 77 | 78 | To get tox, just pip install it. 79 | 80 | 5. Commit your changes and push your branch to GitHub: 81 | 82 | git add . 83 | git commit -m "Your detailed description of your changes." 84 | git push origin name-of-your-bugfix-or-feature 85 | 86 | 6. Submit a pull request through the GitHub website. 87 | 88 | ### Pull Request Guidelines 89 | 90 | Before you submit a pull request, check that it meets these guidelines: 91 | 92 | 1. The pull request should include tests. 93 | 2. The pull request should work for CPython 2.7, 3.6, 3.7, 3.8, 3.9, 3.10, and PyPy. Check 94 | under pull requests for 95 | active pull requests or run the `tox` command and make sure that the 96 | tests pass for all supported Python versions. 97 | 3. Make sure to add yourself to the Contributors list in AUTHORS.rst :) 98 | 99 | ### Tips 100 | 101 | To run a subset of tests: 102 | 103 | tox -e -- tests/[::test] 104 | 105 | To run all the test environments in *parallel* (you need to `pip install 106 | detox`): 107 | 108 | detox 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE NOTICE AUTHORS.rst 2 | recursive-include tests *.py 3 | global-exclude *.py[co] 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: help clean rebuild test-all test 3 | 4 | help: 5 | @echo "Please use \`make ' where is one of:" 6 | @echo " clean to remove build artifacts." 7 | @echo " rebuild remove and recreate all tox virtual environments." 8 | @echo " test-all to run tests with all required python interpreters." 9 | @echo " test to run tests with every python interpreter available." 10 | 11 | clean: 12 | @rm -fr 'dist/' 13 | @rm -fr 'build/' 14 | @rm -fr '.cache/' 15 | @rm -fr '.pytest_cache/' 16 | @find . -path '*/.*' -prune -o -name '__pycache__' -exec rm -fr {} + 17 | @find . -path '*/.*' -prune -o -name '*.egg-info' -exec rm -fr {} + 18 | @find . -path '*/.*' -prune -o -name '*.py[co]' -exec rm -fr {} + 19 | @find . -path '*/.*' -prune -o -name '*.build' -exec rm -fr {} + 20 | @find . -path '*/.*' -prune -o -name '*.so' -exec rm -fr {} + 21 | @find . -path '*/.*' -prune -o -name '*.c' -exec rm -fr {} + 22 | @find . -path '*/.*' -prune -o -name '*~' -exec rm -fr {} + 23 | 24 | rebuild: 25 | @make clean 26 | rm -fr .tox 27 | python -m tox --skip-missing-interpreters --recreate --notest 28 | @make clean 29 | 30 | 31 | test-all: 32 | @make clean 33 | python -m tox 34 | @make clean 35 | 36 | test: 37 | @make clean 38 | python -m tox --skip-missing-interpreters 39 | @make clean 40 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | 2 | crossplane 3 | 4 | Copyright 2018 NGINX, Inc. 5 | Copyright 2018 Arie van Luttikhuizen 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Crossplane Logo](https://raw.githubusercontent.com/nginxinc/crossplane/master/ext/crossplane-logo.png) 2 |

crossplane

3 |

Reliable and fast NGINX configuration file parser and builder

4 | 5 |

6 | 7 | 8 | 9 | 10 |

11 | 12 | - [Install](#install) 13 | - [Command Line Interface](#command-line-interface) 14 | - [crossplane parse](#crossplane-parse) 15 | - [crossplane build](#crossplane-build) 16 | - [crossplane lex](#crossplane-lex) 17 | - [crossplane format](#crossplane-format) 18 | - [crossplane minify](#crossplane-minify) 19 | - [Python Module](#python-module) 20 | - [crossplane.parse()](#crossplaneparse) 21 | - [crossplane.build()](#crossplanebuild) 22 | - [crossplane.lex()](#crossplanelex) 23 | - [Other Languages](#other-languages) 24 | 25 | ## Install 26 | 27 | You can install both the [Command Line 28 | Interface](#command-line-interface) and [Python Module](#python-module) 29 | via: 30 | 31 | pip install crossplane 32 | 33 | ## Command Line Interface 34 | 35 | ``` 36 | usage: crossplane [options] 37 | 38 | various operations for nginx config files 39 | 40 | optional arguments: 41 | -h, --help show this help message and exit 42 | -V, --version show program's version number and exit 43 | 44 | commands: 45 | parse parses a json payload for an nginx config 46 | build builds an nginx config from a json payload 47 | lex lexes tokens from an nginx config file 48 | minify removes all whitespace from an nginx config 49 | format formats an nginx config file 50 | help show help for commands 51 | ``` 52 | 53 | ### crossplane parse 54 | 55 | This command will take a path to a main NGINX config file as input, then 56 | parse the entire config into the schema defined below, and dumps the 57 | entire thing as a JSON payload. 58 | 59 | ``` 60 | usage: crossplane parse [-h] [-o OUT] [-i NUM] [--ignore DIRECTIVES] 61 | [--no-catch] [--tb-onerror] [--single-file] 62 | [--include-comments] [--strict] 63 | filename 64 | 65 | parses a json payload for an nginx config 66 | 67 | positional arguments: 68 | filename the nginx config file 69 | 70 | optional arguments: 71 | -h, --help show this help message and exit 72 | -o OUT, --out OUT write output to a file 73 | -i NUM, --indent NUM number of spaces to indent output 74 | --ignore DIRECTIVES ignore directives (comma-separated) 75 | --no-catch only collect first error in file 76 | --tb-onerror include tracebacks in config errors 77 | --combine use includes to create one single file 78 | --single-file do not include other config files 79 | --include-comments include comments in json 80 | --strict raise errors for unknown directives 81 | ``` 82 | 83 | **Privacy and Security** 84 | 85 | Since `crossplane` is usually used to create payloads that are sent to 86 | different servers, it's important to keep security in mind. For that 87 | reason, the `--ignore` option was added. It can be used to keep certain 88 | sensitive directives out of the payload output entirely. 89 | 90 | For example, we always use the equivalent of this flag in the [NGINX Amplify 91 | Agent](https://github.com/nginxinc/nginx-amplify-agent/) out of respect 92 | for our users' 93 | privacy: 94 | 95 | --ignore=auth_basic_user_file,secure_link_secret,ssl_certificate_key,ssl_client_certificate,ssl_password_file,ssl_stapling_file,ssl_trusted_certificate 96 | 97 | #### Schema 98 | 99 | **Response Object** 100 | 101 | ```js 102 | { 103 | "status": String, // "ok" or "failed" if "errors" is not empty 104 | "errors": Array, // aggregation of "errors" from Config objects 105 | "config": Array // Array of Config objects 106 | } 107 | ``` 108 | 109 | **Config Object** 110 | 111 | ```js 112 | { 113 | "file": String, // the full path of the config file 114 | "status": String, // "ok" or "failed" if errors is not empty array 115 | "errors": Array, // Array of Error objects 116 | "parsed": Array // Array of Directive objects 117 | } 118 | ``` 119 | 120 | **Directive Object** 121 | 122 | ```js 123 | { 124 | "directive": String, // the name of the directive 125 | "line": Number, // integer line number the directive started on 126 | "args": Array, // Array of String arguments 127 | "includes": Array, // Array of integers (included iff this is an include directive) 128 | "block": Array // Array of Directive Objects (included iff this is a block) 129 | } 130 | ``` 131 | 132 |
133 | 134 |
135 | 136 | Note 137 | 138 |
139 | 140 | If this is an `include` directive and the `--single-file` flag was not 141 | used, an `"includes"` value will be used that holds an Array of indices 142 | of the configs that are included by this directive. 143 | 144 | If this is a block directive, a `"block"` value will be used that holds 145 | an Array of more Directive Objects that define the block context. 146 | 147 |
148 | 149 | **Error Object** 150 | 151 | ```js 152 | { 153 | "file": String, // the full path of the config file 154 | "line": Number, // integer line number the directive that caused the error 155 | "error": String, // the error message 156 | "callback": Object // only included iff an "onerror" function was passed to parse() 157 | } 158 | ``` 159 | 160 |
161 | 162 |
163 | 164 | Note 165 | 166 |
167 | 168 | If the `--tb-onerror` flag was used by crossplane parse, `"callback"` 169 | will contain a string that represents the traceback that the error 170 | caused. 171 | 172 |
173 | 174 | #### Example 175 | 176 | The main NGINX config file is at `/etc/nginx/nginx.conf`: 177 | 178 | ```nginx 179 | events { 180 | worker_connections 1024; 181 | } 182 | 183 | http { 184 | include conf.d/*.conf; 185 | } 186 | ``` 187 | 188 | And this config file is at `/etc/nginx/conf.d/servers.conf`: 189 | 190 | ```nginx 191 | server { 192 | listen 8080; 193 | location / { 194 | try_files 'foo bar' baz; 195 | } 196 | } 197 | 198 | server { 199 | listen 8081; 200 | location / { 201 | return 200 'success!'; 202 | } 203 | } 204 | ``` 205 | 206 | So then if you run this: 207 | 208 | crossplane parse --indent=4 /etc/nginx/nginx.conf 209 | 210 | The prettified JSON output would look like this: 211 | 212 | ```js 213 | { 214 | "status": "ok", 215 | "errors": [], 216 | "config": [ 217 | { 218 | "file": "/etc/nginx/nginx.conf", 219 | "status": "ok", 220 | "errors": [], 221 | "parsed": [ 222 | { 223 | "directive": "events", 224 | "line": 1, 225 | "args": [], 226 | "block": [ 227 | { 228 | "directive": "worker_connections", 229 | "line": 2, 230 | "args": [ 231 | "1024" 232 | ] 233 | } 234 | ] 235 | }, 236 | { 237 | "directive": "http", 238 | "line": 5, 239 | "args": [], 240 | "block": [ 241 | { 242 | "directive": "include", 243 | "line": 6, 244 | "args": [ 245 | "conf.d/*.conf" 246 | ], 247 | "includes": [ 248 | 1 249 | ] 250 | } 251 | ] 252 | } 253 | ] 254 | }, 255 | { 256 | "file": "/etc/nginx/conf.d/servers.conf", 257 | "status": "ok", 258 | "errors": [], 259 | "parsed": [ 260 | { 261 | "directive": "server", 262 | "line": 1, 263 | "args": [], 264 | "block": [ 265 | { 266 | "directive": "listen", 267 | "line": 2, 268 | "args": [ 269 | "8080" 270 | ] 271 | }, 272 | { 273 | "directive": "location", 274 | "line": 3, 275 | "args": [ 276 | "/" 277 | ], 278 | "block": [ 279 | { 280 | "directive": "try_files", 281 | "line": 4, 282 | "args": [ 283 | "foo bar", 284 | "baz" 285 | ] 286 | } 287 | ] 288 | } 289 | ] 290 | }, 291 | { 292 | "directive": "server", 293 | "line": 8, 294 | "args": [], 295 | "block": [ 296 | { 297 | "directive": "listen", 298 | "line": 9, 299 | "args": [ 300 | "8081" 301 | ] 302 | }, 303 | { 304 | "directive": "location", 305 | "line": 10, 306 | "args": [ 307 | "/" 308 | ], 309 | "block": [ 310 | { 311 | "directive": "return", 312 | "line": 11, 313 | "args": [ 314 | "200", 315 | "success!" 316 | ] 317 | } 318 | ] 319 | } 320 | ] 321 | } 322 | ] 323 | } 324 | ] 325 | } 326 | ``` 327 | 328 | #### crossplane parse (advanced) 329 | 330 | This tool uses two flags that can change how `crossplane` handles 331 | errors. 332 | 333 | The first, `--no-catch`, can be used if you'd prefer that crossplane 334 | quit parsing after the first error it finds. 335 | 336 | The second, `--tb-onerror`, will add a `"callback"` key to all error 337 | objects in the JSON output, each containing a string representation of 338 | the traceback that would have been raised by the parser if the exception 339 | had not been caught. This can be useful for logging purposes. 340 | 341 | ### crossplane build 342 | 343 | This command will take a path to a file as input. The file should 344 | contain a JSON representation of an NGINX config that has the structure 345 | defined above. Saving and using the output from `crossplane parse` to 346 | rebuild your config files should not cause any differences in content 347 | except for the formatting. 348 | 349 | ``` 350 | usage: crossplane build [-h] [-d PATH] [-f] [-i NUM | -t] [--no-headers] 351 | [--stdout] [-v] 352 | filename 353 | 354 | builds an nginx config from a json payload 355 | 356 | positional arguments: 357 | filename the file with the config payload 358 | 359 | optional arguments: 360 | -h, --help show this help message and exit 361 | -v, --verbose verbose output 362 | -d PATH, --dir PATH the base directory to build in 363 | -f, --force overwrite existing files 364 | -i NUM, --indent NUM number of spaces to indent output 365 | -t, --tabs indent with tabs instead of spaces 366 | --no-headers do not write header to configs 367 | --stdout write configs to stdout instead 368 | ``` 369 | 370 | ### crossplane lex 371 | 372 | This command takes an NGINX config file, splits it into tokens by 373 | removing whitespace and comments, and dumps the list of tokens as a JSON 374 | array. 375 | 376 | ``` 377 | usage: crossplane lex [-h] [-o OUT] [-i NUM] [-n] filename 378 | 379 | lexes tokens from an nginx config file 380 | 381 | positional arguments: 382 | filename the nginx config file 383 | 384 | optional arguments: 385 | -h, --help show this help message and exit 386 | -o OUT, --out OUT write output to a file 387 | -i NUM, --indent NUM number of spaces to indent output 388 | -n, --line-numbers include line numbers in json payload 389 | ``` 390 | 391 | #### Example 392 | 393 | Passing in this NGINX config file at `/etc/nginx/nginx.conf`: 394 | 395 | ```nginx 396 | events { 397 | worker_connections 1024; 398 | } 399 | 400 | http { 401 | include conf.d/*.conf; 402 | } 403 | ``` 404 | 405 | By running: 406 | 407 | crossplane lex /etc/nginx/nginx.conf 408 | 409 | Will result in this JSON 410 | output: 411 | 412 | ```js 413 | ["events","{","worker_connections","1024",";","}","http","{","include","conf.d/*.conf",";","}"] 414 | ``` 415 | 416 | However, if you decide to use the `--line-numbers` flag, your output 417 | will look 418 | like: 419 | 420 | ```js 421 | [["events",1],["{",1],["worker_connections",2],["1024",2],[";",2],["}",3],["http",5],["{",5],["include",6],["conf.d/*.conf",6],[";",6],["}",7]] 422 | ``` 423 | 424 | ### crossplane format 425 | 426 | This is a quick and dirty tool that uses [crossplane 427 | parse](#crossplane-parse) internally to format an NGINX config file. 428 | It serves the purpose of demonstrating what you can do with `crossplane`'s 429 | parsing abilities. It is not meant to be a fully fleshed out, feature-rich 430 | formatting tool. If that is what you are looking for, then you may want to 431 | look writing your own using crossplane's Python API. 432 | 433 | ``` 434 | usage: crossplane format [-h] [-o OUT] [-i NUM | -t] filename 435 | 436 | formats an nginx config file 437 | 438 | positional arguments: 439 | filename the nginx config file 440 | 441 | optional arguments: 442 | -h, --help show this help message and exit 443 | -o OUT, --out OUT write output to a file 444 | -i NUM, --indent NUM number of spaces to indent output 445 | -t, --tabs indent with tabs instead of spaces 446 | ``` 447 | 448 | ### crossplane minify 449 | 450 | This is a simple and fun little tool that uses [crossplane 451 | lex](#crossplane-lex) internally to remove as much whitespace from an 452 | NGINX config file as possible without affecting what it does. It can't 453 | imagine it will have much of a use to most people, but it demonstrates 454 | the kinds of things you can do with `crossplane`'s lexing abilities. 455 | 456 | ``` 457 | usage: crossplane minify [-h] [-o OUT] filename 458 | 459 | removes all whitespace from an nginx config 460 | 461 | positional arguments: 462 | filename the nginx config file 463 | 464 | optional arguments: 465 | -h, --help show this help message and exit 466 | -o OUT, --out OUT write output to a file 467 | ``` 468 | 469 | ## Python Module 470 | 471 | In addition to the command line tool, you can import `crossplane` as a 472 | python module. There are two basic functions that the module will 473 | provide you: `parse` and `lex`. 474 | 475 | ### crossplane.parse() 476 | 477 | ```python 478 | import crossplane 479 | payload = crossplane.parse('/etc/nginx/nginx.conf') 480 | ``` 481 | 482 | This will return the same payload as described in the [crossplane 483 | parse](#crossplane-parse) section, except it will be Python dicts and 484 | not one giant JSON string. 485 | 486 | ### crossplane.build() 487 | 488 | ```python 489 | import crossplane 490 | config = crossplane.build( 491 | [{ 492 | "directive": "events", 493 | "args": [], 494 | "block": [{ 495 | "directive": "worker_connections", 496 | "args": ["1024"] 497 | }] 498 | }] 499 | ) 500 | ``` 501 | 502 | This will return a single string that contains an entire NGINX config 503 | file. 504 | 505 | ### crossplane.lex() 506 | 507 | ```python 508 | import crossplane 509 | tokens = crossplane.lex('/etc/nginx/nginx.conf') 510 | ``` 511 | 512 | `crossplane.lex` generates 2-tuples. Inserting these pairs into a list 513 | will result in a long list similar to what you can see in the 514 | [crossplane lex](#crossplane-lex) section when the `--line-numbers` flag 515 | is used, except it will obviously be a Python list of tuples and not one 516 | giant JSON string. 517 | 518 | ## Other Languages 519 | 520 | - Go port by [@aluttik](https://github.com/aluttik): 521 | 522 | - Ruby port by [@gdanko](https://github.com/gdanko): 523 | 524 | -------------------------------------------------------------------------------- /crossplane/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .parser import parse 3 | from .lexer import lex 4 | from .builder import build 5 | from .formatter import format 6 | from .ext.lua import LuaBlockPlugin 7 | 8 | __all__ = ['parse', 'lex', 'build', 'format'] 9 | 10 | __title__ = 'crossplane' 11 | __summary__ = 'Reliable and fast NGINX configuration file parser.' 12 | __url__ = 'https://github.com/nginxinc/crossplane' 13 | 14 | __version__ = '0.5.7' 15 | 16 | __author__ = 'Arie van Luttikhuizen' 17 | __email__ = 'aluttik@gmail.com' 18 | 19 | __license__ = 'Apache 2.0' 20 | __copyright__ = 'Copyright 2018 NGINX, Inc.' 21 | 22 | default_enabled_extensions = [LuaBlockPlugin()] 23 | for extension in default_enabled_extensions: 24 | extension.register_extension() 25 | -------------------------------------------------------------------------------- /crossplane/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import io 4 | import os 5 | import sys 6 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 7 | from traceback import format_exception 8 | 9 | from . import __version__ 10 | from .lexer import lex as lex_file 11 | from .parser import parse as parse_file 12 | from .builder import build as build_string, build_files, _enquote, DELIMITERS 13 | from .formatter import format as format_file 14 | from .compat import json, input 15 | 16 | 17 | def _prompt_yes(): 18 | try: 19 | return input('overwrite? (y/n [n]) ').lower().startswith('y') 20 | except (KeyboardInterrupt, EOFError): 21 | sys.exit(1) 22 | 23 | 24 | def _dump_payload(obj, fp, indent): 25 | kwargs = {'indent': indent} 26 | if indent is None: 27 | kwargs['separators'] = ',', ':' 28 | fp.write(json.dumps(obj, **kwargs) + u'\n') 29 | 30 | 31 | def parse(filename, out, indent=None, catch=None, tb_onerror=None, ignore='', 32 | single=False, comments=False, strict=False, combine=False): 33 | 34 | ignore = ignore.split(',') if ignore else [] 35 | 36 | def callback(e): 37 | exc = sys.exc_info() + (10,) 38 | return ''.join(format_exception(*exc)).rstrip() 39 | 40 | kwargs = { 41 | 'catch_errors': catch, 42 | 'ignore': ignore, 43 | 'combine': combine, 44 | 'single': single, 45 | 'comments': comments, 46 | 'strict': strict 47 | } 48 | 49 | if tb_onerror: 50 | kwargs['onerror'] = callback 51 | 52 | payload = parse_file(filename, **kwargs) 53 | o = sys.stdout if out is None else io.open(out, 'w', encoding='utf-8') 54 | try: 55 | _dump_payload(payload, o, indent=indent) 56 | finally: 57 | o.close() 58 | 59 | 60 | def build(filename, dirname=None, force=False, indent=4, tabs=False, 61 | header=True, stdout=False, verbose=False): 62 | 63 | if dirname is None: 64 | dirname = os.getcwd() 65 | 66 | # read the json payload from the specified file 67 | with open(filename, 'r') as fp: 68 | payload = json.load(fp) 69 | 70 | # find which files from the json payload will overwrite existing files 71 | if not force and not stdout: 72 | existing = [] 73 | for config in payload['config']: 74 | path = config['file'] 75 | if not os.path.isabs(path): 76 | path = os.path.join(dirname, path) 77 | if os.path.exists(path): 78 | existing.append(path) 79 | # ask the user if it's okay to overwrite existing files 80 | if existing: 81 | print('building {} would overwrite these files:'.format(filename)) 82 | print('\n'.join(existing)) 83 | if not _prompt_yes(): 84 | print('not overwritten') 85 | return 86 | 87 | # if stdout is set then just print each file after another like nginx -T 88 | if stdout: 89 | for config in payload['config']: 90 | path = config['file'] 91 | if not os.path.isabs(path): 92 | path = os.path.join(dirname, path) 93 | parsed = config['parsed'] 94 | output = build_string(parsed, indent=indent, tabs=tabs, header=header) 95 | output = output.rstrip() + '\n' 96 | print('# ' + path + '\n' + output) 97 | return 98 | 99 | # build the nginx configuration file from the json payload 100 | build_files(payload, dirname=dirname, indent=indent, tabs=tabs, header=header) 101 | 102 | # if verbose print the paths of the config files that were created 103 | if verbose: 104 | for config in payload['config']: 105 | path = config['file'] 106 | if not os.path.isabs(path): 107 | path = os.path.join(dirname, path) 108 | print('wrote to ' + path) 109 | 110 | 111 | def lex(filename, out, indent=None, line_numbers=False): 112 | payload = list(lex_file(filename)) 113 | if line_numbers: 114 | payload = [(token, lineno) for token, lineno, quoted in payload] 115 | else: 116 | payload = [token for token, lineno, quoted in payload] 117 | o = sys.stdout if out is None else io.open(out, 'w', encoding='utf-8') 118 | try: 119 | _dump_payload(payload, o, indent=indent) 120 | finally: 121 | o.close() 122 | 123 | 124 | def minify(filename, out): 125 | payload = parse_file( 126 | filename, 127 | single=True, 128 | catch_errors=False, 129 | check_args=False, 130 | check_ctx=False, 131 | comments=False, 132 | strict=False 133 | ) 134 | o = sys.stdout if out is None else io.open(out, 'w', encoding='utf-8') 135 | def write_block(block): 136 | for stmt in block: 137 | o.write(_enquote(stmt['directive'])) 138 | if stmt['directive'] == 'if': 139 | o.write(u' (%s)' % ' '.join(map(_enquote, stmt['args']))) 140 | else: 141 | o.write(u' %s' % ' '.join(map(_enquote, stmt['args']))) 142 | if 'block' in stmt: 143 | o.write(u'{') 144 | write_block(stmt['block']) 145 | o.write(u'}') 146 | else: 147 | o.write(u';') 148 | try: 149 | write_block(payload['config'][0]['parsed']) 150 | o.write(u'\n') 151 | finally: 152 | o.close() 153 | 154 | 155 | def format(filename, out, indent=4, tabs=False): 156 | output = format_file(filename, indent=indent, tabs=tabs) 157 | o = sys.stdout if out is None else io.open(out, 'w', encoding='utf-8') 158 | try: 159 | o.write(output + u'\n') 160 | finally: 161 | o.close() 162 | 163 | 164 | class _SubparserHelpFormatter(RawDescriptionHelpFormatter): 165 | def _format_action(self, action): 166 | line = super(RawDescriptionHelpFormatter, self)._format_action(action) 167 | 168 | if action.nargs == 'A...': 169 | line = line.split('\n', 1)[-1] 170 | 171 | if line.startswith(' ') and line[4] != ' ': 172 | parts = filter(len, line.lstrip().partition(' ')) 173 | line = ' ' + ' '.join(parts) 174 | 175 | return line 176 | 177 | 178 | def parse_args(args=None): 179 | parser = ArgumentParser( 180 | formatter_class=_SubparserHelpFormatter, 181 | description='various operations for nginx config files', 182 | usage='%(prog)s [options]' 183 | ) 184 | parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__) 185 | subparsers = parser.add_subparsers(title='commands') 186 | 187 | def create_subparser(function, help): 188 | name = function.__name__ 189 | prog = 'crossplane ' + name 190 | p = subparsers.add_parser(name, prog=prog, help=help, description=help) 191 | p.set_defaults(_subcommand=function) 192 | return p 193 | 194 | p = create_subparser(parse, 'parses a json payload for an nginx config') 195 | p.add_argument('filename', help='the nginx config file') 196 | p.add_argument('-o', '--out', type=str, help='write output to a file') 197 | p.add_argument('-i', '--indent', type=int, metavar='NUM', help='number of spaces to indent output') 198 | p.add_argument('--ignore', metavar='DIRECTIVES', default='', help='ignore directives (comma-separated)') 199 | p.add_argument('--no-catch', action='store_false', dest='catch', help='only collect first error in file') 200 | p.add_argument('--tb-onerror', action='store_true', help='include tracebacks in config errors') 201 | p.add_argument('--combine', action='store_true', help='use includes to create one single file') 202 | p.add_argument('--single-file', action='store_true', dest='single', help='do not include other config files') 203 | p.add_argument('--include-comments', action='store_true', dest='comments', help='include comments in json') 204 | p.add_argument('--strict', action='store_true', help='raise errors for unknown directives') 205 | 206 | p = create_subparser(build, 'builds an nginx config from a json payload') 207 | p.add_argument('filename', help='the file with the config payload') 208 | p.add_argument('-v', '--verbose', action='store_true', help='verbose output') 209 | p.add_argument('-d', '--dir', metavar='PATH', default=None, dest='dirname', help='the base directory to build in') 210 | p.add_argument('-f', '--force', action='store_true', help='overwrite existing files') 211 | g = p.add_mutually_exclusive_group() 212 | g.add_argument('-i', '--indent', type=int, metavar='NUM', help='number of spaces to indent output', default=4) 213 | g.add_argument('-t', '--tabs', action='store_true', help='indent with tabs instead of spaces') 214 | p.add_argument('--no-headers', action='store_false', dest='header', help='do not write header to configs') 215 | p.add_argument('--stdout', action='store_true', help='write configs to stdout instead') 216 | 217 | p = create_subparser(lex, 'lexes tokens from an nginx config file') 218 | p.add_argument('filename', help='the nginx config file') 219 | p.add_argument('-o', '--out', type=str, help='write output to a file') 220 | p.add_argument('-i', '--indent', type=int, metavar='NUM', help='number of spaces to indent output') 221 | p.add_argument('-n', '--line-numbers', action='store_true', help='include line numbers in json payload') 222 | 223 | p = create_subparser(minify, 'removes all whitespace from an nginx config') 224 | p.add_argument('filename', help='the nginx config file') 225 | p.add_argument('-o', '--out', type=str, help='write output to a file') 226 | 227 | p = create_subparser(format, 'formats an nginx config file') 228 | p.add_argument('filename', help='the nginx config file') 229 | p.add_argument('-o', '--out', type=str, help='write output to a file') 230 | g = p.add_mutually_exclusive_group() 231 | g.add_argument('-i', '--indent', type=int, metavar='NUM', help='number of spaces to indent output', default=4) 232 | g.add_argument('-t', '--tabs', action='store_true', help='indent with tabs instead of spaces') 233 | 234 | def help(command): 235 | if command not in parser._actions[-1].choices: 236 | parser.error('unknown command %r' % command) 237 | else: 238 | parser._actions[-1].choices[command].print_help() 239 | 240 | p = create_subparser(help, 'show help for commands') 241 | p.add_argument('command', help='command to show help for') 242 | 243 | parsed = parser.parse_args(args=args) 244 | 245 | # this addresses a bug that was added to argparse in Python 3.3 246 | if not parsed.__dict__: 247 | parser.error('too few arguments') 248 | 249 | return parsed 250 | 251 | 252 | def main(): 253 | kwargs = parse_args().__dict__ 254 | func = kwargs.pop('_subcommand') 255 | func(**kwargs) 256 | 257 | 258 | if __name__ == '__main__': 259 | main() 260 | -------------------------------------------------------------------------------- /crossplane/builder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import codecs 3 | import os 4 | import re 5 | 6 | from .compat import PY2 7 | 8 | DELIMITERS = ('{', '}', ';') 9 | EXTERNAL_BUILDERS = {} 10 | ESCAPE_SEQUENCES_RE = re.compile(r'(\\x[0-9a-f]{2}|\\[0-7]{1,3})') 11 | 12 | 13 | def _escape(string): 14 | prev, char = '', '' 15 | for char in string: 16 | if prev == '\\' or prev + char == '${': 17 | prev += char 18 | yield prev 19 | continue 20 | if prev == '$': 21 | yield prev 22 | if char not in ('\\', '$'): 23 | yield char 24 | prev = char 25 | if char in ('\\', '$'): 26 | yield char 27 | 28 | 29 | def _needs_quotes(string): 30 | if string == '': 31 | return True 32 | 33 | # lexer should throw an error when variable expansion syntax 34 | # is messed up, but just wrap it in quotes for now I guess 35 | chars = _escape(string) 36 | 37 | # arguments can't start with variable expansion syntax 38 | char = next(chars) 39 | if char.isspace() or char in ('{', '}', ';', '"', "'", '${'): 40 | return True 41 | 42 | expanding = False 43 | for char in chars: 44 | if char.isspace() or char in ('{', ';', '"', "'"): 45 | return True 46 | elif char == ('${' if expanding else '}'): 47 | return True 48 | elif char == ('}' if expanding else '${'): 49 | expanding = not expanding 50 | 51 | return char in ('\\', '$') or expanding 52 | 53 | 54 | def _replace_escape_sequences(match): 55 | return match.group(1).decode('string-escape') 56 | 57 | 58 | def _enquote(arg): 59 | if not _needs_quotes(arg): 60 | return arg 61 | 62 | if PY2: 63 | arg = codecs.encode(arg, 'utf-8') if isinstance(arg, unicode) else arg 64 | arg = codecs.decode(arg, 'raw-unicode-escape') 65 | arg = repr(arg).replace('\\\\', '\\').lstrip('u') 66 | arg = ESCAPE_SEQUENCES_RE.sub(_replace_escape_sequences, arg) 67 | arg = unicode(arg, 'utf-8') 68 | else: 69 | arg = repr(arg).replace('\\\\', '\\') 70 | 71 | return arg 72 | 73 | 74 | def build(payload, indent=4, tabs=False, header=False): 75 | padding = '\t' if tabs else ' ' * indent 76 | 77 | head = '' 78 | if header: 79 | head += '# This config was built from JSON using NGINX crossplane.\n' 80 | head += '# If you encounter any bugs please report them here:\n' 81 | head += '# https://github.com/nginxinc/crossplane/issues\n' 82 | head += '\n' 83 | 84 | def _build_block(output, block, depth, last_line): 85 | margin = padding * depth 86 | 87 | for stmt in block: 88 | directive = _enquote(stmt['directive']) 89 | line = stmt.get('line', 0) 90 | 91 | if directive == '#' and line == last_line: 92 | output += ' #' + stmt['comment'] 93 | continue 94 | elif directive == '#': 95 | built = '#' + stmt['comment'] 96 | elif directive in EXTERNAL_BUILDERS: 97 | external_builder = EXTERNAL_BUILDERS[directive] 98 | built = external_builder(stmt, padding, indent, tabs) 99 | else: 100 | args = [_enquote(arg) for arg in stmt['args']] 101 | 102 | if directive == 'if': 103 | built = 'if (' + ' '.join(args) + ')' 104 | elif args: 105 | built = directive + ' ' + ' '.join(args) 106 | else: 107 | built = directive 108 | 109 | if stmt.get('block') is None: 110 | built += ';' 111 | else: 112 | built += ' {' 113 | built = _build_block(built, stmt['block'], depth+1, line) 114 | built += '\n' + margin + '}' 115 | 116 | output += ('\n' if output else '') + margin + built 117 | last_line = line 118 | 119 | return output 120 | 121 | body = '' 122 | body = _build_block(body, payload, 0, 0) 123 | return head + body 124 | 125 | 126 | def build_files(payload, dirname=None, indent=4, tabs=False, header=False): 127 | """ 128 | Uses a full nginx config payload (output of crossplane.parse) to build 129 | config files, then writes those files to disk. 130 | """ 131 | if dirname is None: 132 | dirname = os.getcwd() 133 | 134 | for config in payload['config']: 135 | path = config['file'] 136 | if not os.path.isabs(path): 137 | path = os.path.join(dirname, path) 138 | 139 | # make directories that need to be made for the config to be built 140 | dirpath = os.path.dirname(path) 141 | if not os.path.exists(dirpath): 142 | os.makedirs(dirpath) 143 | 144 | # build then create the nginx config file using the json payload 145 | parsed = config['parsed'] 146 | output = build(parsed, indent=indent, tabs=tabs, header=header) 147 | output = output.rstrip() + '\n' 148 | with codecs.open(path, 'w', encoding='utf-8') as fp: 149 | fp.write(output) 150 | 151 | 152 | def register_external_builder(builder, directives): 153 | for directive in directives: 154 | EXTERNAL_BUILDERS[directive] = builder 155 | -------------------------------------------------------------------------------- /crossplane/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import functools 3 | import sys 4 | 5 | try: 6 | import simplejson as json 7 | except ImportError: 8 | import json 9 | 10 | PY2 = (sys.version_info[0] == 2) 11 | PY3 = (sys.version_info[0] == 3) 12 | 13 | if PY2: 14 | input = raw_input 15 | basestring = basestring 16 | else: 17 | input = input 18 | basestring = str 19 | 20 | 21 | def fix_pep_479(generator): 22 | """ 23 | Python 3.7 breaks crossplane's lexer because of PEP 479 24 | Read more here: https://www.python.org/dev/peps/pep-0479/ 25 | """ 26 | @functools.wraps(generator) 27 | def _wrapped_generator(*args, **kwargs): 28 | try: 29 | for x in generator(*args, **kwargs): 30 | yield x 31 | except RuntimeError: 32 | return 33 | 34 | return _wrapped_generator 35 | -------------------------------------------------------------------------------- /crossplane/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class NgxParserBaseException(Exception): 5 | def __init__(self, strerror, filename, lineno): 6 | self.args = (strerror, filename, lineno) 7 | self.filename = filename 8 | self.lineno = lineno 9 | self.strerror = strerror 10 | 11 | def __str__(self): 12 | if self.lineno is not None: 13 | return '%s in %s:%s' % self.args 14 | else: 15 | return '%s in %s' % self.args 16 | 17 | 18 | class NgxParserSyntaxError(NgxParserBaseException): 19 | pass 20 | 21 | 22 | class NgxParserDirectiveError(NgxParserBaseException): 23 | pass 24 | 25 | 26 | class NgxParserDirectiveArgumentsError(NgxParserDirectiveError): 27 | pass 28 | 29 | 30 | class NgxParserDirectiveContextError(NgxParserDirectiveError): 31 | pass 32 | 33 | 34 | class NgxParserDirectiveUnknownError(NgxParserDirectiveError): 35 | pass 36 | -------------------------------------------------------------------------------- /crossplane/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nginxinc/crossplane/3e616a890e9ad53c236bc934de0e0ed3ad290ee4/crossplane/ext/__init__.py -------------------------------------------------------------------------------- /crossplane/ext/abstract.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from crossplane.analyzer import register_external_directives 3 | from crossplane.lexer import register_external_lexer 4 | from crossplane.parser import register_external_parser 5 | from crossplane.builder import register_external_builder 6 | 7 | 8 | class CrossplaneExtension(object): 9 | directives = {} 10 | 11 | def register_extension(self): 12 | register_external_directives(directive=self.directives) 13 | register_external_lexer(directives=self.directives, lexer=self.lex) 14 | register_external_parser(directives=self.directives, parser=self.parse) 15 | register_external_builder(directives=self.directives, builder=self.build) 16 | 17 | def lex(self, token_iterator, directive): 18 | raise NotImplementedError 19 | 20 | def parse(self, stmt, parsing, tokens, ctx=(), consume=False): 21 | raise NotImplementedError 22 | 23 | def build(self, stmt, padding, state, indent=4, tabs=False): 24 | raise NotImplementedError 25 | -------------------------------------------------------------------------------- /crossplane/ext/lua.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from crossplane.lexer import register_external_lexer 3 | from crossplane.builder import register_external_builder 4 | from crossplane.compat import fix_pep_479 5 | from crossplane.errors import NgxParserBaseException 6 | from crossplane.ext.abstract import CrossplaneExtension 7 | 8 | 9 | class EmplaceIter: 10 | def __init__(self, it): 11 | self.it = it 12 | self.ret = [] 13 | 14 | def __iter__(self): 15 | return self 16 | 17 | def __next__(self): 18 | if len(self.ret) > 0: 19 | v = self.ret.pop() 20 | return v 21 | return next(self.it) 22 | 23 | next = __next__ 24 | 25 | def put_back(self, v): 26 | self.ret.append(v) 27 | 28 | 29 | 30 | class LuaBlockPlugin(CrossplaneExtension): 31 | """ 32 | This plugin adds special handling for Lua code block directives (*_by_lua_block) 33 | We don't need to handle non-block or file directives because those are parsed 34 | correctly by base Crossplane functionality. 35 | """ 36 | # todo maybe: populate the actual directive bit masks if analyzer/parser logic is needed 37 | directives = { 38 | 'access_by_lua_block': [], 39 | 'balancer_by_lua_block': [], 40 | 'body_filter_by_lua_block': [], 41 | 'content_by_lua_block': [], 42 | 'header_filter_by_lua_block': [], 43 | 'init_by_lua_block': [], 44 | 'init_worker_by_lua_block': [], 45 | 'log_by_lua_block': [], 46 | 'rewrite_by_lua_block': [], 47 | 'set_by_lua_block': [], 48 | 'ssl_certificate_by_lua_block': [], 49 | 'ssl_session_fetch_by_lua_block': [], 50 | 'ssl_session_store_by_lua_block': [], 51 | } 52 | 53 | def register_extension(self): 54 | register_external_lexer(directives=self.directives, lexer=self.lex) 55 | register_external_builder(directives=self.directives, builder=self.build) 56 | 57 | @fix_pep_479 58 | def lex(self, char_iterator, directive): 59 | if directive == "set_by_lua_block": 60 | # https://github.com/openresty/lua-nginx-module#set_by_lua_block 61 | # The sole *_by_lua_block directive that has an arg 62 | arg = '' 63 | for char, line in char_iterator: 64 | if char.isspace(): 65 | if arg: 66 | yield (arg, line, False) 67 | break 68 | while char.isspace(): 69 | char, line = next(char_iterator) 70 | 71 | arg += char 72 | 73 | depth = 0 74 | token = '' 75 | 76 | # check that Lua block starts correctly 77 | while True: 78 | char, line = next(char_iterator) 79 | if not char.isspace(): 80 | break 81 | 82 | if char != "{": 83 | reason = 'expected { to start Lua block' 84 | raise LuaBlockParserSyntaxError(reason, filename=None, lineno=line) 85 | 86 | depth += 1 87 | 88 | char_iterator = EmplaceIter(char_iterator) 89 | 90 | # Grab everything in Lua block as a single token 91 | # and watch for curly brace '{' in strings 92 | for char, line in char_iterator: 93 | if char == '-': 94 | prev_char, prev_line = char, line 95 | char, comment_line = next(char_iterator) 96 | if char == '-': 97 | token += '-' 98 | while char != '\n': 99 | token += char 100 | char, line = next(char_iterator) 101 | else: 102 | char_iterator.put_back((char, comment_line)) 103 | char, line = prev_char, prev_line 104 | elif char == '{': 105 | depth += 1 106 | elif char == '}': 107 | depth -= 1 108 | elif char in ('"', "'"): 109 | quote = char 110 | token += quote 111 | char, line = next(char_iterator) 112 | while char != quote: 113 | token += quote if char == quote else char 114 | char, line = next(char_iterator) 115 | 116 | if depth < 0: 117 | reason = 'unxpected "}"' 118 | raise LuaBlockParserSyntaxError(reason, filename=None, lineno=line) 119 | 120 | if depth == 0: 121 | yield (token, line, True) # True because this is treated like a string 122 | yield (';', line, False) 123 | raise StopIteration 124 | token += char 125 | 126 | def parse(self, stmt, parsing, tokens, ctx=(), consume=False): 127 | pass 128 | 129 | def build(self, stmt, padding, indent=4, tabs=False): 130 | built = stmt['directive'] 131 | if built == 'set_by_lua_block': 132 | block = stmt['args'][1] 133 | built += " %s" % stmt['args'][0] 134 | else: 135 | block = stmt['args'][0] 136 | return built + ' {' + block + '}' 137 | 138 | 139 | class LuaBlockParserSyntaxError(NgxParserBaseException): 140 | pass 141 | -------------------------------------------------------------------------------- /crossplane/formatter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .errors import NgxParserBaseException 3 | from .builder import build 4 | from .parser import parse 5 | 6 | 7 | def format(filename, indent=4, tabs=False): 8 | payload = parse( 9 | filename, 10 | comments=True, 11 | single=True, 12 | check_ctx=False, 13 | check_args=False 14 | ) 15 | 16 | if payload['status'] != 'ok': 17 | e = payload['errors'][0] 18 | raise NgxParserBaseException(e['error'], e['file'], e['line']) 19 | 20 | parsed = payload['config'][0]['parsed'] 21 | output = build(parsed, indent=indent, tabs=tabs) 22 | return output 23 | -------------------------------------------------------------------------------- /crossplane/lexer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import itertools 3 | import io 4 | 5 | from .compat import fix_pep_479 6 | from .errors import NgxParserSyntaxError 7 | 8 | EXTERNAL_LEXERS = {} 9 | 10 | 11 | @fix_pep_479 12 | def _iterescape(iterable): 13 | chars = iter(iterable) 14 | for char in chars: 15 | if char == '\\': 16 | char = char + next(chars) 17 | yield char 18 | 19 | 20 | def _iterlinecount(iterable): 21 | line = 1 22 | chars = iter(iterable) 23 | for char in chars: 24 | if char.endswith('\n'): 25 | line += 1 26 | yield (char, line) 27 | 28 | 29 | @fix_pep_479 30 | def _lex_file_object(file_obj): 31 | """ 32 | Generates token tuples from an nginx config file object 33 | 34 | Yields 3-tuples like (token, lineno, quoted) 35 | """ 36 | token = '' # the token buffer 37 | token_line = 0 # the line the token starts on 38 | next_token_is_directive = True 39 | 40 | it = itertools.chain.from_iterable(file_obj) 41 | it = _iterescape(it) # treat escaped characters differently 42 | it = _iterlinecount(it) # count the number of newline characters 43 | 44 | for char, line in it: 45 | # handle whitespace 46 | if char.isspace(): 47 | # if token complete yield it and reset token buffer 48 | if token: 49 | yield (token, token_line, False) 50 | if next_token_is_directive and token in EXTERNAL_LEXERS: 51 | for custom_lexer_token in EXTERNAL_LEXERS[token](it, token): 52 | yield custom_lexer_token 53 | next_token_is_directive = True 54 | else: 55 | next_token_is_directive = False 56 | token = '' 57 | 58 | # disregard until char isn't a whitespace character 59 | while char.isspace(): 60 | char, line = next(it) 61 | 62 | # if starting comment 63 | if not token and char == '#': 64 | while not char.endswith('\n'): 65 | token = token + char 66 | char, _ = next(it) 67 | yield (token, line, False) 68 | token = '' 69 | continue 70 | 71 | if not token: 72 | token_line = line 73 | 74 | # handle parameter expansion syntax (ex: "${var[@]}") 75 | if token and token[-1] == '$' and char == '{': 76 | next_token_is_directive = False 77 | while token[-1] != '}' and not char.isspace(): 78 | token += char 79 | char, line = next(it) 80 | 81 | # if a quote is found, add the whole string to the token buffer 82 | if char in ('"', "'"): 83 | # if a quote is inside a token, treat it like any other char 84 | if token: 85 | token += char 86 | continue 87 | 88 | quote = char 89 | char, line = next(it) 90 | while char != quote: 91 | token += quote if char == '\\' + quote else char 92 | char, line = next(it) 93 | 94 | yield (token, token_line, True) # True because this is in quotes 95 | 96 | # handle quoted external directives 97 | if next_token_is_directive and token in EXTERNAL_LEXERS: 98 | for custom_lexer_token in EXTERNAL_LEXERS[token](it, token): 99 | yield custom_lexer_token 100 | next_token_is_directive = True 101 | else: 102 | next_token_is_directive = False 103 | 104 | token = '' 105 | continue 106 | 107 | # handle special characters that are treated like full tokens 108 | if char in ('{', '}', ';'): 109 | # if token complete yield it and reset token buffer 110 | if token: 111 | yield (token, token_line, False) 112 | token = '' 113 | 114 | # this character is a full token so yield it now 115 | yield (char, line, False) 116 | next_token_is_directive = True 117 | continue 118 | 119 | # append char to the token buffer 120 | token += char 121 | 122 | 123 | def _balance_braces(tokens, filename=None): 124 | """Raises syntax errors if braces aren't balanced""" 125 | depth = 0 126 | 127 | for token, line, quoted in tokens: 128 | if token == '}' and not quoted: 129 | depth -= 1 130 | elif token == '{' and not quoted: 131 | depth += 1 132 | 133 | # raise error if we ever have more right braces than left 134 | if depth < 0: 135 | reason = 'unexpected "}"' 136 | raise NgxParserSyntaxError(reason, filename, line) 137 | else: 138 | yield (token, line, quoted) 139 | 140 | # raise error if we have less right braces than left at EOF 141 | if depth > 0: 142 | reason = 'unexpected end of file, expecting "}"' 143 | raise NgxParserSyntaxError(reason, filename, line) 144 | 145 | 146 | def lex(filename): 147 | """Generates tokens from an nginx config file""" 148 | with io.open(filename, mode='r', encoding='utf-8', errors='replace') as f: 149 | it = _lex_file_object(f) 150 | it = _balance_braces(it, filename) 151 | for token, line, quoted in it: 152 | yield (token, line, quoted) 153 | 154 | 155 | def register_external_lexer(directives, lexer): 156 | for directive in directives: 157 | EXTERNAL_LEXERS[directive] = lexer 158 | -------------------------------------------------------------------------------- /crossplane/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import glob 3 | import os 4 | 5 | from .lexer import lex 6 | from .analyzer import analyze, enter_block_ctx 7 | from .errors import NgxParserDirectiveError 8 | 9 | # map of external / third-party directives to a parse function 10 | EXTERNAL_PARSERS = {} 11 | 12 | 13 | # TODO: raise special errors for invalid "if" args 14 | def _prepare_if_args(stmt): 15 | """Removes parentheses from an "if" directive's arguments""" 16 | args = stmt['args'] 17 | if args and args[0].startswith('(') and args[-1].endswith(')'): 18 | args[0] = args[0][1:].lstrip() 19 | args[-1] = args[-1][:-1].rstrip() 20 | start = int(not args[0]) 21 | end = len(args) - int(not args[-1]) 22 | args[:] = args[start:end] 23 | 24 | 25 | def parse(filename, onerror=None, catch_errors=True, ignore=(), single=False, 26 | comments=False, strict=False, combine=False, check_ctx=True, 27 | check_args=True): 28 | """ 29 | Parses an nginx config file and returns a nested dict payload 30 | 31 | :param filename: string contianing the name of the config file to parse 32 | :param onerror: function that determines what's saved in "callback" 33 | :param catch_errors: bool; if False, parse stops after first error 34 | :param ignore: list or tuple of directives to exclude from the payload 35 | :param combine: bool; if True, use includes to create a single config obj 36 | :param single: bool; if True, including from other files doesn't happen 37 | :param comments: bool; if True, including comments to json payload 38 | :param strict: bool; if True, unrecognized directives raise errors 39 | :param check_ctx: bool; if True, runs context analysis on directives 40 | :param check_args: bool; if True, runs arg count analysis on directives 41 | :returns: a payload that describes the parsed nginx config 42 | """ 43 | config_dir = os.path.dirname(filename) 44 | 45 | payload = { 46 | 'status': 'ok', 47 | 'errors': [], 48 | 'config': [], 49 | } 50 | 51 | # start with the main nginx config file/context 52 | includes = [(filename, ())] # stores (filename, config context) tuples 53 | included = {filename: 0} # stores {filename: array index} map 54 | 55 | def _handle_error(parsing, e): 56 | """Adds representaions of an error to the payload""" 57 | file = parsing['file'] 58 | error = str(e) 59 | line = getattr(e, 'lineno', None) 60 | 61 | parsing_error = {'error': error, 'line': line} 62 | payload_error = {'file': file, 'error': error, 'line': line} 63 | if onerror is not None: 64 | payload_error['callback'] = onerror(e) 65 | 66 | parsing['status'] = 'failed' 67 | parsing['errors'].append(parsing_error) 68 | 69 | payload['status'] = 'failed' 70 | payload['errors'].append(payload_error) 71 | 72 | def _parse(parsing, tokens, ctx=(), consume=False): 73 | """Recursively parses nginx config contexts""" 74 | fname = parsing['file'] 75 | parsed = [] 76 | 77 | # parse recursively by pulling from a flat stream of tokens 78 | for token, lineno, quoted in tokens: 79 | comments_in_args = [] 80 | 81 | # we are parsing a block, so break if it's closing 82 | if token == '}' and not quoted: 83 | break 84 | 85 | # if we are consuming, then just continue until end of context 86 | if consume: 87 | # if we find a block inside this context, consume it too 88 | if token == '{' and not quoted: 89 | _parse(parsing, tokens, consume=True) 90 | continue 91 | 92 | # the first token should always(?) be an nginx directive 93 | directive = token 94 | 95 | if combine: 96 | stmt = { 97 | 'file': fname, 98 | 'directive': directive, 99 | 'line': lineno, 100 | 'args': [] 101 | } 102 | else: 103 | stmt = { 104 | 'directive': directive, 105 | 'line': lineno, 106 | 'args': [] 107 | } 108 | 109 | # if token is comment 110 | if directive.startswith('#') and not quoted: 111 | if comments: 112 | stmt['directive'] = '#' 113 | stmt['comment'] = token[1:] 114 | parsed.append(stmt) 115 | continue 116 | 117 | # TODO: add external parser checking and handling 118 | 119 | # parse arguments by reading tokens 120 | args = stmt['args'] 121 | token, __, quoted = next(tokens) # disregard line numbers of args 122 | while token not in ('{', ';', '}') or quoted: 123 | if token.startswith('#') and not quoted: 124 | comments_in_args.append(token[1:]) 125 | else: 126 | stmt['args'].append(token) 127 | 128 | token, __, quoted = next(tokens) 129 | 130 | # consume the directive if it is ignored and move on 131 | if stmt['directive'] in ignore: 132 | # if this directive was a block consume it too 133 | if token == '{' and not quoted: 134 | _parse(parsing, tokens, consume=True) 135 | continue 136 | 137 | # prepare arguments 138 | if stmt['directive'] == 'if': 139 | _prepare_if_args(stmt) 140 | 141 | try: 142 | # raise errors if this statement is invalid 143 | analyze( 144 | fname=fname, stmt=stmt, term=token, ctx=ctx, strict=strict, 145 | check_ctx=check_ctx, check_args=check_args 146 | ) 147 | except NgxParserDirectiveError as e: 148 | if catch_errors: 149 | _handle_error(parsing, e) 150 | 151 | # if it was a block but shouldn't have been then consume 152 | if e.strerror.endswith(' is not terminated by ";"'): 153 | if token != '}' and not quoted: 154 | _parse(parsing, tokens, consume=True) 155 | else: 156 | break 157 | 158 | # keep on parsin' 159 | continue 160 | else: 161 | raise e 162 | 163 | # add "includes" to the payload if this is an include statement 164 | if not single and stmt['directive'] == 'include': 165 | pattern = args[0] 166 | if not os.path.isabs(args[0]): 167 | pattern = os.path.join(config_dir, args[0]) 168 | 169 | stmt['includes'] = [] 170 | 171 | # get names of all included files 172 | if glob.has_magic(pattern): 173 | fnames = glob.glob(pattern) 174 | fnames.sort() 175 | else: 176 | try: 177 | # if the file pattern was explicit, nginx will check 178 | # that the included file can be opened and read 179 | open(str(pattern)).close() 180 | fnames = [pattern] 181 | except Exception as e: 182 | fnames = [] 183 | e.lineno = stmt['line'] 184 | if catch_errors: 185 | _handle_error(parsing, e) 186 | else: 187 | raise e 188 | 189 | for fname in fnames: 190 | # the included set keeps files from being parsed twice 191 | # TODO: handle files included from multiple contexts 192 | if fname not in included: 193 | included[fname] = len(includes) 194 | includes.append((fname, ctx)) 195 | index = included[fname] 196 | stmt['includes'].append(index) 197 | 198 | # if this statement terminated with '{' then it is a block 199 | if token == '{' and not quoted: 200 | inner = enter_block_ctx(stmt, ctx) # get context for block 201 | stmt['block'] = _parse(parsing, tokens, ctx=inner) 202 | 203 | parsed.append(stmt) 204 | 205 | # add all comments found inside args after stmt is added 206 | for comment in comments_in_args: 207 | comment_stmt = { 208 | 'directive': '#', 209 | 'line': stmt['line'], 210 | 'args': [], 211 | 'comment': comment 212 | } 213 | parsed.append(comment_stmt) 214 | 215 | return parsed 216 | 217 | # the includes list grows as "include" directives are found in _parse 218 | for fname, ctx in includes: 219 | tokens = lex(fname) 220 | parsing = { 221 | 'file': fname, 222 | 'status': 'ok', 223 | 'errors': [], 224 | 'parsed': [] 225 | } 226 | try: 227 | parsing['parsed'] = _parse(parsing, tokens, ctx=ctx) 228 | except Exception as e: 229 | _handle_error(parsing, e) 230 | 231 | payload['config'].append(parsing) 232 | 233 | if combine: 234 | return _combine_parsed_configs(payload) 235 | else: 236 | return payload 237 | 238 | 239 | def _combine_parsed_configs(old_payload): 240 | """ 241 | Combines config files into one by using include directives. 242 | 243 | :param old_payload: payload that's normally returned by parse() 244 | :return: the new combined payload 245 | """ 246 | old_configs = old_payload['config'] 247 | 248 | def _perform_includes(block): 249 | for stmt in block: 250 | if 'block' in stmt: 251 | stmt['block'] = list(_perform_includes(stmt['block'])) 252 | if 'includes' in stmt: 253 | for index in stmt['includes']: 254 | config = old_configs[index]['parsed'] 255 | for stmt in _perform_includes(config): 256 | yield stmt 257 | else: 258 | yield stmt # do not yield include stmt itself 259 | 260 | combined_config = { 261 | 'file': old_configs[0]['file'], 262 | 'status': 'ok', 263 | 'errors': [], 264 | 'parsed': [] 265 | } 266 | 267 | for config in old_configs: 268 | combined_config['errors'] += config.get('errors', []) 269 | if config.get('status', 'ok') == 'failed': 270 | combined_config['status'] = 'failed' 271 | 272 | first_config = old_configs[0]['parsed'] 273 | combined_config['parsed'] += _perform_includes(first_config) 274 | 275 | combined_payload = { 276 | 'status': old_payload.get('status', 'ok'), 277 | 'errors': old_payload.get('errors', []), 278 | 'config': [combined_config] 279 | } 280 | return combined_payload 281 | 282 | 283 | def register_external_parser(parser, directives): 284 | """ 285 | :param parser: parser function 286 | :param directives: list of directive strings 287 | :return: 288 | """ 289 | for directive in directives: 290 | EXTERNAL_PARSERS[directive] = parser 291 | -------------------------------------------------------------------------------- /ext/crossplane-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nginxinc/crossplane/3e616a890e9ad53c236bc934de0e0ed3ad290ee4/ext/crossplane-logo.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | 5 | import io 6 | import os 7 | import shutil 8 | import sys 9 | 10 | from setuptools import find_packages, setup, Command 11 | 12 | from crossplane import ( 13 | __title__, __summary__, __url__, __version__, __author__, __email__, 14 | __license__ 15 | ) 16 | 17 | here = os.path.abspath(os.path.dirname(__file__)) 18 | 19 | 20 | def get_readme(): 21 | path = os.path.join(here, 'README.md') 22 | with io.open(path, encoding='utf-8') as f: 23 | return '\n' + f.read() 24 | 25 | 26 | class UploadCommand(Command): 27 | """Support setup.py upload.""" 28 | 29 | description = 'Build and publish the package.' 30 | user_options = [] 31 | 32 | @staticmethod 33 | def status(s): 34 | """Prints things in bold.""" 35 | print('\033[1m{0}\033[0m'.format(s)) 36 | 37 | def initialize_options(self): 38 | pass 39 | 40 | def finalize_options(self): 41 | pass 42 | 43 | def run(self): 44 | try: 45 | self.status('Removing previous builds…') 46 | shutil.rmtree(os.path.join(here, 'dist')) 47 | except OSError: 48 | pass 49 | 50 | self.status('Building Source and Wheel (universal) distribution…') 51 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 52 | 53 | self.status('Uploading the package to PyPI via Twine…') 54 | os.system('twine upload dist/*') 55 | 56 | sys.exit() 57 | 58 | 59 | setup( 60 | name=__title__, 61 | version=__version__, 62 | description=__summary__, 63 | long_description=get_readme(), 64 | long_description_content_type='text/markdown', 65 | author=__author__, 66 | author_email=__email__, 67 | url=__url__, 68 | packages=find_packages(exclude=['tests','tests.*']), 69 | license=__license__, 70 | classifiers=[ 71 | 'Development Status :: 3 - Alpha', 72 | 'Intended Audience :: Developers', 73 | 'Intended Audience :: Information Technology', 74 | 'License :: OSI Approved :: Apache Software License', 75 | 'Programming Language :: Python', 76 | 'Programming Language :: Python :: 2', 77 | 'Programming Language :: Python :: 2.7', 78 | 'Programming Language :: Python :: 3', 79 | 'Programming Language :: Python :: 3.6', 80 | 'Programming Language :: Python :: 3.7', 81 | 'Programming Language :: Python :: 3.8', 82 | 'Programming Language :: Python :: 3.9', 83 | 'Programming Language :: Python :: 3.10', 84 | 'Programming Language :: Python :: Implementation :: CPython', 85 | 'Programming Language :: Python :: Implementation :: PyPy' 86 | ], 87 | entry_points={ 88 | 'console_scripts': [ 89 | 'crossplane = crossplane.__main__:main' 90 | ], 91 | }, 92 | cmdclass={'upload': UploadCommand} 93 | ) 94 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from crossplane.compat import basestring 5 | from crossplane.parser import parse 6 | from crossplane.builder import build, _enquote 7 | 8 | here = os.path.dirname(__file__) 9 | 10 | 11 | def assert_equal_payloads(a, b, ignore_keys=()): 12 | assert type(a) == type(b) 13 | if isinstance(a, list): 14 | assert len(a) == len(b) 15 | for args in zip(a, b): 16 | assert_equal_payloads(*args, ignore_keys=ignore_keys) 17 | elif isinstance(a, dict): 18 | keys = set(a.keys()) | set(b.keys()) 19 | keys.difference_update(ignore_keys) 20 | for key in keys: 21 | assert_equal_payloads(a[key], b[key], ignore_keys=ignore_keys) 22 | elif isinstance(a, basestring): 23 | assert _enquote(a) == _enquote(b) 24 | else: 25 | assert a == b 26 | 27 | 28 | def compare_parsed_and_built(conf_dirname, conf_basename, tmpdir, **kwargs): 29 | original_dirname = os.path.join(here, 'configs', conf_dirname) 30 | original_path = os.path.join(original_dirname, conf_basename) 31 | original_payload = parse(original_path, **kwargs) 32 | original_parsed = original_payload['config'][0]['parsed'] 33 | 34 | build1_config = build(original_parsed) 35 | build1_file = tmpdir.join('build1.conf') 36 | build1_file.write_text(build1_config, encoding='utf-8') 37 | build1_payload = parse(build1_file.strpath, **kwargs) 38 | build1_parsed = build1_payload['config'][0]['parsed'] 39 | 40 | assert_equal_payloads(original_parsed, build1_parsed, ignore_keys=['line']) 41 | 42 | build2_config = build(build1_parsed) 43 | build2_file = tmpdir.join('build2.conf') 44 | build2_file.write_text(build2_config, encoding='utf-8') 45 | build2_payload = parse(build2_file.strpath, **kwargs) 46 | build2_parsed = build2_payload['config'][0]['parsed'] 47 | 48 | assert build1_config == build2_config 49 | assert_equal_payloads(build1_parsed, build2_parsed, ignore_keys=[]) 50 | -------------------------------------------------------------------------------- /tests/configs/bad-args/nginx.conf: -------------------------------------------------------------------------------- 1 | user; 2 | events {} 3 | http {} 4 | -------------------------------------------------------------------------------- /tests/configs/comments-between-args/nginx.conf: -------------------------------------------------------------------------------- 1 | http { #comment 1 2 | log_format #comment 2 3 | \#arg\ 1 #comment 3 4 | '#arg 2' #comment 4 5 | #comment 5 6 | ; 7 | } 8 | -------------------------------------------------------------------------------- /tests/configs/directive-with-space/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | } 3 | http { 4 | map $http_user_agent $mobile { 5 | default 0; 6 | '~Opera Mini' 1; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/configs/empty-value-map/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | } 3 | http { 4 | map string $variable { 5 | '' $arg; 6 | *.example.com ''; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/http.conf: -------------------------------------------------------------------------------- 1 | http { 2 | include servers/*.conf; 3 | } 4 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/locations/location1.conf: -------------------------------------------------------------------------------- 1 | location /foo { 2 | return 200 'foo'; 3 | } 4 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/locations/location2.conf: -------------------------------------------------------------------------------- 1 | location /bar { 2 | return 200 'bar'; 3 | } 4 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | include http.conf; 3 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/servers/locations/not-included.conf: -------------------------------------------------------------------------------- 1 | # this file should never be included 2 | asdfasdfasdfasdfasdfasdfasdfasdfasdf 3 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/servers/server1.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | include locations/*.conf; 4 | } 5 | -------------------------------------------------------------------------------- /tests/configs/includes-globbed/servers/server2.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8081; 3 | include locations/*.conf; 4 | } 5 | -------------------------------------------------------------------------------- /tests/configs/includes-regular/conf.d/bar.conf: -------------------------------------------------------------------------------- 1 | # this file should never be included 2 | asdfasdfasdfasdfasdfasdfasdfasdfasdf 3 | -------------------------------------------------------------------------------- /tests/configs/includes-regular/conf.d/foo.conf: -------------------------------------------------------------------------------- 1 | # this file should never be included 2 | asdfasdfasdfasdfasdfasdfasdfasdfasdf 3 | -------------------------------------------------------------------------------- /tests/configs/includes-regular/conf.d/server.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 127.0.0.1:8080; 3 | server_name default_server; 4 | include foo.conf; 5 | include bar.conf; 6 | } 7 | -------------------------------------------------------------------------------- /tests/configs/includes-regular/foo.conf: -------------------------------------------------------------------------------- 1 | location /foo { 2 | return 200 'foo'; 3 | } 4 | -------------------------------------------------------------------------------- /tests/configs/includes-regular/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | http { 3 | include conf.d/server.conf; 4 | } 5 | -------------------------------------------------------------------------------- /tests/configs/lua-block-larger/nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | content_by_lua_block { 3 | ngx.req.read_body() -- explicitly read the req body 4 | local data = ngx.req.get_body_data() 5 | if data then 6 | ngx.say("body data:") 7 | ngx.print(data) 8 | return 9 | end 10 | 11 | -- body may get buffered in a temp file: 12 | local file = ngx.req.get_body_file() 13 | if file then 14 | ngx.say("body is in file ", file) 15 | else 16 | ngx.say("no body found") 17 | end 18 | } 19 | access_by_lua_block { 20 | -- check the client IP address is in our black list 21 | if ngx.var.remote_addr == "132.5.72.3" then 22 | ngx.exit(ngx.HTTP_FORBIDDEN) 23 | end 24 | 25 | -- check if the URI contains bad words 26 | if ngx.var.uri and 27 | string.match(ngx.var.request_body, "evil") 28 | then 29 | return ngx.redirect("/terms_of_use.html") 30 | end 31 | 32 | -- tests passed 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/configs/lua-block-simple/nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | init_by_lua_block { 3 | print("Lua block code with curly brace str {") 4 | } 5 | init_worker_by_lua_block { 6 | print("Work that every worker") 7 | } 8 | body_filter_by_lua_block { 9 | local data, eof = ngx.arg[1], ngx.arg[2] 10 | } 11 | header_filter_by_lua_block { 12 | ngx.header["content-length"] = nil 13 | } 14 | server { 15 | listen 127.0.0.1:8080; 16 | location / { 17 | content_by_lua_block { 18 | ngx.say("I need no extra escaping here, for example: \r\nblah") 19 | } 20 | return 200 "foo bar baz"; 21 | } 22 | ssl_certificate_by_lua_block { 23 | print("About to initiate a new SSL handshake!") 24 | } 25 | location /a { 26 | client_max_body_size 100k; 27 | client_body_buffer_size 100k; 28 | } 29 | } 30 | 31 | upstream foo { 32 | server 127.0.0.1; 33 | balancer_by_lua_block { 34 | -- use Lua that'll do something interesting here with external bracket for testing { 35 | } 36 | log_by_lua_block { 37 | print("I need no extra escaping here, for example: \r\nblah") 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/configs/lua-block-tricky/nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | server { 3 | listen 127.0.0.1:8080; 4 | server_name content_by_lua_block ; # make sure this doesn't trip up lexers 5 | set_by_lua_block $res { -- irregular lua block directive 6 | local a = 32 7 | local b = 56 8 | 9 | ngx.var.diff = a - b; -- write to $diff directly 10 | return a + b; -- return the $sum value normally 11 | } 12 | "rewrite_by_lua_block" { -- have valid braces in Lua code and quotes around directive 13 | do_something("hello, world!\nhiya\n") 14 | a = { 1, 2, 3 } 15 | btn = iup.button({title="ok"}) 16 | } 17 | } 18 | upstream content_by_lua_block { 19 | # stuff 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/configs/messy/nginx.conf: -------------------------------------------------------------------------------- 1 | user nobody; 2 | # hello\n\\n\\\n worlddd \#\\#\\\# dfsf\n \\n \\\n \ 3 | "events" { "worker_connections" "2048"; } 4 | 5 | "http" {#forteen 6 | # this is a comment 7 | "access_log" off;default_type "text/plain"; error_log "off"; 8 | server { 9 | "listen" "8083" ; 10 | "return" 200 "Ser\" ' ' ver\\ \ $server_addr:\$server_port\n\nTime: $time_local\n\n"; 11 | } 12 | "server" {"listen" 8080; 13 | 'root' /usr/share/nginx/html; 14 | location ~ "/hello/world;"{"return" 301 /status.html;} 15 | location /foo{}location /bar{} 16 | location /\{\;\}\ #\ ab {}# hello 17 | if ($request_method = P\{O\)\###\;ST ){} 18 | location "/status.html" { 19 | try_files /abc/${uri} /abc/${uri}.html =404 ; 20 | } 21 | "location" "/sta; 22 | tus" {"return" 302 /status.html;} 23 | "location" /upstream_conf { "return" 200 /status.html; }} 24 | server 25 | {}} 26 | -------------------------------------------------------------------------------- /tests/configs/missing-semicolon/broken-above.conf: -------------------------------------------------------------------------------- 1 | http { 2 | server { 3 | location /is-broken { 4 | proxy_pass http://is.broken.example 5 | } 6 | location /not-broken { 7 | proxy_pass http://not.broken.example; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/configs/missing-semicolon/broken-below.conf: -------------------------------------------------------------------------------- 1 | http { 2 | server { 3 | location /not-broken { 4 | proxy_pass http://not.broken.example; 5 | } 6 | location /is-broken { 7 | proxy_pass http://is.broken.example 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/configs/non-unicode/nginx.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nginxinc/crossplane/3e616a890e9ad53c236bc934de0e0ed3ad290ee4/tests/configs/non-unicode/nginx.conf -------------------------------------------------------------------------------- /tests/configs/quote-behavior/nginx.conf: -------------------------------------------------------------------------------- 1 | "outer-quote" "left"-quote right-"quote" inner"-"quote; 2 | "" ""left-empty right-empty"" inner""empty right-empty-single"; 3 | -------------------------------------------------------------------------------- /tests/configs/quoted-right-brace/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | http { 3 | log_format main escape=json 4 | '{ "@timestamp": "$time_iso8601", ' 5 | '"server_name": "$server_name", ' 6 | '"host": "$host", ' 7 | '"status": "$status", ' 8 | '"request": "$request", ' 9 | '"uri": "$uri", ' 10 | '"args": "$args", ' 11 | '"https": "$https", ' 12 | '"request_method": "$request_method", ' 13 | '"referer": "$http_referer", ' 14 | '"agent": "$http_user_agent"' 15 | '}'; 16 | } 17 | -------------------------------------------------------------------------------- /tests/configs/russian-text/nginx.conf: -------------------------------------------------------------------------------- 1 | env 'русский текст'; 2 | events {} 3 | -------------------------------------------------------------------------------- /tests/configs/simple/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | server { 7 | listen 127.0.0.1:8080; 8 | server_name default_server; 9 | location / { 10 | return 200 "foo bar baz"; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/configs/spelling-mistake/nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | server { 5 | location / { 6 | #directive is misspelled 7 | proxy_passs http://foo.bar; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/configs/with-comments/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | #comment 5 | http { 6 | server { 7 | listen 127.0.0.1:8080; #listen 8 | server_name default_server; 9 | location / { ## this is brace 10 | # location / 11 | return 200 "foo bar baz"; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nginxinc/crossplane/3e616a890e9ad53c236bc934de0e0ed3ad290ee4/tests/ext/__init__.py -------------------------------------------------------------------------------- /tests/ext/test_lua.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import crossplane 5 | from .. import compare_parsed_and_built, here as tests_dir 6 | 7 | 8 | def test_lex_lua_block_simple(): 9 | dirname = os.path.join(tests_dir, 'configs', 'lua-block-simple') 10 | config = os.path.join(dirname, 'nginx.conf') 11 | tokens = crossplane.lex(config) 12 | assert list((token, line) for token, line, quoted in tokens) == [ 13 | ('http', 1), 14 | ('{', 1), 15 | ('init_by_lua_block', 2), 16 | ('\n print("Lua block code with curly brace str {")\n ', 4), 17 | (';', 4), 18 | ('init_worker_by_lua_block', 5), 19 | ('\n print("Work that every worker")\n ', 7), 20 | (';', 7), 21 | ('body_filter_by_lua_block', 8), 22 | ('\n local data, eof = ngx.arg[1], ngx.arg[2]\n ', 10), 23 | (';', 10), 24 | ('header_filter_by_lua_block', 11), 25 | ('\n ngx.header["content-length"] = nil\n ', 13), 26 | (';', 13), 27 | ('server', 14), 28 | ('{', 14), 29 | ('listen', 15), 30 | ('127.0.0.1:8080', 15), 31 | (';', 15), 32 | ('location', 16), 33 | ('/', 16), 34 | ('{', 16), 35 | ('content_by_lua_block', 17), 36 | ('\n ngx.say("I need no extra escaping here, for example: \\r\\nblah")\n ', 19), 37 | (';', 19), 38 | ('return', 20), 39 | ('200', 20), 40 | ('foo bar baz', 20), 41 | (';', 20), 42 | ('}', 21), 43 | ('ssl_certificate_by_lua_block', 22), 44 | ('\n print("About to initiate a new SSL handshake!")\n ', 24), 45 | (';', 24), 46 | ('location', 25), 47 | ('/a', 25), 48 | ('{', 25), 49 | ('client_max_body_size', 26), 50 | ('100k', 26), 51 | (';', 26), 52 | ('client_body_buffer_size', 27), 53 | ('100k', 27), 54 | (';', 27), 55 | ('}', 28), 56 | ('}', 29), 57 | ('upstream', 31), 58 | ('foo', 31), 59 | ('{', 31), 60 | ('server', 32), 61 | ('127.0.0.1', 32), 62 | (';', 32), 63 | ('balancer_by_lua_block', 33), 64 | ('\n -- use Lua that\'ll do something interesting here with external bracket for testing {\n ', 35), 65 | (';', 35), 66 | ('log_by_lua_block', 36), 67 | ('\n print("I need no extra escaping here, for example: \\r\\nblah")\n ', 38), 68 | (';', 38), 69 | ('}', 39), 70 | ('}', 40) 71 | ] 72 | 73 | 74 | def test_lex_lua_block_larger(): 75 | dirname = os.path.join(tests_dir, 'configs', 'lua-block-larger') 76 | config = os.path.join(dirname, 'nginx.conf') 77 | tokens = crossplane.lex(config) 78 | assert list((token, line) for token, line, quoted in tokens) == [ 79 | ('http', 1), 80 | ('{', 1), 81 | ('content_by_lua_block', 2), 82 | ( 83 | '\n ngx.req.read_body() -- explicitly read the req body' 84 | '\n local data = ngx.req.get_body_data()' 85 | '\n if data then' 86 | '\n ngx.say("body data:")' 87 | '\n ngx.print(data)' 88 | '\n return' 89 | '\n end' 90 | '\n' 91 | '\n -- body may get buffered in a temp file:' 92 | '\n local file = ngx.req.get_body_file()' 93 | '\n if file then' 94 | '\n ngx.say("body is in file ", file)' 95 | '\n else' 96 | '\n ngx.say("no body found")' 97 | '\n end' 98 | '\n ', 99 | 18 100 | ), 101 | (';', 18), 102 | ('access_by_lua_block', 19), 103 | ( 104 | '\n -- check the client IP address is in our black list' 105 | '\n if ngx.var.remote_addr == "132.5.72.3" then' 106 | '\n ngx.exit(ngx.HTTP_FORBIDDEN)' 107 | '\n end' 108 | '\n' 109 | '\n -- check if the URI contains bad words' 110 | '\n if ngx.var.uri and' 111 | '\n string.match(ngx.var.request_body, "evil")' 112 | '\n then' 113 | '\n return ngx.redirect("/terms_of_use.html")' 114 | '\n end' 115 | '\n' 116 | '\n -- tests passed' 117 | '\n ', 118 | 33 119 | ), 120 | (';', 33), 121 | ('}', 34) 122 | ] 123 | 124 | 125 | def test_lex_lua_block_tricky(): 126 | dirname = os.path.join(tests_dir, 'configs', 'lua-block-tricky') 127 | config = os.path.join(dirname, 'nginx.conf') 128 | tokens = crossplane.lex(config) 129 | assert list((token, line) for token, line, quoted in tokens) == [ 130 | ('http', 1), 131 | ('{', 1), 132 | ('server', 2), 133 | ('{', 2), 134 | ('listen', 3), 135 | ('127.0.0.1:8080', 3), 136 | (';', 3), 137 | ('server_name', 4), 138 | ('content_by_lua_block', 4), 139 | (';', 4), 140 | ("# make sure this doesn't trip up lexers", 4), 141 | ('set_by_lua_block', 5), 142 | ('$res', 5), 143 | ( 144 | ' -- irregular lua block directive' 145 | '\n local a = 32' 146 | '\n local b = 56' 147 | '\n' 148 | '\n ngx.var.diff = a - b; -- write to $diff directly' 149 | '\n return a + b; -- return the $sum value normally' 150 | '\n ', 151 | 11 152 | ), 153 | (';', 11), 154 | ('rewrite_by_lua_block', 12), 155 | ( 156 | ' -- have valid braces in Lua code and quotes around directive' 157 | '\n do_something("hello, world!\\nhiya\\n")' 158 | '\n a = { 1, 2, 3 }' 159 | '\n btn = iup.button({title="ok"})' 160 | '\n ', 161 | 16 162 | ), 163 | (';', 16), 164 | ('}', 17), 165 | ('upstream', 18), 166 | ('content_by_lua_block', 18), 167 | ('{', 18), 168 | ('# stuff', 19), 169 | ('}', 20), 170 | ('}', 21) 171 | ] 172 | 173 | 174 | def test_parse_lua_block_simple(): 175 | dirname = os.path.join(tests_dir, 'configs', 'lua-block-simple') 176 | config = os.path.join(dirname, 'nginx.conf') 177 | payload = crossplane.parse(config) 178 | assert payload == { 179 | 'status': 'ok', 180 | 'errors': [], 181 | 'config': [ 182 | { 183 | 'file': os.path.join(dirname, 'nginx.conf'), 184 | 'status': 'ok', 185 | 'errors': [], 186 | 'parsed': [ 187 | { 188 | 'line': 1, 189 | 'args': [], 190 | 'block': [ 191 | { 192 | 'line': 2, 193 | 'args': ['\n print("Lua block code with curly brace str {")\n '], 194 | 'directive': 'init_by_lua_block' 195 | }, 196 | { 197 | 'line': 5, 198 | 'args': ['\n print("Work that every worker")\n '], 199 | 'directive': 'init_worker_by_lua_block' 200 | }, 201 | { 202 | 'line': 8, 203 | 'args': ['\n local data, eof = ngx.arg[1], ngx.arg[2]\n '], 204 | 'directive': 'body_filter_by_lua_block' 205 | }, 206 | { 207 | 'line': 11, 208 | 'args': ['\n ngx.header["content-length"] = nil\n '], 209 | 'directive': 'header_filter_by_lua_block' 210 | }, 211 | { 212 | 'line': 14, 213 | 'args': [], 214 | 'block': [ 215 | { 216 | 'line': 15, 217 | 'args': ['127.0.0.1:8080'], 218 | 'directive': 'listen' 219 | }, 220 | { 221 | 'line': 16, 222 | 'args': ['/'], 223 | 'block': [ 224 | { 225 | 'line': 17, 226 | 'args': [ 227 | '\n ngx.say("I need no extra escaping here, for example: \\r\\nblah")' 228 | '\n ' 229 | ], 230 | 'directive': 'content_by_lua_block' 231 | }, 232 | { 233 | 'line': 20, 234 | 'args': ['200', 'foo bar baz'], 235 | 'directive': 'return' 236 | } 237 | ], 238 | 'directive': 'location' 239 | }, 240 | { 241 | 'line': 22, 242 | 'args': [ 243 | '\n print("About to initiate a new SSL handshake!")' 244 | '\n ' 245 | ], 246 | 'directive': 'ssl_certificate_by_lua_block' 247 | }, 248 | { 249 | 'line': 25, 250 | 'args': ['/a'], 251 | 'block': [ 252 | { 253 | 'line': 26, 254 | 'args': ['100k'], 255 | 'directive': 'client_max_body_size' 256 | }, 257 | { 258 | 'line': 27, 259 | 'args': ['100k'], 260 | 'directive': 'client_body_buffer_size' 261 | } 262 | ], 263 | 'directive': 'location' 264 | } 265 | ], 266 | 'directive': 'server' 267 | }, 268 | { 269 | 'line': 31, 270 | 'args': ['foo'], 271 | 'block': [ 272 | { 273 | 'line': 32, 274 | 'args': ['127.0.0.1'], 275 | 'directive': 'server' 276 | }, 277 | { 278 | 'line': 33, 279 | 'args': [ 280 | '\n -- use Lua that\'ll do something interesting here with external bracket for testing {' 281 | '\n ' 282 | ], 283 | 'directive': 'balancer_by_lua_block' 284 | }, 285 | { 286 | 'line': 36, 287 | 'args': [ 288 | '\n print("I need no extra escaping here, for example: \\r\\nblah")' 289 | '\n ' 290 | ], 291 | 'directive': 'log_by_lua_block' 292 | } 293 | ], 294 | 'directive': 'upstream' 295 | } 296 | ], 297 | 'directive': 'http' 298 | } 299 | ] 300 | } 301 | ] 302 | } 303 | 304 | 305 | def test_parse_lua_block_tricky(): 306 | dirname = os.path.join(tests_dir, 'configs', 'lua-block-tricky') 307 | config = os.path.join(dirname, 'nginx.conf') 308 | payload = crossplane.parse(config, comments=True) 309 | assert payload == { 310 | 'status': 'ok', 311 | 'errors': [], 312 | 'config': [ 313 | { 314 | 'file': os.path.join(dirname, 'nginx.conf'), 315 | 'status': 'ok', 316 | 'errors': [], 317 | 'parsed': [ 318 | { 319 | 'line': 1, 320 | 'args': [], 321 | 'block': [ 322 | { 323 | 'line': 2, 324 | 'args': [], 325 | 'block': [ 326 | { 327 | 'line': 3, 328 | 'args': ['127.0.0.1:8080'], 329 | 'directive': 'listen' 330 | }, 331 | { 332 | 'line': 4, 333 | 'args': ['content_by_lua_block'], 334 | 'directive': 'server_name' 335 | }, 336 | { 337 | 'comment': u" make sure this doesn't trip up lexers", 338 | 'line': 4, 339 | 'args': [], 340 | 'directive': '#' 341 | }, 342 | { 343 | 'line': 5, 344 | 'args': [ 345 | '$res', 346 | ' -- irregular lua block directive' 347 | '\n local a = 32' 348 | '\n local b = 56' 349 | '\n' 350 | '\n ngx.var.diff = a - b; -- write to $diff directly' 351 | '\n return a + b; -- return the $sum value normally' 352 | '\n ' 353 | ], 354 | 'directive': 'set_by_lua_block' 355 | }, 356 | { 357 | 'line': 12, 358 | 'args': [ 359 | ' -- have valid braces in Lua code and quotes around directive' 360 | '\n do_something("hello, world!\\nhiya\\n")' 361 | '\n a = { 1, 2, 3 }' 362 | '\n btn = iup.button({title="ok"})' 363 | '\n ' 364 | ], 365 | 'directive': 'rewrite_by_lua_block' 366 | } 367 | ], 368 | 'directive': 'server' 369 | }, 370 | { 371 | 'line': 18, 372 | 'args': ['content_by_lua_block'], 373 | 'block': [ 374 | { 375 | 'comment': ' stuff', 376 | 'line': 19, 377 | 'args': [], 378 | 'directive': '#' 379 | } 380 | ], 381 | 'directive': 'upstream' 382 | } 383 | ], 384 | 'directive': 'http' 385 | } 386 | ] 387 | } 388 | ] 389 | } 390 | 391 | 392 | def test_build_lua_blocks_simple(tmpdir): 393 | compare_parsed_and_built('lua-block-simple', 'nginx.conf', tmpdir) 394 | 395 | 396 | def test_build_lua_blocks_larger(tmpdir): 397 | compare_parsed_and_built('lua-block-larger', 'nginx.conf', tmpdir) 398 | 399 | 400 | def test_build_lua_blocks_tricky(tmpdir): 401 | compare_parsed_and_built('lua-block-tricky', 'nginx.conf', tmpdir) 402 | -------------------------------------------------------------------------------- /tests/test_analyze.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import crossplane 3 | 4 | 5 | def test_state_directive(): 6 | fname = '/path/to/nginx.conf' 7 | 8 | stmt = { 9 | 'directive': 'state', 10 | 'args': ['/path/to/state/file.conf'], 11 | 'line': 5 # this is arbitrary 12 | } 13 | 14 | # the state directive should not cause errors if it's in these contexts 15 | good_contexts = set([ 16 | ('http', 'upstream'), 17 | ('stream', 'upstream'), 18 | ('some_third_party_context',) 19 | ]) 20 | 21 | for ctx in good_contexts: 22 | crossplane.analyzer.analyze(fname, stmt, term=';', ctx=ctx) 23 | 24 | # the state directive should not be in any of these contexts 25 | bad_contexts = set(crossplane.analyzer.CONTEXTS) - good_contexts 26 | 27 | for ctx in bad_contexts: 28 | try: 29 | crossplane.analyzer.analyze(fname, stmt, term=';', ctx=ctx) 30 | raise Exception("bad context for 'state' passed: " + repr(ctx)) 31 | except crossplane.errors.NgxParserDirectiveContextError: 32 | continue 33 | 34 | 35 | def test_flag_directive_args(): 36 | fname = '/path/to/nginx.conf' 37 | ctx = ('events',) 38 | 39 | # an NGINX_CONF_FLAG directive 40 | stmt = { 41 | 'directive': 'accept_mutex', 42 | 'line': 2 # this is arbitrary 43 | } 44 | 45 | good_args = [['on'], ['off'], ['On'], ['Off'], ['ON'], ['OFF']] 46 | 47 | for args in good_args: 48 | stmt['args'] = args 49 | crossplane.analyzer.analyze(fname, stmt, term=';', ctx=ctx) 50 | 51 | bad_args = [['1'], ['0'], ['true'], ['okay'], ['']] 52 | 53 | for args in bad_args: 54 | stmt['args'] = args 55 | try: 56 | crossplane.analyzer.analyze(fname, stmt, term=';', ctx=ctx) 57 | raise Exception('bad args for flag directive: ' + repr(args)) 58 | except crossplane.errors.NgxParserDirectiveArgumentsError as e: 59 | assert e.strerror.endswith('it must be "on" or "off"') 60 | -------------------------------------------------------------------------------- /tests/test_build.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import crossplane 5 | from . import compare_parsed_and_built 6 | 7 | 8 | def test_build_nested_and_multiple_args(): 9 | payload = [ 10 | { 11 | "directive": "events", 12 | "args": [], 13 | "block": [ 14 | { 15 | "directive": "worker_connections", 16 | "args": ["1024"] 17 | } 18 | ] 19 | }, 20 | { 21 | "directive": "http", 22 | "args": [], 23 | "block": [ 24 | { 25 | "directive": "server", 26 | "args": [], 27 | "block": [ 28 | { 29 | "directive": "listen", 30 | "args": ["127.0.0.1:8080"] 31 | }, 32 | { 33 | "directive": "server_name", 34 | "args": ["default_server"] 35 | }, 36 | { 37 | "directive": "location", 38 | "args": ["/"], 39 | "block": [ 40 | { 41 | "directive": "return", 42 | "args": ["200", "foo bar baz"] 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | ] 51 | built = crossplane.build(payload, indent=4, tabs=False) 52 | assert built == '\n'.join([ 53 | 'events {', 54 | ' worker_connections 1024;', 55 | '}', 56 | 'http {', 57 | ' server {', 58 | ' listen 127.0.0.1:8080;', 59 | ' server_name default_server;', 60 | ' location / {', 61 | " return 200 'foo bar baz';", 62 | ' }', 63 | ' }', 64 | '}' 65 | ]) 66 | 67 | 68 | def test_build_with_comments(): 69 | payload = [ 70 | { 71 | "directive": "events", 72 | "line": 1, 73 | "args": [], 74 | "block": [ 75 | { 76 | "directive": "worker_connections", 77 | "line": 2, 78 | "args": ["1024"] 79 | } 80 | ] 81 | }, 82 | { 83 | "directive": "#", 84 | "line": 4, 85 | "args": [], 86 | "comment": "comment" 87 | }, 88 | { 89 | "directive": "http", 90 | "line": 5, 91 | "args": [], 92 | "block": [ 93 | { 94 | "directive": "server", 95 | "line": 6, 96 | "args": [], 97 | "block": [ 98 | { 99 | "directive": "listen", 100 | "line": 7, 101 | "args": ["127.0.0.1:8080"] 102 | }, 103 | { 104 | "directive": "#", 105 | "line": 7, 106 | "args": [], 107 | "comment": "listen" 108 | }, 109 | { 110 | "directive": "server_name", 111 | "line": 8, 112 | "args": ["default_server"] 113 | }, 114 | { 115 | "directive": "location", 116 | "line": 9, 117 | "args": ["/"], 118 | "block": [ 119 | { 120 | "directive": "#", 121 | "line": 9, 122 | "args": [], 123 | "comment": "# this is brace" 124 | }, 125 | { 126 | "directive": "#", 127 | "line": 10, 128 | "args": [], 129 | "comment": " location /" 130 | }, 131 | { 132 | "directive": "#", 133 | "line": 11, 134 | "args": [], 135 | "comment": " is here" 136 | }, 137 | { 138 | "directive": "return", 139 | "line": 12, 140 | "args": ["200", "foo bar baz"] 141 | } 142 | ] 143 | } 144 | ] 145 | } 146 | ] 147 | } 148 | ] 149 | built = crossplane.build(payload, indent=4, tabs=False) 150 | assert built == '\n'.join([ 151 | 'events {', 152 | ' worker_connections 1024;', 153 | '}', 154 | '#comment', 155 | 'http {', 156 | ' server {', 157 | ' listen 127.0.0.1:8080; #listen', 158 | ' server_name default_server;', 159 | ' location / { ## this is brace', 160 | ' # location /', 161 | ' # is here', 162 | " return 200 'foo bar baz';", 163 | ' }', 164 | ' }', 165 | '}' 166 | ]) 167 | 168 | 169 | def test_build_starts_with_comments(): 170 | payload = [ 171 | { 172 | "directive": "#", 173 | "line": 1, 174 | "args": [], 175 | "comment": " foo" 176 | }, 177 | { 178 | "directive": "user", 179 | "line": 5, 180 | "args": ["root"] 181 | } 182 | ] 183 | built = crossplane.build(payload, indent=4, tabs=False) 184 | assert built == '# foo\nuser root;' 185 | 186 | 187 | def test_build_with_quoted_unicode(): 188 | payload = [ 189 | { 190 | "directive": "env", 191 | "line": 1, 192 | "args": ["русский текст"], 193 | } 194 | ] 195 | built = crossplane.build(payload, indent=4, tabs=False) 196 | assert built == u"env 'русский текст';" 197 | 198 | 199 | def test_build_multiple_comments_on_one_line(): 200 | payload = [ 201 | { 202 | "directive": "#", 203 | "line": 1, 204 | "args": [], 205 | "comment": "comment1" 206 | }, 207 | { 208 | "directive": "user", 209 | "line": 2, 210 | "args": ["root"] 211 | }, 212 | { 213 | "directive": "#", 214 | "line": 2, 215 | "args": [], 216 | "comment": "comment2" 217 | }, 218 | { 219 | "directive": "#", 220 | "line": 2, 221 | "args": [], 222 | "comment": "comment3" 223 | } 224 | ] 225 | built = crossplane.build(payload, indent=4, tabs=False) 226 | assert built == '#comment1\nuser root; #comment2 #comment3' 227 | 228 | 229 | 230 | def test_build_files_with_missing_status_and_errors(tmpdir): 231 | assert len(tmpdir.listdir()) == 0 232 | payload = { 233 | "config": [ 234 | { 235 | "file": "nginx.conf", 236 | "parsed": [ 237 | { 238 | "directive": "user", 239 | "line": 1, 240 | "args": ["nginx"], 241 | } 242 | ] 243 | } 244 | ] 245 | } 246 | crossplane.builder.build_files(payload, dirname=tmpdir.strpath) 247 | built_files = tmpdir.listdir() 248 | assert len(built_files) == 1 249 | assert built_files[0].strpath == os.path.join(tmpdir.strpath, 'nginx.conf') 250 | assert built_files[0].read_text('utf-8') == 'user nginx;\n' 251 | 252 | 253 | def test_build_files_with_unicode(tmpdir): 254 | assert len(tmpdir.listdir()) == 0 255 | payload = { 256 | "status": "ok", 257 | "errors": [], 258 | "config": [ 259 | { 260 | "file": "nginx.conf", 261 | "status": "ok", 262 | "errors": [], 263 | "parsed": [ 264 | { 265 | "directive": "user", 266 | "line": 1, 267 | "args": [u"測試"], 268 | } 269 | ] 270 | } 271 | ] 272 | } 273 | crossplane.builder.build_files(payload, dirname=tmpdir.strpath) 274 | built_files = tmpdir.listdir() 275 | assert len(built_files) == 1 276 | assert built_files[0].strpath == os.path.join(tmpdir.strpath, 'nginx.conf') 277 | assert built_files[0].read_text('utf-8') == u'user 測試;\n' 278 | 279 | 280 | def test_compare_parsed_and_built_simple(tmpdir): 281 | compare_parsed_and_built('simple', 'nginx.conf', tmpdir) 282 | 283 | 284 | def test_compare_parsed_and_built_messy(tmpdir): 285 | compare_parsed_and_built('messy', 'nginx.conf', tmpdir) 286 | 287 | 288 | def test_compare_parsed_and_built_messy_with_comments(tmpdir): 289 | compare_parsed_and_built('with-comments', 'nginx.conf', tmpdir, comments=True) 290 | 291 | 292 | def test_compare_parsed_and_built_empty_map_values(tmpdir): 293 | compare_parsed_and_built('empty-value-map', 'nginx.conf', tmpdir) 294 | 295 | 296 | def test_compare_parsed_and_built_russian_text(tmpdir): 297 | compare_parsed_and_built('russian-text', 'nginx.conf', tmpdir) 298 | 299 | 300 | def test_compare_parsed_and_built_quoted_right_brace(tmpdir): 301 | compare_parsed_and_built('quoted-right-brace', 'nginx.conf', tmpdir) 302 | 303 | 304 | def test_compare_parsed_and_built_directive_with_space(tmpdir): 305 | compare_parsed_and_built('directive-with-space', 'nginx.conf', tmpdir) 306 | -------------------------------------------------------------------------------- /tests/test_format.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import crossplane 5 | from . import here 6 | 7 | 8 | def test_format_messy_config(): 9 | dirname = os.path.join(here, 'configs', 'messy') 10 | config = os.path.join(dirname, 'nginx.conf') 11 | output = crossplane.format(config) 12 | assert output == '\n'.join([ 13 | 'user nobody;', 14 | r'# hello\n\\n\\\n worlddd \#\\#\\\# dfsf\n \\n \\\n ', 15 | 'events {', 16 | ' worker_connections 2048;', 17 | '}', 18 | 'http { #forteen', 19 | ' # this is a comment', 20 | ' access_log off;', 21 | ' default_type text/plain;', 22 | ' error_log off;', 23 | ' server {', 24 | ' listen 8083;', 25 | r""" return 200 'Ser" \' \' ver\\ \ $server_addr:\$server_port\n\nTime: $time_local\n\n';""", 26 | ' }', 27 | ' server {', 28 | ' listen 8080;', 29 | ' root /usr/share/nginx/html;', 30 | " location ~ '/hello/world;' {", 31 | ' return 301 /status.html;', 32 | ' }', 33 | ' location /foo {', 34 | ' }', 35 | ' location /bar {', 36 | ' }', 37 | ' location /\{\;\}\ #\ ab {', 38 | ' } # hello', 39 | ' if ($request_method = P\{O\)\###\;ST) {', 40 | ' }', 41 | ' location /status.html {', 42 | " try_files '/abc/${uri} /abc/${uri}.html' =404;", 43 | ' }', 44 | r" location '/sta;\n tus' {", 45 | ' return 302 /status.html;', 46 | ' }', 47 | ' location /upstream_conf {', 48 | ' return 200 /status.html;', 49 | ' }', 50 | ' }', 51 | ' server {', 52 | ' }', 53 | '}' 54 | ]) 55 | 56 | 57 | def test_format_not_main_file(): 58 | dirname = os.path.join(here, 'configs', 'includes-globbed', 'servers') 59 | config = os.path.join(dirname, 'server1.conf') 60 | output = crossplane.format(config) 61 | assert output == '\n'.join([ 62 | 'server {', 63 | ' listen 8080;', 64 | ' include locations/*.conf;', 65 | '}' 66 | ]) 67 | 68 | 69 | def test_format_args_not_analyzed(): 70 | dirname = os.path.join(here, 'configs', 'bad-args') 71 | config = os.path.join(dirname, 'nginx.conf') 72 | output = crossplane.format(config) 73 | assert output == '\n'.join([ 74 | 'user;', 75 | 'events {', 76 | '}', 77 | 'http {', 78 | '}' 79 | ]) 80 | 81 | 82 | def test_format_with_comments(): 83 | dirname = os.path.join(here, 'configs', 'with-comments') 84 | config = os.path.join(dirname, 'nginx.conf') 85 | output = crossplane.format(config) 86 | assert output == '\n'.join([ 87 | 'events {', 88 | ' worker_connections 1024;', 89 | '}', 90 | '#comment', 91 | 'http {', 92 | ' server {', 93 | ' listen 127.0.0.1:8080; #listen', 94 | ' server_name default_server;', 95 | ' location / { ## this is brace', 96 | ' # location /', 97 | " return 200 'foo bar baz';", 98 | ' }', 99 | ' }', 100 | '}' 101 | ]) 102 | -------------------------------------------------------------------------------- /tests/test_lex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import crossplane 5 | from . import here 6 | 7 | 8 | def test_simple_config(): 9 | dirname = os.path.join(here, 'configs', 'simple') 10 | config = os.path.join(dirname, 'nginx.conf') 11 | tokens = crossplane.lex(config) 12 | assert list((token, line) for token, line, quoted in tokens) == [ 13 | ('events', 1), ('{', 1), ('worker_connections', 2), ('1024', 2), 14 | (';', 2), ('}', 3), ('http', 5), ('{', 5), ('server', 6), ('{', 6), 15 | ('listen', 7), ('127.0.0.1:8080', 7), (';', 7), ('server_name', 8), 16 | ('default_server', 8), (';', 8), ('location', 9), ('/', 9), ('{', 9), 17 | ('return', 10), ('200', 10), ('foo bar baz', 10), (';', 10), ('}', 11), 18 | ('}', 12), ('}', 13) 19 | ] 20 | 21 | 22 | def test_with_config_comments(): 23 | dirname = os.path.join(here, 'configs', 'with-comments') 24 | config = os.path.join(dirname, 'nginx.conf') 25 | tokens = crossplane.lex(config) 26 | assert list((token, line) for token, line, quoted in tokens) == [ 27 | ('events', 1), ('{', 1), ('worker_connections', 2), ('1024', 2), 28 | (';', 2), ('}', 3),('#comment', 4), ('http', 5), ('{', 5), 29 | ('server', 6), ('{', 6), ('listen', 7), ('127.0.0.1:8080', 7), 30 | (';', 7), ('#listen', 7), ('server_name', 8), 31 | ('default_server', 8),(';', 8), ('location', 9), ('/', 9), 32 | ('{', 9), ('## this is brace', 9), ('# location /', 10), ('return', 11), ('200', 11), 33 | ('foo bar baz', 11), (';', 11), ('}', 12), ('}', 13), ('}', 14) 34 | ] 35 | 36 | 37 | def test_messy_config(): 38 | dirname = os.path.join(here, 'configs', 'messy') 39 | config = os.path.join(dirname, 'nginx.conf') 40 | tokens = crossplane.lex(config) 41 | assert list((token, line) for token, line, quoted in tokens) == [ 42 | ('user', 1), ('nobody', 1), (';', 1), 43 | ('# hello\\n\\\\n\\\\\\n worlddd \\#\\\\#\\\\\\# dfsf\\n \\\\n \\\\\\n ', 2), 44 | ('events', 3), ('{', 3), ('worker_connections', 3), ('2048', 3), 45 | (';', 3), ('}', 3), ('http', 5), ('{', 5), ('#forteen', 5), 46 | ('# this is a comment', 6),('access_log', 7), ('off', 7), (';', 7), 47 | ('default_type', 7), ('text/plain', 7), (';', 7), ('error_log', 7), 48 | ('off', 7), (';', 7), ('server', 8), ('{', 8), ('listen', 9), 49 | ('8083', 9), (';', 9), ('return', 10), ('200', 10), 50 | ('Ser" \' \' ver\\\\ \\ $server_addr:\\$server_port\\n\\nTime: $time_local\\n\\n', 10), 51 | (';', 10), ('}', 11), ('server', 12), ('{', 12), ('listen', 12), 52 | ('8080', 12), (';', 12), ('root', 13), ('/usr/share/nginx/html', 13), 53 | (';', 13), ('location', 14), ('~', 14), ('/hello/world;', 14), 54 | ('{', 14), ('return', 14), ('301', 14), ('/status.html', 14), 55 | (';', 14), ('}', 14), ('location', 15), ('/foo', 15), 56 | ('{', 15), ('}', 15), ('location', 15), ('/bar', 15), 57 | ('{', 15), ('}', 15), ('location', 16), ('/\\{\\;\\}\\ #\\ ab', 16), 58 | ('{', 16), ('}', 16), ('# hello', 16), ('if', 17), 59 | ('($request_method', 17), ('=', 17), ('P\\{O\\)\\###\\;ST', 17), 60 | (')', 17), ('{', 17), ('}', 17), ('location', 18), ('/status.html', 18), 61 | ('{', 18), ('try_files', 19), ('/abc/${uri} /abc/${uri}.html', 19), 62 | ('=404', 19), (';', 19), ('}', 20), ('location', 21), 63 | ('/sta;\n tus', 21), ('{', 22), ('return', 22), 64 | ('302', 22), ('/status.html', 22), (';', 22), ('}', 22), 65 | ('location', 23), ('/upstream_conf', 23), ('{', 23), 66 | ('return', 23), ('200', 23), ('/status.html', 23), (';', 23), 67 | ('}', 23), ('}', 23), ('server', 24), ('{', 25), ('}', 25), 68 | ('}', 25) 69 | ] 70 | 71 | 72 | def test_quote_behavior(): 73 | dirname = os.path.join(here, 'configs', 'quote-behavior') 74 | config = os.path.join(dirname, 'nginx.conf') 75 | tokens = crossplane.lex(config) 76 | assert list(token for token, line, quoted in tokens) == [ 77 | 'outer-quote', 'left', '-quote', 'right-"quote"', 'inner"-"quote', ';', 78 | '', '', 'left-empty', 'right-empty""', 'inner""empty', 'right-empty-single"', ';', 79 | ] 80 | 81 | 82 | def test_quoted_right_brace(): 83 | dirname = os.path.join(here, 'configs', 'quoted-right-brace') 84 | config = os.path.join(dirname, 'nginx.conf') 85 | tokens = crossplane.lex(config) 86 | assert list(token for token, line, quoted in tokens) == [ 87 | 'events', '{', '}', 'http', '{', 'log_format', 'main', 'escape=json', 88 | '{ "@timestamp": "$time_iso8601", ', '"server_name": "$server_name", ', 89 | '"host": "$host", ', '"status": "$status", ', 90 | '"request": "$request", ', '"uri": "$uri", ', '"args": "$args", ', 91 | '"https": "$https", ', '"request_method": "$request_method", ', 92 | '"referer": "$http_referer", ', '"agent": "$http_user_agent"', '}', 93 | ';', '}' 94 | ] 95 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import crossplane 5 | from . import here 6 | 7 | 8 | def test_includes_regular(): 9 | dirname = os.path.join(here, 'configs', 'includes-regular') 10 | config = os.path.join(dirname, 'nginx.conf') 11 | payload = crossplane.parse(config) 12 | assert payload == { 13 | 'status': 'failed', 14 | 'errors': [ 15 | { 16 | 'file': os.path.join(dirname, 'conf.d', 'server.conf'), 17 | 'error': '[Errno 2] No such file or directory: %r' % os.path.join(dirname, 'bar.conf'), 18 | 'line': 5 19 | } 20 | ], 21 | 'config': [ 22 | { 23 | 'file': os.path.join(dirname, 'nginx.conf'), 24 | 'status': 'ok', 25 | 'errors': [], 26 | 'parsed': [ 27 | { 28 | 'directive': 'events', 29 | 'line': 1, 30 | 'args': [], 31 | 'block': [] 32 | }, 33 | { 34 | 'directive': 'http', 35 | 'line': 2, 36 | 'args': [], 37 | 'block': [ 38 | { 39 | 'directive': 'include', 40 | 'line': 3, 41 | 'args': ['conf.d/server.conf'], 42 | 'includes': [1] 43 | } 44 | ] 45 | } 46 | ] 47 | }, 48 | { 49 | 'file': os.path.join(dirname, 'conf.d', 'server.conf'), 50 | 'status': 'failed', 51 | 'errors': [ 52 | { 53 | 'error': '[Errno 2] No such file or directory: %r' % os.path.join(dirname, 'bar.conf'), 54 | 'line': 5 55 | } 56 | ], 57 | 'parsed': [ 58 | { 59 | 'directive': 'server', 60 | 'line': 1, 61 | 'args': [], 62 | 'block': [ 63 | { 64 | 'directive': 'listen', 65 | 'line': 2, 66 | 'args': ['127.0.0.1:8080'] 67 | }, 68 | { 69 | 'directive': 'server_name', 70 | 'line': 3, 71 | 'args': ['default_server'] 72 | }, 73 | { 74 | 'directive': 'include', 75 | 'line': 4, 76 | 'args': ['foo.conf'], 77 | 'includes': [2] 78 | }, 79 | { 80 | 'directive': 'include', 81 | 'line': 5, 82 | 'args': ['bar.conf'], 83 | 'includes': [] 84 | } 85 | ] 86 | } 87 | ] 88 | }, 89 | { 90 | 'file': os.path.join(dirname, 'foo.conf'), 91 | 'status': 'ok', 92 | 'errors': [], 93 | 'parsed': [ 94 | { 95 | 'directive': 'location', 96 | 'line': 1, 97 | 'args': ['/foo'], 98 | 'block': [ 99 | { 100 | 'directive': 'return', 101 | 'line': 2, 102 | 'args': ['200', 'foo'] 103 | } 104 | ] 105 | } 106 | ] 107 | } 108 | ] 109 | } 110 | 111 | 112 | def test_includes_globbed(): 113 | dirname = os.path.join(here, 'configs', 'includes-globbed') 114 | config = os.path.join(dirname, 'nginx.conf') 115 | payload = crossplane.parse(config) 116 | assert payload == { 117 | 'status': 'ok', 118 | 'errors': [], 119 | 'config': [ 120 | { 121 | 'file': os.path.join(dirname, 'nginx.conf'), 122 | 'status': 'ok', 123 | 'errors': [], 124 | 'parsed': [ 125 | { 126 | 'directive': 'events', 127 | 'line': 1, 128 | 'args': [], 129 | 'block': [] 130 | }, 131 | { 132 | 'directive': 'include', 133 | 'line': 2, 134 | 'args': ['http.conf'], 135 | 'includes': [1] 136 | } 137 | ] 138 | }, 139 | { 140 | 'file': os.path.join(dirname, 'http.conf'), 141 | 'status': 'ok', 142 | 'errors': [], 143 | 'parsed': [ 144 | { 145 | 'directive': 'http', 146 | 'args': [], 147 | 'line': 1, 148 | 'block': [ 149 | { 150 | 'directive': 'include', 151 | 'line': 2, 152 | 'args': ['servers/*.conf'], 153 | 'includes': [2, 3] 154 | } 155 | ] 156 | } 157 | ] 158 | }, 159 | { 160 | 'file': os.path.join(dirname, 'servers', 'server1.conf'), 161 | 'status': 'ok', 162 | 'errors': [], 163 | 'parsed': [ 164 | { 165 | 'directive': 'server', 166 | 'args': [], 167 | 'line': 1, 168 | 'block': [ 169 | { 170 | 'directive': 'listen', 171 | 'args': ['8080'], 172 | 'line': 2 173 | }, 174 | { 175 | 'directive': 'include', 176 | 'args': ['locations/*.conf'], 177 | 'line': 3, 178 | 'includes': [4, 5] 179 | } 180 | ] 181 | } 182 | ] 183 | }, 184 | { 185 | 'file': os.path.join(dirname, 'servers', 'server2.conf'), 186 | 'status': 'ok', 187 | 'errors': [], 188 | 'parsed': [ 189 | { 190 | 'directive': 'server', 191 | 'args': [], 192 | 'line': 1, 193 | 'block': [ 194 | { 195 | 'directive': 'listen', 196 | 'args': ['8081'], 197 | 'line': 2 198 | }, 199 | { 200 | 'directive': 'include', 201 | 'args': ['locations/*.conf'], 202 | 'line': 3, 203 | 'includes': [4, 5] 204 | } 205 | ] 206 | } 207 | ] 208 | }, 209 | { 210 | 'file': os.path.join(dirname, 'locations', 'location1.conf'), 211 | 'status': 'ok', 212 | 'errors': [], 213 | 'parsed': [ 214 | { 215 | 'directive': 'location', 216 | 'args': ['/foo'], 217 | 'line': 1, 218 | 'block': [ 219 | { 220 | 'directive': 'return', 221 | 'args': ['200', 'foo'], 222 | 'line': 2 223 | } 224 | ] 225 | } 226 | ] 227 | }, 228 | { 229 | 'file': os.path.join(dirname, 'locations', 'location2.conf'), 230 | 'status': 'ok', 231 | 'errors': [], 232 | 'parsed': [ 233 | { 234 | 'directive': 'location', 235 | 'args': ['/bar'], 236 | 'line': 1, 237 | 'block': [ 238 | { 239 | 'directive': 'return', 240 | 'args': ['200', 'bar'], 241 | 'line': 2 242 | } 243 | ] 244 | } 245 | ] 246 | } 247 | ] 248 | } 249 | 250 | 251 | def test_includes_globbed_combined(): 252 | dirname = os.path.join(here, 'configs', 'includes-globbed') 253 | config = os.path.join(dirname, 'nginx.conf') 254 | payload = crossplane.parse(config, combine=True) 255 | assert payload == { 256 | "status": "ok", 257 | "errors": [], 258 | "config": [ 259 | { 260 | "file": os.path.join(dirname, "nginx.conf"), 261 | "status": "ok", 262 | "errors": [], 263 | "parsed": [ 264 | { 265 | "directive": "events", 266 | "args": [], 267 | "file": os.path.join(dirname, "nginx.conf"), 268 | "line": 1, 269 | "block": [] 270 | }, 271 | { 272 | "directive": "http", 273 | "args": [], 274 | "file": os.path.join(dirname, "http.conf"), 275 | "line": 1, 276 | "block": [ 277 | { 278 | "directive": "server", 279 | "args": [], 280 | "file": os.path.join(dirname, "servers", "server1.conf"), 281 | "line": 1, 282 | "block": [ 283 | { 284 | "directive": "listen", 285 | "args": ["8080"], 286 | "file": os.path.join(dirname, "servers", "server1.conf"), 287 | "line": 2 288 | }, 289 | { 290 | "directive": "location", 291 | "args": ["/foo"], 292 | "file": os.path.join(dirname, "locations", "location1.conf"), 293 | "line": 1, 294 | "block": [ 295 | { 296 | "directive": "return", 297 | "args": ["200", "foo"], 298 | "file": os.path.join(dirname, "locations", "location1.conf"), 299 | "line": 2 300 | } 301 | ] 302 | }, 303 | { 304 | "directive": "location", 305 | "args": ["/bar"], 306 | "file": os.path.join(dirname, "locations", "location2.conf"), 307 | "line": 1, 308 | "block": [ 309 | { 310 | "directive": "return", 311 | "args": ["200", "bar"], 312 | "file": os.path.join(dirname, "locations", "location2.conf"), 313 | "line": 2 314 | } 315 | ] 316 | } 317 | ] 318 | }, 319 | { 320 | "directive": "server", 321 | "args": [], 322 | "file": os.path.join(dirname, "servers", "server2.conf"), 323 | "line": 1, 324 | "block": [ 325 | { 326 | "directive": "listen", 327 | "args": ["8081"], 328 | "file": os.path.join(dirname, "servers", "server2.conf"), 329 | "line": 2 330 | }, 331 | { 332 | "directive": "location", 333 | "args": ["/foo"], 334 | "file": os.path.join(dirname, "locations", "location1.conf"), 335 | "line": 1, 336 | "block": [ 337 | { 338 | "directive": "return", 339 | "args": ["200", "foo"], 340 | "file": os.path.join(dirname, "locations", "location1.conf"), 341 | "line": 2 342 | } 343 | ] 344 | }, 345 | { 346 | "directive": "location", 347 | "args": ["/bar"], 348 | "file": os.path.join(dirname, "locations", "location2.conf"), 349 | "line": 1, 350 | "block": [ 351 | { 352 | "directive": "return", 353 | "args": ["200", "bar"], 354 | "file": os.path.join(dirname, "locations", "location2.conf"), 355 | "line": 2 356 | } 357 | ] 358 | } 359 | ] 360 | } 361 | ] 362 | } 363 | ] 364 | } 365 | ] 366 | } 367 | 368 | 369 | def test_includes_single(): 370 | dirname = os.path.join(here, 'configs', 'includes-regular') 371 | config = os.path.join(dirname, 'nginx.conf') 372 | payload = crossplane.parse(config, single=True) 373 | assert payload == { 374 | 'status': 'ok', 375 | 'errors': [], 376 | 'config': [ 377 | { 378 | 'file': os.path.join(dirname, 'nginx.conf'), 379 | 'status': 'ok', 380 | 'errors': [], 381 | 'parsed': [ 382 | { 383 | 'directive': 'events', 384 | 'line': 1, 385 | 'args': [], 386 | 'block': [] 387 | }, 388 | { 389 | 'directive': 'http', 390 | 'line': 2, 391 | 'args': [], 392 | 'block': [ 393 | { 394 | 'directive': 'include', 395 | 'line': 3, 396 | 'args': ['conf.d/server.conf'] 397 | # no 'includes' key 398 | } 399 | ] 400 | } 401 | ] 402 | } 403 | # single config parsed 404 | ] 405 | } 406 | 407 | 408 | def test_ignore_directives(): 409 | dirname = os.path.join(here, 'configs', 'simple') 410 | config = os.path.join(dirname, 'nginx.conf') 411 | 412 | # check that you can ignore multiple directives 413 | payload = crossplane.parse(config, ignore=['listen', 'server_name']) 414 | assert payload == { 415 | "status": "ok", 416 | "errors": [], 417 | "config": [ 418 | { 419 | "file": os.path.join(dirname, 'nginx.conf'), 420 | "status": "ok", 421 | "errors": [], 422 | "parsed": [ 423 | { 424 | "directive": "events", 425 | "line": 1, 426 | "args": [], 427 | "block": [ 428 | { 429 | "directive": "worker_connections", 430 | "line": 2, 431 | "args": ["1024"] 432 | } 433 | ] 434 | }, 435 | { 436 | "directive": "http", 437 | "line": 5, 438 | "args": [], 439 | "block": [ 440 | { 441 | "directive": "server", 442 | "line": 6, 443 | "args": [], 444 | "block": [ 445 | { 446 | "directive": "location", 447 | "line": 9, 448 | "args": ["/"], 449 | "block": [ 450 | { 451 | "directive": "return", 452 | "line": 10, 453 | "args": ["200", "foo bar baz"] 454 | } 455 | ] 456 | } 457 | ] 458 | } 459 | ] 460 | } 461 | ] 462 | } 463 | ] 464 | } 465 | 466 | # check that you can also ignore block directives 467 | payload = crossplane.parse(config, ignore=['events', 'server']) 468 | assert payload == { 469 | "status": "ok", 470 | "errors": [], 471 | "config": [ 472 | { 473 | "file": os.path.join(dirname, 'nginx.conf'), 474 | "status": "ok", 475 | "errors": [], 476 | "parsed": [ 477 | { 478 | "directive": "http", 479 | "line": 5, 480 | "args": [], 481 | "block": [] 482 | } 483 | ] 484 | } 485 | ] 486 | } 487 | 488 | 489 | def test_config_with_comments(): 490 | dirname = os.path.join(here, 'configs', 'with-comments') 491 | config = os.path.join(dirname, 'nginx.conf') 492 | payload = crossplane.parse(config, comments=True) 493 | assert payload == { 494 | "errors" : [], 495 | "status" : "ok", 496 | "config" : [ 497 | { 498 | "errors" : [], 499 | "parsed" : [ 500 | { 501 | "block" : [ 502 | { 503 | "directive" : "worker_connections", 504 | "args" : [ 505 | "1024" 506 | ], 507 | "line" : 2 508 | } 509 | ], 510 | "line" : 1, 511 | "args" : [], 512 | "directive" : "events" 513 | }, 514 | { 515 | "line" : 4, 516 | "directive": "#", 517 | "args": [], 518 | "comment" : "comment" 519 | }, 520 | { 521 | "block" : [ 522 | { 523 | "args" : [], 524 | "directive" : "server", 525 | "line" : 6, 526 | "block" : [ 527 | { 528 | "args" : [ 529 | "127.0.0.1:8080" 530 | ], 531 | "directive" : "listen", 532 | "line" : 7 533 | }, 534 | { 535 | "args": [], 536 | "directive": "#", 537 | "comment" : "listen", 538 | "line" : 7 539 | }, 540 | { 541 | "args" : [ 542 | "default_server" 543 | ], 544 | "directive" : "server_name", 545 | "line" : 8 546 | }, 547 | { 548 | "block" : [ 549 | { 550 | "args": [], 551 | "directive": "#", 552 | "line" : 9, 553 | "comment" : "# this is brace" 554 | }, 555 | { 556 | "args": [], 557 | "directive": "#", 558 | "line" : 10, 559 | "comment" : " location /" 560 | }, 561 | { 562 | "line" : 11, 563 | "directive" : "return", 564 | "args" : [ 565 | "200", 566 | "foo bar baz" 567 | ] 568 | } 569 | ], 570 | "line" : 9, 571 | "directive" : "location", 572 | "args" : [ 573 | "/" 574 | ] 575 | } 576 | ] 577 | } 578 | ], 579 | "line" : 5, 580 | "args" : [], 581 | "directive" : "http" 582 | } 583 | ], 584 | "status" : "ok", 585 | "file" : config 586 | } 587 | ] 588 | } 589 | 590 | 591 | def test_config_without_comments(): 592 | dirname = os.path.join(here, 'configs', 'with-comments') 593 | config = os.path.join(dirname, 'nginx.conf') 594 | payload = crossplane.parse(config, comments=False) 595 | assert payload == { 596 | "errors" : [], 597 | "status" : "ok", 598 | "config" : [ 599 | { 600 | "errors" : [], 601 | "parsed" : [ 602 | { 603 | "block" : [ 604 | { 605 | "directive" : "worker_connections", 606 | "args" : [ 607 | "1024" 608 | ], 609 | "line" : 2 610 | } 611 | ], 612 | "line" : 1, 613 | "args" : [], 614 | "directive" : "events" 615 | }, 616 | { 617 | "block" : [ 618 | { 619 | "args" : [], 620 | "directive" : "server", 621 | "line" : 6, 622 | "block" : [ 623 | { 624 | "args" : [ 625 | "127.0.0.1:8080" 626 | ], 627 | "directive" : "listen", 628 | "line" : 7 629 | }, 630 | { 631 | "args" : [ 632 | "default_server" 633 | ], 634 | "directive" : "server_name", 635 | "line" : 8 636 | }, 637 | { 638 | "block" : [ 639 | { 640 | "line" : 11, 641 | "directive" : "return", 642 | "args" : [ 643 | "200", 644 | "foo bar baz" 645 | ] 646 | } 647 | ], 648 | "line" : 9, 649 | "directive" : "location", 650 | "args" : [ 651 | "/" 652 | ] 653 | } 654 | ] 655 | } 656 | ], 657 | "line" : 5, 658 | "args" : [], 659 | "directive" : "http" 660 | } 661 | ], 662 | "status" : "ok", 663 | "file" : os.path.join(dirname, 'nginx.conf') 664 | } 665 | ] 666 | } 667 | 668 | 669 | def test_parse_strict(): 670 | dirname = os.path.join(here, 'configs', 'spelling-mistake') 671 | config = os.path.join(dirname, 'nginx.conf') 672 | payload = crossplane.parse(config, comments=True, strict=True) 673 | assert payload == { 674 | 'status' : 'failed', 675 | 'errors' : [ 676 | { 677 | 'file': os.path.join(dirname, 'nginx.conf'), 678 | 'error': 'unknown directive "proxy_passs" in %s:7' % os.path.join(dirname, 'nginx.conf'), 679 | 'line': 7 680 | } 681 | ], 682 | 'config' : [ 683 | { 684 | 'file' : os.path.join(dirname, 'nginx.conf'), 685 | 'status' : 'failed', 686 | 'errors' : [ 687 | { 688 | 'error': 'unknown directive "proxy_passs" in %s:7' % os.path.join(dirname, 'nginx.conf'), 689 | 'line': 7 690 | } 691 | ], 692 | 'parsed' : [ 693 | { 694 | 'directive' : 'events', 695 | 'line' : 1, 696 | 'args' : [], 697 | 'block' : [] 698 | }, 699 | { 700 | 'directive' : 'http', 701 | 'line' : 3, 702 | 'args' : [], 703 | 'block' : [ 704 | { 705 | 'directive' : 'server', 706 | 'line' : 4, 707 | 'args' : [], 708 | 'block' : [ 709 | { 710 | 'directive' : 'location', 711 | 'line' : 5, 712 | 'args' : ['/'], 713 | 'block' : [ 714 | { 715 | 'directive' : '#', 716 | 'line' : 6, 717 | 'args' : [], 718 | 'comment': 'directive is misspelled' 719 | } 720 | ] 721 | } 722 | ] 723 | } 724 | ] 725 | } 726 | ] 727 | } 728 | ] 729 | } 730 | 731 | 732 | def test_parse_missing_semicolon(): 733 | dirname = os.path.join(here, 'configs', 'missing-semicolon') 734 | 735 | # test correct error is raised when broken proxy_pass is in upper block 736 | above_config = os.path.join(dirname, 'broken-above.conf') 737 | above_payload = crossplane.parse(above_config) 738 | assert above_payload == { 739 | "status": "failed", 740 | "errors": [ 741 | { 742 | "file": above_config, 743 | "error": "directive \"proxy_pass\" is not terminated by \";\" in %s:4" % above_config, 744 | "line": 4 745 | } 746 | ], 747 | "config": [ 748 | { 749 | "file": above_config, 750 | "status": "failed", 751 | "errors": [ 752 | { 753 | "error": "directive \"proxy_pass\" is not terminated by \";\" in %s:4" % above_config, 754 | "line": 4 755 | } 756 | ], 757 | "parsed": [ 758 | { 759 | "directive": "http", 760 | "line": 1, 761 | "args": [], 762 | "block": [ 763 | { 764 | "directive": "server", 765 | "line": 2, 766 | "args": [], 767 | "block": [ 768 | { 769 | "directive": "location", 770 | "line": 3, 771 | "args": ["/is-broken"], 772 | "block": [] 773 | }, 774 | { 775 | "directive": "location", 776 | "line": 6, 777 | "args": ["/not-broken"], 778 | "block": [ 779 | { 780 | "directive": "proxy_pass", 781 | "line": 7, 782 | "args": ["http://not.broken.example"] 783 | } 784 | ] 785 | } 786 | ] 787 | } 788 | ] 789 | } 790 | ] 791 | } 792 | ] 793 | } 794 | 795 | # test correct error is raised when broken proxy_pass is in lower block 796 | below_config = os.path.join(dirname, 'broken-below.conf') 797 | below_payload = crossplane.parse(below_config) 798 | assert below_payload == { 799 | "status": "failed", 800 | "errors": [ 801 | { 802 | "file": below_config, 803 | "error": "directive \"proxy_pass\" is not terminated by \";\" in %s:7" % below_config, 804 | "line": 7 805 | } 806 | ], 807 | "config": [ 808 | { 809 | "file": below_config, 810 | "status": "failed", 811 | "errors": [ 812 | { 813 | "error": "directive \"proxy_pass\" is not terminated by \";\" in %s:7" % below_config, 814 | "line": 7 815 | } 816 | ], 817 | "parsed": [ 818 | { 819 | "directive": "http", 820 | "line": 1, 821 | "args": [], 822 | "block": [ 823 | { 824 | "directive": "server", 825 | "line": 2, 826 | "args": [], 827 | "block": [ 828 | { 829 | "directive": "location", 830 | "line": 3, 831 | "args": ["/not-broken"], 832 | "block": [ 833 | { 834 | "directive": "proxy_pass", 835 | "line": 4, 836 | "args": ["http://not.broken.example"] 837 | } 838 | ] 839 | }, 840 | { 841 | "directive": "location", 842 | "line": 6, 843 | "args": ["/is-broken"], 844 | "block": [] 845 | } 846 | ] 847 | } 848 | ] 849 | } 850 | ] 851 | } 852 | ] 853 | } 854 | 855 | 856 | def test_combine_parsed_missing_values(): 857 | dirname = os.path.join(here, 'configs', 'includes-regular') 858 | config = os.path.join(dirname, 'nginx.conf') 859 | separate = { 860 | "config": [ 861 | { 862 | "file": "example1.conf", 863 | "parsed": [ 864 | { 865 | "directive": "include", 866 | "line": 1, 867 | "args": ["example2.conf"], 868 | "includes": [1] 869 | } 870 | ] 871 | }, 872 | { 873 | "file": "example2.conf", 874 | "parsed": [ 875 | { 876 | "directive": "events", 877 | "line": 1, 878 | "args": [], 879 | "block": [] 880 | }, 881 | { 882 | "directive": "http", 883 | "line": 2, 884 | "args": [], 885 | "block": [] 886 | } 887 | ] 888 | } 889 | ] 890 | } 891 | combined = crossplane.parser._combine_parsed_configs(separate) 892 | assert combined == { 893 | "status": "ok", 894 | "errors": [], 895 | "config": [ 896 | { 897 | "file": "example1.conf", 898 | "status": "ok", 899 | "errors": [], 900 | "parsed": [ 901 | { 902 | "directive": "events", 903 | "line": 1, 904 | "args": [], 905 | "block": [] 906 | }, 907 | { 908 | "directive": "http", 909 | "line": 2, 910 | "args": [], 911 | "block": [] 912 | } 913 | ] 914 | } 915 | ] 916 | } 917 | 918 | 919 | def test_comments_between_args(): 920 | dirname = os.path.join(here, 'configs', 'comments-between-args') 921 | config = os.path.join(dirname, 'nginx.conf') 922 | payload = crossplane.parse(config, comments=True) 923 | assert payload == { 924 | 'status': 'ok', 925 | 'errors': [], 926 | 'config': [ 927 | { 928 | 'file': config, 929 | 'status': 'ok', 930 | 'errors': [], 931 | 'parsed': [ 932 | { 933 | 'directive': 'http', 934 | 'line': 1, 935 | 'args': [], 936 | 'block': [ 937 | { 938 | 'directive': '#', 939 | 'line': 1, 940 | 'args': [], 941 | 'comment': 'comment 1' 942 | }, 943 | { 944 | 'directive': 'log_format', 945 | 'line': 2, 946 | 'args': ['\\#arg\\ 1', '#arg 2'] 947 | }, 948 | { 949 | 'directive': '#', 950 | 'line': 2, 951 | 'args': [], 952 | 'comment': 'comment 2' 953 | }, 954 | { 955 | 'directive': '#', 956 | 'line': 2, 957 | 'args': [], 958 | 'comment': 'comment 3' 959 | }, 960 | { 961 | 'directive': '#', 962 | 'line': 2, 963 | 'args': [], 964 | 'comment': 'comment 4' 965 | }, 966 | { 967 | 'directive': '#', 968 | 'line': 2, 969 | 'args': [], 970 | 'comment': 'comment 5' 971 | } 972 | ] 973 | } 974 | ] 975 | } 976 | ] 977 | } 978 | 979 | def test_non_unicode(): 980 | dirname = os.path.join(here, 'configs', 'non-unicode') 981 | config = os.path.join(dirname, 'nginx.conf') 982 | 983 | payload = crossplane.parse(config, comments=True) 984 | 985 | assert payload == { 986 | "errors": [], 987 | "status": "ok", 988 | "config": [ 989 | { 990 | "status": "ok", 991 | "errors": [], 992 | "file": os.path.join(dirname, 'nginx.conf'), 993 | "parsed": [ 994 | { 995 | "directive": "http", 996 | "line": 1, 997 | "args": [], 998 | "block": [ 999 | { 1000 | "directive": "server", 1001 | "line": 2, 1002 | "args": [], 1003 | "block": [ 1004 | { 1005 | "directive": "location", 1006 | "line": 3, 1007 | "args": [ 1008 | "/city" 1009 | ], 1010 | "block": [ 1011 | { 1012 | "directive": "#", 1013 | "line": 4, 1014 | "args": [], 1015 | "comment": u" M\ufffdlln" 1016 | }, 1017 | { 1018 | "directive": "return", 1019 | "line": 5, 1020 | "args": [ 1021 | "200", 1022 | u"M\ufffdlln\\n" 1023 | ] 1024 | } 1025 | ] 1026 | } 1027 | ] 1028 | } 1029 | ] 1030 | } 1031 | ] 1032 | } 1033 | ] 1034 | } 1035 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | # 6 | # See also https://tox.readthedocs.io/en/latest/config.html for more 7 | # configuration options. 8 | 9 | [pytest] 10 | addopts = -vv --showlocals --disable-warnings -rf -p no:warnings 11 | testpaths = tests/ 12 | 13 | [tox] 14 | envlist = py27, py36, py37, py38, py39, py310, pypy 15 | skipsdist = true 16 | 17 | [testenv] 18 | usedevelop = true 19 | deps = 20 | pytest 21 | commands = 22 | py.test {posargs} --basetemp={envtmpdir} 23 | --------------------------------------------------------------------------------