├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE.txt ├── README.md ├── bin ├── build-docker-image └── test-watcher ├── requirements.txt ├── setup.cfg ├── setup.cfg.pytest-watch ├── setup.py └── tedi ├── README.md ├── __init__.py ├── asset.py ├── assetset.py ├── cli.py ├── commands.py ├── factset.py ├── fileset.py ├── image.py ├── jinja_renderer.py ├── logging.py ├── paths.py ├── process.py ├── project.py └── tests ├── __init__.py ├── fixtures ├── assets │ └── text-asset ├── projects │ ├── simple │ │ ├── tedi.yml │ │ └── template │ │ │ ├── Dockerfile │ │ │ ├── README.md.j2 │ │ │ ├── deep │ │ │ └── directory │ │ │ │ └── structure │ │ │ │ └── deepfile01 │ │ │ └── files │ │ │ ├── file01 │ │ │ └── file02 │ └── with-docker-registry │ │ ├── tedi.yml │ │ └── template │ │ └── thi └── template │ └── simple.j2 ├── paths.py ├── test_asset.py ├── test_assetset.py ├── test_builder.py ├── test_cli.py ├── test_factset.py ├── test_fileset.py ├── test_jinja_renderer.py └── test_project.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/*.pyc 3 | **/*.test.tmp 4 | **/.mypy_cache 5 | **/.pytest_cache 6 | .cache 7 | .coverage 8 | .coverage.* 9 | .eggs 10 | .tedi 11 | /.pytest_cache 12 | /build 13 | /dist 14 | /htmlcov 15 | /renders 16 | /tedi.egg-info 17 | /venv 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: ['3.6'] 3 | install: ['python setup.py install'] 4 | script: ['python setup.py test'] 5 | sudo: required 6 | services: ['docker'] 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | # Add Docker client. 4 | RUN apt-get update && \ 5 | apt-get -y install apt-transport-https ca-certificates curl gnupg2 software-properties-common && \ 6 | curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - && \ 7 | add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" && \ 8 | apt-get update && \ 9 | apt-get -y install docker-ce && \ 10 | apt-get clean 11 | 12 | # Add Tedi. 13 | WORKDIR /usr/src/app 14 | 15 | # Pre-install run-time dependencies before we install Tedi. 16 | # This is a layer caching optimisation. 17 | COPY requirements.txt ./ 18 | RUN pip install -r requirements.txt 19 | 20 | # Install Tedi. 21 | COPY setup.* ./ 22 | COPY tedi tedi 23 | 24 | # This next layer will be nice and small/fast because we pre-installed the 25 | # libraries we need. If we hadn't done that, then the COPY line above would 26 | # invalidate the layer cache and cause all the libraries to be installed every 27 | # time. 28 | RUN python setup.py install 29 | 30 | WORKDIR /mnt 31 | ENTRYPOINT ["/usr/local/bin/tedi"] 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/elastic/tedi.svg?branch=master)](https://travis-ci.org/elastic/tedi) 2 | 3 | # Tedi: A Template Engine for Docker Images 4 | Tedi is a tool for building Docker images for your project. It adds a templating 5 | layer to your Dockerfile and any other files you choose. It then renders 6 | those templates to a build context and arranges for Docker to build it. 7 | 8 | ## Usage 9 | 10 | Tedi is a CLI tool written in Python 3.6. It can be installed with Pip or run 11 | as a Docker container. 12 | 13 | If you are interested in using Tedi but not in developing it, Docker is the 14 | recommended approach. 15 | 16 | ### Running in Docker 17 | 18 | ``` shell 19 | # Pull the image 20 | docker pull docker.elastic.co/tedi/tedi:0.13 21 | 22 | # Give it a nice, short name for local use. 23 | docker tag docker.elastic.co/tedi/tedi:0.13 tedi 24 | 25 | # Run a build (from your project directory). 26 | docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -v $PWD:/mnt tedi build 27 | ``` 28 | 29 | * Because Tedi is itself a Docker client, we mount the Docker socket inside 30 | the container. 31 | * We also mount the directory of whatever project we want to build on `/mnt`, 32 | where Tedi expects to find it. In particular, it expects to a find a `tedi.yml` 33 | there (see below). 34 | 35 | #### Controlling verbosity 36 | 37 | Verbose output, including the output from the Docker build, can be requested by 38 | setting the `TEDI_DEBUG` environment variable to `true`: 39 | 40 | ``` shell 41 | docker run -e TEDI_DEBUG=true [...] tedi build 42 | ``` 43 | 44 | #### Other commands 45 | You can explore other commands via the built-in help: 46 | 47 | ``` shell 48 | docker run --rm tedi --help 49 | ``` 50 | 51 | ### Declaring `tedi.yml` 52 | 53 | To build a Docker image for your project, create a file within your project at 54 | `.tedi/tedi.yml`. 55 | 56 | Like this: 57 | 58 | ``` yaml 59 | # Tedi can produce multiple images for a single project. 60 | # Declare them under the "images" key. 61 | images: 62 | elasticsearch-full: 63 | # Aliases will cause the image to have several names. Having both a short 64 | # local name and a fully qualified name in a registry is particularly handy. 65 | aliases: 66 | - elasticsearch 67 | - docker.elastic.co/elasticsearch/elasticsearch 68 | 69 | # Differences between images can be expressed with "facts". Facts are used 70 | # for variable expansion in template files, notably the Dockerfile template. 71 | facts: 72 | image_flavor: full 73 | 74 | elasticsearch-oss: 75 | facts: 76 | image_flavor: oss 77 | aliases: 78 | - docker.elastic.co/elasticsearch/elasticsearch-oss 79 | 80 | # Global facts apply to all images. 81 | facts: 82 | elastic_version: 6.3.1 83 | 84 | # The "image_tag" fact has explicit support inside Tedi. Specify it to have 85 | # your images tagged something other than "latest". 86 | image_tag: 6.3.1-SNAPSHOT 87 | 88 | # Asset sets declare files that need be acquired (downloaded) before building 89 | # the image. Declaring different asset sets is a good way to build different 90 | # variants of your project without having to have a bunch of conditionals in 91 | # your templates. 92 | asset_sets: 93 | 94 | # The "default" asset set is special and will be used unless you specify 95 | # otherwise. 96 | default: 97 | # Global facts can be used in asset declarations. 98 | - filename: elasticsearch-{{ elastic_version }}.tar.gz 99 | source: https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-{{ elastic_version }}.tar.gz 100 | 101 | # Alternative asset sets can be used with the "--asset-set" CLI flag. 102 | remote_snapshot: 103 | - filename: elasticsearch-{{ elastic_version }}.tar.gz 104 | source: https://snapshots.elastic.co/downloads/elasticsearch/elasticsearch-{{ elastic_version }}-SNAPSHOT.tar.gz 105 | 106 | ``` 107 | 108 | ### Build context files 109 | Add any other files that are needed to complete your image to the `.tedi/template/` 110 | directory. They will be included in the Docker build context. Each declared image 111 | will create a build context directory under `.tedi/build/`. 112 | 113 | Both plain files and Jinja2 templates are supported. 114 | 115 | The contents of `.tedi/build/` are temporary and can be regenerated. You'll likely 116 | want to add it to your `.gitignore` file. 117 | 118 | #### Plain files 119 | Any plain files, including arbitrarily deep directory structures will be copied 120 | into the Docker build context. 121 | 122 | #### Jinja2 123 | Any files with a `.j2` extension will be rendered through the Jinja2 template 124 | engine before being added to the Docker build context. Generally, you will want 125 | to create (at least) a Dockerfile template at `tedi/template/Dockerfile.j2`. 126 | 127 | The template engine will expand variables from the _facts_ defined in `tedi.yml` 128 | and elsewhere (see below). 129 | 130 | ### Facts 131 | Facts can be defined in `tedi.yml`, either at the project (top) level, or the image 132 | level. 133 | 134 | They can also be set on the command line with this colon delimited syntax: 135 | 136 | ``` 137 | tedi build --fact=elastic_version:7.0.0 138 | ``` 139 | 140 | ...and via environment variables: 141 | 142 | ``` 143 | TEDI_FACT_image_tag=7.0.0-SNAPSHOT tedi build 144 | ``` 145 | 146 | All standard environment variables are also mapped as facts, with a prefix of 147 | `ENV_`. So, for example, you can find the current working directory as the fact 148 | `ENV_PWD` and the current username as `ENV_USER`. 149 | 150 | ### Build troubleshooting 151 | If the Docker build fails, it can be handy to try running the build with pure 152 | Docker. That way, you don't have to think through as many layers of 153 | abstraction. One of the design principles of Tedi is that the rendered build 154 | context sent to Docker is very much "normal" and can be fed straight to 155 | Docker. Like this: 156 | 157 | ``` 158 | tedi render 159 | docker build .tedi/build/elasticsearch-full 160 | ``` 161 | 162 | This also provides a mechanism for building the results of `tedi render` with 163 | alternative build tools. 164 | 165 | ## Development 166 | 167 | Tedi is written in Python 3.6 with tests in pytest. Some type annotations can be 168 | found sprinkled around, for consumption by [mypy](http://mypy-lang.org/). 169 | 170 | There's a [separate README](./tedi/README.md) covering some of the design and 171 | implementation details. 172 | 173 | ### Initial setup 174 | 175 | ``` shell 176 | git clone git@github.com:elastic/tedi.git 177 | cd tedi 178 | virtualenv --python=python3.6 venv 179 | . venv/bin/activate 180 | pip install -e . 181 | ``` 182 | 183 | ### Running the tests 184 | 185 | ``` shell 186 | python setup.py test 187 | ``` 188 | 189 | #### Test Watcher 190 | 191 | A small wrapper is provided to run a fast suite of tests continuously while 192 | doing Test Driven Development. It uses pytest-watch with some options to skip 193 | slow things like coverage reporting and mypy type checking. 194 | ``` shell 195 | ./bin/test-watcher 196 | ``` 197 | -------------------------------------------------------------------------------- /bin/build-docker-image: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | image=tedi:${TEDI_VERSION} 6 | 7 | find . -name *.pyc -delete 8 | docker image build -t ${image} . 9 | docker image tag ${image} docker.elastic.co/tedi/${image} 10 | -------------------------------------------------------------------------------- /bin/test-watcher: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Run a reduced test-suite (no MyPy) every time a file is modified. 4 | # 5 | # Super handy while doing Test Driven Development. 6 | 7 | for module in pytest-watch pytest-flake8; do 8 | pip freeze | egrep -q "^${module}==" || pip install ${module} 9 | done 10 | 11 | pytest-watch --clear --wait tedi -- -c setup.cfg.pytest-watch --flake8 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==6.7 2 | docker==3.4.1 3 | jinja2==2.10 4 | pyconfig>=3,<4 5 | pyyaml==3.13 6 | wget==3.2 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [tool:pytest] 5 | addopts = --verbose --cov --cov-report term --cov-report html --flake8 --mypy 6 | 7 | [coverage:run] 8 | omit = 9 | /etc/python3.6/sitecustomize.py 10 | venv/**, 11 | tedi/tests/**, 12 | .eggs/** 13 | /home/travis/virtualenv/** 14 | 15 | [flake8] 16 | max_line_length = 120 17 | 18 | [mypy] 19 | ignore_missing_imports = True 20 | -------------------------------------------------------------------------------- /setup.cfg.pytest-watch: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --flake8 3 | 4 | [flake8] 5 | max_line_length = 120 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup(name='tedi', 5 | version='0.13', 6 | description='Build tool for Elastic Stack Docker images', 7 | url='http://github.com/elastic/tedi', 8 | author='Elastic', 9 | author_email='infra@elastic.co', 10 | packages=['tedi'], 11 | python_requires='>=3.6<4', 12 | install_requires=open('requirements.txt').read().split('\n'), 13 | tests_require=[ 14 | 'flake8==3.5.0', 15 | 'mypy==0.560', 16 | 'pytest-cov==2.5.1', 17 | 'pytest-flake8==0.9.1', 18 | 'pytest-mypy==0.3.0', 19 | 'pytest==3.4.0', 20 | ], 21 | setup_requires=['pytest-runner==4.2.0'], 22 | entry_points=''' 23 | [console_scripts] 24 | tedi=tedi.cli:cli 25 | ''') 26 | -------------------------------------------------------------------------------- /tedi/README.md: -------------------------------------------------------------------------------- 1 | # Tedi Developer README 2 | 3 | ## Design Goals 4 | 5 | ### Stage isolation 6 | Each major step in the build process can be invoked in isolation. For example, 7 | it should be possible to render all templates to create a Docker build context, 8 | without invoking `docker build`. 9 | 10 | ### Stage transparency 11 | The results of each stage shall be expressed as human-readable files on the 12 | filesystem. These are accessible and familiar. It also allows "breaking in" 13 | to the build process at various points when needed for debugging. 14 | 15 | In particular, the _build context_ which is passed to Docker (or other build 16 | tool) should be in the standard on-disk format so that Tedi can be used to 17 | render the context but is not required to execute the build (though it can do 18 | that, too). 19 | 20 | ### Minimal path manipulation 21 | File and URL paths shall be as flat as possible. When a complex path must be 22 | manipulated, it should done once only, with the intent of shortening, 23 | flattening, simplifying the path. 24 | 25 | ### Get logic out of templates 26 | If logic, particularly string manipulation, can be provided in Tedi that can 27 | reduce the need for such logic in template files, Tedi should aim to provide it. 28 | 29 | ### Fail fast 30 | Be strict about proceeding with each stage of the build. For example, some 31 | systems silently continue after rendering null values to a template. That's 32 | an error. Tedi should halt abruptly and loudly! 33 | 34 | It's better to crash than to proceed in a indeterminate state. It's even better 35 | to halt with a helpful message. 36 | 37 | ## Important Classes 38 | 39 | Tedi is (mostly) object-oriented in implementation. Many of the most important 40 | classes map closely to sections in the `tedi.yml` file. Where appropriate, those 41 | classes will have a `from_config` class method which is a factory method for 42 | instantiating, for example, an `Image` object from a block of YAML in the 43 | `images` section of `tedi.yml`. 44 | 45 | ### Project 46 | This is the highest level class. There will be only one `Project` object 47 | per run. It maps to the _entire_ contents of `tedi.yml`. 48 | 49 | ### Image 50 | The image class represents a single image build, and in particular the Docker 51 | _build context_ that goes into it. In `tedi.yml`, each block declared under the 52 | top-level `images` key becomes an `Image` object. 53 | 54 | An `Image` knows how to prepare a build context for itself, which consists 55 | mostly of plain and template _files_ in a `Fileset`, and _assets_ (think tarballs) 56 | that are managed by an `Assetset`. 57 | 58 | ### Fileset 59 | A `Fileset` is all the hand-crafted files, directories, and templates that go 60 | into a build. In practice, this is the contents of the `.tedi/template` 61 | directory. The `Fileset` does not include files that are downloaded or copied 62 | from elsewhere i.e. "assets". 63 | 64 | ### Asset 65 | An `Asset` defines a file that we need to get from somewhere else. It has a URL 66 | and a local filename. `Asset`s know how to _acquire_ themselves for use in the 67 | build context, where they will show up as the specified local filename. HTTP 68 | URLs will be downloaded and `file://` URLs will be copied into the build context 69 | from elsewhere on the filesystem. 70 | 71 | Assets are cached quite aggressively (perhaps excessively) to speed up 72 | subsequent builds. The cache can be invalidated; see `tedi clean --help`. 73 | 74 | ### Assetset 75 | An `Assetset` is a collection of `Assets`. A Tedi build is invoked with a single 76 | `Assetset`. The `Assets` defined therein will be acquired from their URLs and 77 | then made available to the build context as local files. 78 | 79 | `Assetset`s are created from blocks declared under the `asset_sets` key in 80 | `tedi.yml`. 81 | 82 | ## Glossary 83 | 84 | #### Build context 85 | All the files that are provided to Docker when building an image. After the 86 | rendering stage, the build context consists of: 87 | 88 | * The whole directory structure of `./tedi/template/`, excluding `.j2` files. 89 | * Rendered versions of all `.j2` files, with the extension removed. 90 | * Copies (actually hard links) of any assets. 91 | 92 | Each image declared in `tedi.yml` produces a complete build context, which is 93 | stored in its own directory under `.tedi/build/`. 94 | 95 | #### Fact 96 | A datum that is needed to correctly build an image. Mostly used for 97 | string expansions in Jinja2 templates, such as `Dockerfile` templates. The 98 | current `elastic_version` is a good example of a fact. Facts are generally 99 | defined in `tedi.yml`, either at the project level: 100 | 101 | ``` yaml 102 | facts: 103 | color: green 104 | ``` 105 | 106 | or on a per-image basis: 107 | 108 | ``` yaml 109 | images: 110 | gelato: 111 | facts: 112 | flavor: lemon 113 | ``` 114 | 115 | They can also be specified on the command-line and through the environment. Check 116 | the [user README](../README.md). 117 | 118 | #### Tedi directory 119 | The directory `.tedi` within the project that is to be built. This directory 120 | must contain `tedi.yml` and a `template` directory 121 | 122 | #### Template directory 123 | The template directory at `.tedi/template` provides the skeleton for Docker build 124 | contexts. It can contain an arbitrary directory structure with Jinja2 files and/or 125 | plain files. To be useful, the template directory will need a `Dockerfile.j2` (or 126 | possibly a plain `Dockerfile`) at its root. 127 | 128 | #### Template file 129 | A Jinja2 template file. Any file in the template directory with a `.j2` extension 130 | will be processed through Jinja2, expanding any facts, before it is placed in 131 | the build context (without the `.j2` extension). 132 | 133 | #### Plain file 134 | Any file in the tedi directory that does not have a `.j2` extension is simply 135 | copied to the build context, preserving directory structure. 136 | -------------------------------------------------------------------------------- /tedi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/__init__.py -------------------------------------------------------------------------------- /tedi/asset.py: -------------------------------------------------------------------------------- 1 | import wget 2 | from pathlib import Path 3 | from typing import Union 4 | from urllib.error import HTTPError, URLError 5 | from .logging import getLogger 6 | from .process import fail 7 | 8 | logger = getLogger(__name__) 9 | 10 | 11 | class Asset(): 12 | """A file that can be acquired (downloaded or copied) for use in a build.""" 13 | def __init__(self, filename: str, source: str) -> None: 14 | self.filename = filename 15 | self.source = source 16 | logger.debug(f'New Asset: {self}') 17 | 18 | def __repr__(self): 19 | return f'Asset(filename="{self.filename}", source="{self.source}")' 20 | 21 | def acquire(self, target_dir: Union[Path, str]) -> None: 22 | """Acquire this asset from its source. 23 | 24 | The file will be downloaded (or copied) to the target_dir, with the name 25 | stored in the filename property. 26 | """ 27 | target_dir = Path(target_dir) 28 | target_dir.mkdir(parents=True, exist_ok=True) 29 | target = target_dir / self.filename 30 | if target.exists(): 31 | logger.debug(f'Using cached asset: {self.source} -> {target}') 32 | else: 33 | logger.info(f'Acquiring asset: {self.source} -> {target}') 34 | try: 35 | wget.download(self.source, str(target), bar=None) 36 | except (HTTPError, URLError) as e: 37 | logger.critical(f'Download error: {self.source}') 38 | logger.critical(e) 39 | fail() 40 | -------------------------------------------------------------------------------- /tedi/assetset.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from .asset import Asset 3 | from .jinja_renderer import JinjaRenderer 4 | from .logging import getLogger 5 | from .paths import Paths 6 | from .process import fail 7 | from .factset import Factset 8 | 9 | logger = getLogger(__name__) 10 | paths = Paths() 11 | 12 | 13 | class Assetset(): 14 | """A collection of Assets (external files) for use in a build.""" 15 | def __init__(self, assets: List[Asset]) -> None: 16 | self.assets = assets 17 | logger.debug(f'New Assetset: {self}') 18 | 19 | @classmethod 20 | def from_config(cls, config: List[dict]=[], facts: Factset=Factset()): 21 | """Create an Assetset using a configuration block in tedi.yml 22 | 23 | The YAML form looks like this: 24 | 25 | - filename: bigball.tar.gz 26 | source: http://example.org/downloads/bigball-v1.tar.gz 27 | - filename: mince_pie 28 | source: file:///usr/local/pies/{{ pie_type_fact }} 29 | 30 | This method accepts the above data structure as a Python list. It also 31 | arranges for template tokens in the configuration to be expanded, using 32 | the facts available in the Factset. 33 | """ 34 | assets = [] 35 | renderer = JinjaRenderer(facts) 36 | for asset in config: 37 | if 'filename' not in asset or 'source' not in asset: 38 | logger.critical('Each asset must declare "filename" and "source".') 39 | fail() 40 | 41 | # Expand any facts in the asset declaration. 42 | filename = renderer.render_string(asset['filename']) 43 | source = renderer.render_string(asset['source']) 44 | assets.append(Asset(filename, source)) 45 | return cls(assets) 46 | 47 | def __repr__(self) -> str: 48 | return f'Assetset({self.assets})' 49 | 50 | def acquire(self) -> None: 51 | """Acquire (download) all the assets in this Assetset.""" 52 | for asset in self.assets: 53 | asset.acquire(paths.assets_path) 54 | -------------------------------------------------------------------------------- /tedi/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pyconfig 3 | from typing import Any, Tuple 4 | from . import commands 5 | from .logging import getLogger 6 | from .process import fail 7 | 8 | logger = getLogger(__name__) 9 | 10 | 11 | @click.group() 12 | def cli(): 13 | pass 14 | 15 | 16 | @cli.command() 17 | @click.option('--fact', multiple=True, help='Set a fact, like --fact=color:blue.') 18 | def render(fact: Tuple[str]): 19 | """Render build contexts (.tedi/*).""" 20 | logger.debug('render subcommand called from cli') 21 | set_fact_flags(fact) 22 | commands.clean() 23 | commands.render() 24 | 25 | 26 | @cli.command() 27 | @click.option('--clean-assets', is_flag=True, help='Also remove assets.') 28 | def clean(clean_assets: bool): 29 | """Remove rendered files and downloaded assets.""" 30 | logger.debug('clean subcommand called from cli') 31 | set_flag('clean-assets', clean_assets) 32 | commands.clean() 33 | 34 | 35 | @cli.command() 36 | @click.option('--clean-assets', is_flag=True, help='Reaquire assets.') 37 | @click.option('--asset-set', default='default', help='Specify the asset set to build with.') 38 | @click.option('--fact', multiple=True, help='Set a fact, like --fact=color:blue.') 39 | def build(clean_assets: bool, asset_set: str, fact: Tuple[str]): 40 | """Build images.""" 41 | logger.debug('build subcommand called from cli') 42 | set_flag('asset-set', asset_set) 43 | set_flag('clean-assets', clean_assets) 44 | set_fact_flags(fact) 45 | # FIXME: Should we auto-clean assets if the assetset changes? 46 | commands.clean() 47 | commands.acquire() 48 | commands.render() 49 | commands.build() 50 | 51 | 52 | @cli.command() 53 | @click.option('--asset-set', default='default', help='Specify the asset set to acquire.') 54 | @click.option('--fact', multiple=True, help='Set a fact, like --fact=color:blue.') 55 | def acquire(asset_set: str, fact: Tuple[str]): 56 | """Acquire assets.""" 57 | logger.debug('acquire subcommand called from cli') 58 | set_flag('asset-set', asset_set) 59 | set_fact_flags(fact) 60 | 61 | # Since the user explicitly called "acquire", make sure they get fresh assets 62 | # by cleaning the assets dir first. 63 | set_flag('clean-assets', True) 64 | commands.clean() 65 | 66 | commands.acquire() 67 | 68 | 69 | def set_flag(flag: str, value: Any) -> None: 70 | """Store a CLI flag in the config as "cli.flags.FLAG".""" 71 | pyconfig.set(f'cli.flags.{flag}', value) 72 | 73 | 74 | def get_flag(flag: str, default: Any = None) -> Any: 75 | """Get a CLI flag from the config.""" 76 | return pyconfig.get(f'cli.flags.{flag}', default) 77 | 78 | 79 | def set_fact_flags(flag_args: Tuple[str]) -> None: 80 | """Take "--fact" flags from the CLI and store them in the config as a dict.""" 81 | facts = {} 82 | 83 | for arg in flag_args: 84 | if ':' not in arg: 85 | logger.critical('Arguments to "--fact" must be colon seperated.') 86 | logger.critical('Like: "tedi --fact=temperature:hot') 87 | fail() 88 | fact, value = arg.split(':', 1) 89 | logger.debug(f'Setting fact from cli: "{fact}" -> "{value}"') 90 | facts[fact] = value 91 | 92 | set_flag('fact', facts) 93 | -------------------------------------------------------------------------------- /tedi/commands.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from . import cli 3 | from .paths import Paths 4 | from .project import Project 5 | from .logging import getLogger 6 | 7 | logger = getLogger(__name__) 8 | paths = Paths() 9 | 10 | 11 | def render() -> None: 12 | """Render the projects to static files.""" 13 | Project().render() 14 | 15 | 16 | def clean() -> None: 17 | """Remove all rendered files and, optionally, assets.""" 18 | if paths.build_path.exists(): 19 | logger.debug('Recursively deleting render path: %s' % str(paths.build_path)) 20 | shutil.rmtree(str(paths.build_path)) 21 | 22 | if cli.get_flag('clean-assets') and paths.assets_path.exists(): 23 | logger.debug('Recursively deleting asset path: %s' % str(paths.assets_path)) 24 | shutil.rmtree(str(paths.assets_path)) 25 | 26 | 27 | def build() -> None: 28 | """Build the images from the rendered files.""" 29 | Project().build() 30 | 31 | 32 | def acquire() -> None: 33 | """Acquire assets.""" 34 | Project().acquire_assets() 35 | -------------------------------------------------------------------------------- /tedi/factset.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .logging import getLogger 3 | from .paths import Paths 4 | import pyconfig 5 | 6 | logger = getLogger(__name__) 7 | paths = Paths() 8 | 9 | 10 | class Factset(object): 11 | """A dictionary like object for storing facts. 12 | 13 | A Factset is simply a mapping of strings to strings, as used for variable 14 | expansions in Jinja2 templates. 15 | 16 | The most useful feature of a Factset, when compared to a Dict, is that it 17 | sets various defaults at contruction time. 18 | 19 | Null (None) values are not supported in a Factset. Setting a fact to 20 | None deletes it entirely. 21 | """ 22 | def __init__(self, **keyword_facts): 23 | self.facts = keyword_facts 24 | 25 | # Look for facts passed in via the environment. 26 | for key, value in os.environ.items(): 27 | if key.startswith('TEDI_FACT_'): 28 | fact = key.split('_', 2)[-1] 29 | self[fact] = value 30 | 31 | if 'image_tag' not in self: 32 | self['image_tag'] = 'latest' 33 | 34 | # Then look for facts from the command line. 35 | cli_facts = pyconfig.get(f'cli.flags.fact') 36 | if cli_facts: 37 | for fact, value in cli_facts.items(): 38 | self[fact] = value 39 | 40 | # FIXME: Make this something you can switch off or on. 41 | for var, value in os.environ.items(): 42 | self[f'ENV_{var}'] = value 43 | 44 | logger.debug(f'New Factset: {self}') 45 | 46 | def __repr__(self): 47 | return 'Factset(**%s)' % {k: v for k, v in self.facts.items() if not k.startswith('ENV_')} 48 | 49 | def __getitem__(self, key): 50 | return self.facts[key] 51 | 52 | def __setitem__(self, key, value): 53 | # Setting a fact to None deletes it. 54 | if value is None: 55 | try: 56 | del(self[key]) 57 | except KeyError: 58 | pass 59 | finally: 60 | return 61 | 62 | # All facts should be string->string mappings, since they are intended 63 | # for use in Jinja2 templates. 64 | if not isinstance(key, str): 65 | raise ValueError 66 | if not isinstance(value, str): 67 | raise ValueError 68 | 69 | self.facts[key] = value 70 | 71 | def __delitem__(self, key): 72 | del(self.facts[key]) 73 | 74 | def __iter__(self): 75 | for key in self.facts.keys(): 76 | yield key 77 | 78 | def __contains__(self, key): 79 | return key in self.facts 80 | 81 | def get(self, key, default=None): 82 | return self.facts.get(key, default) 83 | 84 | def to_dict(self): 85 | """Return a dictionary representation of this Factset.""" 86 | return self.copy().facts 87 | 88 | def update(self, *args, **kwargs): 89 | """Update with new facts, like Dict.update().""" 90 | self.facts.update(*args, **kwargs) 91 | 92 | def copy(self): 93 | """Return a copy of this Factset (not a reference to it).""" 94 | return Factset(**self.facts) 95 | -------------------------------------------------------------------------------- /tedi/fileset.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | from .logging import warn_once 5 | 6 | 7 | logger = logging.getLogger('tedi.fileset') 8 | 9 | 10 | class Fileset: 11 | """A Fileset is a collection of all the files under a given top_dir directory.""" 12 | def __init__(self, top_dir): 13 | self.top_dir = Path(top_dir) 14 | if not self.top_dir.exists() or not self.top_dir.is_dir(): 15 | warn_once(logger, f'Directory {top_dir.resolve()} not found.') 16 | 17 | self.files = [] 18 | for root, subdirs, files in os.walk(str(self.top_dir)): 19 | for subdir in subdirs: 20 | files.append(Path(subdir)) 21 | for f in files: 22 | self.files.append(Path(root) / f) 23 | 24 | def __repr__(self): 25 | return "Fileset('%s')" % self.top_dir 26 | 27 | def __iter__(self): 28 | """Iterate over all files in the fileset, relative to top_dir.""" 29 | for f in self.files: 30 | yield f 31 | 32 | def __len__(self): 33 | return len(self.files) 34 | 35 | def __contains__(self, path): 36 | return Path(path) in self.files 37 | -------------------------------------------------------------------------------- /tedi/image.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | from typing import List 6 | from .fileset import Fileset 7 | from .jinja_renderer import JinjaRenderer 8 | from .factset import Factset 9 | from .logging import getLogger 10 | from .paths import Paths 11 | 12 | logger = getLogger(__name__) 13 | paths = Paths() 14 | 15 | 16 | class Image(): 17 | def __init__(self, image_name: str, facts: Factset = Factset(), 18 | image_aliases: List[str] = [], path=paths.template_path) -> None: 19 | self.image_name = image_name 20 | self.image_aliases = image_aliases 21 | self.facts = facts 22 | self.source_dir = path 23 | 24 | self.target_dir = paths.build_path / image_name 25 | self.files = Fileset(self.source_dir) 26 | self.renderer = JinjaRenderer(self.facts) 27 | self.docker = docker.from_env() 28 | logger.debug(f'New Image: {self}') 29 | 30 | @classmethod 31 | def from_config(cls, name, config, facts: Factset = Factset()): 32 | """Create an Image using a configuration block from tedi.yml 33 | 34 | Like this: 35 | 36 | nyancat: 37 | aliases: 38 | - rainbow_cat 39 | - happy_cat 40 | facts: 41 | colorful: true 42 | 43 | Facts defined in the configuration will be added to the Factset 44 | passed in as the "facts" parameter. This is useful for adding 45 | image-specific facts on top of more general facts from the project. 46 | 47 | The underlying Factset is not mutated. An independant copy is made 48 | for the Image's use. 49 | """ 50 | if not config: 51 | config = {} 52 | 53 | logger.debug(f'Loaded image config for {name}: {config}') 54 | facts = facts.copy() 55 | 56 | if 'facts' in config: 57 | facts.update(config['facts']) 58 | 59 | return cls( 60 | image_name=name, 61 | facts=facts, 62 | image_aliases=config.get('aliases', []) 63 | ) 64 | 65 | def __repr__(self): 66 | return "Image(source_dir='%s', target_dir='%s', facts=%s)" % \ 67 | (self.files.top_dir, self.target_dir, self.renderer.facts) 68 | 69 | def render(self): 70 | """Render the template files to a ready-to-build directory.""" 71 | if self.target_dir.exists(): 72 | logger.debug(f'Removing old build context: {self.target_dir}') 73 | shutil.rmtree(str(self.target_dir)) 74 | 75 | logger.info(f'Rendering build context: {self.source_dir} -> {self.target_dir}') 76 | self.target_dir.mkdir(parents=True) 77 | 78 | for source in self.files: 79 | target = self.target_dir / source.relative_to(self.files.top_dir) 80 | if source.is_dir(): 81 | logger.debug(f'Creating directory: {target}') 82 | target.mkdir() 83 | elif source.suffix == '.j2': 84 | target = Path(target.with_suffix('')) # Remove '.j2' 85 | logger.debug(f'Rendering file: {source} -> {target}') 86 | with target.open('w') as f: 87 | f.write(self.renderer.render(source)) 88 | else: 89 | logger.debug(f'Copying file: {source} -> {target}') 90 | shutil.copy2(str(source), str(target)) 91 | self.link_assets() 92 | 93 | def link_assets(self): 94 | """Make assets available in the build context via hard links.""" 95 | if not paths.assets_path.exists(): 96 | return 97 | 98 | for asset in os.listdir(paths.assets_path): 99 | source = paths.assets_path / asset 100 | target = self.target_dir / asset 101 | logger.debug(f'Hard linking: {source} -> {target}') 102 | try: 103 | os.link(source, target) 104 | except PermissionError: 105 | # This happens on vboxfs, as often used with Vagrant. 106 | logger.warn(f'Hard linking failed. Copying: {source} -> {target}') 107 | shutil.copyfile(source, target) 108 | 109 | def build(self): 110 | """Run a "docker build" on the rendered image files.""" 111 | dockerfile = self.target_dir / 'Dockerfile' 112 | if not dockerfile.exists(): 113 | logger.warn(f'No Dockerfile found at {dockerfile}. Cannot build {self.image_name}.') 114 | return 115 | 116 | tag = self.facts["image_tag"] 117 | fqin = f'{self.image_name}:{tag}' 118 | 119 | logger.info(f'Building image: {fqin}') 120 | 121 | image, build_log = self.docker.images.build( 122 | path=str(self.target_dir), 123 | # Frustratingly, Docker change their minds about which part is the "tag". 124 | # We say that the part after the colon is the tag, like ":latest". 125 | # Docker does too, most of the time, but for this function, the "tag" 126 | # parameter takes a fully qualified image name. 127 | tag=fqin 128 | ) 129 | 130 | # The output you'd normally get on the terminal from `docker build` can 131 | # be found in the build log, along with some extra metadata lines we 132 | # don't care about. The good stuff is in the lines that have a 'stream' 133 | # field. 134 | for line in build_log: 135 | if 'stream' in line: 136 | message = line['stream'].strip() 137 | if message: 138 | logger.debug(message) 139 | 140 | for alias in self.image_aliases: 141 | logger.info(f'Aliasing image: {self.image_name}:{tag} -> {alias}:{tag}') 142 | image.tag(f'{alias}:{tag}') 143 | -------------------------------------------------------------------------------- /tedi/jinja_renderer.py: -------------------------------------------------------------------------------- 1 | from jinja2 import Environment, FileSystemLoader, StrictUndefined 2 | from jinja2.exceptions import UndefinedError 3 | from .logging import getLogger 4 | from .process import fail 5 | 6 | logger = getLogger(__name__) 7 | 8 | 9 | class JinjaRenderer(): 10 | def __init__(self, facts): 11 | self.facts = facts 12 | 13 | def render(self, template_file) -> str: 14 | """Render a template file with Jinja2. Return the result.""" 15 | return self.render_string(open(template_file).read()) 16 | 17 | def render_string(self, template_string: str) -> str: 18 | """Render a template string with Jinja2. Return the result.""" 19 | jinja_env = Environment( 20 | loader=FileSystemLoader('.'), 21 | undefined=StrictUndefined) 22 | template = jinja_env.from_string(template_string) 23 | try: 24 | rendered = template.render(self.facts.to_dict()) 25 | except UndefinedError as e: 26 | logger.critical('Template rendering failed.') 27 | logger.critical(f'The Jinja2 template engine said: "{e.message}".') 28 | logger.critical('Declare this fact in tedi.yml or use a CLI flag like: "--fact=FACT:VALUE".') 29 | fail() 30 | return rendered 31 | -------------------------------------------------------------------------------- /tedi/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | from os import environ 4 | from logging import Logger 5 | from typing import List 6 | 7 | emitted_warnings: List[str] = [] 8 | 9 | if 'TEDI_DEBUG' in environ and environ['TEDI_DEBUG'].lower() == 'true': 10 | log_level = 'DEBUG' 11 | log_format = 'verbose' 12 | else: 13 | log_level = 'INFO' 14 | log_format = 'terse' 15 | 16 | config = { 17 | 'version': 1, 18 | 'formatters': { 19 | 'terse': { 20 | 'format': '%(message)s' 21 | }, 22 | 'verbose': { 23 | 'format': '%(name)s:%(lineno)s %(levelname)s: %(message)s' 24 | } 25 | }, 26 | 'handlers': { 27 | 'console': { 28 | 'class': 'logging.StreamHandler', 29 | 'level': log_level, 30 | 'formatter': log_format, 31 | 'stream': 'ext://sys.stdout' 32 | } 33 | }, 34 | 'loggers': { 35 | 'tedi': { 36 | 'level': log_level, 37 | 'handlers': ['console'] 38 | } 39 | } 40 | } 41 | 42 | 43 | def getLogger(name=None): 44 | logging.config.dictConfig(config) 45 | logger = logging.getLogger(name) 46 | logger.warn_once = warn_once 47 | return logger 48 | 49 | 50 | # FIXME: Make a custom logger by inheriting from Logger 51 | def warn_once(logger: Logger, message: str): 52 | if message not in emitted_warnings: 53 | logger.warning(message) 54 | emitted_warnings.append(message) 55 | -------------------------------------------------------------------------------- /tedi/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class Paths(object): 5 | @property 6 | def tedi_path(self): 7 | return Path('.tedi') 8 | 9 | @property 10 | def assets_path(self): 11 | return self.tedi_path / 'assets' 12 | 13 | @property 14 | def build_path(self): 15 | return self.tedi_path / 'build' 16 | 17 | @property 18 | def template_path(self): 19 | return self.tedi_path / 'template' 20 | -------------------------------------------------------------------------------- /tedi/process.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .logging import getLogger 3 | 4 | logger = getLogger(__name__) 5 | 6 | 7 | def fail(exit_code=1): 8 | logger.critical("** FATAL ERROR **") 9 | sys.exit(exit_code) 10 | -------------------------------------------------------------------------------- /tedi/project.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import yaml 3 | from . import cli 4 | from .paths import Paths 5 | from .image import Image 6 | from .factset import Factset 7 | from .assetset import Assetset 8 | from .process import fail 9 | 10 | logger = logging.getLogger('tedi.project') 11 | paths = Paths() 12 | 13 | 14 | class Project(): 15 | def __init__(self, path=paths.tedi_path): 16 | self.path = path 17 | config_path = self.path / 'tedi.yml' 18 | 19 | try: 20 | with open(config_path) as config_file: 21 | self.config = yaml.load(config_file.read()) 22 | except FileNotFoundError: 23 | logger.critical(f'No configuration file found at {config_path.resolve()}') 24 | fail() 25 | if self.config is None: 26 | logger.critical(f'Config file "{self.path}/tedi.yml" is empty.') 27 | fail() 28 | logger.debug(f'Loaded project config from {self.path}: {self.config}') 29 | 30 | if 'facts' in self.config: 31 | self.facts = Factset(**self.config['facts']) 32 | else: 33 | self.facts = Factset() 34 | 35 | # Set any facts that were passed as CLI flags. 36 | self.facts.update(cli.get_flag('fact', {})) 37 | 38 | # A project has a collection of one or more images. 39 | self.images = [] 40 | for image_name, image_config in self.config['images'].items(): 41 | self.images.append(Image.from_config(image_name, image_config, self.facts)) 42 | 43 | # A project can have "asset sets" ie. files to be downloaded or copied 44 | # into the build context. 45 | self.asset_sets = {} 46 | if 'asset_sets' in self.config: 47 | for name, assets in self.config['asset_sets'].items(): 48 | if assets: 49 | self.asset_sets[name] = Assetset.from_config(assets, self.facts) 50 | else: 51 | # Die with a helpful message if an empty asset set was declared. 52 | logger.critical(f'Empty asset set "{name}" in tedi.yml') 53 | fail() 54 | 55 | def __repr__(self): 56 | return f'Project("{self.path}")' 57 | 58 | def render(self): 59 | """Render the build contexts.""" 60 | for image in self.images: 61 | image.render() 62 | 63 | def build(self): 64 | """Build all Images.""" 65 | for image in self.images: 66 | image.build() 67 | 68 | def acquire_assets(self): 69 | asset_set = cli.get_flag('asset-set') 70 | if not self.asset_sets: 71 | logger.debug('No asset sets for this project. Will not acquire any files.') 72 | return 73 | 74 | if asset_set not in self.asset_sets: 75 | logger.critical(f'Asset set "{asset_set}" not defined in tedi.yml.') 76 | fail() 77 | 78 | self.asset_sets[asset_set].acquire() 79 | -------------------------------------------------------------------------------- /tedi/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/tests/__init__.py -------------------------------------------------------------------------------- /tedi/tests/fixtures/assets/text-asset: -------------------------------------------------------------------------------- 1 | I am an asset! 2 | -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/tedi.yml: -------------------------------------------------------------------------------- 1 | images: 2 | simple-vanilla: 3 | facts: 4 | image_flavor: vanilla 5 | 6 | simple-chocolate: 7 | facts: 8 | image_flavor: chocolate 9 | 10 | facts: 11 | animal: cow 12 | cow_color: Null 13 | fact_based_filename: filename-from-fact 14 | fact_based_source: file:///source-from-fact 15 | 16 | asset_sets: 17 | default: 18 | - filename: default.tar.gz 19 | source: file:///etc/issue 20 | special: 21 | - filename: special.tar.gz 22 | source: file:///etc/issue 23 | fact_based_assets: 24 | - filename: '{{ fact_based_filename }}.tar.gz' 25 | source: '{{ fact_based_source }}' 26 | 27 | 28 | default_asset_set: general 29 | -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/template/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ENV TEST=true 3 | -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/template/README.md.j2: -------------------------------------------------------------------------------- 1 | Test 2 | ==== 3 | 4 | How now, {{ cow_color }} cow? 5 | -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/template/deep/directory/structure/deepfile01: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/tests/fixtures/projects/simple/template/deep/directory/structure/deepfile01 -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/template/files/file01: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/tests/fixtures/projects/simple/template/files/file01 -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/simple/template/files/file02: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/tests/fixtures/projects/simple/template/files/file02 -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/with-docker-registry/tedi.yml: -------------------------------------------------------------------------------- 1 | images: 2 | repository/image: {} 3 | 4 | facts: 5 | docker_registry: docker.example.org 6 | -------------------------------------------------------------------------------- /tedi/tests/fixtures/projects/with-docker-registry/template/thi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/tedi/446f3eae3fdda1fbf8eae4bfd5568b87c2823d63/tedi/tests/fixtures/projects/with-docker-registry/template/thi -------------------------------------------------------------------------------- /tedi/tests/fixtures/template/simple.j2: -------------------------------------------------------------------------------- 1 | The canary is a {{ color }} bird. 2 | -------------------------------------------------------------------------------- /tedi/tests/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | fixtures_path = Path('tedi/tests/fixtures').resolve() 5 | -------------------------------------------------------------------------------- /tedi/tests/test_asset.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from .. import process 3 | 4 | 5 | def test_fail_exits(): 6 | with patch('sys.exit') as exit: 7 | process.fail() 8 | assert exit.called 9 | -------------------------------------------------------------------------------- /tedi/tests/test_assetset.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, join 2 | from pytest import fixture 3 | from shutil import rmtree 4 | from ..asset import Asset 5 | from ..assetset import Assetset 6 | from ..paths import Paths 7 | 8 | paths = Paths() 9 | fixtures_dir_path = join(dirname(__file__), 'fixtures', 'assets') 10 | 11 | 12 | @fixture 13 | def assetset() -> Assetset: 14 | asset = Asset( 15 | filename='acquired_asset', 16 | source=f'file://{fixtures_dir_path}/text-asset' 17 | ) 18 | 19 | try: 20 | rmtree(str(paths.assets_path)) 21 | except FileNotFoundError: 22 | pass 23 | return Assetset([asset]) 24 | 25 | 26 | def test_acquire_downloads_files(assetset): 27 | assetset.acquire() 28 | assert (paths.assets_path / 'acquired_asset').exists() 29 | -------------------------------------------------------------------------------- /tedi/tests/test_builder.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import shutil 3 | from pathlib import Path 4 | from pytest import fixture 5 | from ..image import Image 6 | from ..factset import Factset 7 | from uuid import uuid4 8 | 9 | test_run_id = uuid4() 10 | source_dir = Path('tedi/tests/fixtures/projects/simple/template') 11 | target_dir = Path(f'.tedi/build/tedi-test-{test_run_id}') 12 | assets_dir = Path(f'.tedi/assets') 13 | assert source_dir.exists() 14 | 15 | docker_client = docker.from_env() 16 | test_image_name = f'tedi-test-{test_run_id}' 17 | test_image_aliases = [ 18 | f'tedi-test-alias1-{test_run_id}', 19 | f'tedi-test-alias2-{test_run_id}', 20 | f'tedi.example.org/tedi-test-{test_run_id}' 21 | ] 22 | 23 | 24 | @fixture 25 | def image(): 26 | if target_dir.exists(): 27 | shutil.rmtree(str(target_dir)) 28 | facts = Factset(cow_color='brown') 29 | try: 30 | docker_client.images.remove(test_image_name) 31 | except docker.errors.ImageNotFound: 32 | pass 33 | return Image(test_image_name, facts, image_aliases=test_image_aliases, path=source_dir) 34 | 35 | 36 | def image_exists(name, tag='latest'): 37 | image_found = False 38 | for image in docker_client.images.list(): 39 | for fqin in image.tags: 40 | print(fqin) 41 | if f'{name}:{tag}' in image.tags: 42 | image_found = True 43 | return image_found 44 | 45 | 46 | def test_render_creates_the_target_dir(image): 47 | image.render() 48 | assert target_dir.exists() 49 | assert target_dir.is_dir() 50 | 51 | 52 | def test_render_copies_normal_files(image): 53 | image.render() 54 | assert (target_dir / 'Dockerfile').exists() 55 | 56 | 57 | def test_render_links_files_from_the_assets_dir(image): 58 | assets_dir.mkdir(parents=True, exist_ok=True) 59 | canary_id = str(uuid4()) 60 | asset = (assets_dir / canary_id) 61 | with asset.open('w') as f: 62 | f.write('asset_contents') 63 | 64 | image.render() 65 | assert (target_dir / canary_id).exists() 66 | 67 | 68 | def test_render_renders_jinja2_templates(image): 69 | target = (target_dir / 'README.md') 70 | image.render() 71 | assert target.exists() 72 | with target.open() as f: 73 | assert 'How now, brown cow?' in f.read() 74 | 75 | 76 | def test_build_creates_a_docker_image(image): 77 | image.render() 78 | image.build() 79 | assert image_exists(test_image_name) 80 | 81 | 82 | def test_names_image_with_all_aliases(image): 83 | image.render() 84 | image.build() 85 | for alias in test_image_aliases: 86 | assert image_exists(alias) 87 | 88 | 89 | def test_from_config_updates_facts_from_the_config(): 90 | config = { 91 | 'facts': { 92 | 'freshness': 'intense' 93 | } 94 | } 95 | 96 | image = Image.from_config( 97 | name=test_image_name, 98 | config=config, 99 | ) 100 | 101 | assert image.facts['freshness'] == 'intense' 102 | 103 | 104 | def test_images_have_independant_factsets(): 105 | base_facts = Factset(color='black') 106 | 107 | config_one = {'facts': {'color': 'red'}} 108 | image_one = Image.from_config(name=test_image_name, config=config_one, facts=base_facts) 109 | 110 | config_two = {'facts': {'color': 'blue'}} 111 | image_two = Image.from_config(name=test_image_name, config=config_two, facts=base_facts) 112 | 113 | assert base_facts['color'] == 'black' 114 | assert image_one.facts['color'] == 'red' 115 | assert image_two.facts['color'] == 'blue' 116 | -------------------------------------------------------------------------------- /tedi/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | import shutil 3 | from click.testing import CliRunner 4 | from contextlib import contextmanager 5 | import pyconfig 6 | from ..cli import cli, get_flag, set_flag, set_fact_flags 7 | from ..paths import Paths 8 | from .paths import fixtures_path 9 | 10 | paths = Paths() 11 | 12 | 13 | @contextmanager 14 | def project_runner(fixture='simple'): 15 | fixture_path = (fixtures_path / 'projects' / fixture) 16 | runner = CliRunner() 17 | with runner.isolated_filesystem(): 18 | # Copy the project fixture into the isolated filesystem dir. 19 | shutil.copytree(fixture_path, '.tedi') 20 | 21 | # Monkeypatch a helper method onto the runner to make running commands 22 | # easier. 23 | runner.run = lambda command: runner.invoke(cli, command.split()) 24 | 25 | # And another for checkout the text output by the command. 26 | runner.output_of = lambda command: runner.run(command).output 27 | yield runner 28 | 29 | 30 | def in_file(string, test_file='simple-vanilla/README.md') -> bool: 31 | return (string in (paths.build_path / test_file).open().read()) 32 | 33 | 34 | def assert_command_cleans_path(runner, path, command): 35 | """Given a TEDI subcommand name, assert that it cleans up rendered files.""" 36 | path.mkdir(parents=True, exist_ok=True) 37 | canary = path / ('test-canary-%s' % uuid4()) 38 | canary.touch() 39 | assert runner.run(command).exit_code == 0 40 | assert canary.exists() is False 41 | 42 | 43 | def command_acquires_asset(runner, command, filename): 44 | """Check that an asset file was acquired to the assets dir.""" 45 | assert runner.run(command).exit_code == 0 46 | return (paths.assets_path / filename).exists() 47 | 48 | 49 | def test_render_command_has_valid_help_text(): 50 | with project_runner() as runner: 51 | assert 'Render' in runner.output_of('render --help') 52 | 53 | 54 | def test_clean_command_removes_rendered_files(): 55 | with project_runner() as runner: 56 | assert_command_cleans_path(runner, paths.build_path, 'clean') 57 | 58 | 59 | def test_render_command_cleans_build_path(): 60 | with project_runner() as runner: 61 | assert_command_cleans_path(runner, paths.build_path, 'render') 62 | 63 | 64 | def test_render_command_accepts_facts_as_cli_flags(): 65 | with project_runner() as runner: 66 | runner.run('render --fact=cow_color:cherry') 67 | assert in_file('How now, cherry cow?') 68 | 69 | 70 | def test_build_command_accepts_facts_as_cli_flags(): 71 | with project_runner() as runner: 72 | runner.run('build --fact=cow_color:cinnabar') 73 | assert in_file('How now, cinnabar cow?') 74 | 75 | 76 | def test_clean_command_removes_assets_with_clean_assets_flag(): 77 | with project_runner() as runner: 78 | assert_command_cleans_path(runner, paths.assets_path, 'clean --clean-assets') 79 | 80 | 81 | def test_build_command_removes_assets_with_clean_assets_flag(): 82 | with project_runner() as runner: 83 | assert_command_cleans_path(runner, paths.assets_path, 'build --clean-assets') 84 | 85 | 86 | def test_acquire_command_acquires_default_assets(): 87 | with project_runner() as runner: 88 | assert command_acquires_asset(runner, 'acquire', 'default.tar.gz') 89 | 90 | 91 | def test_acquire_command_does_not_acquire_non_default_assets(): 92 | with project_runner() as runner: 93 | assert not command_acquires_asset(runner, 'acquire', 'special.tar.gz') 94 | 95 | 96 | def test_acquire_command_acquires_assets_specified_by_asset_set_flag(): 97 | with project_runner() as runner: 98 | assert command_acquires_asset(runner, 'acquire --asset-set=special', 'special.tar.gz') 99 | 100 | 101 | def test_set_flag_assigns_facts_in_config(): 102 | set_flag('explode', False) 103 | assert pyconfig.get('cli.flags.explode') is False 104 | 105 | 106 | def test_get_flag_return_cli_flags(): 107 | pyconfig.set('cli.flags.fly', True) 108 | assert get_flag('fly') is True 109 | 110 | 111 | def test_get_flag_can_return_a_default(): 112 | assert get_flag('no-bananas', 'have-a-peanut') == 'have-a-peanut' 113 | 114 | 115 | def test_set_fact_flags_assigns_facts_in_config(): 116 | args = ( 117 | 'key:minor', 118 | 'tempo:adagio', 119 | 'time_signature:3:4' # <- Extra colon. Will it work? 120 | ) 121 | set_fact_flags(args) 122 | assert get_flag('fact')['key'] == 'minor' 123 | assert get_flag('fact')['tempo'] == 'adagio' 124 | assert get_flag('fact')['time_signature'] == '3:4' 125 | -------------------------------------------------------------------------------- /tedi/tests/test_factset.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ..factset import Factset 3 | from pytest import fixture, raises 4 | from unittest.mock import patch 5 | 6 | 7 | @fixture 8 | def facts(): 9 | return Factset(sky_color='blue') 10 | 11 | 12 | def test_can_test_membership(facts): 13 | assert 'sky_color' in facts 14 | 15 | 16 | def test_can_set_facts_like_a_dictionary(facts): 17 | facts['key_canary'] = 'bird' 18 | assert facts['key_canary'] == 'bird' 19 | 20 | 21 | def test_can_set_facts_via_contructor_keywords(facts): 22 | assert facts['sky_color'] == 'blue' 23 | 24 | 25 | def test_can_delete_facts(facts): 26 | del(facts['sky_color']) 27 | assert 'sky_color' not in facts 28 | 29 | 30 | def test_setting_a_fact_to_none_deletes_it(facts): 31 | facts['sky_color'] = None 32 | assert 'sky_color' not in facts 33 | 34 | 35 | def test_setting_a_new_key_to_none_does_nothing(facts): 36 | facts['point'] = None 37 | assert 'point' not in facts 38 | 39 | 40 | def test_setting_non_string_facts_is_an_error(facts): 41 | for value in (True, False, 5, object()): 42 | with raises(ValueError): 43 | facts['test'] = value 44 | 45 | 46 | def test_using_non_string_keys_is_an_error(facts): 47 | for value in (True, False, 5, object()): 48 | with raises(ValueError): 49 | facts[value] = 'test' 50 | 51 | 52 | def test_to_dict_returns_a_dictionary(facts): 53 | assert isinstance(facts.to_dict(), dict) 54 | assert 'sky_color' in facts.to_dict() 55 | 56 | 57 | def test_update_updates_facts_from_a_dict(facts): 58 | facts.update({'sky_color': 'black'}) 59 | assert facts['sky_color'] == 'black' 60 | 61 | 62 | def test_update_updates_facts_from_keywords(facts): 63 | facts.update(sky_color='pink') 64 | assert facts['sky_color'] == 'pink' 65 | 66 | 67 | def test_get_returns_none_on_missing_key(facts): 68 | assert facts.get('complaints') is None 69 | 70 | 71 | def test_get_returns_default_on_missing_key_when_asked(facts): 72 | assert facts.get('first_prize', 'consolation_prize') == 'consolation_prize' 73 | 74 | 75 | def test_copy_returns_an_independant_factset(facts): 76 | new_facts = facts.copy() 77 | new_facts['sky_color'] = 'grey' 78 | assert facts['sky_color'] == 'blue' 79 | assert new_facts['sky_color'] == 'grey' 80 | 81 | 82 | def test_it_can_glean_facts_from_special_environment_variables(): 83 | with patch.dict('os.environ', {'TEDI_FACT_bird': 'emu'}): 84 | assert Factset()['bird'] == 'emu' 85 | 86 | 87 | def test_it_maps_standard_environment_variable_to_facts(): 88 | for var, value in os.environ.items(): 89 | assert Factset()[f'ENV_{var}'] == value 90 | -------------------------------------------------------------------------------- /tedi/tests/test_fileset.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pytest import fixture 3 | from ..fileset import Fileset 4 | 5 | 6 | @fixture 7 | def fileset() -> Fileset: 8 | return Fileset(Path('tedi/tests/fixtures/projects/simple/template')) 9 | 10 | 11 | def test_top_dir_is_a_path(fileset): 12 | assert isinstance(fileset.top_dir, Path) 13 | 14 | 15 | def test_repr_is_valid_python(fileset): 16 | reparsed = eval(fileset.__repr__()) 17 | assert isinstance(reparsed, Fileset) 18 | assert fileset.top_dir == reparsed.top_dir 19 | 20 | 21 | def test_fileset_is_a_set_of_paths(fileset): 22 | assert len(list(fileset)) > 5 23 | assert all([isinstance(f, Path) for f in fileset]) 24 | 25 | 26 | def test_fileset_files_are_relative_to_top_dir(fileset): 27 | for f in fileset: 28 | assert not f.is_absolute() 29 | top = fileset.top_dir 30 | assert (top / f.relative_to(top)).exists() 31 | 32 | 33 | def test_fileset_contains_subdirectories(fileset): 34 | for f in fileset.files: 35 | print(f.parts[-3:]) 36 | if f.parts[-3:] == ('deep', 'directory', 'structure'): 37 | return 38 | raise AssertionError 39 | -------------------------------------------------------------------------------- /tedi/tests/test_jinja_renderer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pytest import fixture 3 | from ..factset import Factset 4 | from ..jinja_renderer import JinjaRenderer 5 | 6 | 7 | @fixture 8 | def renderer(): 9 | facts = Factset(color='yellow') 10 | return JinjaRenderer(facts) 11 | 12 | 13 | @fixture 14 | def template(): 15 | return Path('tedi/tests/fixtures/template/simple.j2') 16 | 17 | 18 | def test_facts_is_a_factset(renderer): 19 | assert isinstance(renderer.facts, Factset) 20 | 21 | 22 | def test_render_expands_facts_in_the_template(renderer, template): 23 | rendered = renderer.render(template) 24 | assert rendered == 'The canary is a yellow bird.' 25 | 26 | 27 | def test_render_string_expands_facts_in_the_string(renderer, template): 28 | rendered = renderer.render_string('The canary is a {{ color }} bird.') 29 | assert rendered == 'The canary is a yellow bird.' 30 | -------------------------------------------------------------------------------- /tedi/tests/test_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pytest import fixture 3 | from ..project import Project 4 | from ..assetset import Assetset 5 | from ..factset import Factset 6 | from unittest.mock import Mock 7 | 8 | 9 | def get_project(name) -> Project: 10 | return Project(Path(f'tedi/tests/fixtures/projects/{name}')) 11 | 12 | 13 | @fixture 14 | def project() -> Project: 15 | return get_project('simple') 16 | 17 | 18 | def test_path_is_a_path(project): 19 | assert isinstance(project.path, Path) 20 | 21 | 22 | def test_render_calls_render_on_each_image(project): 23 | for image in project.images: 24 | image.render = Mock() 25 | project.render() 26 | for image in project.images: 27 | assert image.render.called 28 | 29 | 30 | def test_build_calls_build_on_each_image(project): 31 | for image in project.images: 32 | image.build = Mock() 33 | project.build() 34 | for image in project.images: 35 | assert image.build.called 36 | 37 | 38 | def test_projects_have_a_dictionary_of_asset_sets(project): 39 | assert isinstance(project.asset_sets, dict) 40 | for asset_set in project.asset_sets.values(): 41 | assert isinstance(asset_set, Assetset) 42 | 43 | 44 | def test_projects_expand_facts_in_assets(project): 45 | asset = project.asset_sets['fact_based_assets'].assets[0] 46 | assert asset.filename == 'filename-from-fact.tar.gz' 47 | assert asset.source == 'file:///source-from-fact' 48 | 49 | 50 | def test_projects_have_a_factset(project): 51 | assert isinstance(project.facts, Factset) 52 | 53 | 54 | def test_projects_can_declare_project_level_facts(project): 55 | assert project.facts['animal'] == 'cow' 56 | assert project.facts['cow_color'] is None 57 | --------------------------------------------------------------------------------