├── .gitignore ├── LICENSE ├── README.md ├── charts └── seedbox │ ├── Chart.yaml │ ├── templates │ ├── postgres │ │ ├── deployment.yaml │ │ ├── pvc.yaml │ │ └── service.yaml │ └── seedbox │ │ ├── deployment.yaml │ │ └── service.yaml │ └── values.yaml ├── docker-compose.yml ├── docs └── img │ ├── cluster-create.png │ ├── cluster-list.png │ ├── node-create.png │ ├── node-list.png │ └── user-list.png ├── scripts ├── compare-ignition.py ├── unpack_ingnition.py └── vbox_cluster.py └── src ├── Dockerfile ├── requirements.txt └── seedbox ├── __init__.py ├── __main__.py ├── admin ├── __init__.py ├── base.py ├── cluster.py ├── credentials_data.py ├── disk.py ├── node.py ├── provision.py ├── templates │ └── admin │ │ ├── cluster_details.html │ │ ├── cluster_list.html │ │ ├── cluster_macros.html │ │ ├── credentials_details.html │ │ ├── credentials_list.html │ │ ├── credentials_macros.html │ │ ├── index.html │ │ ├── node_details.html │ │ ├── node_list.html │ │ ├── node_macros.html │ │ ├── provision_details.html │ │ ├── provision_list.html │ │ ├── provision_macros.html │ │ ├── user_details.html │ │ ├── user_list.html │ │ └── user_macros.html └── user.py ├── app.py ├── config.py ├── config_renderer ├── __init__.py ├── charts.py ├── ignition │ ├── __init__.py │ ├── base.py │ ├── credentials.py │ ├── dnsmasq │ │ ├── __init__.py │ │ ├── dnsmasq.service │ │ └── resolved.conf │ ├── etcd_server │ │ ├── __init__.py │ │ └── etcd-member.service.d │ │ │ ├── 30-image.conf │ │ │ ├── 40-etcd-cluster.conf │ │ │ ├── 40-oom.conf │ │ │ ├── 40-persistent.conf │ │ │ └── 40-ssl.conf │ ├── flannel │ │ ├── __init__.py │ │ ├── cni-conf.json │ │ └── flanneld.service.d │ │ │ ├── 30-proxy.conf │ │ │ ├── 40-etcd-cluster.conf │ │ │ ├── 40-iface.conf │ │ │ ├── 40-network-config.conf │ │ │ └── 40-oom.conf │ ├── k8s_master_manifests │ │ ├── __init__.py │ │ ├── kube-apiserver.yaml │ │ ├── kube-controller-manager.yaml │ │ └── kube-scheduler.yaml │ ├── kube_proxy │ │ ├── __init__.py │ │ └── kube-proxy.yaml │ ├── kubeconfig │ │ ├── __init__.py │ │ └── kubeconfig.yaml │ ├── kubelet │ │ ├── __init__.py │ │ ├── kubelet.service │ │ └── kubelet.service.d │ │ │ └── 30-proxy.conf │ └── system │ │ ├── __init__.py │ │ ├── add-http-proxy-ca-certificate.service │ │ ├── addresses.network │ │ ├── cluster-etcdctl │ │ ├── containerd.service.d │ │ └── 40-oom.conf │ │ ├── docker.service.d │ │ └── 30-proxy.conf │ │ ├── fleet.service.d │ │ └── 40-etcd-cluster.conf │ │ ├── locksmithd.service.d │ │ ├── 40-etcd-cluster.conf │ │ └── 40-etcd-lock.conf │ │ ├── provision-report.service │ │ ├── sshd.service.d │ │ └── 40-oom.conf │ │ ├── sshd@.service.d │ │ └── 40-oom.conf │ │ ├── sysctl-max-user-watches.conf │ │ └── volume.mount ├── ipxe.py └── kubeconfig.py ├── exceptions.py ├── ignition_parser.py ├── manage.py ├── migrations ├── README ├── __init__.py ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 028018cd8818_.py │ ├── 03314dc2f789_.py │ ├── 2093be28531b_.py │ ├── 235f683efac0_.py │ ├── 25fc3f52af44_.py │ ├── 283dc9256301_.py │ ├── 35099fc974d2_.py │ ├── 450edaa5f10d_.py │ ├── 469154bafe22_.py │ ├── 519c11fc090d_.py │ ├── 52e82e7e9376_.py │ ├── 579f89f6b1a0_.py │ ├── 635f6467bb33_.py │ ├── 91248bf831b8_.py │ ├── 92f518191c12_.py │ ├── 9a65898a0e2f_.py │ ├── __init__.py │ ├── aa55bedebb6f_.py │ ├── ae00e7974dca_.py │ ├── b70159a8c7c2_.py │ ├── bf3ac4dda2ab_.py │ ├── da3084151a1d_.py │ └── f08e29e85dc0_.py ├── models ├── __init__.py ├── address.py ├── cluster.py ├── credentials_data.py ├── db.py ├── disk.py ├── disk_partition.py ├── mountpoint.py ├── node.py ├── provision.py └── user.py ├── pki.py ├── update_watcher.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /.idea/ 3 | /test.db 4 | /tmp 5 | -------------------------------------------------------------------------------- /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 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Dmitry Bashkatov 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # seedbox 2 | 3 | Baremetal CoreOS cluster provisioner with web UI. Currently its primary goal is to boot CoreOS 4 | clusters using PXE without installation. But it easily can be extended to render kubernetes assets for 5 | different deployments. 6 | 7 | 8 | ## Installation 9 | 10 | ``` 11 | $ docker-compose up 12 | $ docker-compose exec seedbox python -m seedbox db upgrade 13 | ``` 14 | 15 | Open http://localhost:5000/admin/ 16 | 17 | 18 | ### helm chart 19 | 20 | If you have already running Kubernetes cluster and want to provision another one, take a took 21 | at [seedbox helm chart](charts/seedbox). 22 | 23 | 24 | ## Comparison with other projects 25 | 26 | * [CoreOS matchbox](https://github.com/coreos/matchbox). It doesn't provide complete config rendering facility. 27 | There are examples, but they are hard to read and not modularized. 28 | 29 | * [Tectonic installer](https://github.com/coreos/tectonic-installer). This is missing config rendering app 30 | for matchbox. This project was open-sourced after seedbox was started. It's highly modularized 31 | and uses HCL (HashiCorp configuration language) to render config files. But it's tied to CoreOS tectonic. 32 | There is no option to render clean Kubernetes configuration. 33 | 34 | Seedbox is all-in-one project compared to those above. You will get web UI, iPXE handler, config renderer, PKI, 35 | cluster state tracking and maybe something more in future versions. 36 | 37 | Actually seedbox config template files are based on files rendered by **closed**-source tectonic installer. 38 | They are split into packages by function and rendered using Python Jinja2 template engine. 39 | 40 | **Closed**-source Tectonic installer is claimed as installer for production grade clusters. So you can 41 | say this about seedbox as well. 😀 But I'm not sure of that. 42 | 43 | 44 | ## Web UI 45 | 46 | ![](docs/img/cluster-create.png) 47 | 48 | ![](docs/img/cluster-list.png) 49 | 50 | ![](docs/img/node-create.png) 51 | 52 | ![](docs/img/node-list.png) 53 | 54 | ![](docs/img/user-list.png) 55 | 56 | 57 | ## PKI 58 | 59 | You will have PKI out of the box. It's simple but powerful enough. It provides one CA per cluster and will 60 | automatically issue certificates for nodes and users. Also it will warn you if there is something 61 | wrong with certificates (expired, changed name, etc). 62 | 63 | Credentials are automatically transferred to nodes in most secure manner possible for automatic provision. 64 | 65 | 66 | ## Node state tracking 67 | 68 | Nodes notify seedbox after successful boot and upload active ignition config, so seedbox can track 69 | current state of a cluster. 70 | 71 | 72 | ## Toubleshooting 73 | 74 | Due to some CoreOS bug host hangs on boot without any error messages if root partition doesn't exist. If this happens to 75 | you, just set "Wipe root disk on next boot" checkbox in node admin. 76 | -------------------------------------------------------------------------------- /charts/seedbox/Chart.yaml: -------------------------------------------------------------------------------- 1 | description: Baremetal CoreOS cluster provisioner with web UI 2 | name: seedbox 3 | version: 1.0.0 4 | -------------------------------------------------------------------------------- /charts/seedbox/templates/postgres/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: extensions/v1beta1 3 | kind: Deployment 4 | metadata: 5 | labels: 6 | app: postgres 7 | release: {{ .Release.Name }} 8 | version: {{ .Chart.Version }} 9 | name: postgres 10 | spec: 11 | replicas: 1 12 | template: 13 | metadata: 14 | name: postgres 15 | labels: 16 | app: postgres 17 | release: {{ .Release.Name }} 18 | version: {{ .Chart.Version }} 19 | spec: 20 | volumes: 21 | - name: postgres 22 | persistentVolumeClaim: 23 | claimName: postgres 24 | containers: 25 | - name: main 26 | image: postgres:9.6 27 | ports: 28 | - containerPort: 5432 29 | env: 30 | - name: POSTGRES_USER 31 | value: seedbox 32 | volumeMounts: 33 | - name: postgres 34 | mountPath: /var/lib/postgresql/data 35 | subPath: data 36 | -------------------------------------------------------------------------------- /charts/seedbox/templates/postgres/pvc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: PersistentVolumeClaim 3 | apiVersion: v1 4 | metadata: 5 | name: postgres 6 | spec: 7 | accessModes: [ "ReadWriteOnce" ] 8 | resources: 9 | requests: 10 | storage: 256Mi 11 | -------------------------------------------------------------------------------- /charts/seedbox/templates/postgres/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: postgres 6 | labels: 7 | app: postgres 8 | release: {{ .Release.Name }} 9 | spec: 10 | ports: 11 | - port: 5432 12 | protocol: TCP 13 | targetPort: 5432 14 | selector: 15 | app: postgres 16 | release: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /charts/seedbox/templates/seedbox/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: extensions/v1beta1 3 | kind: Deployment 4 | metadata: 5 | labels: 6 | app: seedbox 7 | release: {{ .Release.Name }} 8 | version: {{ .Chart.Version }} 9 | name: seedbox 10 | spec: 11 | replicas: 1 12 | template: 13 | metadata: 14 | name: seedbox 15 | labels: 16 | app: seedbox 17 | release: {{ .Release.Name }} 18 | version: {{ .Chart.Version }} 19 | spec: 20 | containers: 21 | - name: web 22 | image: {{ .Values.image }} 23 | args: [runserver, --host, 0.0.0.0] 24 | ports: 25 | - containerPort: 5000 26 | env: 27 | - name: SECRET_KEY 28 | value: {{ .Values.secret_key | quote }} 29 | - name: ALLOW_INSECURE_TRANSPORT 30 | value: {{ .Values.allow_insecure_transport | quote }} 31 | - name: ADMIN_PASSWORD 32 | value: {{ .Values.admin_password | quote }} 33 | - name: DATABASE_URI 34 | value: postgres://seedbox@postgres/seedbox 35 | - name: REVERSE_PROXY_COUNT 36 | value: {{ .Values.reverse_proxy_count | quote }} 37 | - name: UPDATE_STATE_FILE 38 | value: /seedbox/versions.json 39 | volumeMounts: 40 | - name: state 41 | mountPath: /seedbox 42 | - name: update-watcher 43 | image: {{ .Values.image }} 44 | args: [watch_updates] 45 | env: 46 | - name: UPDATE_STATE_FILE 47 | value: /seedbox/versions.json 48 | volumeMounts: 49 | - name: state 50 | mountPath: /seedbox 51 | volumes: 52 | - name: state 53 | emptyDir: {} 54 | -------------------------------------------------------------------------------- /charts/seedbox/templates/seedbox/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: web 6 | labels: 7 | app: seedbox 8 | release: {{ .Release.Name }} 9 | spec: 10 | ports: 11 | - port: 80 12 | protocol: TCP 13 | targetPort: 5000 14 | selector: 15 | app: seedbox 16 | release: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /charts/seedbox/values.yaml: -------------------------------------------------------------------------------- 1 | image: nailgun/seedbox 2 | secret_key: 'my secret key' 3 | allow_insecure_transport: 'false' 4 | admin_password: admin 5 | reverse_proxy_count: 0 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '2' 3 | 4 | services: 5 | seedbox: 6 | image: seedbox 7 | build: ./src/ 8 | command: runserver --host 0.0.0.0 --port 5000 --debug 9 | environment: 10 | - DATABASE_URI=postgres://seedbox:password@postgres:5432/seedbox 11 | ports: 12 | - '5000:5000' 13 | volumes: 14 | - './src:/usr/src/app' 15 | depends_on: 16 | - postgres 17 | 18 | postgres: 19 | image: postgres:9.6 20 | ports: 21 | - '5432:5432' 22 | environment: 23 | - POSTGRES_USER=seedbox 24 | -------------------------------------------------------------------------------- /docs/img/cluster-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/docs/img/cluster-create.png -------------------------------------------------------------------------------- /docs/img/cluster-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/docs/img/cluster-list.png -------------------------------------------------------------------------------- /docs/img/node-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/docs/img/node-create.png -------------------------------------------------------------------------------- /docs/img/node-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/docs/img/node-list.png -------------------------------------------------------------------------------- /docs/img/user-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/docs/img/user-list.png -------------------------------------------------------------------------------- /scripts/compare-ignition.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import difflib 5 | import argparse 6 | import urllib.parse 7 | from collections import defaultdict 8 | 9 | not_set = object() 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument('v1') 15 | parser.add_argument('v2') 16 | args = parser.parse_args() 17 | 18 | with open(args.v1, 'rb') as v1: 19 | v1 = json.load(v1) 20 | 21 | with open(args.v2, 'rb') as v2: 22 | v2 = json.load(v2) 23 | 24 | compare('', v1, v2) 25 | 26 | 27 | def compare(path, v1, v2): 28 | if v1 is not_set: 29 | return print('+', path, '\n\n') 30 | 31 | if v2 is not_set: 32 | return print('-', path, '\n\n') 33 | 34 | if type(v1) != type(v2): 35 | print('-', path, 'type:', type(v1)) 36 | print('+', path, 'type:', type(v1)) 37 | return print('\n') 38 | 39 | if path == '/storage/files': 40 | v1src = v1 41 | v1 = defaultdict(list) 42 | for f in v1src: 43 | v1['[{filesystem}:{path}]'.format(**f)].append(f) 44 | v2src = v2 45 | v2 = defaultdict(list) 46 | for f in v2src: 47 | v2['[{filesystem}:{path}]'.format(**f)].append(f) 48 | 49 | if path == '/systemd/units': 50 | v1src = v1 51 | v1 = defaultdict(list) 52 | for u in v1src: 53 | v1['[{name}]'.format(**u)].append(u) 54 | v2src = v2 55 | v2 = defaultdict(list) 56 | for u in v2src: 57 | v2['[{name}]'.format(**u)].append(u) 58 | 59 | if isinstance(v1, dict): 60 | return compare_dict(path, v1, v2) 61 | 62 | if isinstance(v1, list): 63 | return compare_list(path, v1, v2) 64 | 65 | if isinstance(v1, str) and v1.startswith('data:,'): 66 | v1 = v1[len('data:,'):] 67 | v1 = urllib.parse.unquote(v1) 68 | 69 | if isinstance(v2, str) and v2.startswith('data:,'): 70 | v2 = v2[len('data:,'):] 71 | v2 = urllib.parse.unquote(v2) 72 | 73 | if v1 != v2: 74 | if isinstance(v1, str): 75 | diff = difflib.unified_diff(v1.splitlines(keepends=True), v2.splitlines(keepends=True)) 76 | delta = ''.join(x for x in diff) 77 | print(path, ':') 78 | print(delta, '\n') 79 | else: 80 | print(path, ':') 81 | print('-', v1) 82 | print('+', v2, '\n\n') 83 | 84 | 85 | def compare_dict(path, v1, v2): 86 | keys = set(v1.keys()) | set(v2.keys()) 87 | keys = sorted(keys) 88 | 89 | for k in keys: 90 | v1i = v1.get(k, not_set) 91 | v2i = v2.get(k, not_set) 92 | compare(path + '/' + k, v1i, v2i) 93 | 94 | 95 | def compare_list(path, v1, v2): 96 | max_len = max(len(v1), len(v2)) 97 | 98 | for idx in range(max_len): 99 | try: 100 | v1i = v1[idx] 101 | except IndexError: 102 | v1i = not_set 103 | try: 104 | v2i = v2[idx] 105 | except IndexError: 106 | v2i = not_set 107 | compare(path + '/' + str(idx), v1i, v2i) 108 | 109 | 110 | if __name__ == '__main__': 111 | main() 112 | -------------------------------------------------------------------------------- /scripts/unpack_ingnition.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import json 5 | import shutil 6 | import argparse 7 | 8 | from seedbox import ignition_parser 9 | 10 | 11 | def main(): 12 | arg_parser = argparse.ArgumentParser() 13 | arg_parser.add_argument('file') 14 | arg_parser.add_argument('dir') 15 | args = arg_parser.parse_args() 16 | 17 | with open(args.file, 'rb') as f: 18 | ignition = json.load(f) 19 | 20 | if os.path.exists(args.dir): 21 | shutil.rmtree(args.dir) 22 | 23 | for path, data in ignition_parser.iter_files(ignition): 24 | out_path = os.path.join(args.dir, path) 25 | os.makedirs(os.path.dirname(out_path), exist_ok=True) 26 | with open(out_path, 'wb') as f: 27 | f.write(data) 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /scripts/vbox_cluster.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import time 6 | import logging 7 | import argparse 8 | import requests 9 | import subprocess 10 | import urllib.parse 11 | 12 | 13 | log = logging.getLogger('main') 14 | tmp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'tmp') 15 | ipxe_iso = os.path.join(tmp_dir, 'ipxe.iso') 16 | 17 | if sys.platform == 'darwin': 18 | vbox_tftp_dir = os.path.expanduser('~/Library/VirtualBox/TFTP') 19 | else: 20 | vbox_tftp_dir = os.path.expanduser('~/.VirtualBox/TFTP') 21 | 22 | 23 | def main(): 24 | args = parse_args() 25 | logging.basicConfig(level='NOTSET') 26 | 27 | seedbox_url = args.seedbox_url 28 | if not seedbox_url: 29 | seedbox_ip = get_hostonlyif_ipaddress(args.host_only_network) 30 | if not seedbox_ip: 31 | raise Exception("Can't get host-only network IP", args.host_only_network) 32 | seedbox_url = 'http://{}:5000/'.format(seedbox_ip) 33 | 34 | ipxe_script_name = args.cluster_name + '.ipxe' 35 | prepare_vbox_ipxe(ipxe_script_name, seedbox_url) 36 | 37 | instance_names = [format_instance_name(args.cluster_name, idx + 1) for idx in range(args.num_instances)] 38 | 39 | if args.delete: 40 | wait = False 41 | for instance_name in instance_names: 42 | log.info('Powering off VM %s', instance_name) 43 | poweroff_cmd = subprocess.run(['VBoxManage', 'controlvm', 44 | instance_name, 45 | 'poweroff']) 46 | if poweroff_cmd.returncode == 0: 47 | wait = True # VirtualBox doesn't release a lock for some time after poweroff 48 | 49 | if wait: 50 | time.sleep(10) 51 | 52 | for instance_name in instance_names: 53 | log.info('Deleting VM %s', instance_name) 54 | subprocess.run(['VBoxManage', 'unregistervm', 55 | instance_name, 56 | '--delete']) 57 | 58 | for instance_name in instance_names: 59 | log.info('Creating VM %s', instance_name) 60 | subprocess.run(['VBoxManage', 'createvm', 61 | '--name', instance_name, 62 | '--groups', '/' + args.cluster_name, 63 | '--ostype', 'Linux26_64', 64 | '--register'], 65 | check=True) 66 | 67 | log.info('Configuring VM %s', instance_name) 68 | subprocess.run(['VBoxManage', 'modifyvm', 69 | instance_name, 70 | '--memory', str(args.memory), 71 | '--nic1', 'nat', 72 | '--nattftpfile1', ipxe_script_name, 73 | '--nic2', 'hostonly', 74 | '--hostonlyadapter2', args.host_only_network], 75 | check=True) 76 | 77 | config_path = get_vm_config_file_path(instance_name) 78 | if not config_path: 79 | raise Exception("Can't get VM config path", instance_name) 80 | 81 | base_path = os.path.dirname(config_path) 82 | disk_path = os.path.join(base_path, 'ide1.vdi') 83 | 84 | log.info('Creating a disk for VM %s', instance_name) 85 | subprocess.run(['VBoxManage', 'createhd', 86 | '--filename', disk_path, 87 | '--size', str(args.disk_size)], 88 | check=True) 89 | 90 | storage_controller_name = 'IDE Controller' 91 | 92 | log.info('Attaching IDE controller to VM %s', instance_name) 93 | subprocess.run(['VBoxManage', 'storagectl', 94 | instance_name, 95 | '--name', storage_controller_name, 96 | '--add', 'ide', 97 | '--controller', 'PIIX4'], 98 | check=True) 99 | 100 | log.info('Attaching disk to VM %s', instance_name) 101 | subprocess.run(['VBoxManage', 'storageattach', 102 | instance_name, 103 | '--storagectl', storage_controller_name, 104 | '--port', '0', 105 | '--device', '0', 106 | '--type', 'hdd', 107 | '--medium', disk_path], 108 | check=True) 109 | 110 | log.info('Attaching ipxe.iso to VM %s', instance_name) 111 | subprocess.run(['VBoxManage', 'storageattach', 112 | instance_name, 113 | '--storagectl', storage_controller_name, 114 | '--port', '0', 115 | '--device', '1', 116 | '--type', 'dvddrive', 117 | '--medium', ipxe_iso], 118 | check=True) 119 | 120 | if args.start: 121 | for instance_name in instance_names: 122 | log.info('Starting VM %s', instance_name) 123 | subprocess.run(['VBoxManage', 'startvm', 124 | instance_name, 125 | '--type', 'headless'], 126 | check=True) 127 | 128 | 129 | def parse_args(): 130 | arg_parser = argparse.ArgumentParser() 131 | arg_parser.add_argument('cluster_name', 132 | help='cluster name') 133 | arg_parser.add_argument('num_instances', type=int, 134 | help='number of instances to create') 135 | arg_parser.add_argument('-d', '--delete', action='store_true', default=False, 136 | help='delete all VMs first') 137 | arg_parser.add_argument('--memory', type=int, default=1024, 138 | help='amount of RAM on an instance') 139 | arg_parser.add_argument('--disk-size', type=int, default=262144, 140 | help='instance disk size in megabytes') 141 | arg_parser.add_argument('--start', action='store_true', default=False, 142 | help='start instances after creation') 143 | arg_parser.add_argument('--host-only-network', default='vboxnet0', 144 | help='VirtualBox host-only network name') 145 | arg_parser.add_argument('--seedbox-url', 146 | help='URL of your seedbox installation (defaults to first IP of host-only network)') 147 | return arg_parser.parse_args() 148 | 149 | 150 | def format_instance_name(cluster_name, idx): 151 | return '{}-node{:02}'.format(cluster_name, idx) 152 | 153 | 154 | def prepare_vbox_ipxe(ipxe_script_name, seedbox_url): 155 | try: 156 | os.mkdir(vbox_tftp_dir) 157 | except FileExistsError: 158 | pass 159 | 160 | ipxe_script_path = os.path.join(vbox_tftp_dir, ipxe_script_name) 161 | ipxe_script = '#!ipxe\ndhcp net1\nchain {}\n'.format(urllib.parse.urljoin(seedbox_url, 'ipxe')) 162 | with open(ipxe_script_path, 'w') as f: 163 | f.write(ipxe_script) 164 | 165 | if not os.path.exists(ipxe_iso): 166 | os.makedirs(tmp_dir, exist_ok=True) 167 | log.info('Downloading ipxe.iso') 168 | source_url = 'http://boot.ipxe.org/ipxe.iso' 169 | resp = requests.get(source_url, stream=True) 170 | resp.raise_for_status() 171 | with open(ipxe_iso, 'wb') as f: 172 | for chunk in resp.iter_content(chunk_size=1024): 173 | f.write(chunk) 174 | 175 | 176 | def get_vm_config_file_path(vm_name): 177 | stdout = subprocess.run(['VBoxManage', 'showvminfo', vm_name], check=True, stdout=subprocess.PIPE).stdout 178 | for line in stdout.splitlines(): 179 | parts = line.split(b':', maxsplit=1) 180 | if len(parts) == 2 and parts[0] == b'Config file': 181 | return os.fsdecode(parts[1].strip()) 182 | 183 | 184 | def get_hostonlyif_ipaddress(ifname): 185 | stdout = subprocess.run(['VBoxManage', 'list', 'hostonlyifs'], check=True, stdout=subprocess.PIPE).stdout 186 | 187 | current_if = None 188 | for line in stdout.splitlines(): 189 | if not line: 190 | current_if = None 191 | else: 192 | parts = line.split(b':', maxsplit=1) 193 | key = parts[0] 194 | value = parts[1].strip() 195 | if key == b'Name': 196 | current_if = value.decode() 197 | elif current_if == ifname: 198 | if key == b'IPAddress': 199 | return value.decode() 200 | 201 | 202 | if __name__ == '__main__': 203 | main() 204 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-onbuild 2 | 3 | ENTRYPOINT ["python", "-m", "seedbox"] 4 | CMD ["runserver"] 5 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask~=0.12.1 2 | Flask-Admin~=1.5.0 3 | Flask-SQLAlchemy~=2.2 4 | Flask-Migrate~=2.0.3 5 | Flask-Script~=2.0.5 6 | cryptography~=1.8.1 7 | pyOpenSSL~=16.2.0 8 | psycopg2~=2.7.1 9 | PyYAML~=3.12 10 | requests~=2.13.0 11 | -------------------------------------------------------------------------------- /src/seedbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/src/seedbox/__init__.py -------------------------------------------------------------------------------- /src/seedbox/__main__.py: -------------------------------------------------------------------------------- 1 | from seedbox import manage 2 | 3 | 4 | if __name__ == '__main__': 5 | manage.run() 6 | -------------------------------------------------------------------------------- /src/seedbox/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_admin import Admin 2 | 3 | from seedbox import models, config 4 | from .cluster import ClusterView 5 | from .node import NodeView 6 | from .user import UserView 7 | from .credentials_data import CredentialsDataView 8 | from .provision import ProvisionView 9 | from .disk import DiskView 10 | 11 | 12 | admin = Admin(name='seedbox', url=config.admin_base_url, template_mode='bootstrap3') 13 | admin.add_view(ClusterView(models.Cluster, models.db.session)) 14 | admin.add_view(NodeView(models.Node, models.db.session)) 15 | admin.add_view(DiskView(models.Disk, models.db.session)) 16 | admin.add_view(UserView(models.User, models.db.session)) 17 | admin.add_view(CredentialsDataView(models.CredentialsData, models.db.session)) 18 | admin.add_view(ProvisionView(models.Provision, models.db.session)) 19 | -------------------------------------------------------------------------------- /src/seedbox/admin/base.py: -------------------------------------------------------------------------------- 1 | from flask_admin.contrib.sqla import ModelView as BaseModelView 2 | 3 | 4 | class ModelView(BaseModelView): 5 | can_view_details = True 6 | -------------------------------------------------------------------------------- /src/seedbox/admin/cluster.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from flask import request, flash, redirect 5 | from flask_admin import expose 6 | from flask_admin.form import rules 7 | from flask_admin.helpers import get_redirect_target 8 | from flask_admin.model.template import macro 9 | 10 | from seedbox import pki, models, config_renderer, config 11 | from .base import ModelView 12 | 13 | log = logging.getLogger() 14 | 15 | 16 | class ClusterView(ModelView): 17 | column_list = ['name', 'ca_credentials', 'is_configured', 'assert_etcd_cluster_exists', 'info'] 18 | list_template = 'admin/cluster_list.html' 19 | details_template = 'admin/cluster_details.html' 20 | form_excluded_columns = [ 21 | 'ca_credentials', 22 | 'nodes', 23 | 'users', 24 | 'k8s_service_account_public_key', 25 | 'k8s_service_account_private_key', 26 | ] 27 | column_formatters = { 28 | 'ca_credentials': macro('render_ca_credentials'), 29 | 'info': macro('render_info'), 30 | } 31 | column_labels = { 32 | 'ca_credentials': "CA credentials", 33 | 'etcd_image_tag': "etcd image tag", 34 | 'assert_etcd_cluster_exists': "Assert etcd cluster already exists", 35 | 'etcd_nodes_dns_name': "DNS name of any etcd node", 36 | 'install_dnsmasq': "Install dnsmasq on cluster nodes", 37 | 'k8s_apiservers_audit_log': "Enable audit log on apiservers", 38 | 'k8s_apiservers_swagger_ui': "Enable Swagger-UI on apiservers", 39 | 'dnsmasq_static_records': "Add static records to dnsmasq", 40 | 'explicitly_advertise_addresses': "Explicitly advertise addresses", 41 | 'k8s_pod_network': "Pod network CIDR", 42 | 'k8s_service_network': "Service network CIDR", 43 | 'k8s_hyperkube_tag': "Hyperkube image tag", 44 | 'k8s_cni': "Use CNI", 45 | 'k8s_apiservers_dns_name': "DNS name of any master node", 46 | 'k8s_is_rbac_enabled': "Enable RBAC", 47 | 'k8s_admission_control': "Admission control", 48 | 'custom_coreos_images_base_url': "Custom base HTTP URL of CoreOS images", 49 | 'aci_proxy_url': "ACI proxy URL", 50 | 'aci_proxy_ca_cert': "ACI proxy CA certificate (PEM)", 51 | 'coreos_channel': "CoreOS channel", 52 | 'coreos_version': "CoreOS version", 53 | } 54 | column_descriptions = { 55 | 'name': "Human readable cluster name. Don't use spaces.", 56 | 'assert_etcd_cluster_exists': "This will set `initial-cluster-state` to `existing` for newly provisioned " 57 | "etcd members. Use it to add etcd members to existing cluster.", 58 | 'etcd_nodes_dns_name': "Must be round-robin DNS record. If this is set it will be used by " 59 | "all components to access etcd instead of hardcoded node list. You can " 60 | "add/remove nodes at any time just by updating DNS record.", 61 | 'install_dnsmasq': "If this is set, dnsmasq will be run on each node for resolving cluster.local zone " 62 | "using k8s DNS and for DNS caching.", 63 | 'dnsmasq_static_records': "Hosts' dnsmasq will serve cluster nodes' FQDNs and cluster components " 64 | "like etcd and apiserver.", 65 | 'explicitly_advertise_addresses': "If this is set, cluster components will explicitly advertise " 66 | "node IP as it set in seedbox.", 67 | 'k8s_apiservers_dns_name': "Must be round-robin DNS record. If this is set it will be used by " 68 | "all components to access apiserver instead of hardcoded node list. You can " 69 | "add/remove nodes at any time just by updating DNS record.", 70 | 'k8s_is_rbac_enabled': "Set kube-apiserver authentication mode to RBAC. Otherwise it will be AlwaysAllow.", 71 | 'k8s_admission_control': "Ordered list of plug-ins to do admission control of resources into cluster.", 72 | 'aci_proxy_url': "Docker and rkt will use this proxy to download container images.", 73 | 'aci_proxy_ca_cert': "Docker and rkt download images via HTTPS. If your proxy intercepts " 74 | "HTTPS connections you should add proxy CA certificate here. It will be " 75 | "added to system root CA certificates on each node.", 76 | 'custom_coreos_images_base_url': "coreos_production_pxe.vmlinuz and coreos_production_pxe_image.cpio.gz. It " 77 | "will be used instead of default if set.", 78 | 'coreos_channel': "stable, beta or alpha", 79 | 'docker_config': "~/.docker/config.json file with auth and other configuration." 80 | } 81 | form_rules = [ 82 | rules.Field('name'), 83 | rules.Field('install_dnsmasq'), 84 | rules.Field('docker_config'), 85 | rules.FieldSet([ 86 | 'etcd_image_tag', 87 | 'assert_etcd_cluster_exists', 88 | 'etcd_nodes_dns_name', 89 | ], 'etcd'), 90 | rules.FieldSet([ 91 | 'k8s_apiservers_audit_log', 92 | 'k8s_apiservers_swagger_ui', 93 | 'k8s_pod_network', 94 | 'k8s_service_network', 95 | 'k8s_hyperkube_tag', 96 | 'k8s_cni', 97 | 'k8s_apiservers_dns_name', 98 | 'k8s_is_rbac_enabled', 99 | 'k8s_admission_control', 100 | ], 'Kubernetes'), 101 | rules.FieldSet([ 102 | 'coreos_channel', 103 | 'coreos_version', 104 | 'custom_coreos_images_base_url', 105 | 'aci_proxy_url', 106 | 'aci_proxy_ca_cert', 107 | ], 'Images'), 108 | rules.FieldSet([ 109 | 'dnsmasq_static_records', 110 | 'explicitly_advertise_addresses', 111 | ], 'Testing environment'), 112 | ] 113 | 114 | def _issue_creds(self, model): 115 | ca = models.CredentialsData() 116 | ca.cert, ca.key = pki.create_ca_certificate(model.name, 117 | certify_days=10000) 118 | self.session.add(ca) 119 | model.ca_credentials = ca 120 | model.k8s_service_account_public_key, model.k8s_service_account_private_key = pki.generate_rsa_keypair() 121 | 122 | def on_model_change(self, form, model, is_created): 123 | if is_created: 124 | self._issue_creds(model) 125 | else: 126 | model.nodes.update({models.Node.target_config_version: models.Node.target_config_version + 1}) 127 | 128 | def after_model_delete(self, model): 129 | models.CredentialsData.query.filter_by(id=model.ca_credentials_id).delete() 130 | 131 | @expose('/reissue-ca-credentials', methods=['POST']) 132 | def reissue_ca_creds_view(self): 133 | model = self.get_one(request.args.get('id')) 134 | model.nodes.update({models.Node.target_config_version: models.Node.target_config_version + 1}) 135 | self._issue_creds(model) 136 | self.session.add(model) 137 | self.session.commit() 138 | return_url = get_redirect_target() or self.get_url('.index_view') 139 | flash('The credentials successfully reissued', 'success') 140 | return redirect(return_url) 141 | 142 | @property 143 | def k8s_addons_dict(self): 144 | return config_renderer.charts.addons 145 | 146 | @expose('/reset-state', methods=['POST']) 147 | def reset_state(self): 148 | model = self.get_one(request.args.get('id')) 149 | model.nodes.update({ 150 | models.Node.target_config_version: 1, 151 | models.Node.active_config_version: 0, 152 | }) 153 | models.Disk.query.filter(models.Disk.node.has(cluster_id=model.id)).update({ 154 | models.Disk.wipe_next_boot: True 155 | }, synchronize_session='fetch') 156 | model.assert_etcd_cluster_exists = False 157 | self.session.add(model) 158 | self.session.commit() 159 | return_url = get_redirect_target() or self.get_url('.index_view') 160 | flash('The cluster state has been successfully reset', 'success') 161 | return redirect(return_url) 162 | 163 | @property 164 | def latest_component_versions(self): 165 | try: 166 | with open(config.update_state_file, 'r') as fp: 167 | return json.load(fp) 168 | except Exception: 169 | log.exception('Failed to load component versions file') 170 | return None 171 | -------------------------------------------------------------------------------- /src/seedbox/admin/credentials_data.py: -------------------------------------------------------------------------------- 1 | from flask import request, Response 2 | from flask_admin import expose 3 | from flask_admin.model.template import macro 4 | 5 | from seedbox import pki 6 | from .base import ModelView 7 | 8 | 9 | class CredentialsDataView(ModelView): 10 | column_list = ['cert', 'key'] 11 | list_template = 'admin/credentials_list.html' 12 | details_template = 'admin/credentials_details.html' 13 | column_formatters = { 14 | 'cert': macro('render_cert'), 15 | 'key': macro('render_key'), 16 | } 17 | 18 | @expose('/cert.pem') 19 | def cert_view(self): 20 | creds = self.get_one(request.args.get('id')) 21 | return Response(creds.cert, mimetype='text/plain') 22 | 23 | @expose('/key.pem') 24 | def key_view(self): 25 | creds = self.get_one(request.args.get('id')) 26 | return Response(creds.key, mimetype='text/plain') 27 | 28 | @expose('/cert.txt') 29 | def cert_text_view(self): 30 | creds = self.get_one(request.args.get('id')) 31 | info = pki.get_certificate_text(creds.cert) 32 | return Response(info, mimetype='text/plain') 33 | 34 | def is_visible(self): 35 | return False 36 | -------------------------------------------------------------------------------- /src/seedbox/admin/disk.py: -------------------------------------------------------------------------------- 1 | from seedbox import models 2 | from .base import ModelView 3 | 4 | 5 | class DiskView(ModelView): 6 | column_list = [ 7 | 'node', 8 | 'device', 9 | 'wipe_next_boot', 10 | ] 11 | 12 | column_labels = { 13 | 'sector_size_bytes': "Size of a sector (bytes)", 14 | } 15 | column_descriptions = { 16 | 'device': "E.g. /dev/sda.", 17 | 'wipe_next_boot': "If this is set, disk partition table will be wiped on next boot.", 18 | 'sector_size_bytes': "Usually it equals 512.", 19 | } 20 | inline_models = [ 21 | (models.DiskPartition, { 22 | 'column_labels': { 23 | 'size_mibs': "Size (MiB)", 24 | }, 25 | 'column_descriptions': { 26 | 'number': "The partition number in the partition table (one - indexed).", 27 | 'label': "Label for partition and filesystem.", 28 | 'size_mibs': "If not set, the partition will fill the remainder of the disk.", 29 | 'format': "Filesystem format (ext4, btrfs, or xfs).", 30 | } 31 | }), 32 | ] 33 | 34 | def on_model_change(self, form, model, is_created): 35 | model.node.target_config_version += 1 36 | self.session.add(model.node) 37 | -------------------------------------------------------------------------------- /src/seedbox/admin/node.py: -------------------------------------------------------------------------------- 1 | from flask import request, Response, flash, redirect 2 | from flask_admin import expose 3 | from flask_admin.form import rules 4 | from flask_admin.helpers import get_redirect_target 5 | from flask_admin.model.template import macro 6 | 7 | from seedbox import pki, models, config_renderer 8 | from .base import ModelView 9 | 10 | 11 | class NodeView(ModelView): 12 | column_list = [ 13 | 'cluster', 14 | 'fqdn', 15 | 'ip', 16 | 'maintenance_mode', 17 | 'credentials', 18 | 'config', 19 | ] 20 | list_template = 'admin/node_list.html' 21 | details_template = 'admin/node_details.html' 22 | form_excluded_columns = [ 23 | 'credentials', 24 | 'target_config_version', 25 | 'active_config_version', 26 | 'provisions', 27 | 'disks', 28 | ] 29 | column_formatters = { 30 | 'credentials': macro('render_credentials'), 31 | 'config': macro('render_config'), 32 | } 33 | column_labels = { 34 | 'ip': "Public IP", 35 | 'fqdn': "Fully Qualified Domain Name", 36 | 'maintenance_mode': "Maintenance mode", 37 | 'debug_boot': "Debug boot", 38 | 'coreos_autologin': "Enable terminal autologin", 39 | 'linux_consoles': "Linux console devices", 40 | 'disable_ipv6': "Disable IPv6 in Linux kernel", 41 | 'is_etcd_server': "etcd server", 42 | 'is_k8s_schedulable': "Kubernetes schedulable", 43 | 'is_k8s_master': "Kubernetes master", 44 | 'mountpoints': 'Additional mountpoints', 45 | 'addresses': 'Additional IP addresses', 46 | } 47 | column_descriptions = { 48 | 'maintenance_mode': "If this is enabled, node will be booted in minimal CoreOS environment without " 49 | "touching root partition.", 50 | 'debug_boot': "Forward all system journal messages to kmsg for troubleshooting.", 51 | 'coreos_autologin': "If this is set, main terminal will be logged-in with `core` user after boot. Useful " 52 | "for debugging. Don't enable in production.", 53 | 'linux_consoles': "Passed to kernel as `console` arguments. (Separate by comma.)", 54 | 'disable_ipv6': "Passed to kernel as `ipv6.disable=1` argument.", 55 | 'is_etcd_server': "Run etcd server on this node and connect other nodes to it.", 56 | 'is_k8s_schedulable': "Run kubelet on this node and register it as schedulable.", 57 | 'is_k8s_master': "Run kubelet on this node and add persistent kube-apiserver, kube-controller-manager, " 58 | "kube-scheduler pods to it.", 59 | } 60 | inline_models = [ 61 | (models.Mountpoint, { 62 | 'column_descriptions': { 63 | 'what': 'Device to mount.', 64 | 'where': 'Mount path.', 65 | 'wanted_by': 'WantedBy systemd unit.', 66 | 'is_persistent': 'Use this partition to store critical data that should survive reboots.', 67 | } 68 | }), 69 | (models.Address, { 70 | 'column_descriptions': { 71 | 'interface': 'Network interface.', 72 | 'ip': 'IP address.', 73 | } 74 | }), 75 | ] 76 | form_rules = [ 77 | rules.Field('cluster'), 78 | rules.Field('ip'), 79 | rules.Field('fqdn'), 80 | rules.FieldSet([ 81 | 'maintenance_mode', 82 | 'debug_boot', 83 | 'coreos_autologin', 84 | 'linux_consoles', 85 | 'disable_ipv6', 86 | 'mountpoints', 87 | 'addresses', 88 | 'additional_kernel_cmdline', 89 | ], 'Boot'), 90 | rules.FieldSet([ 91 | 'is_etcd_server', 92 | 'is_k8s_schedulable', 93 | 'is_k8s_master', 94 | ], 'Components'), 95 | ] 96 | 97 | # without this, Node is saved to database before on_model_change() gets called 98 | # and this happens only when there is inline_models 99 | def create_model(self, *args, **kwargs): 100 | with self.session.no_autoflush: 101 | return super().create_model(*args, **kwargs) 102 | 103 | def _issue_creds(self, model): 104 | with self.session.no_autoflush: 105 | ca_creds = model.cluster.ca_credentials 106 | creds = models.CredentialsData() 107 | 108 | creds.cert, creds.key = pki.issue_certificate('system:node:' + model.fqdn, 109 | ca_cert=ca_creds.cert, 110 | ca_key=ca_creds.key, 111 | organizations=['system:nodes'], 112 | san_dns=model.certificate_alternative_dns_names, 113 | san_ips=model.certificate_alternative_ips, 114 | certify_days=10000, 115 | is_web_server=True, 116 | is_web_client=True) 117 | self.session.add(creds) 118 | model.credentials = creds 119 | 120 | def on_model_change(self, form, model, is_created): 121 | if is_created: 122 | self._issue_creds(model) 123 | else: 124 | model.target_config_version += 1 125 | 126 | def on_model_delete(self, model): 127 | model.mountpoints.delete() 128 | model.addresses.delete() 129 | 130 | def after_model_delete(self, model): 131 | models.CredentialsData.query.filter_by(id=model.credentials_id).delete() 132 | 133 | @expose('/reissue-credentials', methods=['POST']) 134 | def reissue_creds_view(self): 135 | model = self.get_one(request.args.get('id')) 136 | model.target_config_version += 1 137 | self._issue_creds(model) 138 | self.session.add(model) 139 | self.session.commit() 140 | return_url = get_redirect_target() or self.get_url('.index_view') 141 | flash('The credentials successfully reissued', 'success') 142 | return redirect(return_url) 143 | 144 | @expose('/target-ignition.json') 145 | def target_ignition_config_view(self): 146 | node = self.get_one(request.args.get('id')) 147 | response = config_renderer.ignition.render(node, indent=True) 148 | return Response(response, mimetype='application/json') 149 | 150 | @expose('/target.ipxe') 151 | def target_ipxe_config_view(self): 152 | node = self.get_one(request.args.get('id')) 153 | response = config_renderer.ipxe.render(node, request.url_root) 154 | return Response(response, mimetype='text/plain') 155 | -------------------------------------------------------------------------------- /src/seedbox/admin/provision.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask import request, Response 4 | from flask_admin import expose 5 | from flask_admin.model.template import macro 6 | 7 | from .base import ModelView 8 | from seedbox import ignition_parser 9 | 10 | 11 | class ProvisionView(ModelView): 12 | column_list = ['applied_at', 'config_version', 'data'] 13 | column_details_list = ['applied_at', 'config_version', 'data'] 14 | column_default_sort = ('applied_at', True) 15 | list_template = 'admin/provision_list.html' 16 | details_template = 'admin/provision_details.html' 17 | column_formatters = { 18 | 'data': macro('render_data'), 19 | } 20 | 21 | def get_query(self): 22 | query = super().get_query() 23 | node_id = request.args.get('node_id') 24 | if node_id: 25 | query = query.filter_by(node_id=node_id) 26 | return query 27 | 28 | @expose('/ipxe') 29 | def raw_ipxe_view(self): 30 | provision = self.get_one(request.args.get('id')) 31 | return Response(provision.ipxe_config, mimetype='text/plain') 32 | 33 | @expose('/ignition.json') 34 | def raw_ignition_view(self): 35 | provision = self.get_one(request.args.get('id')) 36 | data = json.loads(provision.ignition_config) 37 | data = json.dumps(data, indent=2) 38 | return Response(data, mimetype='application/json') 39 | 40 | @expose('/ignition.tar.gz') 41 | def ignition_filesystem_view(self): 42 | provision = self.get_one(request.args.get('id')) 43 | tgz_data = ignition_parser.render_ignition_tgz(json.loads(provision.ignition_config)) 44 | return Response(tgz_data, mimetype='application/tar+gzip') 45 | 46 | def is_visible(self): 47 | return False 48 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/cluster_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | {% from 'admin/cluster_macros.html' import render_ca_credentials %} 3 | {% from 'admin/cluster_macros.html' import render_info %} 4 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/cluster_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | {% from 'admin/cluster_macros.html' import render_ca_credentials,render_info with context %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/cluster_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_ca_credentials(model, column) %} 2 | [cert] 3 | [text] 4 | [key] 5 | 6 |
7 | 10 |
11 | 12 | {% set ca_credentials_error = model.ca_credentials_error %} 13 | {% if ca_credentials_error %} 14 | 15 | {% endif %} 16 | {% endmacro %} 17 | 18 | {% macro render_info(model, column) %} 19 |
20 | 23 |
24 | 25 |
26 | {% if not model.k8s_kube_proxy_user %} 27 | no system:kube-proxy user 28 | {% endif %} 29 |
30 | 31 |
32 |
33 | 40 |
41 |
42 |
    43 |
  • 44 | CoreOS: {{ model.coreos_channel }} {{ model.coreos_version }} 45 | {% if admin_view.latest_component_versions and admin_view.latest_component_versions.coreos[model.coreos_channel] and model.coreos_version != admin_view.latest_component_versions.coreos[model.coreos_channel] %} 46 | 47 | {% endif %} 48 |
  • 49 |
  • 50 | etcd: {{ model.etcd_image_tag }} 51 | {% if admin_view.latest_component_versions and admin_view.latest_component_versions.etcd and model.etcd_image_tag != admin_view.latest_component_versions.etcd %} 52 | 53 | {% endif %} 54 |
  • 55 |
  • 56 | hyperkube: {{ model.k8s_hyperkube_tag }} 57 | {% if admin_view.latest_component_versions and admin_view.latest_component_versions.hyperkube and model.k8s_hyperkube_tag != admin_view.latest_component_versions.hyperkube %} 58 | 59 | {% endif %} 60 |
  • 61 |
  • 62 | etcd servers: 63 |
      64 | {% set etcd_servers_count = model.nodes.filter_by(is_etcd_server=True).count() %} 65 | {% if etcd_servers_count and etcd_servers_count % 2 == 0 %} 66 |
    • Even etcd servers number!
    • 67 | {% endif %} 68 | {% for node in model.nodes.filter_by(is_etcd_server=True) %} 69 |
    • {{ node.fqdn }}
    • 70 | {% endfor %} 71 |
    72 |
  • 73 |
  • 74 | Kubernetes master nodes: 75 |
      76 | {% for node in model.nodes.filter_by(is_k8s_master=True) %} 77 |
    • {{ node.fqdn }}
    • 78 | {% endfor %} 79 |
    80 |
  • 81 |
  • 82 | Kubernetes schedulable nodes: 83 |
      84 | {% for node in model.nodes.filter_by(is_k8s_schedulable=True) %} 85 |
    • {{ node.fqdn }}
    • 86 | {% endfor %} 87 |
    88 |
  • 89 |
90 |
91 |
92 |
93 |
94 | 101 |
102 |
103 |
    104 |
  1. 105 | Download kubeconfig and activate it:
    106 | $ export KUBECONFIG=/path/to/kubeconfig 107 |
  2. 108 |
  3. 109 | Init helm:
    110 | $ helm init 111 |
  4. 112 |
  5. 113 | If you have RBAC enabled, install helm tiller workaround. 114 |
  6. 115 |
  7. 116 | Install any addons:
    117 | $ helm install -n RELEASE_NAME CHART_URL.
    118 | Where RELEASE_NAME is any name you like, and CHART_URL is one of the following: 119 |
      120 | {% for addon_name, addon_versions in admin_view.k8s_addons_dict.items() %} 121 |
    • 122 | {{ addon_name }} 123 | {% for addon_version in addon_versions.keys() %} 124 | {{ addon_version }} 125 | {% endfor %} 126 |
    • 127 | {% endfor %} 128 |
    129 |
  8. 130 |
131 |
132 |
133 |
134 |
135 | {% endmacro %} 136 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/credentials_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | {% from 'admin/credentials_macros.html' import render_cert, render_key %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/credentials_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | {% from 'admin/credentials_macros.html' import render_cert, render_key %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/credentials_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_cert(model, column) %} 2 | raw 3 | text 4 | {% endmacro %} 5 | 6 | {% macro render_key(model, column) %} 7 | raw 8 | {% endmacro %} 9 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | 3 | {% block body %} 4 |

Usage manual

5 | 6 |
    7 |
  1. Define a cluster in Cluster tab.
  2. 8 |
  3. Add some nodes to cluster in Node tab. You will need at least one node with etcd server (three 9 | for fault tolerance), one k8s master (two for HA) and one k8s schedulable node. For testing purposes 10 | this can be a single node.
  4. 11 |
  5. Add a user with system:masters group and download generated kubeconfig.
  6. 12 |
  7. Configure your cluster servers for iPXE boot with this script: 13 |
    14 | #!ipxe
    15 | chain http://seedbox_host:seedbox_port/ipxe
    16 | 
    17 |
  8. 18 |
  9. Startup the servers.
  10. 19 |
  11. After some time required to bootstrap full k8s cluster, you will be able to access it using 20 | kubectl and downloaded kubeconfig.
  12. 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/node_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | {% from 'admin/node_macros.html' import render_credentials, render_config %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/node_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | {% from 'admin/node_macros.html' import render_credentials, render_config %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/node_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_credentials(model, column) %} 2 | [cert] 3 | [text] 4 | [key] 5 | 6 |
7 | 10 |
11 | 12 | {% set credentials_error=model.credentials_error %} 13 | {% if credentials_error %} 14 | 15 | {% else %} 16 | {% set credentials_warning=model.credentials_warning %} 17 | {% if credentials_warning %} 18 | 19 | {% endif %} 20 | {% endif %} 21 | {% endmacro %} 22 | 23 | {% macro render_config(model, column) %} 24 | {% if model.is_config_match %} 25 | 26 | {% else %} 27 | 28 | {% endif %} 29 | 30 | [target ignition] 31 | [target ipxe] 32 | 33 | {% if model.active_config %} 34 | [active] 35 | {% endif %} 36 | 37 | [history] 38 | 39 | {% set disks_error=model.disks_error %} 40 | {% if disks_error %} 41 | 42 | {% else %} 43 | {% set disks_warning=model.disks_warning %} 44 | {% if disks_warning %} 45 | 46 | {% endif %} 47 | {% endif %} 48 | {% endmacro %} 49 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/provision_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | {% from 'admin/provision_macros.html' import render_data %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/provision_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | {% from 'admin/provision_macros.html' import render_data %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/provision_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_data(model, column) %} 2 | {% if model.ignition_config %} 3 | [raw ignition] 4 | [ignition fs] 5 | {% endif %} 6 | 7 | {% if model.ipxe_config %} 8 | [raw ipxe] 9 | {% endif %} 10 | {% endmacro %} 11 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/user_details.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/details.html' %} 2 | {% from 'admin/user_macros.html' import render_credentials, render_kubeconfig %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/user_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | {% from 'admin/user_macros.html' import render_credentials, render_kubeconfig %} 3 | -------------------------------------------------------------------------------- /src/seedbox/admin/templates/admin/user_macros.html: -------------------------------------------------------------------------------- 1 | {% macro render_credentials(model, column) %} 2 | [cert] 3 | [text] 4 | [key] 5 | 6 |
7 | 10 |
11 | 12 | {% set credentials_error=model.credentials_error %} 13 | {% if credentials_error %} 14 | 15 | {% endif %} 16 | {% endmacro %} 17 | 18 | {% macro render_kubeconfig(model, column) %} 19 | raw 20 | {% endmacro %} 21 | -------------------------------------------------------------------------------- /src/seedbox/admin/user.py: -------------------------------------------------------------------------------- 1 | from flask import request, Response, flash, redirect, abort 2 | from flask_admin import expose 3 | from flask_admin.form import rules 4 | from flask_admin.actions import action 5 | from flask_admin.helpers import get_redirect_target 6 | from flask_admin.model.template import macro 7 | 8 | from seedbox import pki, models, config_renderer, exceptions 9 | from .base import ModelView 10 | 11 | 12 | class UserView(ModelView): 13 | column_list = ['cluster', 'name', 'credentials', 'kubeconfig'] 14 | list_template = 'admin/user_list.html' 15 | details_template = 'admin/user_details.html' 16 | form_excluded_columns = [ 17 | 'credentials', 18 | ] 19 | column_formatters = { 20 | 'credentials': macro('render_credentials'), 21 | 'kubeconfig': macro('render_kubeconfig'), 22 | } 23 | column_labels = { 24 | 'ssh_key': 'SSH key', 25 | 'k8s_groups': 'Kubernetes groups', 26 | } 27 | column_descriptions = { 28 | 'name': 'Will be used as CommonName in TLS certificate.', 29 | 'k8s_groups': 'Will be added as Organization(s) in TLS certificate. (Separate by comma.)', 30 | 'ssh_key': 'This key will be authorized on all nodes of the cluster (as `core` user).', 31 | } 32 | form_rules = [ 33 | rules.Field('cluster'), 34 | rules.Field('name'), 35 | rules.Field('k8s_groups'), 36 | rules.Field('ssh_key'), 37 | ] 38 | 39 | def _issue_creds(self, model): 40 | with self.session.no_autoflush: 41 | ca_creds = model.cluster.ca_credentials 42 | creds = models.CredentialsData() 43 | if model.k8s_groups: 44 | orgs = model.k8s_groups.split(',') 45 | else: 46 | orgs = [] 47 | creds.cert, creds.key = pki.issue_certificate(model.name, 48 | ca_cert=ca_creds.cert, 49 | ca_key=ca_creds.key, 50 | organizations=orgs, 51 | certify_days=365, 52 | is_web_client=True) 53 | self.session.add(creds) 54 | model.credentials = creds 55 | 56 | def on_model_change(self, form, model, is_created): 57 | if is_created: 58 | self._issue_creds(model) 59 | 60 | def after_model_delete(self, model): 61 | models.CredentialsData.query.filter_by(id=model.credentials_id).delete() 62 | 63 | @expose('/reissue-credentials', methods=['POST']) 64 | def reissue_creds_view(self): 65 | model = self.get_one(request.args.get('id')) 66 | self._issue_creds(model) 67 | self.session.add(model) 68 | self.session.commit() 69 | return_url = get_redirect_target() or self.get_url('.index_view') 70 | flash('The credentials successfully reissued', 'success') 71 | return redirect(return_url) 72 | 73 | @expose('/kubeconfig') 74 | def kubeconfig_view(self): 75 | user = self.get_one(request.args.get('id')) 76 | return Response(render_kubeconfig([user]), mimetype='text/x-yaml') 77 | 78 | @action('kubeconfig', 'Get kubeconfig') 79 | def kubeconfig_action(self, ids): 80 | users = models.User.query.filter(models.User.id.in_(ids)) 81 | return Response(render_kubeconfig(list(users)), mimetype='text/x-yaml') 82 | 83 | 84 | def render_kubeconfig(users): 85 | try: 86 | default_user = None 87 | if len(users) == 1: 88 | default_user = users[0] 89 | 90 | return config_renderer.kubeconfig.render(users, default_user=default_user) 91 | except exceptions.K8sNoClusterApiserver as e: 92 | return abort(404, str(e)) 93 | -------------------------------------------------------------------------------- /src/seedbox/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from flask import Flask, Response, request, abort, redirect 5 | from flask_migrate import Migrate 6 | from werkzeug.contrib.fixers import ProxyFix 7 | 8 | from seedbox import config, models, config_renderer 9 | from seedbox.admin import admin 10 | 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | app = Flask(__name__, template_folder='admin/templates') 16 | if config.reverse_proxy_count: 17 | app.wsgi_app = ProxyFix(app.wsgi_app, num_proxies=config.reverse_proxy_count) 18 | app.secret_key = config.secret_key 19 | app.config['SQLALCHEMY_DATABASE_URI'] = config.database_uri 20 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 21 | models.db.app = app 22 | models.db.init_app(app) 23 | migrate = Migrate(app, models.db, directory=os.path.join(config.app_root, 'seedbox', 'migrations')) 24 | admin.init_app(app) 25 | 26 | 27 | def get_node(node_ip): 28 | node = models.Node.query.filter_by(ip=node_ip).first() 29 | if node is None: 30 | log.error('Node %s is unknown', node_ip) 31 | return abort(403) 32 | return node 33 | 34 | 35 | @app.before_request 36 | def check_admin_secure(): 37 | if request.path.startswith(config.admin_base_url + '/'): 38 | response = ensure_secure_request() or ensure_admin_authenticated() 39 | if response is not None: 40 | return response 41 | 42 | 43 | def route(rule, request_name, secure=True, **route_kwargs): 44 | def decorator(func): 45 | def wrapped(*args, **kwargs): 46 | node_ip = request.remote_addr 47 | log.info('%s request from %s', request_name, node_ip) 48 | if secure: 49 | response = ensure_secure_request() 50 | if response is not None: 51 | return response 52 | node = get_node(node_ip) 53 | return func(node, *args, **kwargs) 54 | 55 | wrapped.__name__ = func.__name__ 56 | return app.route(rule, **route_kwargs)(wrapped) 57 | return decorator 58 | 59 | 60 | def ensure_secure_request(): 61 | is_request_secure = request.environ['wsgi.url_scheme'] == 'https' 62 | if not is_request_secure and not config.allow_insecure_transport: 63 | if request.method in ('POST', 'PUT', 'PATCH'): 64 | # request body already sent in insecure manner 65 | # return error in this case to notify cluster admin 66 | return abort(400) 67 | else: 68 | return redirect(request.url.replace('http://', 'https://', 1)) 69 | 70 | 71 | def ensure_admin_authenticated(): 72 | auth = request.authorization 73 | if not auth or auth.username != config.admin_username or auth.password != config.admin_password: 74 | return Response('401 Unauthorized', 401, { 75 | 'WWW-Authenticate': 'Basic realm="Login Required"' 76 | }) 77 | 78 | 79 | @route('/ipxe', 'iPXE boot', secure=False) 80 | def ipxe_boot(node): 81 | response = config_renderer.ipxe.render(node, request.url_root) 82 | return Response(response, mimetype='text/plain') 83 | 84 | 85 | @route('/ignition', 'Ignition config') 86 | def ignition(node): 87 | response = config_renderer.ignition.render(node, 'indent' in request.args) 88 | return Response(response, mimetype='application/json') 89 | 90 | 91 | @route('/credentials/.pem', 'Credentials download') 92 | def credentials(node, cred_type): 93 | if cred_type == 'ca': 94 | return Response(node.cluster.ca_credentials.cert, mimetype='text/plain') 95 | 96 | if cred_type == 'node': 97 | return Response(node.credentials.cert, mimetype='text/plain') 98 | 99 | if cred_type == 'node-key': 100 | return Response(node.credentials.key, mimetype='text/plain') 101 | 102 | return abort(404) 103 | 104 | 105 | @route('/report', 'Provision report', methods=['POST']) 106 | def report(node): 107 | if not node.maintenance_mode: 108 | try: 109 | node.active_config_version = int(request.args.get('version')) 110 | except (ValueError, TypeError): 111 | return abort(400) 112 | 113 | if request.content_type != 'application/json': 114 | return abort(400) 115 | 116 | provision = models.Provision() 117 | provision.node = node 118 | provision.config_version = node.active_config_version 119 | provision.ignition_config = request.data 120 | if node.target_config_version == node.active_config_version: 121 | provision.ipxe_config = config_renderer.ipxe.render(node, request.url_root) 122 | models.db.session.add(provision) 123 | 124 | models.db.session.add(node) 125 | 126 | node.disks.update({ 127 | models.Disk.wipe_next_boot: False 128 | }) 129 | 130 | if node.cluster.are_etcd_nodes_configured: 131 | node.cluster.assert_etcd_cluster_exists = True 132 | models.db.session.add(node.cluster) 133 | 134 | models.db.session.commit() 135 | return Response('ok', mimetype='application/json') 136 | 137 | 138 | @app.route('/addons//-.tar.gz') 139 | def k8s_addons_helm_chart(cluster_name, addon_name, addon_version): 140 | cluster = models.Cluster.query.filter_by(name=cluster_name).first() 141 | if cluster is None: 142 | return abort(404) 143 | 144 | try: 145 | addon = config_renderer.charts.addons[addon_name][addon_version] 146 | except KeyError: 147 | return abort(404) 148 | 149 | return Response(config_renderer.charts.render_addon_tgz(cluster, addon), 150 | mimetype='application/tar+gzip') 151 | -------------------------------------------------------------------------------- /src/seedbox/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | app_root = os.path.dirname(os.path.dirname(__file__)) 4 | dev_root = os.path.dirname(os.path.dirname(app_root)) 5 | 6 | secret_key = os.environ.get('SECRET_KEY', '-') 7 | allow_insecure_transport = os.environ.get('ALLOW_INSECURE_TRANSPORT', '1').lower() in ('1', 'true', 'yes') 8 | admin_username = os.environ.get('ADMIN_USERNAME', 'admin') 9 | admin_password = os.environ.get('ADMIN_PASSWORD', 'admin') 10 | database_uri = os.environ.get('DATABASE_URI', 'sqlite:///' + os.path.join(dev_root, 'test.db')) 11 | reverse_proxy_count = int(os.environ.get('REVERSE_PROXY_COUNT', 0)) 12 | 13 | update_check_interval_sec = 24 * 60 * 60 # 24 hours 14 | update_state_file = os.environ.get('UPDATE_STATE_FILE', '/tmp/seedbox.json') 15 | 16 | admin_base_url = '/admin' 17 | 18 | default_coreos_channel = 'stable' 19 | default_coreos_version = '1353.7.0' # 'current' is also applicable 20 | default_coreos_images_base_url = 'http://{channel}.release.core-os.net/amd64-usr/{version}/' 21 | etcd_image = 'quay.io/coreos/etcd' 22 | default_etcd_image_tag = 'v3.1.7' 23 | k8s_hyperkube_image = 'quay.io/coreos/hyperkube' 24 | default_k8s_hyperkube_tag = 'v1.6.2_coreos.0' 25 | default_k8s_pod_network = '10.2.0.0/16' 26 | default_k8s_service_network = '10.3.0.0/24' 27 | default_k8s_admission_control = 'NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,' \ 28 | 'DefaultStorageClass,ResourceQuota,DefaultTolerationSeconds' 29 | default_linux_consoles = 'tty0,ttyS0' 30 | 31 | etcd_client_port = 2379 32 | etcd_peer_port = 2380 33 | k8s_apiserver_secure_port = 6443 34 | k8s_apiserver_insecure_port = 8080 35 | k8s_cluster_domain = 'cluster.local' 36 | 37 | cluster_credentials_path = '/etc/ssl/cluster' 38 | ca_cert_filename = 'ca.pem' 39 | node_cert_filename = 'node.pem' 40 | node_key_filename = 'node-key.pem' 41 | ca_cert_path = cluster_credentials_path + '/' + ca_cert_filename 42 | node_cert_path = cluster_credentials_path + '/' + node_cert_filename 43 | node_key_path = cluster_credentials_path + '/' + node_key_filename 44 | 45 | k8s_config_path = '/etc/kubernetes' # don't change. hardcoded in kubelet-wrapper 46 | k8s_secrets_path = k8s_config_path + '/secrets' 47 | k8s_service_account_public_key_path = k8s_secrets_path + '/service-account.pub' 48 | k8s_service_account_private_key_path = k8s_secrets_path + '/service-account.priv' 49 | k8s_manifests_path = k8s_config_path + '/manifests' 50 | k8s_kubeconfig_path = k8s_config_path + '/kubeconfig.yaml' 51 | k8s_kube_proxy_config_path = k8s_config_path + '/kube-proxy-config.yaml' 52 | k8s_cni_conf_path = k8s_config_path + '/cni/net.d' 53 | k8s_kube_proxy_user_name = 'system:kube-proxy' 54 | 55 | node_ca_certificates_path = '/usr/share/ca-certificates' 56 | 57 | aci_proxy_ca_cert_path = '/etc/ssl/certs/aci-proxy-ca.pem' 58 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ipxe 2 | from . import charts 3 | from . import ignition 4 | from . import kubeconfig 5 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/charts.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | from urllib.parse import urljoin 5 | 6 | import requests 7 | from jinja2 import Environment 8 | 9 | from seedbox import config, utils 10 | 11 | NOT_SPECIFIED = object() 12 | jinja_var_env = Environment(autoescape=False) 13 | jinja_env = Environment(keep_trailing_newline=True, autoescape=False) 14 | 15 | 16 | class Addon: 17 | base_url = 'https://github.com/kubernetes/kubernetes/raw/{branch}/cluster/addons/' 18 | encoding = 'utf-8' 19 | 20 | def __init__(self, name, version, manifest_files, vars_map=None, is_salt_template=False, base_url=None, notes=None): 21 | if vars_map is None: 22 | vars_map = {} 23 | 24 | if base_url is None: 25 | base_url = urljoin(self.base_url, name + '/') 26 | else: 27 | base_url = urljoin(self.base_url, base_url) 28 | 29 | if version == 'master': 30 | branch = 'master' 31 | else: 32 | branch = 'release-' + version 33 | base_url = base_url.format(branch=branch) 34 | 35 | self.name = name 36 | self.version = version 37 | 38 | self.manifest_files = [] 39 | for fname in manifest_files: 40 | fname = urljoin(base_url, fname) 41 | self.manifest_files.append(fname) 42 | 43 | self.vars_map = vars_map 44 | self.is_salt_template = is_salt_template 45 | self.notes = notes 46 | 47 | def render_files(self, cluster): 48 | yield 'Chart.yaml', self.render_chart_yaml() 49 | yield 'values.yaml', self.render_values_yaml(cluster) 50 | 51 | for url in self.manifest_files: 52 | filename, content = self.render_manifest_file(cluster, url) 53 | yield os.path.join('templates', filename), content 54 | 55 | if self.notes: 56 | yield os.path.join('templates', 'NOTES.txt'), self.notes.encode(self.encoding) 57 | 58 | def render_chart_yaml(self): 59 | return 'name: {}\nversion: {}\n'.format(self.name, self.version).encode(self.encoding) 60 | 61 | def render_values_yaml(self, cluster): 62 | return ''.join('{}: {}\n'.format(var_name, jinja_var_env.from_string(var_tpl).render({ 63 | 'config': config, 64 | 'cluster': cluster, 65 | })) for var_name, var_tpl in self.vars_map.items()).encode(self.encoding) 66 | 67 | def render_manifest_file(self, cluster, url): 68 | pillar = SaltPillarEmulator(cluster) 69 | 70 | resp = requests.get(url) 71 | resp.raise_for_status() 72 | 73 | content = resp.content 74 | if self.is_salt_template: 75 | t = jinja_env.from_string(content.decode(self.encoding)) 76 | content = t.render({ 77 | 'pillar': pillar, 78 | }).encode(self.encoding) 79 | else: 80 | for var_name in self.vars_map.keys(): 81 | var_name = var_name.encode(self.encoding) 82 | content = content.replace(b'$' + var_name, b'{{ .Values.%s }}' % var_name) 83 | 84 | filename = os.path.basename(url) 85 | m = re.match(r'(.*\.yaml).*', filename) 86 | if m: 87 | filename = m.group(1) 88 | 89 | return filename, content 90 | 91 | 92 | class SaltPillarEmulator: 93 | def __init__(self, cluster): 94 | self.cluster = cluster 95 | 96 | def get(self, var_name, default=NOT_SPECIFIED): 97 | try: 98 | return getattr(self, '_' + var_name) 99 | except AttributeError: 100 | if default is NOT_SPECIFIED: 101 | raise 102 | else: 103 | return default 104 | 105 | @property 106 | def _num_nodes(self): 107 | return self.cluster.nodes.count() 108 | 109 | dashboard_notes = '''1. Start kube proxy: 110 | $ kubectl proxy 111 | 2. Open dashboard in a browser: http://localhost:8001/ui/ 112 | ''' 113 | 114 | addons = { 115 | 'dns': { 116 | '1.5': Addon('dns', '1.5', [ 117 | 'skydns-rc.yaml.sed', 118 | 'skydns-svc.yaml.sed', 119 | ], { 120 | 'DNS_DOMAIN': '{{ config.k8s_cluster_domain }}', 121 | 'DNS_SERVER_IP': '{{ cluster.k8s_dns_service_ip }}', 122 | }), 123 | '1.6': Addon('dns', '1.6', [ 124 | 'kubedns-cm.yaml', 125 | 'kubedns-sa.yaml', 126 | 'kubedns-controller.yaml.sed', 127 | 'kubedns-svc.yaml.sed', 128 | ], { 129 | 'DNS_DOMAIN': '{{ config.k8s_cluster_domain }}', 130 | 'DNS_SERVER_IP': '{{ cluster.k8s_dns_service_ip }}', 131 | }), 132 | }, 133 | 'dns-horizontal-autoscaler': { 134 | '1.5': Addon('dns-horizontal-autoscaler', '1.5', ['dns-horizontal-autoscaler.yaml']), 135 | '1.6': Addon('dns-horizontal-autoscaler', '1.6', ['dns-horizontal-autoscaler.yaml']), 136 | 'master': Addon('dns-horizontal-autoscaler', 'master', [ 137 | 'dns-horizontal-autoscaler.yaml', 138 | 'dns-horizontal-autoscaler-rbac.yaml', 139 | ]), 140 | }, 141 | 'dashboard': { 142 | '1.5': Addon('dashboard', '1.5', [ 143 | 'dashboard-controller.yaml', 144 | 'dashboard-service.yaml', 145 | ], notes=dashboard_notes), 146 | '1.6': Addon('dashboard', '1.6', ['kubernetes-dashboard.yaml'], 147 | notes=dashboard_notes, 148 | base_url='https://github.com/kubernetes/dashboard/raw/master/src/deploy/'), 149 | }, 150 | 'heapster': { 151 | '1.5': Addon('heapster', '1.5', [ 152 | 'heapster-controller.yaml', 153 | 'heapster-service.yaml', 154 | ], is_salt_template=True, base_url='cluster-monitoring/standalone/'), 155 | '1.6': Addon('heapster', '1.6', [ 156 | 'heapster-controller.yaml', 157 | 'heapster-service.yaml', 158 | ], is_salt_template=True, base_url='cluster-monitoring/standalone/', notes='RBAC instructions: ' 159 | 'https://gist.github.com/nailgun/5a4413c8e2fd0bba8e8aa443e8ba9cee'), 160 | }, 161 | 'fluentd-elasticsearch': { 162 | '1.6': Addon('fluentd-elasticsearch', '1.6', [ 163 | 'es-controller.yaml', 164 | 'es-service.yaml', 165 | 'fluentd-es-ds.yaml', 166 | 'kibana-controller.yaml', 167 | 'kibana-service.yaml', 168 | ], notes='Documentation: ' 169 | 'https://kubernetes.io/docs/tasks/debug-application-cluster/logging-elasticsearch-kibana/'), 170 | }, 171 | } 172 | 173 | 174 | def render_addon_tgz(cluster, addon): 175 | tgz_fp = io.BytesIO() 176 | with utils.TarFile.open(fileobj=tgz_fp, mode='w:gz') as tgz: 177 | for path, content in addon.render_files(cluster): 178 | tgz.adddata(os.path.join(addon.name, path), content) 179 | return tgz_fp.getvalue() 180 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/__init__.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import json 3 | 4 | from flask import request 5 | 6 | from seedbox import models 7 | 8 | 9 | def render(node, indent=False): 10 | return IgnitionConfig(node).render(indent) 11 | 12 | 13 | class IgnitionConfig(object): 14 | def __init__(self, node): 15 | self.node = node 16 | self.cluster = node.cluster 17 | 18 | def render(self, indent=False): 19 | content = self.get_content() 20 | if indent: 21 | return json.dumps(content, indent=2) 22 | else: 23 | return json.dumps(content, separators=(',', ':')) 24 | 25 | def get_content(self): 26 | packages = [P(self.node, request.url_root) for P in self.get_package_classes()] 27 | files = list(itertools.chain.from_iterable(p.get_files() for p in packages)) 28 | units = list(itertools.chain.from_iterable(p.get_units() for p in packages)) 29 | networkd_units = list(itertools.chain.from_iterable(p.get_networkd_units() for p in packages)) 30 | ssh_keys = self.get_ssh_keys() 31 | 32 | return { 33 | 'ignition': { 34 | 'version': '2.0.0', 35 | 'config': {}, 36 | }, 37 | 'storage': self.get_storage_config(files), 38 | 'networkd': { 39 | 'units': networkd_units 40 | }, 41 | 'passwd': { 42 | 'users': [{ 43 | 'name': 'root', 44 | 'sshAuthorizedKeys': ssh_keys, 45 | }, { 46 | 'name': 'core', 47 | 'sshAuthorizedKeys': ssh_keys, 48 | }], 49 | }, 50 | 'systemd': { 51 | 'units': units 52 | }, 53 | } 54 | 55 | def get_package_classes(self): 56 | from .system import SystemPackage 57 | 58 | if self.node.maintenance_mode: 59 | return [SystemPackage] 60 | 61 | from .credentials import CredentialsPackage 62 | from .flannel import FlannelPackage 63 | 64 | packages = [ 65 | SystemPackage, 66 | CredentialsPackage, 67 | FlannelPackage, 68 | ] 69 | 70 | if self.cluster.install_dnsmasq: 71 | from .dnsmasq import DnsmasqPackage 72 | packages += [ 73 | DnsmasqPackage, 74 | ] 75 | 76 | if self.node.is_etcd_server: 77 | from .etcd_server import EtcdServerPackage 78 | packages += [ 79 | EtcdServerPackage, 80 | ] 81 | 82 | if self.node.is_k8s_schedulable or self.node.is_k8s_master: 83 | from .kubeconfig import KubeconfigPackage 84 | from .kubelet import KubeletPackage 85 | from .kube_proxy import KubeProxyPackage 86 | packages += [ 87 | KubeconfigPackage, 88 | KubeletPackage, 89 | KubeProxyPackage, 90 | ] 91 | 92 | if self.node.is_k8s_master: 93 | from .k8s_master_manifests import K8sMasterManifestsPackage 94 | packages += [ 95 | K8sMasterManifestsPackage, 96 | ] 97 | 98 | return packages 99 | 100 | def get_storage_config(self, files): 101 | disks = [] 102 | filesystems = [] 103 | 104 | config = { 105 | 'disks': disks, 106 | 'filesystems': filesystems, 107 | 'files': files, 108 | } 109 | 110 | if self.node.maintenance_mode: 111 | return config 112 | 113 | root_fs = False 114 | 115 | for disk in self.node.disks.filter_by(wipe_next_boot=True): 116 | partitions = [] 117 | 118 | disks += [{ 119 | 'device': disk.device, 120 | 'wipeTable': True, 121 | 'partitions': partitions, 122 | }] 123 | 124 | for partition in disk.partitions.all(): 125 | if partition.size_mibs: 126 | size_sectors = partition.size_mibs * 1024 * 1024 // disk.sector_size_bytes 127 | else: 128 | size_sectors = 0 129 | 130 | partitions += [{ 131 | 'number': partition.number, 132 | 'start': 0, 133 | 'size': size_sectors, 134 | 'label': partition.label, 135 | }] 136 | 137 | filesystems += [partition2ignitionfs(partition)] 138 | 139 | if partition.is_root: 140 | root_fs = True 141 | 142 | if not root_fs: 143 | filesystems += [partition2ignitionfs(self.node.root_partition)] 144 | 145 | return config 146 | 147 | def get_ssh_keys(self): 148 | return [user.ssh_key for user in self.cluster.users.filter(models.User.ssh_key != '')] 149 | 150 | 151 | def partition2ignitionfs(partition): 152 | fs = { 153 | 'mount': { 154 | 'device': partition.device, 155 | 'format': partition.format, 156 | 'create': { 157 | 'force': True, 158 | 'options': ['-L{}'.format(partition.label)], 159 | }, 160 | }, 161 | } 162 | 163 | if partition.is_root: 164 | fs['name'] = 'root' 165 | 166 | return fs 167 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | import inspect 4 | import urllib.parse 5 | 6 | from flask.helpers import locked_cached_property 7 | from jinja2 import Environment, ChoiceLoader, FileSystemLoader 8 | 9 | from seedbox import config 10 | 11 | 12 | class BaseIgnitionPackage(object): 13 | name = None 14 | template_context = {} 15 | 16 | def __init__(self, node, url_root): 17 | self.node = node 18 | self.cluster = node.cluster 19 | self.url_root = url_root 20 | 21 | def get_files(self): 22 | return () 23 | 24 | def get_units(self): 25 | return () 26 | 27 | def get_networkd_units(self): 28 | return () 29 | 30 | def enable_unit(self, name): 31 | return { 32 | 'name': name, 33 | 'enable': True, 34 | } 35 | 36 | def get_unit(self, name, enable=False, template_name=None, additional_context=None): 37 | if template_name is None: 38 | template_name = name 39 | unit = { 40 | 'name': name, 41 | 'contents': self.render_template(template_name, additional_context), 42 | } 43 | if enable: 44 | unit['enable'] = True 45 | return unit 46 | 47 | def get_unit_dropins(self, unitname, dropins, enableunit=False): 48 | dropin = { 49 | 'name': unitname, 50 | 'dropins': [{ 51 | 'name': dropin, 52 | 'contents': self.render_template('{}.d/{}'.format(unitname, dropin)), 53 | } for dropin in dropins], 54 | } 55 | if enableunit: 56 | dropin['enable'] = True 57 | return dropin 58 | 59 | def get_full_template_context(self, additional_context=None): 60 | context = { 61 | 'config': config, 62 | 'node': self.node, 63 | 'cluster': self.cluster, 64 | 'url_root': self.url_root, 65 | } 66 | 67 | context.update(self.get_template_context()) 68 | 69 | if additional_context: 70 | context.update(additional_context) 71 | 72 | return context 73 | 74 | def get_template_context(self): 75 | return self.template_context 76 | 77 | def render_template(self, name, additional_context=None): 78 | return self.jinja_env.get_template(name).render(self.get_full_template_context(additional_context)) 79 | 80 | @locked_cached_property 81 | def jinja_env(self): 82 | return Environment(loader=self.create_template_loader(), 83 | keep_trailing_newline=True, 84 | autoescape=False) 85 | 86 | def create_template_loader(self): 87 | template_roots = [] 88 | 89 | cls = self.__class__ 90 | while True: 91 | template_roots.append(os.path.dirname(inspect.getfile(cls))) 92 | cls = get_base_class(cls) 93 | if cls in (BaseIgnitionPackage, None): 94 | break 95 | 96 | return ChoiceLoader([FileSystemLoader(root) for root in template_roots]) 97 | 98 | @staticmethod 99 | def to_data_url(data, mediatype='', b64=False): 100 | if b64: 101 | if not isinstance(data, bytes): 102 | data = data.encode('utf-8') 103 | return 'data:{};base64,{}'.format(mediatype, base64.b64encode(data).decode('ascii')) 104 | else: 105 | return 'data:{},{}'.format(mediatype, urllib.parse.quote(data)) 106 | 107 | 108 | def get_base_class(cls): 109 | for base in cls.__bases__: 110 | if issubclass(base, BaseIgnitionPackage): 111 | return base 112 | return None 113 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/credentials.py: -------------------------------------------------------------------------------- 1 | from seedbox import config 2 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 3 | 4 | 5 | class CredentialsPackage(BaseIgnitionPackage): 6 | def get_files(self): 7 | return [ 8 | { 9 | 'filesystem': 'root', 10 | 'path': config.ca_cert_path, 11 | 'mode': 0o444, 12 | 'contents': { 13 | 'source': self.url_root + 'credentials/ca.pem', 14 | }, 15 | }, 16 | { 17 | 'filesystem': 'root', 18 | 'path': config.node_cert_path, 19 | 'mode': 0o444, 20 | 'contents': { 21 | 'source': self.url_root + 'credentials/node.pem', 22 | }, 23 | }, 24 | { 25 | 'filesystem': 'root', 26 | 'path': config.node_key_path, 27 | 'mode': 0o444, 28 | 'contents': { 29 | 'source': self.url_root + 'credentials/node-key.pem', 30 | }, 31 | }, 32 | ] 33 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/dnsmasq/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 2 | 3 | 4 | class DnsmasqPackage(BaseIgnitionPackage): 5 | def get_files(self): 6 | return [{ 7 | 'filesystem': 'root', 8 | 'path': '/etc/systemd/resolved.conf.d/30-dnsmasq.conf', 9 | 'mode': 0o644, 10 | 'contents': { 11 | 'source': self.to_data_url(self.render_template('resolved.conf')), 12 | }, 13 | }] 14 | 15 | def get_units(self): 16 | return [ 17 | self.get_unit('dnsmasq.service', enable=True) 18 | ] 19 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/dnsmasq/dnsmasq.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=dnsmasq DNS service 3 | 4 | [Service] 5 | Restart=always 6 | RestartSec=10 7 | OOMScoreAdjust=-998 8 | ExecStart=/usr/bin/rkt run \ 9 | --insecure-options=image \ 10 | --net=host \ 11 | --volume resolv,kind=host,source=/etc/resolv.conf \ 12 | --mount volume=resolv,target=/etc/resolv.conf \ 13 | quay.io/coreos/dnsmasq \ 14 | -- \ 15 | --keep-in-foreground \ 16 | --user=root \ 17 | --log-facility=- \ 18 | --no-hosts \ 19 | --server=/cluster.local/{{ cluster.k8s_dns_service_ip }} \ 20 | {% if cluster.dnsmasq_static_records -%} 21 | {% for n in cluster.nodes.all() -%} 22 | --host-record={{ n.fqdn }},{{ n.ip }} \ 23 | {% endfor -%} 24 | {% if cluster.etcd_nodes_dns_name -%} 25 | {% for n in cluster.nodes.filter_by(is_etcd_server=True) -%} 26 | --host-record={{ cluster.etcd_nodes_dns_name }},{{ n.ip }} \ 27 | {% endfor -%} 28 | {% endif -%} 29 | {% if cluster.k8s_apiservers_dns_name -%} 30 | {% for n in cluster.nodes.filter_by(is_k8s_master=True) -%} 31 | --host-record={{ cluster.k8s_apiservers_dns_name }},{{ n.ip }} \ 32 | {% endfor -%} 33 | {% endif -%} 34 | {% endif -%} 35 | \ 36 | 37 | [Install] 38 | WantedBy=multi-user.target 39 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/dnsmasq/resolved.conf: -------------------------------------------------------------------------------- 1 | [Resolve] 2 | DNS={{ node.ip }} 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 2 | 3 | 4 | class EtcdServerPackage(BaseIgnitionPackage): 5 | def get_units(self): 6 | dropins = [ 7 | '40-etcd-cluster.conf', 8 | '40-ssl.conf', 9 | '40-oom.conf', 10 | '30-image.conf', 11 | ] 12 | 13 | if self.node.persistent_mountpoint: 14 | dropins += ['40-persistent.conf'] 15 | 16 | return [ 17 | self.get_unit_dropins('etcd-member.service', dropins=dropins, enableunit=True), 18 | ] 19 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/etcd-member.service.d/30-image.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="ETCD_IMAGE_URL={{ config.etcd_image }}" 3 | Environment="ETCD_IMAGE_TAG={{ cluster.etcd_image_tag }}" 4 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/etcd-member.service.d/40-etcd-cluster.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="ETCD_NAME={{ node.fqdn }}" 3 | Environment="ETCD_ADVERTISE_CLIENT_URLS=https://{{ node.fqdn }}:{{ config.etcd_client_port }}" 4 | Environment="ETCD_INITIAL_ADVERTISE_PEER_URLS=https://{{ node.fqdn }}:{{ config.etcd_peer_port }}" 5 | Environment="ETCD_LISTEN_CLIENT_URLS=https://0.0.0.0:{{ config.etcd_client_port }}" 6 | Environment="ETCD_LISTEN_PEER_URLS=https://0.0.0.0:{{ config.etcd_peer_port }}" 7 | Environment="ETCD_INITIAL_CLUSTER={% for n in cluster.etcd_nodes %}{{ n.fqdn }}=https://{{ n.fqdn }}:{{ config.etcd_peer_port }}{% if not loop.last %},{% endif %}{% endfor %}" 8 | Environment="ETCD_STRICT_RECONFIG_CHECK=true" 9 | {% if cluster.assert_etcd_cluster_exists -%} 10 | Environment="ETCD_INITIAL_CLUSTER_STATE=existing" 11 | {% endif -%} 12 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/etcd-member.service.d/40-oom.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | OOMScoreAdjust=-1000 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/etcd-member.service.d/40-persistent.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="ETCD_DATA_DIR={{ node.persistent_mountpoint.where }}/etcd" 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/etcd_server/etcd-member.service.d/40-ssl.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="ETCD_SSL_DIR={{ config.cluster_credentials_path }}" 3 | Environment="ETCD_CA_FILE=/etc/ssl/certs/{{ config.ca_cert_filename }}" 4 | Environment="ETCD_CERT_FILE=/etc/ssl/certs/{{ config.node_cert_filename }}" 5 | Environment="ETCD_KEY_FILE=/etc/ssl/certs/{{ config.node_key_filename }}" 6 | Environment="ETCD_PEER_CA_FILE=/etc/ssl/certs/{{ config.ca_cert_filename }}" 7 | Environment="ETCD_PEER_CERT_FILE=/etc/ssl/certs/{{ config.node_cert_filename }}" 8 | Environment="ETCD_PEER_KEY_FILE=/etc/ssl/certs/{{ config.node_key_filename }}" 9 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox import config 2 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 3 | 4 | 5 | class FlannelPackage(BaseIgnitionPackage): 6 | def get_files(self): 7 | files = [] 8 | if self.cluster.k8s_cni: 9 | files += [{ 10 | 'filesystem': 'root', 11 | 'path': config.k8s_cni_conf_path + '/10-flannel.conf', 12 | 'mode': 0o644, 13 | 'contents': { 14 | 'source': self.to_data_url(self.render_template('cni-conf.json')), 15 | }, 16 | }] 17 | return files 18 | 19 | def get_units(self): 20 | dropins = [ 21 | '40-etcd-cluster.conf', 22 | '40-network-config.conf', 23 | '40-oom.conf', 24 | ] 25 | 26 | if self.cluster.explicitly_advertise_addresses: 27 | dropins += [ 28 | '40-iface.conf', 29 | ] 30 | 31 | if self.cluster.aci_proxy_url: 32 | dropins += [ 33 | '30-proxy.conf', 34 | ] 35 | 36 | return [ 37 | self.get_unit_dropins('flanneld.service', dropins=dropins), 38 | ] 39 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/cni-conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cbr0", 3 | "type": "flannel", 4 | "delegate": { 5 | "isDefaultGateway": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/flanneld.service.d/30-proxy.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment=HTTP_PROXY={{ cluster.aci_proxy_url }} 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/flanneld.service.d/40-etcd-cluster.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="FLANNELD_ETCD_ENDPOINTS={{ cluster.etcd_client_endpoints|join(',') }}" 3 | Environment="ETCD_SSL_DIR={{ config.cluster_credentials_path }}" 4 | Environment="FLANNELD_ETCD_CAFILE={{ config.ca_cert_path }}" 5 | Environment="FLANNELD_ETCD_CERTFILE={{ config.node_cert_path }}" 6 | Environment="FLANNELD_ETCD_KEYFILE={{ config.node_key_path }}" 7 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/flanneld.service.d/40-iface.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="FLANNELD_IFACE={{ node.ip }}" 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/flanneld.service.d/40-network-config.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | ExecStartPre=/opt/bin/cluster-etcdctl --timeout 30s \ 3 | set /coreos.com/network/config '{"Network": "{{ cluster.k8s_pod_network }}", "Backend": {"Type": "vxlan"}}' 4 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/flannel/flanneld.service.d/40-oom.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | OOMScoreAdjust=-999 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/k8s_master_manifests/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox import config 2 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 3 | 4 | 5 | class K8sMasterManifestsPackage(BaseIgnitionPackage): 6 | def get_files(self): 7 | return [ 8 | { 9 | 'filesystem': 'root', 10 | 'path': config.k8s_service_account_public_key_path, 11 | 'mode': 0o444, 12 | 'contents': { 13 | 'source': self.to_data_url(self.cluster.k8s_service_account_public_key), 14 | }, 15 | }, 16 | { 17 | 'filesystem': 'root', 18 | 'path': config.k8s_service_account_private_key_path, 19 | 'mode': 0o444, 20 | 'contents': { 21 | 'source': self.to_data_url(self.cluster.k8s_service_account_private_key), 22 | }, 23 | }, 24 | { 25 | 'filesystem': 'root', 26 | 'path': config.k8s_manifests_path + '/kube-apiserver.yaml', 27 | 'mode': 0o644, 28 | 'contents': { 29 | 'source': self.to_data_url(self.render_template('kube-apiserver.yaml')), 30 | }, 31 | }, 32 | { 33 | 'filesystem': 'root', 34 | 'path': config.k8s_manifests_path + '/kube-controller-manager.yaml', 35 | 'mode': 0o644, 36 | 'contents': { 37 | 'source': self.to_data_url(self.render_template('kube-controller-manager.yaml')), 38 | }, 39 | }, 40 | { 41 | 'filesystem': 'root', 42 | 'path': config.k8s_manifests_path + '/kube-scheduler.yaml', 43 | 'mode': 0o644, 44 | 'contents': { 45 | 'source': self.to_data_url(self.render_template('kube-scheduler.yaml')), 46 | }, 47 | }, 48 | ] 49 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/k8s_master_manifests/kube-apiserver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: kube-apiserver 5 | namespace: kube-system 6 | spec: 7 | hostNetwork: true 8 | containers: 9 | - name: kube-apiserver 10 | image: {{ config.k8s_hyperkube_image }}:{{ cluster.k8s_hyperkube_tag }} 11 | command: 12 | - /hyperkube 13 | - apiserver 14 | - --bind-address=0.0.0.0 15 | {% if cluster.explicitly_advertise_addresses -%} 16 | - --advertise-address={{ node.ip }} 17 | {% endif -%} 18 | - --secure-port={{ config.k8s_apiserver_secure_port }} 19 | - --insecure-port={{ config.k8s_apiserver_insecure_port }} 20 | - --etcd-servers={{ cluster.etcd_client_endpoints|join(',') }} 21 | - --etcd-cafile={{ config.ca_cert_path }} 22 | - --etcd-certfile={{ config.node_cert_path }} 23 | - --etcd-keyfile={{ config.node_key_path }} 24 | - --storage-backend=etcd3 25 | - --allow-privileged=true 26 | - --service-cluster-ip-range={{ cluster.k8s_service_network }} 27 | - --admission-control={{ cluster.k8s_admission_control }} 28 | - --service-account-key-file={{ config.k8s_service_account_public_key_path }} 29 | {% if cluster.k8s_apiservers_audit_log -%} 30 | - --audit-log-path=/dev/stdout 31 | {% endif -%} 32 | {% if cluster.k8s_apiservers_swagger_ui -%} 33 | - --enable-swagger-ui 34 | {% endif -%} 35 | - --anonymous-auth=false # * -> apiserver 36 | - --tls-cert-file={{ config.node_cert_path }} # * -> apiserver, auth cert 37 | - --tls-private-key-file={{ config.node_key_path }} # * -> apiserver, sign key 38 | - --client-ca-file={{ config.ca_cert_path }} # * -> apiserver, ca 39 | - --kubelet-client-certificate={{ config.node_cert_path }} # apiserver -> kubelet, auth cert 40 | - --kubelet-client-key={{ config.node_key_path }} # apiserver -> kubelet, sign key 41 | {% if cluster.k8s_is_rbac_enabled -%} 42 | - --authorization-mode=RBAC 43 | {% else -%} 44 | - --authorization-mode=AlwaysAllow 45 | {% endif -%} 46 | livenessProbe: 47 | httpGet: 48 | host: 127.0.0.1 49 | port: 8080 50 | path: /healthz 51 | initialDelaySeconds: 15 52 | timeoutSeconds: 15 53 | volumeMounts: 54 | - mountPath: {{ config.cluster_credentials_path }} 55 | name: ssl-certs-cluster 56 | readOnly: true 57 | - mountPath: /etc/ssl/certs 58 | name: ssl-certs-host 59 | readOnly: true 60 | - mountPath: {{ config.k8s_secrets_path }} 61 | name: k8s-secrets 62 | readOnly: true 63 | resources: 64 | limits: 65 | cpu: 200m 66 | memory: 256Mi 67 | volumes: 68 | - hostPath: 69 | path: {{ config.cluster_credentials_path }} 70 | name: ssl-certs-cluster 71 | - hostPath: 72 | path: {{ config.node_ca_certificates_path }} 73 | name: ssl-certs-host 74 | - hostPath: 75 | path: {{ config.k8s_secrets_path }} 76 | name: k8s-secrets 77 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/k8s_master_manifests/kube-controller-manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: kube-controller-manager 5 | namespace: kube-system 6 | spec: 7 | hostNetwork: true 8 | containers: 9 | - name: kube-controller-manager 10 | image: {{ config.k8s_hyperkube_image }}:{{ cluster.k8s_hyperkube_tag }} 11 | command: 12 | - /hyperkube 13 | - controller-manager 14 | - --master=http://127.0.0.1:{{ config.k8s_apiserver_insecure_port }} 15 | - --root-ca-file={{ config.ca_cert_path }} 16 | - --service-account-private-key-file={{ config.k8s_service_account_private_key_path }} 17 | - --leader-elect=true 18 | resources: 19 | limits: 20 | cpu: 200m 21 | memory: 100Mi 22 | livenessProbe: 23 | httpGet: 24 | host: 127.0.0.1 25 | path: /healthz 26 | port: 10252 27 | initialDelaySeconds: 15 28 | timeoutSeconds: 15 29 | volumeMounts: 30 | - mountPath: {{ config.cluster_credentials_path }} 31 | name: ssl-certs-cluster 32 | readOnly: true 33 | - mountPath: /etc/ssl/certs 34 | name: ssl-certs-host 35 | readOnly: true 36 | - mountPath: {{ config.k8s_secrets_path }} 37 | name: k8s-secrets 38 | readOnly: true 39 | volumes: 40 | - hostPath: 41 | path: {{ config.cluster_credentials_path }} 42 | name: ssl-certs-cluster 43 | - hostPath: 44 | path: {{ config.node_ca_certificates_path }} 45 | name: ssl-certs-host 46 | - hostPath: 47 | path: {{ config.k8s_secrets_path }} 48 | name: k8s-secrets 49 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/k8s_master_manifests/kube-scheduler.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: kube-scheduler 5 | namespace: kube-system 6 | spec: 7 | hostNetwork: true 8 | containers: 9 | - name: kube-scheduler 10 | image: {{ config.k8s_hyperkube_image }}:{{ cluster.k8s_hyperkube_tag }} 11 | command: 12 | - /hyperkube 13 | - scheduler 14 | - --master=http://127.0.0.1:{{ config.k8s_apiserver_insecure_port }} 15 | - --leader-elect=true 16 | resources: 17 | limits: 18 | cpu: 200m 19 | memory: 50Mi 20 | livenessProbe: 21 | httpGet: 22 | host: 127.0.0.1 23 | path: /healthz 24 | port: 10251 25 | initialDelaySeconds: 15 26 | timeoutSeconds: 15 27 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kube_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from seedbox import config, config_renderer 4 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | class KubeProxyPackage(BaseIgnitionPackage): 10 | def get_files(self): 11 | user = self.cluster.k8s_kube_proxy_user 12 | if not user: 13 | log.warning('No user "%s" for kube-proxy in cluster %s', config.k8s_kube_proxy_user_name, self.cluster) 14 | return [] 15 | 16 | return [ 17 | { 18 | 'filesystem': 'root', 19 | 'path': config.k8s_manifests_path + '/kube-proxy.yaml', 20 | 'mode': 0o644, 21 | 'contents': { 22 | 'source': self.to_data_url(self.render_template('kube-proxy.yaml')), 23 | }, 24 | }, 25 | { 26 | 'filesystem': 'root', 27 | 'path': config.k8s_kube_proxy_config_path, 28 | 'mode': 0o644, 29 | 'contents': { 30 | 'source': self.to_data_url(config_renderer.kubeconfig.render([user], default_user=user)), 31 | }, 32 | }, 33 | ] 34 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kube_proxy/kube-proxy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: kube-proxy 5 | namespace: kube-system 6 | spec: 7 | hostNetwork: true 8 | containers: 9 | - name: kube-proxy 10 | image: {{ config.k8s_hyperkube_image }}:{{ cluster.k8s_hyperkube_tag }} 11 | command: 12 | - /hyperkube 13 | - proxy 14 | - --kubeconfig=/etc/kubeconfig.yaml 15 | securityContext: 16 | privileged: true 17 | volumeMounts: 18 | - mountPath: /etc/ssl/certs 19 | name: ssl-certs-host 20 | readOnly: true 21 | - mountPath: {{ config.cluster_credentials_path }} 22 | name: ssl-certs-cluster 23 | readOnly: true 24 | - mountPath: /etc/kubeconfig.yaml 25 | name: kubeconfig 26 | readOnly: true 27 | volumes: 28 | - hostPath: 29 | path: {{ config.node_ca_certificates_path }} 30 | name: ssl-certs-host 31 | - hostPath: 32 | path: {{ config.cluster_credentials_path }} 33 | name: ssl-certs-cluster 34 | - hostPath: 35 | path: {{ config.k8s_kube_proxy_config_path }} 36 | name: kubeconfig 37 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kubeconfig/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox import config 2 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 3 | 4 | 5 | class KubeconfigPackage(BaseIgnitionPackage): 6 | def get_files(self): 7 | return [ 8 | { 9 | 'filesystem': 'root', 10 | 'path': config.k8s_kubeconfig_path, 11 | 'mode': 0o644, 12 | 'contents': { 13 | 'source': self.to_data_url(self.render_template('kubeconfig.yaml')), 14 | }, 15 | }, 16 | ] 17 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kubeconfig/kubeconfig.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Config 3 | clusters: 4 | - name: {{ cluster.name }} 5 | cluster: 6 | server: {{ cluster.k8s_apiserver_endpoint }} 7 | certificate-authority: {{ config.ca_cert_path }} 8 | users: 9 | - name: kubelet 10 | user: 11 | client-certificate: {{ config.node_cert_path }} 12 | client-key: {{ config.node_key_path }} 13 | contexts: 14 | - context: 15 | cluster: {{ cluster.name }} 16 | user: kubelet 17 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kubelet/__init__.py: -------------------------------------------------------------------------------- 1 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 2 | 3 | 4 | class KubeletPackage(BaseIgnitionPackage): 5 | def get_units(self): 6 | units = [ 7 | self.get_unit('kubelet.service', enable=True), 8 | ] 9 | 10 | if self.cluster.aci_proxy_url: 11 | units += [ 12 | self.get_unit_dropins('kubelet.service', ['30-proxy.conf']), 13 | ] 14 | 15 | return units 16 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kubelet/kubelet.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Kubelet via Hyperkube ACI 3 | Wants=flanneld.service 4 | 5 | [Service] 6 | Environment="KUBELET_ACI={{ config.k8s_hyperkube_image }}" 7 | Environment="KUBELET_IMAGE_TAG={{ cluster.k8s_hyperkube_tag }}" 8 | Environment="RKT_RUN_ARGS=--uuid-file-save=/var/run/kubelet-pod.uuid \ 9 | --volume resolv,kind=host,source=/etc/resolv.conf \ 10 | --mount volume=resolv,target=/etc/resolv.conf \ 11 | --volume ssl-cluster-credentials,kind=host,source={{ config.cluster_credentials_path }},readOnly=true \ 12 | --mount volume=ssl-cluster-credentials,target={{ config.cluster_credentials_path }} \ 13 | --volume var-log,kind=host,source=/var/log \ 14 | --mount volume=var-log,target=/var/log \ 15 | {% if cluster.k8s_cni -%} 16 | --volume var-lib-cni,kind=host,source=/var/lib/cni \ 17 | --mount volume=var-lib-cni,target=/var/lib/cni \ 18 | {% endif -%} 19 | --volume modprobe,kind=host,source=/usr/sbin/modprobe \ 20 | --mount volume=modprobe,target=/usr/sbin/modprobe \ 21 | --volume lib-modules,kind=host,source=/lib/modules \ 22 | --mount volume=lib-modules,target=/lib/modules \ 23 | --volume docker-config,kind=host,source=/root/.docker,readOnly=true \ 24 | --mount volume=docker-config,target=/.docker \ 25 | " 26 | 27 | ExecStartPre=/usr/bin/mkdir -p {{ config.k8s_manifests_path }} 28 | ExecStartPre=/usr/bin/mkdir -p /var/log/containers 29 | {% if cluster.k8s_cni -%} 30 | ExecStartPre=/usr/bin/mkdir -p /var/lib/cni 31 | {% endif -%} 32 | ExecStartPre=/usr/bin/systemctl is-active flanneld.service 33 | ExecStartPre=-/usr/bin/rkt rm --uuid-file=/var/run/kubelet-pod.uuid 34 | 35 | # --client-ca-file and --anonymous-auth arguments are for apiserver -> kubelet communication 36 | ExecStart=/usr/lib/coreos/kubelet-wrapper \ 37 | --kubeconfig={{ config.k8s_kubeconfig_path }} \ 38 | --require-kubeconfig \ 39 | --client-ca-file={{ config.ca_cert_path }} \ 40 | --anonymous-auth=false \ 41 | {% if cluster.k8s_cni -%} 42 | --cni-conf-dir={{ config.k8s_cni_conf_path }} \ 43 | --network-plugin=cni \ 44 | {% endif -%} 45 | --pod-manifest-path={{ config.k8s_manifests_path }} \ 46 | --allow-privileged=true \ 47 | --hostname-override={{ node.fqdn }} \ 48 | {% if node.is_k8s_master -%} 49 | --node-labels=node-role.kubernetes.io/master \ 50 | {% else -%} 51 | --node-labels=node-role.kubernetes.io/node \ 52 | {% endif -%} 53 | --cluster-dns={{ cluster.k8s_dns_service_ip }} \ 54 | --cluster-domain={{ config.k8s_cluster_domain }} \ 55 | {% if node.is_k8s_schedulable -%} 56 | --register-schedulable=true \ 57 | {% else %} 58 | --register-schedulable=false \ 59 | {% endif -%} 60 | \ 61 | 62 | ExecStop=-/usr/bin/rkt stop --uuid-file=/var/run/kubelet-pod.uuid 63 | 64 | Restart=always 65 | RestartSec=10 66 | 67 | [Install] 68 | WantedBy=multi-user.target 69 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/kubelet/kubelet.service.d/30-proxy.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment=HTTP_PROXY={{ cluster.aci_proxy_url }} 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/__init__.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from collections import defaultdict 3 | 4 | from seedbox import config 5 | from seedbox.config_renderer.ignition.base import BaseIgnitionPackage 6 | 7 | 8 | class SystemPackage(BaseIgnitionPackage): 9 | def get_files(self): 10 | files = [ 11 | { 12 | 'filesystem': 'root', 13 | 'path': '/etc/sysctl.d/max-user-watches.conf', 14 | 'mode': 0o644, 15 | 'contents': { 16 | 'source': self.to_data_url(self.render_template('sysctl-max-user-watches.conf')), 17 | }, 18 | }, 19 | { 20 | 'filesystem': 'root', 21 | 'path': '/etc/hostname', 22 | 'mode': 0o644, 23 | 'contents': { 24 | 'source': self.to_data_url(self.node.fqdn + '\n'), 25 | }, 26 | }, 27 | { 28 | 'filesystem': 'root', 29 | 'path': '/opt/bin/cluster-etcdctl', 30 | 'mode': 0o755, 31 | 'contents': { 32 | 'source': self.to_data_url(self.render_template('cluster-etcdctl')), 33 | }, 34 | }, 35 | { 36 | 'filesystem': 'root', 37 | 'path': '/root/.docker/config.json', 38 | 'mode': 0o600, 39 | 'contents': { 40 | 'source': self.to_data_url(self.cluster.docker_config), 41 | }, 42 | }, 43 | ] 44 | 45 | if self.cluster.aci_proxy_ca_cert: 46 | files += [ 47 | { 48 | 'filesystem': 'root', 49 | 'path': config.aci_proxy_ca_cert_path, 50 | 'mode': 0o644, 51 | 'contents': { 52 | 'source': self.to_data_url(self.cluster.aci_proxy_ca_cert + '\n'), 53 | }, 54 | }, 55 | ] 56 | 57 | return files 58 | 59 | def get_units(self): 60 | units = [ 61 | self.get_unit('provision-report.service', enable=True), 62 | self.get_unit_dropins('fleet.service', ['40-etcd-cluster.conf']), 63 | self.get_unit_dropins('locksmithd.service', [ 64 | '40-etcd-cluster.conf', 65 | '40-etcd-lock.conf', 66 | ]), 67 | self.get_unit_dropins('sshd.service', ['40-oom.conf']), 68 | self.get_unit_dropins('sshd@.service', ['40-oom.conf']), 69 | self.get_unit_dropins('containerd.service', ['40-oom.conf']), 70 | ] 71 | 72 | if self.cluster.aci_proxy_url: 73 | units += [ 74 | self.get_unit_dropins('docker.service', ['30-proxy.conf']), 75 | ] 76 | 77 | if self.cluster.aci_proxy_ca_cert: 78 | units += [ 79 | self.get_unit('add-http-proxy-ca-certificate.service', enable=True) 80 | ] 81 | 82 | for mountpoint in self.node.mountpoints.all(): 83 | units += [ 84 | self.get_unit(mountpoint2unitname(mountpoint), 85 | enable=bool(mountpoint.wanted_by), 86 | template_name='volume.mount', 87 | additional_context={ 88 | 'mountpoint': mountpoint, 89 | }) 90 | ] 91 | 92 | return units 93 | 94 | def get_networkd_units(self): 95 | interfaces = defaultdict(list) 96 | for address in self.node.addresses.all(): 97 | interfaces[address.interface].append(address) 98 | 99 | return [ 100 | self.get_unit('aa-{}-addresses.network'.format(interface), 101 | template_name='addresses.network', 102 | additional_context={ 103 | 'interface': interface, 104 | 'addresses': addresses, 105 | }) 106 | for interface, addresses in interfaces.items() 107 | ] 108 | 109 | 110 | def mountpoint2unitname(mountpoint): 111 | unit_name = mountpoint.where.replace('/', '-') 112 | while unit_name[0] == '-': 113 | unit_name = unit_name[1:] 114 | return unit_name + '.mount' 115 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/add-http-proxy-ca-certificate.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Add http proxy CA certificate to /etc/ssl/certs/ca-certificates.crt 3 | After=update-ca-certificates.service 4 | 5 | [Service] 6 | Type=oneshot 7 | ExecStartPre=/bin/rm /etc/ssl/certs/ca-certificates.crt 8 | ExecStart=/bin/sh -c 'cat /usr/share/ca-certificates/ca-certificates.crt {{ config.aci_proxy_ca_cert_path }} > /etc/ssl/certs/ca-certificates.crt' 9 | 10 | [Install] 11 | WantedBy=update-ca-certificates.service 12 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/addresses.network: -------------------------------------------------------------------------------- 1 | [Match] 2 | Name={{ interface }} 3 | 4 | [Network] 5 | DHCP=yes 6 | {% for addr in addresses -%} 7 | Address={{ addr.ip }}/32 8 | {% endfor %} 9 | 10 | [DHCP] 11 | UseMTU=true 12 | UseDomains=true 13 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/cluster-etcdctl: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # etcd-wrapper options 5 | export ETCD_IMAGE_TAG={{ cluster.etcd_image_tag }} 6 | export ETCD_USER=etcd 7 | export ETCD_DATA_DIR=/tmp/etdctl 8 | export ETCD_SSL_DIR={{ config.cluster_credentials_path }} 9 | export ETCD_IMAGE_ARGS='--exec /usr/bin/env' 10 | 11 | # used for ETCDCTL_API=2 12 | export ETCDCTL_CA_FILE=/etc/ssl/certs/{{ config.ca_cert_filename }} 13 | export ETCDCTL_CERT_FILE=/etc/ssl/certs/{{ config.node_cert_filename }} 14 | export ETCDCTL_KEY_FILE=/etc/ssl/certs/{{ config.node_key_filename }} 15 | 16 | # used for ETCDCTL_API=3 17 | export ETCDCTL_CACERT=$ETCDCTL_CA_FILE 18 | export ETCDCTL_CERT=$ETCDCTL_CERT_FILE 19 | export ETCDCTL_KEY=$ETCDCTL_KEY_FILE 20 | 21 | export ETCDCTL_ENDPOINTS={{ cluster.etcd_client_endpoints|join(',') }} 22 | 23 | exec /usr/lib/coreos/etcd-wrapper etcdctl "$@" 24 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/containerd.service.d/40-oom.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | OOMScoreAdjust=-999 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/docker.service.d/30-proxy.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | # works only to fetch images 3 | Environment=HTTP_PROXY={{ cluster.aci_proxy_url }} 4 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/fleet.service.d/40-etcd-cluster.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="FLEET_ETCD_SERVERS={{ cluster.etcd_client_endpoints|join(',') }}" 3 | Environment="FLEET_ETCD_CAFILE={{ config.ca_cert_path }}" 4 | Environment="FLEET_ETCD_CERTFILE={{ config.node_cert_path }}" 5 | Environment="FLEET_ETCD_KEYFILE={{ config.node_key_path }}" 6 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/locksmithd.service.d/40-etcd-cluster.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="LOCKSMITHD_ENDPOINT={{ cluster.etcd_client_endpoints|join(',') }}" 3 | Environment="LOCKSMITHD_ETCD_CAFILE={{ config.ca_cert_path }}" 4 | Environment="LOCKSMITHD_ETCD_CERTFILE={{ config.node_cert_path }}" 5 | Environment="LOCKSMITHD_ETCD_KEYFILE={{ config.node_key_path }}" 6 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/locksmithd.service.d/40-etcd-lock.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | Environment="REBOOT_STRATEGY=etcd-lock" 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/provision-report.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Provision Report 3 | Requires=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/usr/bin/curl --fail \ 9 | --silent \ 10 | --show-error \ 11 | --location \ 12 | -H 'Content-Type: application/json' \ 13 | -X POST \ 14 | --data-binary @/run/ignition.json \ 15 | '{{ url_root }}report?version={{ node.target_config_version }}' 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/sshd.service.d/40-oom.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | OOMScoreAdjust=-1000 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/sshd@.service.d/40-oom.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | OOMScoreAdjust=-1000 3 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/sysctl-max-user-watches.conf: -------------------------------------------------------------------------------- 1 | fs.inotify.max_user_watches=16184 2 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ignition/system/volume.mount: -------------------------------------------------------------------------------- 1 | [Mount] 2 | What={{ mountpoint.what }} 3 | Where={{ mountpoint.where }} 4 | 5 | [Install] 6 | WantedBy={{ mountpoint.wanted_by }} 7 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/ipxe.py: -------------------------------------------------------------------------------- 1 | response_template = """#!ipxe 2 | set base-url {images_base_url} 3 | kernel ${{base-url}}coreos_production_pxe.vmlinuz {kernel_args} 4 | initrd ${{base-url}}coreos_production_pxe_image.cpio.gz 5 | boot 6 | """ 7 | 8 | 9 | def render(node, url_root): 10 | kernel_args = get_kernel_arguments(node, url_root) 11 | return response_template.format(images_base_url=node.cluster.coreos_images_base_url, 12 | kernel_args=' '.join(kernel_args)) 13 | 14 | 15 | def get_kernel_arguments(node, url_root): 16 | args = [ 17 | 'coreos.config.url={}ignition'.format(url_root), 18 | 'coreos.first_boot=yes', 19 | ] 20 | 21 | if node.coreos_autologin: 22 | args.append('coreos.autologin') 23 | 24 | for console in node.linux_consoles.split(','): 25 | args.append('console=' + console) 26 | 27 | if not node.maintenance_mode: 28 | args.append('root=' + node.root_partition.device) 29 | 30 | if node.disable_ipv6: 31 | args.append('ipv6.disable=1') 32 | 33 | if node.debug_boot: 34 | args += [ 35 | 'systemd.journald.forward_to_kmsg=1', 36 | 'systemd.journald.max_level_kmsg=debug', 37 | ] 38 | 39 | if node.additional_kernel_cmdline: 40 | args += node.additional_kernel_cmdline.split() 41 | 42 | return args 43 | -------------------------------------------------------------------------------- /src/seedbox/config_renderer/kubeconfig.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from collections import OrderedDict 3 | 4 | import yaml 5 | import yaml.resolver 6 | 7 | 8 | def render(users, default_user=None): 9 | clusters_map = {} 10 | users_map = {} 11 | 12 | for user in users: 13 | users_map[user.name] = user 14 | clusters_map[user.cluster.name] = user.cluster 15 | 16 | contexts = [{ 17 | 'name': user.name, 18 | 'context': { 19 | 'cluster': user.cluster.name, 20 | 'user': user.name, 21 | }, 22 | } for user in users_map.values()] 23 | 24 | clusters = [{ 25 | 'name': cluster.name, 26 | 'cluster': { 27 | 'server': cluster.k8s_apiserver_endpoint, 28 | 'certificate-authority-data': base64.b64encode(cluster.ca_credentials.cert).decode('ascii'), 29 | }, 30 | } for cluster in clusters_map.values()] 31 | 32 | users = [{ 33 | 'name': user.name, 34 | 'user': { 35 | 'client-certificate-data': base64.b64encode(user.credentials.cert).decode('ascii'), 36 | 'client-key-data': base64.b64encode(user.credentials.key).decode('ascii'), 37 | }, 38 | } for user in users_map.values()] 39 | 40 | config = OrderedDict([ 41 | ('apiVersion', 'v1'), 42 | ('kind', 'Config'), 43 | ('clusters', clusters), 44 | ('users', users), 45 | ('contexts', contexts), 46 | ]) 47 | 48 | if default_user: 49 | config['current-context'] = default_user.name 50 | 51 | return yaml.dump(config, default_flow_style=False, Dumper=Dumper) 52 | 53 | 54 | class Dumper(yaml.SafeDumper): 55 | pass 56 | 57 | 58 | def _dict_representer(dumper, data): 59 | return dumper.represent_mapping(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, data.items()) 60 | 61 | 62 | Dumper.add_representer(OrderedDict, _dict_representer) 63 | -------------------------------------------------------------------------------- /src/seedbox/exceptions.py: -------------------------------------------------------------------------------- 1 | class K8sNoClusterApiserver(Exception): 2 | def __str__(self): 3 | return "No node with k8s apiserver" 4 | 5 | 6 | class NoRootPartition(Exception): 7 | def __str__(self): 8 | return "No partition labeled ROOT defined" 9 | 10 | 11 | class MultipleRootPartitions(Exception): 12 | def __str__(self): 13 | return "More than one partition labeled ROOT defined" 14 | 15 | 16 | class MultiplePersistentMountpoints(Exception): 17 | def __str__(self): 18 | return "More than one persistent mountpoint defined" 19 | -------------------------------------------------------------------------------- /src/seedbox/ignition_parser.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import json 4 | import urllib.parse 5 | 6 | from seedbox import utils 7 | 8 | encoding = 'utf-8' 9 | 10 | 11 | def iter_files(ignition): 12 | if 'storage' in ignition and 'files' in ignition['storage']: 13 | files = ignition['storage'].pop('files') 14 | 15 | for file in files: 16 | file_path = file['path'] 17 | if file_path[0] == '/': 18 | file_path = file_path[1:] 19 | file_path = os.path.join(file['filesystem'], file_path) 20 | 21 | file_mode = file.get('mode') 22 | file_uid = file.get('user', {}).get('id') 23 | file_gid = file.get('group', {}).get('id') 24 | 25 | file_attrs = b'' 26 | if file_mode is not None: 27 | file_attrs += b'mode: %s\n' % oct(int(file_mode))[2:].encode(encoding) 28 | if file_uid is not None: 29 | file_attrs += b'uid: %s\n' % file_uid 30 | if file_gid is not None: 31 | file_attrs += b'gid: %s\n' % file_gid 32 | 33 | if file_attrs: 34 | yield file_path + '.attrs', file_attrs 35 | 36 | file_source = file['contents']['source'] 37 | if file_source.startswith('data:,'): 38 | file_data = urllib.parse.unquote(file_source[len('data:,'):]).encode(encoding) 39 | else: 40 | file_path += '.src' 41 | file_data = file_source.encode(encoding) + b'\n' 42 | 43 | yield file_path, file_data 44 | 45 | if 'systemd' in ignition and 'units' in ignition['systemd']: 46 | units = ignition['systemd'].pop('units') 47 | units_dir_path = os.path.join('root', 'etc', 'systemd', 'system') 48 | 49 | for unit in units: 50 | unitname = unit['name'] 51 | unit_file_path = os.path.join(units_dir_path, unitname) 52 | 53 | if unit.get('enable'): 54 | yield unit_file_path + '.enabled', b'' 55 | 56 | if 'contents' in unit: 57 | yield unit_file_path, unit['contents'].encode(encoding) 58 | 59 | if 'dropins' in unit: 60 | unit_dropins_path = os.path.join(units_dir_path, unitname + '.d') 61 | for dropin in unit['dropins']: 62 | yield os.path.join(unit_dropins_path, dropin['name']), dropin['contents'].encode(encoding) 63 | 64 | yield 'non-fs.json', json.dumps(ignition, indent=2).encode(encoding) 65 | 66 | 67 | def render_ignition_tgz(ignition): 68 | tgz_fp = io.BytesIO() 69 | with utils.TarFile.open(fileobj=tgz_fp, mode='w:gz') as tgz: 70 | for path, data in iter_files(ignition): 71 | tgz.adddata(os.path.join('ignition', path), data) 72 | return tgz_fp.getvalue() 73 | -------------------------------------------------------------------------------- /src/seedbox/manage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask_script import Manager 4 | from flask_migrate import MigrateCommand 5 | 6 | from seedbox.app import app 7 | 8 | manager = Manager(app) 9 | manager.add_command('db', MigrateCommand) 10 | 11 | 12 | @manager.command 13 | def watch_updates(): 14 | """Starts component updates watcher in foreground (CoreOS, k8s, etcd)""" 15 | from seedbox import update_watcher 16 | update_watcher.watch() 17 | 18 | 19 | def run(): 20 | logging.basicConfig(level='NOTSET') 21 | manager.run() 22 | 23 | 24 | if __name__ == '__main__': 25 | run() 26 | -------------------------------------------------------------------------------- /src/seedbox/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/seedbox/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/src/seedbox/migrations/__init__.py -------------------------------------------------------------------------------- /src/seedbox/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /src/seedbox/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /src/seedbox/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/028018cd8818_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 028018cd8818 4 | Revises: 25fc3f52af44 5 | Create Date: 2017-04-10 23:21:49.523249 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '028018cd8818' 14 | down_revision = '25fc3f52af44' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('cluster', sa.Column('dnsmasq_static_records', sa.Boolean(), nullable=True)) 22 | op.execute('UPDATE cluster SET dnsmasq_static_records=install_dnsmasq') 23 | op.alter_column('cluster', 'dnsmasq_static_records', nullable=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('cluster', 'dnsmasq_static_records') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/03314dc2f789_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 03314dc2f789 4 | Revises: 235f683efac0 5 | Create Date: 2017-06-19 15:39:34.872360 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '03314dc2f789' 14 | down_revision = '235f683efac0' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('cluster', sa.Column('docker_config', sa.Text(), nullable=False, server_default='{}')) 22 | op.alter_column('cluster', 'docker_config', server_default=None) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('cluster', 'docker_config') 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/2093be28531b_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2093be28531b 4 | Revises: da3084151a1d 5 | Create Date: 2017-04-28 18:55:40.347204 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2093be28531b' 14 | down_revision = 'da3084151a1d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('node', sa.Column('additional_kernel_cmdline', sa.String(length=255), nullable=False, 22 | server_default='')) 23 | op.alter_column('node', 'additional_kernel_cmdline', server_default=None) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('node', 'additional_kernel_cmdline') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/235f683efac0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 235f683efac0 4 | Revises: 35099fc974d2 5 | Create Date: 2017-05-19 18:46:59.356128 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '235f683efac0' 14 | down_revision = '35099fc974d2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_unique_constraint('_node_mountpoint_what_uc', 'mountpoint', ['node_id', 'what']) 22 | op.create_unique_constraint('_node_mountpoint_where_uc', 'mountpoint', ['node_id', 'where']) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_constraint('_node_mountpoint_where_uc', 'mountpoint', type_='unique') 29 | op.drop_constraint('_node_mountpoint_what_uc', 'mountpoint', type_='unique') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/25fc3f52af44_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 25fc3f52af44 4 | Revises: 5 | Create Date: 2017-04-10 17:15:01.049591 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '25fc3f52af44' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('credentials_data', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('cert', sa.Binary(), nullable=False), 24 | sa.Column('key', sa.Binary(), nullable=False), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_table('cluster', 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('name', sa.String(length=80), nullable=False), 30 | sa.Column('ca_credentials_id', sa.Integer(), nullable=False), 31 | sa.Column('etcd_version', sa.Integer(), nullable=False), 32 | sa.Column('suppose_etcd_cluster_exists', sa.Boolean(), nullable=False), 33 | sa.Column('etcd_nodes_dns_name', sa.String(length=80), nullable=False), 34 | sa.Column('install_dnsmasq', sa.Boolean(), nullable=False), 35 | sa.Column('allow_insecure_provision', sa.Boolean(), nullable=False), 36 | sa.Column('apiservers_audit_log', sa.Boolean(), nullable=False), 37 | sa.Column('apiservers_swagger_ui', sa.Boolean(), nullable=False), 38 | sa.Column('explicitly_advertise_addresses', sa.Boolean(), nullable=False), 39 | sa.Column('k8s_pod_network', sa.String(length=80), nullable=False), 40 | sa.Column('k8s_service_network', sa.String(length=80), nullable=False), 41 | sa.Column('k8s_hyperkube_tag', sa.String(length=80), nullable=False), 42 | sa.Column('k8s_cni', sa.Boolean(), nullable=False), 43 | sa.Column('k8s_apiservers_dns_name', sa.String(length=80), nullable=False), 44 | sa.Column('boot_images_base_url', sa.String(length=80), nullable=False), 45 | sa.Column('aci_proxy_url', sa.String(length=80), nullable=False), 46 | sa.Column('aci_proxy_ca_cert', sa.Text(), nullable=False), 47 | sa.Column('service_account_keypair_id', sa.Integer(), nullable=False), 48 | sa.ForeignKeyConstraint(['ca_credentials_id'], ['credentials_data.id'], ), 49 | sa.ForeignKeyConstraint(['service_account_keypair_id'], ['credentials_data.id'], ), 50 | sa.PrimaryKeyConstraint('id'), 51 | sa.UniqueConstraint('name') 52 | ) 53 | op.create_table('node', 54 | sa.Column('id', sa.Integer(), nullable=False), 55 | sa.Column('ip', sa.String(length=80), nullable=False), 56 | sa.Column('fqdn', sa.String(length=80), nullable=False), 57 | sa.Column('maintenance_mode', sa.Boolean(), nullable=False), 58 | sa.Column('cluster_id', sa.Integer(), nullable=False), 59 | sa.Column('credentials_id', sa.Integer(), nullable=False), 60 | sa.Column('target_config_version', sa.Integer(), nullable=False), 61 | sa.Column('active_config_version', sa.Integer(), nullable=True), 62 | sa.Column('active_ignition_config', sa.Text(), nullable=False), 63 | sa.Column('coreos_autologin', sa.Boolean(), nullable=False), 64 | sa.Column('root_disk', sa.String(length=80), nullable=False), 65 | sa.Column('wipe_root_disk_next_boot', sa.Boolean(), nullable=False), 66 | sa.Column('root_disk_size_sectors', sa.Integer(), nullable=True), 67 | sa.Column('linux_consoles', sa.String(length=80), nullable=False), 68 | sa.Column('disable_ipv6', sa.Boolean(), nullable=False), 69 | sa.Column('is_etcd_server', sa.Boolean(), nullable=False), 70 | sa.Column('is_k8s_schedulable', sa.Boolean(), nullable=False), 71 | sa.Column('is_k8s_master', sa.Boolean(), nullable=False), 72 | sa.ForeignKeyConstraint(['cluster_id'], ['cluster.id'], ), 73 | sa.ForeignKeyConstraint(['credentials_id'], ['credentials_data.id'], ), 74 | sa.PrimaryKeyConstraint('id'), 75 | sa.UniqueConstraint('fqdn'), 76 | sa.UniqueConstraint('ip') 77 | ) 78 | op.create_table('user', 79 | sa.Column('id', sa.Integer(), nullable=False), 80 | sa.Column('cluster_id', sa.Integer(), nullable=False), 81 | sa.Column('name', sa.String(length=80), nullable=False), 82 | sa.Column('credentials_id', sa.Integer(), nullable=False), 83 | sa.Column('groups', sa.String(length=255), nullable=False), 84 | sa.Column('ssh_key', sa.Text(), nullable=False), 85 | sa.ForeignKeyConstraint(['cluster_id'], ['cluster.id'], ), 86 | sa.ForeignKeyConstraint(['credentials_id'], ['credentials_data.id'], ), 87 | sa.PrimaryKeyConstraint('id') 88 | ) 89 | op.create_table('address', 90 | sa.Column('id', sa.Integer(), nullable=False), 91 | sa.Column('node_id', sa.Integer(), nullable=False), 92 | sa.Column('interface', sa.String(length=80), nullable=False), 93 | sa.Column('ip', sa.String(length=80), nullable=False), 94 | sa.ForeignKeyConstraint(['node_id'], ['node.id'], ), 95 | sa.PrimaryKeyConstraint('id') 96 | ) 97 | op.create_table('mountpoint', 98 | sa.Column('id', sa.Integer(), nullable=False), 99 | sa.Column('node_id', sa.Integer(), nullable=False), 100 | sa.Column('what', sa.String(length=80), nullable=False), 101 | sa.Column('where', sa.String(length=80), nullable=False), 102 | sa.Column('wanted_by', sa.String(length=80), nullable=False), 103 | sa.ForeignKeyConstraint(['node_id'], ['node.id'], ), 104 | sa.PrimaryKeyConstraint('id') 105 | ) 106 | # ### end Alembic commands ### 107 | 108 | 109 | def downgrade(): 110 | # ### commands auto generated by Alembic - please adjust! ### 111 | op.drop_table('mountpoint') 112 | op.drop_table('address') 113 | op.drop_table('user') 114 | op.drop_table('node') 115 | op.drop_table('cluster') 116 | op.drop_table('credentials_data') 117 | # ### end Alembic commands ### 118 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/283dc9256301_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 283dc9256301 4 | Revises: 52e82e7e9376 5 | Create Date: 2017-05-01 22:10:11.649870 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '283dc9256301' 14 | down_revision = '52e82e7e9376' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('provision', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('node_id', sa.Integer(), nullable=False), 24 | sa.Column('applied_at', sa.DateTime(), nullable=True), 25 | sa.Column('config_version', sa.Integer(), nullable=False), 26 | sa.Column('ignition_config', sa.Binary(), nullable=True), 27 | sa.Column('ipxe_config', sa.Text(), nullable=True), 28 | sa.ForeignKeyConstraint(['node_id'], ['node.id'], ), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.drop_column('node', 'active_ignition_config') 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.add_column('node', sa.Column('active_ignition_config', sa.TEXT(), autoincrement=False, nullable=False)) 38 | op.drop_table('provision') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/35099fc974d2_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 35099fc974d2 4 | Revises: ae00e7974dca 5 | Create Date: 2017-05-19 17:01:48.878196 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '35099fc974d2' 14 | down_revision = 'ae00e7974dca' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.drop_column('node', 'wipe_root_disk_next_boot') 21 | op.drop_column('node', 'root_partition_size_sectors') 22 | op.drop_column('node', 'root_disk') 23 | 24 | 25 | def downgrade(): 26 | op.add_column('node', sa.Column('root_disk', sa.VARCHAR(length=80), autoincrement=False, nullable=False)) 27 | op.add_column('node', sa.Column('root_partition_size_sectors', sa.INTEGER(), autoincrement=False, nullable=True)) 28 | op.add_column('node', sa.Column('wipe_root_disk_next_boot', sa.BOOLEAN(), autoincrement=False, nullable=False)) 29 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/450edaa5f10d_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 450edaa5f10d 4 | Revises: bf3ac4dda2ab 5 | Create Date: 2017-04-30 21:33:02.865692 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '450edaa5f10d' 14 | down_revision = 'bf3ac4dda2ab' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('cluster', 'suppose_etcd_cluster_exists', new_column_name='assert_etcd_cluster_exists') 22 | op.alter_column('cluster', 'apiservers_audit_log', new_column_name='k8s_apiservers_audit_log') 23 | op.alter_column('cluster', 'apiservers_swagger_ui', new_column_name='k8s_apiservers_swagger_ui') 24 | op.alter_column('node', 'root_disk_size_sectors', new_column_name='root_partition_size_sectors') 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.alter_column('node', 'root_partition_size_sectors', new_column_name='root_disk_size_sectors') 31 | op.alter_column('cluster', 'k8s_apiservers_swagger_ui', new_column_name='apiservers_swagger_ui') 32 | op.alter_column('cluster', 'assert_etcd_cluster_exists', new_column_name='suppose_etcd_cluster_exists') 33 | op.alter_column('cluster', 'k8s_apiservers_audit_log', new_column_name='apiservers_audit_log') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/469154bafe22_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 469154bafe22 4 | Revises: 450edaa5f10d 5 | Create Date: 2017-04-30 22:28:25.540557 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '469154bafe22' 14 | down_revision = '450edaa5f10d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('cluster', sa.Column('k8s_service_account_private_key', sa.Binary(), nullable=True)) 22 | op.add_column('cluster', sa.Column('k8s_service_account_public_key', sa.Binary(), nullable=True)) 23 | op.execute('UPDATE cluster SET k8s_service_account_private_key=(SELECT key FROM credentials_data WHERE credentials_data.id = cluster.service_account_keypair_id)') 24 | op.execute('UPDATE cluster SET k8s_service_account_public_key=(SELECT cert FROM credentials_data WHERE credentials_data.id = cluster.service_account_keypair_id)') 25 | op.alter_column('cluster', 'k8s_service_account_private_key', nullable=False) 26 | op.alter_column('cluster', 'k8s_service_account_public_key', nullable=False) 27 | op.drop_constraint('cluster_service_account_keypair_id_fkey', 'cluster', type_='foreignkey') 28 | op.drop_column('cluster', 'service_account_keypair_id') 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.add_column('cluster', sa.Column('service_account_keypair_id', sa.INTEGER(), autoincrement=False, nullable=False)) 35 | op.create_foreign_key('cluster_service_account_keypair_id_fkey', 'cluster', 'credentials_data', ['service_account_keypair_id'], ['id']) 36 | op.drop_column('cluster', 'k8s_service_account_public_key') 37 | op.drop_column('cluster', 'k8s_service_account_private_key') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/519c11fc090d_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 519c11fc090d 4 | Revises: 579f89f6b1a0 5 | Create Date: 2017-05-19 15:37:45.597324 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '519c11fc090d' 14 | down_revision = '579f89f6b1a0' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('disk', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('node_id', sa.Integer(), nullable=False), 24 | sa.Column('device', sa.String(length=80), nullable=False), 25 | sa.Column('wipe_next_boot', sa.Boolean(), nullable=False), 26 | sa.Column('sector_size_bytes', sa.Integer(), nullable=False), 27 | sa.ForeignKeyConstraint(['node_id'], ['node.id'], ), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('node_id', 'device', name='_node_disk_device_uc') 30 | ) 31 | 32 | op.create_table('disk_partition', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('disk_id', sa.Integer(), nullable=False), 35 | sa.Column('number', sa.Integer(), nullable=False), 36 | sa.Column('label', sa.String(length=80), nullable=False), 37 | sa.Column('size_mibs', sa.Integer(), nullable=True), 38 | sa.Column('format', sa.String(length=10), nullable=False), 39 | sa.ForeignKeyConstraint(['disk_id'], ['disk.id'], ), 40 | sa.PrimaryKeyConstraint('id'), 41 | sa.UniqueConstraint('disk_id', 'label', name='_disk_partition_label_uc'), 42 | sa.UniqueConstraint('disk_id', 'number', name='_disk_partition_number_uc') 43 | ) 44 | 45 | op.add_column('mountpoint', sa.Column('is_persistent', sa.Boolean(), nullable=False)) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_column('mountpoint', 'is_persistent') 52 | op.drop_table('disk_partition') 53 | op.drop_table('disk') 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/52e82e7e9376_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 52e82e7e9376 4 | Revises: 635f6467bb33 5 | Create Date: 2017-05-01 08:35:20.772434 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '52e82e7e9376' 14 | down_revision = '635f6467bb33' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_unique_constraint('_cluster_name_uc', 'user', ['cluster_id', 'name']) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_constraint('_cluster_name_uc', 'user', type_='unique') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/579f89f6b1a0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 579f89f6b1a0 4 | Revises: 283dc9256301 5 | Create Date: 2017-05-07 04:48:43.309982 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '579f89f6b1a0' 14 | down_revision = '283dc9256301' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.execute('ALTER TABLE cluster ALTER COLUMN k8s_admission_control TYPE varchar(255)') 21 | 22 | 23 | def downgrade(): 24 | pass 25 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/635f6467bb33_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 635f6467bb33 4 | Revises: 469154bafe22 5 | Create Date: 2017-04-30 22:39:13.776090 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '635f6467bb33' 14 | down_revision = '469154bafe22' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('user', 'groups', new_column_name='k8s_groups') 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.alter_column('user', 'k8s_groups', new_column_name='groups') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/91248bf831b8_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 91248bf831b8 4 | Revises: b70159a8c7c2 5 | Create Date: 2017-04-27 17:31:28.639958 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '91248bf831b8' 14 | down_revision = 'b70159a8c7c2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('cluster', sa.Column('k8s_is_rbac_enabled', sa.Boolean(), nullable=False, server_default='TRUE')) 22 | op.alter_column('cluster', 'k8s_is_rbac_enabled', server_default=None) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('cluster', 'k8s_is_rbac_enabled') 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/92f518191c12_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 92f518191c12 4 | Revises: 91248bf831b8 5 | Create Date: 2017-04-27 17:44:57.672006 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | from seedbox import config 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '92f518191c12' 16 | down_revision = '91248bf831b8' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('cluster', sa.Column('k8s_admission_control', sa.String(length=80), nullable=False, 24 | server_default=config.default_k8s_admission_control)) 25 | op.alter_column('cluster', 'k8s_admission_control', server_default=None) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_column('cluster', 'k8s_admission_control') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/9a65898a0e2f_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 9a65898a0e2f 4 | Revises: aa55bedebb6f 5 | Create Date: 2017-04-30 12:04:42.566828 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | from seedbox import config 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = '9a65898a0e2f' 16 | down_revision = 'aa55bedebb6f' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('cluster', sa.Column('coreos_channel', sa.String(length=80), nullable=False, 24 | server_default=config.default_coreos_channel)) 25 | op.alter_column('cluster', 'coreos_channel', server_default=None) 26 | op.add_column('cluster', sa.Column('coreos_version', sa.String(length=80), nullable=False, 27 | server_default=config.default_coreos_version)) 28 | op.alter_column('cluster', 'coreos_version', server_default=None) 29 | op.alter_column('cluster', 'boot_images_base_url', new_column_name='custom_coreos_images_base_url') 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.alter_column('cluster', 'custom_coreos_images_base_url', new_column_name='boot_images_base_url') 36 | op.drop_column('cluster', 'coreos_version') 37 | op.drop_column('cluster', 'coreos_channel') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nailgun/seedbox/d124f71017dbbe5af81592e76933809b5cdddb08/src/seedbox/migrations/versions/__init__.py -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/aa55bedebb6f_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: aa55bedebb6f 4 | Revises: 2093be28531b 5 | Create Date: 2017-04-28 23:30:59.720792 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'aa55bedebb6f' 14 | down_revision = '2093be28531b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.execute('UPDATE node SET active_config_version=0 WHERE active_config_version IS NULL') 22 | op.alter_column('node', 'active_config_version', nullable=False) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.alter_column('node', 'active_config_version', nullable=True) 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/ae00e7974dca_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: ae00e7974dca 4 | Revises: 519c11fc090d 5 | Create Date: 2017-05-19 17:00:48.920776 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ae00e7974dca' 14 | down_revision = '519c11fc090d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | from seedbox import models 21 | session = models.db.session 22 | 23 | models.Node.root_disk = sa.Column(sa.String(80), nullable=False) 24 | models.Node.wipe_root_disk_next_boot = sa.Column(sa.Boolean, nullable=False) 25 | models.Node.root_partition_size_sectors = sa.Column(sa.Integer, nullable=True) 26 | 27 | for node in models.Node.query.all(): 28 | disk = models.Disk() 29 | disk.node = node 30 | disk.device = node.root_disk 31 | disk.wipe_next_boot = node.wipe_root_disk_next_boot 32 | session.add(disk) 33 | 34 | partition = models.DiskPartition() 35 | partition.disk = disk 36 | partition.number = 1 37 | partition.label = 'ROOT' 38 | partition.format = 'ext4' 39 | 40 | if node.root_partition_size_sectors: 41 | partition.size_mibs = node.root_partition_size_sectors * 512 // 1024 // 1024 42 | 43 | session.add(partition) 44 | 45 | if partition.size_mibs: 46 | partition = models.DiskPartition() 47 | partition.disk = disk 48 | partition.number = 2 49 | partition.label = 'Persistent' 50 | partition.format = 'ext4' 51 | session.add(partition) 52 | 53 | mountpoint = models.Mountpoint() 54 | mountpoint.node = node 55 | mountpoint.what = disk.device + '2' 56 | mountpoint.where = '/mnt/persistent' 57 | mountpoint.is_persistent = True 58 | session.add(mountpoint) 59 | 60 | session.commit() 61 | 62 | 63 | def downgrade(): 64 | pass 65 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/b70159a8c7c2_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: b70159a8c7c2 4 | Revises: c821404e777f 5 | Create Date: 2017-04-13 00:19:37.040664 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'b70159a8c7c2' 14 | down_revision = '028018cd8818' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('node', sa.Column('debug_boot', sa.Boolean(), nullable=False, server_default='FALSE')) 22 | op.alter_column('node', 'debug_boot', server_default=None) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('node', 'debug_boot') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/bf3ac4dda2ab_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: bf3ac4dda2ab 4 | Revises: 9a65898a0e2f 5 | Create Date: 2017-04-30 19:28:10.308314 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bf3ac4dda2ab' 14 | down_revision = '9a65898a0e2f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_column('cluster', 'allow_insecure_provision') 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column('cluster', sa.Column('allow_insecure_provision', sa.BOOLEAN(), autoincrement=False, nullable=False)) 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/da3084151a1d_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: da3084151a1d 4 | Revises: f08e29e85dc0 5 | Create Date: 2017-04-28 16:55:46.427969 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | from seedbox import config 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = 'da3084151a1d' 16 | down_revision = 'f08e29e85dc0' 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('cluster', sa.Column('etcd_image_tag', sa.String(length=80), nullable=False, 24 | server_default=config.default_etcd_image_tag)) 25 | op.alter_column('cluster', 'etcd_image_tag', server_default=None) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_column('cluster', 'etcd_image_tag') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /src/seedbox/migrations/versions/f08e29e85dc0_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: f08e29e85dc0 4 | Revises: 92f518191c12 5 | Create Date: 2017-04-28 15:48:06.677425 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f08e29e85dc0' 14 | down_revision = '92f518191c12' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_column('cluster', 'etcd_version') 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column('cluster', sa.Column('etcd_version', sa.INTEGER(), autoincrement=False, nullable=False)) 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /src/seedbox/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | from .address import Address 3 | from .cluster import Cluster 4 | from .credentials_data import CredentialsData 5 | from .disk import Disk 6 | from .disk_partition import DiskPartition 7 | from .mountpoint import Mountpoint 8 | from .node import Node 9 | from .provision import Provision 10 | from .user import User 11 | -------------------------------------------------------------------------------- /src/seedbox/models/address.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | 4 | class Address(db.Model): 5 | id = db.Column(db.Integer, primary_key=True) 6 | 7 | node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False) 8 | node = db.relationship('Node', backref=db.backref('addresses', lazy='dynamic')) 9 | 10 | interface = db.Column(db.String(80), nullable=False) 11 | ip = db.Column(db.String(80), nullable=False) 12 | 13 | def __repr__(self): 14 | return '
' % self.id 15 | 16 | def __str__(self): 17 | return '{}: {}'.format(self.interface, self.ip) 18 | -------------------------------------------------------------------------------- /src/seedbox/models/cluster.py: -------------------------------------------------------------------------------- 1 | from seedbox import pki, config, exceptions 2 | from .db import db 3 | 4 | 5 | class Cluster(db.Model): 6 | id = db.Column(db.Integer, primary_key=True) 7 | name = db.Column(db.String(80), unique=True, nullable=False) 8 | 9 | ca_credentials_id = db.Column(db.Integer, db.ForeignKey('credentials_data.id'), nullable=False) 10 | ca_credentials = db.relationship('CredentialsData', foreign_keys=[ca_credentials_id]) 11 | install_dnsmasq = db.Column(db.Boolean, nullable=False, default=True) 12 | 13 | etcd_image_tag = db.Column(db.String(80), default=config.default_etcd_image_tag, nullable=False) 14 | assert_etcd_cluster_exists = db.Column(db.Boolean, nullable=False) 15 | etcd_nodes_dns_name = db.Column(db.String(80), default='', nullable=False) 16 | 17 | k8s_apiservers_audit_log = db.Column(db.Boolean, nullable=False) 18 | k8s_apiservers_swagger_ui = db.Column(db.Boolean, nullable=False) 19 | dnsmasq_static_records = db.Column(db.Boolean, nullable=False) 20 | 21 | # workaround for a VirtualBox environment issue 22 | # https://github.com/coreos/flannel/issues/98 23 | explicitly_advertise_addresses = db.Column(db.Boolean, nullable=False) 24 | 25 | k8s_pod_network = db.Column(db.String(80), default=config.default_k8s_pod_network, nullable=False) 26 | k8s_service_network = db.Column(db.String(80), default=config.default_k8s_service_network, nullable=False) 27 | k8s_hyperkube_tag = db.Column(db.String(80), default=config.default_k8s_hyperkube_tag, nullable=False) 28 | k8s_cni = db.Column(db.Boolean, nullable=False) 29 | k8s_apiservers_dns_name = db.Column(db.String(80), default='', nullable=False) 30 | k8s_is_rbac_enabled = db.Column(db.Boolean, nullable=False, default=True) 31 | k8s_admission_control = db.Column(db.String(255), default=config.default_k8s_admission_control, nullable=False) 32 | 33 | coreos_channel = db.Column(db.String(80), default=config.default_coreos_channel, nullable=False) 34 | coreos_version = db.Column(db.String(80), default=config.default_coreos_version, nullable=False) 35 | custom_coreos_images_base_url = db.Column(db.String(80), default='', nullable=False) 36 | 37 | aci_proxy_url = db.Column(db.String(80), default='', nullable=False) 38 | aci_proxy_ca_cert = db.Column(db.Text, default='', nullable=False) 39 | 40 | k8s_service_account_public_key = db.Column(db.Binary, nullable=False) 41 | k8s_service_account_private_key = db.Column(db.Binary, nullable=False) 42 | 43 | docker_config = db.Column(db.Text, default='{}', nullable=False) 44 | 45 | def __repr__(self): 46 | return '' % self.name 47 | 48 | def __str__(self): 49 | return self.name 50 | 51 | @property 52 | def ca_credentials_error(self): 53 | try: 54 | pki.validate_certificate_common_name(self.ca_credentials.cert, self.name) 55 | pki.validate_ca_certificate_constraints(self.ca_credentials.cert) 56 | except pki.InvalidCertificate as e: 57 | return str(e) 58 | 59 | @property 60 | def k8s_apiserver_service_ip(self): 61 | ip = self.k8s_service_network.split('/')[0] 62 | return ip.rsplit('.', maxsplit=1)[0] + '.1' 63 | 64 | @property 65 | def k8s_dns_service_ip(self): 66 | ip = self.k8s_service_network.split('/')[0] 67 | return ip.rsplit('.', maxsplit=1)[0] + '.10' 68 | 69 | @property 70 | def k8s_apiserver_nodes(self): 71 | return self.nodes.filter_by(is_k8s_master=True) 72 | 73 | @property 74 | def k8s_apiserver_endpoint(self): 75 | if self.k8s_apiservers_dns_name: 76 | host = self.k8s_apiservers_dns_name 77 | else: 78 | apiserver = self.k8s_apiserver_nodes.first() 79 | if apiserver is None: 80 | raise exceptions.K8sNoClusterApiserver() 81 | host = apiserver.fqdn 82 | return 'https://{}:{}'.format(host, config.k8s_apiserver_secure_port) 83 | 84 | @property 85 | def etcd_nodes(self): 86 | return self.nodes.filter_by(is_etcd_server=True) 87 | 88 | @property 89 | def etcd_client_endpoints(self): 90 | if self.etcd_nodes_dns_name: 91 | hosts = [self.etcd_nodes_dns_name] 92 | else: 93 | hosts = [n.fqdn for n in self.etcd_nodes] 94 | return ['https://{}:{}'.format(host, config.etcd_client_port) for host in hosts] 95 | 96 | @property 97 | def is_configured(self): 98 | from .node import Node 99 | return not self.nodes.filter(Node.target_config_version != Node.active_config_version).count() 100 | 101 | @property 102 | def are_etcd_nodes_configured(self): 103 | from .node import Node 104 | return not self.nodes.filter(Node.is_etcd_server, 105 | Node.target_config_version != Node.active_config_version).count() 106 | 107 | @property 108 | def coreos_images_base_url(self): 109 | if self.custom_coreos_images_base_url: 110 | return self.custom_coreos_images_base_url 111 | else: 112 | return config.default_coreos_images_base_url.format(channel=self.coreos_channel, 113 | version=self.coreos_version) 114 | 115 | @property 116 | def k8s_kube_proxy_user(self): 117 | return self.users.filter_by(name=config.k8s_kube_proxy_user_name).first() 118 | -------------------------------------------------------------------------------- /src/seedbox/models/credentials_data.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | 4 | class CredentialsData(db.Model): 5 | id = db.Column(db.Integer, primary_key=True) 6 | cert = db.Column(db.Binary, nullable=False) 7 | key = db.Column(db.Binary, nullable=False) 8 | 9 | def __repr__(self): 10 | return '' % self.id 11 | -------------------------------------------------------------------------------- /src/seedbox/models/db.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | db = SQLAlchemy() 3 | -------------------------------------------------------------------------------- /src/seedbox/models/disk.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import UniqueConstraint 2 | 3 | from .db import db 4 | 5 | 6 | class Disk(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | 9 | node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False) 10 | node = db.relationship('Node', backref=db.backref('disks', lazy='dynamic')) 11 | 12 | device = db.Column(db.String(80), nullable=False) 13 | wipe_next_boot = db.Column(db.Boolean, nullable=False, default=True) 14 | sector_size_bytes = db.Column(db.Integer, nullable=False, default=512) 15 | 16 | __table_args__ = ( 17 | UniqueConstraint('node_id', 'device', name='_node_disk_device_uc'), 18 | ) 19 | 20 | def __repr__(self): 21 | return '' % self.id 22 | 23 | def __str__(self): 24 | return self.device 25 | -------------------------------------------------------------------------------- /src/seedbox/models/disk_partition.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import UniqueConstraint 2 | 3 | from .db import db 4 | 5 | 6 | class DiskPartition(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | 9 | disk_id = db.Column(db.Integer, db.ForeignKey('disk.id'), nullable=False) 10 | disk = db.relationship('Disk', backref=db.backref('partitions', lazy='dynamic')) 11 | 12 | number = db.Column(db.Integer, nullable=False) 13 | label = db.Column(db.String(80), nullable=False) 14 | size_mibs = db.Column(db.Integer, nullable=True) 15 | format = db.Column(db.String(10), nullable=False, default='ext4') 16 | 17 | __table_args__ = ( 18 | UniqueConstraint('disk_id', 'number', name='_disk_partition_number_uc'), 19 | UniqueConstraint('disk_id', 'label', name='_disk_partition_label_uc'), 20 | ) 21 | 22 | def __repr__(self): 23 | return '' % self.id 24 | 25 | def __str__(self): 26 | return self.label 27 | 28 | @property 29 | def is_root(self): 30 | return self.label == 'ROOT' 31 | 32 | @property 33 | def device(self): 34 | return '{}{}'.format(self.disk.device, self.number) 35 | -------------------------------------------------------------------------------- /src/seedbox/models/mountpoint.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import UniqueConstraint 2 | 3 | from .db import db 4 | 5 | 6 | class Mountpoint(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | 9 | node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False) 10 | node = db.relationship('Node', backref=db.backref('mountpoints', lazy='dynamic')) 11 | 12 | what = db.Column(db.String(80), nullable=False) 13 | where = db.Column(db.String(80), nullable=False) 14 | wanted_by = db.Column(db.String(80), default='local-fs.target', nullable=False) 15 | is_persistent = db.Column(db.Boolean, nullable=False) 16 | 17 | __table_args__ = ( 18 | UniqueConstraint('node_id', 'what', name='_node_mountpoint_what_uc'), 19 | UniqueConstraint('node_id', 'where', name='_node_mountpoint_where_uc'), 20 | ) 21 | 22 | def __repr__(self): 23 | return '' % self.id 24 | 25 | def __str__(self): 26 | return '{} -> {}'.format(self.what, self.where) 27 | -------------------------------------------------------------------------------- /src/seedbox/models/node.py: -------------------------------------------------------------------------------- 1 | from seedbox import pki, config, exceptions 2 | from .db import db 3 | 4 | 5 | class Node(db.Model): 6 | id = db.Column(db.Integer, primary_key=True) 7 | ip = db.Column(db.String(80), unique=True, nullable=False) 8 | fqdn = db.Column(db.String(80), unique=True, nullable=False) 9 | 10 | maintenance_mode = db.Column(db.Boolean, nullable=False) 11 | debug_boot = db.Column(db.Boolean, nullable=False) 12 | additional_kernel_cmdline = db.Column(db.String(255), default='', nullable=False) 13 | 14 | cluster_id = db.Column(db.Integer, db.ForeignKey('cluster.id'), nullable=False) 15 | cluster = db.relationship('Cluster', backref=db.backref('nodes', lazy='dynamic')) 16 | 17 | credentials_id = db.Column(db.Integer, db.ForeignKey('credentials_data.id'), nullable=False) 18 | credentials = db.relationship('CredentialsData') 19 | 20 | target_config_version = db.Column(db.Integer, default=1, nullable=False) 21 | active_config_version = db.Column(db.Integer, default=0, nullable=False) 22 | 23 | coreos_autologin = db.Column(db.Boolean, nullable=False) 24 | linux_consoles = db.Column(db.String(80), default=config.default_linux_consoles, nullable=False) 25 | disable_ipv6 = db.Column(db.Boolean, nullable=False) 26 | 27 | is_etcd_server = db.Column(db.Boolean, nullable=False) 28 | is_k8s_schedulable = db.Column(db.Boolean, default=True, nullable=False) 29 | is_k8s_master = db.Column(db.Boolean, nullable=False) 30 | 31 | def __repr__(self): 32 | return '' % self.fqdn 33 | 34 | def __str__(self): 35 | return self.fqdn 36 | 37 | @property 38 | def is_config_match(self): 39 | return self.target_config_version == self.active_config_version 40 | 41 | @property 42 | def certificate_alternative_dns_names(self): 43 | names = [self.fqdn] 44 | 45 | if self.is_etcd_server and self.cluster.etcd_nodes_dns_name: 46 | names += [ 47 | self.cluster.etcd_nodes_dns_name, 48 | ] 49 | 50 | if self.is_k8s_master: 51 | if self.cluster.k8s_apiservers_dns_name: 52 | names += [ 53 | self.cluster.k8s_apiservers_dns_name, 54 | ] 55 | 56 | names += [ 57 | 'kubernetes', 58 | 'kubernetes.default', 59 | 'kubernetes.default.svc', 60 | 'kubernetes.default.svc.' + config.k8s_cluster_domain, 61 | ] 62 | 63 | return names 64 | 65 | @property 66 | def certificate_alternative_ips(self): 67 | ips = [self.ip] 68 | 69 | if self.is_k8s_master: 70 | ips += [self.cluster.k8s_apiserver_service_ip] 71 | 72 | return ips 73 | 74 | @property 75 | def credentials_error(self): 76 | try: 77 | pki.verify_certificate_chain(self.cluster.ca_credentials.cert, self.credentials.cert) 78 | pki.validate_certificate_common_name(self.credentials.cert, 'system:node:' + self.fqdn) 79 | pki.validate_certificate_hosts(self.credentials.cert, self.certificate_alternative_dns_names) 80 | pki.validate_certificate_organizations(self.credentials.cert, ['system:nodes']) 81 | pki.validate_certificate_key_usage(self.credentials.cert, is_web_server=True, is_web_client=True) 82 | except pki.InvalidCertificate as e: 83 | return str(e) 84 | 85 | @property 86 | def credentials_warning(self): 87 | try: 88 | pki.validate_certificate_host_ips(self.credentials.cert, self.certificate_alternative_ips) 89 | except pki.InvalidCertificate as e: 90 | return str(e) 91 | 92 | @property 93 | def root_partition(self): 94 | from .disk_partition import DiskPartition 95 | partitions = list(DiskPartition.query.filter(DiskPartition.disk.has(node_id=self.id), 96 | DiskPartition.label == 'ROOT')) 97 | if not partitions: 98 | raise exceptions.NoRootPartition() 99 | if len(partitions) > 1: 100 | raise exceptions.MultipleRootPartitions() 101 | return partitions[0] 102 | 103 | @property 104 | def persistent_mountpoint(self): 105 | mountpoints = list(self.mountpoints.filter_by(is_persistent=True)) 106 | if not mountpoints: 107 | return None 108 | if len(mountpoints) > 1: 109 | raise exceptions.MultiplePersistentMountpoints() 110 | return mountpoints[0] 111 | 112 | @property 113 | def active_config(self): 114 | from . import Provision 115 | if not self.active_config_version: 116 | return None 117 | else: 118 | return self.provisions.order_by(Provision.applied_at.desc()).first() 119 | 120 | @property 121 | def disks_error(self): 122 | try: 123 | _ = self.root_partition 124 | except (exceptions.NoRootPartition, exceptions.MultipleRootPartitions) as e: 125 | return str(e) 126 | try: 127 | _ = self.persistent_mountpoint 128 | except exceptions.MultiplePersistentMountpoints as e: 129 | return str(e) 130 | 131 | @property 132 | def disks_warning(self): 133 | if not self.persistent_mountpoint: 134 | return 'No persistent mountpoint defined' 135 | -------------------------------------------------------------------------------- /src/seedbox/models/provision.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from .db import db 4 | 5 | 6 | class Provision(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | 9 | node_id = db.Column(db.Integer, db.ForeignKey('node.id'), nullable=False) 10 | node = db.relationship('Node', backref=db.backref('provisions', lazy='dynamic')) 11 | applied_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) 12 | 13 | config_version = db.Column(db.Integer, nullable=False) 14 | ignition_config = db.Column(db.Binary, nullable=True) 15 | ipxe_config = db.Column(db.Text, nullable=True) 16 | -------------------------------------------------------------------------------- /src/seedbox/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.schema import UniqueConstraint 2 | 3 | from seedbox import pki 4 | from .db import db 5 | 6 | 7 | class User(db.Model): 8 | id = db.Column(db.Integer, primary_key=True) 9 | cluster_id = db.Column(db.Integer, db.ForeignKey('cluster.id'), nullable=False) 10 | cluster = db.relationship('Cluster', backref=db.backref('users', lazy='dynamic')) 11 | name = db.Column(db.String(80), nullable=False) 12 | credentials_id = db.Column(db.Integer, db.ForeignKey('credentials_data.id'), nullable=False) 13 | credentials = db.relationship('CredentialsData') 14 | k8s_groups = db.Column(db.String(255), nullable=False, default='') 15 | ssh_key = db.Column(db.Text, nullable=False, default='') 16 | 17 | __table_args__ = ( 18 | UniqueConstraint('cluster_id', 'name', name='_cluster_name_uc'), 19 | ) 20 | 21 | def __repr__(self): 22 | return '' % self.name 23 | 24 | def __str__(self): 25 | return self.name 26 | 27 | @property 28 | def credentials_error(self): 29 | try: 30 | pki.verify_certificate_chain(self.cluster.ca_credentials.cert, self.credentials.cert) 31 | pki.validate_certificate_common_name(self.credentials.cert, self.name) 32 | if self.k8s_groups: 33 | pki.validate_certificate_organizations(self.credentials.cert, self.k8s_groups.split(',')) 34 | pki.validate_certificate_key_usage(self.credentials.cert, is_web_server=False, is_web_client=True) 35 | except pki.InvalidCertificate as e: 36 | return str(e) 37 | -------------------------------------------------------------------------------- /src/seedbox/pki.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import idna 3 | import datetime 4 | import operator 5 | import ipaddress 6 | import subprocess 7 | from OpenSSL import crypto 8 | from cryptography import x509 9 | from cryptography.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID 10 | from cryptography.hazmat.backends import default_backend 11 | from cryptography.hazmat.primitives import hashes 12 | from cryptography.hazmat.primitives import serialization 13 | from cryptography.hazmat.primitives.asymmetric import rsa 14 | 15 | 16 | def generate_rsa_keypair(key_size=2048): 17 | key = rsa.generate_private_key(public_exponent=65537, key_size=key_size, backend=default_backend()) 18 | public_key = key.public_key() 19 | public = public_key.public_bytes(encoding=serialization.Encoding.PEM, 20 | format=serialization.PublicFormat.SubjectPublicKeyInfo) 21 | private = key.private_bytes(encoding=serialization.Encoding.PEM, 22 | format=serialization.PrivateFormat.TraditionalOpenSSL, 23 | encryption_algorithm=serialization.NoEncryption()) 24 | return public, private 25 | 26 | 27 | def create_ca_certificate(cn, key_size=4096, certify_days=365): 28 | key = rsa.generate_private_key(public_exponent=65537, key_size=key_size, backend=default_backend()) 29 | key_id = x509.SubjectKeyIdentifier.from_public_key(key.public_key()) 30 | 31 | subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) 32 | 33 | now = datetime.datetime.utcnow() 34 | serial = x509.random_serial_number() 35 | cert = x509.CertificateBuilder() \ 36 | .subject_name(subject) \ 37 | .issuer_name(issuer) \ 38 | .public_key(key.public_key()) \ 39 | .serial_number(serial) \ 40 | .not_valid_before(now) \ 41 | .not_valid_after(now + datetime.timedelta(days=certify_days)) \ 42 | .add_extension(key_id, critical=False) \ 43 | .add_extension(x509.AuthorityKeyIdentifier(key_id.digest, 44 | [x509.DirectoryName(issuer)], 45 | serial), 46 | critical=False) \ 47 | .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) \ 48 | .add_extension(x509.KeyUsage(digital_signature=True, 49 | content_commitment=False, 50 | key_encipherment=False, 51 | data_encipherment=False, 52 | key_agreement=False, 53 | key_cert_sign=True, 54 | crl_sign=True, 55 | encipher_only=False, 56 | decipher_only=False), 57 | critical=True) \ 58 | .sign(key, hashes.SHA256(), default_backend()) 59 | 60 | cert = cert.public_bytes(serialization.Encoding.PEM) 61 | key = key.private_bytes(encoding=serialization.Encoding.PEM, 62 | format=serialization.PrivateFormat.TraditionalOpenSSL, 63 | encryption_algorithm=serialization.NoEncryption()) 64 | return cert, key 65 | 66 | 67 | def issue_certificate(cn, ca_cert, ca_key, 68 | organizations=(), 69 | san_dns=(), 70 | san_ips=(), 71 | key_size=2048, 72 | certify_days=365, 73 | is_web_server=False, 74 | is_web_client=False): 75 | ca_cert = x509.load_pem_x509_certificate(ca_cert, default_backend()) 76 | ca_key = serialization.load_pem_private_key(ca_key, password=None, backend=default_backend()) 77 | ca_key_id = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()) 78 | 79 | key = rsa.generate_private_key(public_exponent=65537, key_size=key_size, backend=default_backend()) 80 | 81 | subject_name_attributes = [x509.NameAttribute(NameOID.COMMON_NAME, cn)] 82 | subject_name_attributes += [x509.NameAttribute(NameOID.ORGANIZATION_NAME, org) for org in organizations] 83 | subject = x509.Name(subject_name_attributes) 84 | 85 | now = datetime.datetime.utcnow() 86 | cert = x509.CertificateBuilder() \ 87 | .subject_name(subject) \ 88 | .issuer_name(ca_cert.issuer) \ 89 | .public_key(key.public_key()) \ 90 | .serial_number(x509.random_serial_number()) \ 91 | .not_valid_before(now) \ 92 | .not_valid_after(now + datetime.timedelta(days=certify_days)) \ 93 | .add_extension(x509.AuthorityKeyIdentifier(ca_key_id.digest, 94 | [x509.DirectoryName(ca_cert.issuer)], 95 | ca_cert.serial_number), 96 | critical=False) \ 97 | .add_extension(x509.KeyUsage(digital_signature=True, 98 | content_commitment=False, 99 | key_encipherment=True, 100 | data_encipherment=False, 101 | key_agreement=False, 102 | key_cert_sign=False, 103 | crl_sign=False, 104 | encipher_only=False, 105 | decipher_only=False), 106 | critical=True) 107 | 108 | extended_usages = [] 109 | if is_web_server: 110 | extended_usages.append(ExtendedKeyUsageOID.SERVER_AUTH) 111 | if is_web_client: 112 | extended_usages.append(ExtendedKeyUsageOID.CLIENT_AUTH) 113 | if extended_usages: 114 | cert = cert.add_extension(x509.ExtendedKeyUsage(extended_usages), critical=False) 115 | 116 | sans = [x509.DNSName(name) for name in san_dns] 117 | sans += [x509.IPAddress(ipaddress.ip_address(ip)) for ip in san_ips] 118 | if sans: 119 | cert = cert.add_extension(x509.SubjectAlternativeName(sans), critical=False) 120 | 121 | cert = cert.sign(ca_key, hashes.SHA256(), default_backend()) 122 | 123 | cert = cert.public_bytes(serialization.Encoding.PEM) 124 | key = key.private_bytes(encoding=serialization.Encoding.PEM, 125 | format=serialization.PrivateFormat.TraditionalOpenSSL, 126 | encryption_algorithm=serialization.NoEncryption()) 127 | return cert, key 128 | 129 | 130 | def get_certificate_text(cert): 131 | return subprocess.run(['openssl', 'x509', 132 | '-in', '/dev/stdin', 133 | '-text'], 134 | check=True, 135 | stdout=subprocess.PIPE, 136 | input=cert).stdout 137 | 138 | 139 | def verify_certificate_chain(ca_pem_data, cert_pem_data): 140 | try: 141 | ca_cert = crypto.load_certificate(crypto.FILETYPE_PEM, ca_pem_data) 142 | cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem_data) 143 | 144 | store = crypto.X509Store() 145 | store.add_cert(ca_cert) 146 | 147 | store_ctx = crypto.X509StoreContext(store, cert) 148 | store_ctx.verify_certificate() 149 | except crypto.Error as e: 150 | raise InvalidCertificate('Broken certificate') from e 151 | except crypto.X509StoreContextError as e: 152 | raise InvalidCertificate('Invalid certificate chain: ' + str(e)) from e 153 | 154 | 155 | def wrap_subject_matching_errors(func): 156 | def wrapped(*args, **kwargs): 157 | try: 158 | return func(*args, **kwargs) 159 | except idna.core.IDNAError as e: 160 | raise InvalidCertificate('Invalid subject IDNA name') from e 161 | return wrapped 162 | 163 | 164 | @wrap_subject_matching_errors 165 | def validate_certificate_common_name(cert_pem_data, subject_name): 166 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 167 | _match_subject_name(cert, subject_name, alt_names=False) 168 | 169 | 170 | @wrap_subject_matching_errors 171 | def validate_certificate_hosts(cert_pem_data, host_names): 172 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 173 | for host_name in host_names: 174 | _match_subject_name(cert, host_name, compare_func=ssl._dnsname_match) 175 | 176 | 177 | @wrap_subject_matching_errors 178 | def validate_certificate_host_ips(cert_pem_data, host_ips): 179 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 180 | for host_ip in host_ips: 181 | _match_subject_ip(cert, host_ip) 182 | 183 | 184 | @wrap_subject_matching_errors 185 | def validate_certificate_key_usage(cert_pem_data, is_web_server, is_web_client): 186 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 187 | try: 188 | key_usage = cert.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE) 189 | key_usage = key_usage.value 190 | except x509.extensions.ExtensionNotFound: 191 | raise InvalidCertificate("Key usage not specified") 192 | 193 | if not key_usage.digital_signature: 194 | raise InvalidCertificate("Not intented for Digital Signature") 195 | 196 | if not key_usage.key_encipherment: 197 | raise InvalidCertificate("Not intented for Key Encipherment") 198 | 199 | if is_web_server or is_web_client: 200 | try: 201 | exteneded_key_usage = cert.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE) 202 | exteneded_key_usage = exteneded_key_usage.value 203 | except x509.extensions.ExtensionNotFound: 204 | raise InvalidCertificate("Extended key usage not specified") 205 | 206 | if is_web_server: 207 | if ExtendedKeyUsageOID.SERVER_AUTH not in exteneded_key_usage: 208 | raise InvalidCertificate("Not intented for TLS Web Server Authentication") 209 | 210 | if is_web_client: 211 | if ExtendedKeyUsageOID.CLIENT_AUTH not in exteneded_key_usage: 212 | raise InvalidCertificate("Not intented for TLS Web Client Authentication") 213 | 214 | 215 | @wrap_subject_matching_errors 216 | def validate_certificate_organizations(cert_pem_data, organizations): 217 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 218 | organization_names = cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) 219 | 220 | organizations_required = organizations 221 | organizations_present = set(o.value for o in organization_names) 222 | 223 | for org in organizations_required: 224 | if org not in organizations_present: 225 | raise InvalidCertificate("Not member of organization {}".format(org)) 226 | 227 | 228 | @wrap_subject_matching_errors 229 | def validate_ca_certificate_constraints(cert_pem_data): 230 | cert = x509.load_pem_x509_certificate(cert_pem_data, default_backend()) 231 | try: 232 | constraints = cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS) 233 | constraints = constraints.value 234 | except x509.extensions.ExtensionNotFound: 235 | return 236 | 237 | if not constraints.ca: 238 | raise InvalidCertificate("Not a CA certificate") 239 | 240 | if constraints.path_length != 0: 241 | raise InvalidCertificate("Invalid pathlen") 242 | 243 | 244 | # based on ssl.match_hostname code 245 | # https://github.com/python/cpython/blob/6f0eb93183519024cb360162bdd81b9faec97ba6/Lib/ssl.py#L279 246 | def _match_subject_name(cert, subject_name, compare_func=operator.eq, alt_names=True): 247 | names = [] 248 | 249 | if alt_names: 250 | try: 251 | alt_names = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) 252 | names = alt_names.value.get_values_for_type(x509.DNSName) 253 | except x509.extensions.ExtensionNotFound: 254 | pass 255 | 256 | if not names: 257 | common_names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) 258 | if common_names: 259 | common_name = common_names[0] 260 | names = [common_name.value] 261 | 262 | if not any(compare_func(name, subject_name) for name in names): 263 | if len(names) > 1: 264 | raise InvalidCertificate("Subject name %r doesn't match either of %s" % (subject_name, ', '.join(map(repr, names)))) 265 | elif len(names) == 1: 266 | raise InvalidCertificate("Subject name %r doesn't match %r" % (subject_name, names[0])) 267 | else: 268 | raise InvalidCertificate("No appropriate commonName or subjectAltName DNSName fields were found") 269 | 270 | 271 | def _match_subject_ip(cert, subject_ip, compare_func=operator.eq): 272 | alt_names = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) 273 | ips = alt_names.value.get_values_for_type(x509.IPAddress) 274 | 275 | subject_ip = ipaddress.ip_address(subject_ip) 276 | if not any(compare_func(ip, subject_ip) for ip in ips): 277 | if len(ips) > 1: 278 | raise InvalidCertificate("Subject ip %s doesn't match either of %s" % (subject_ip, ', '.join(map(repr, ips)))) 279 | elif len(ips) == 1: 280 | raise InvalidCertificate("Subject ip %s doesn't match %s" % (subject_ip, ips[0])) 281 | else: 282 | raise InvalidCertificate("No appropriate subjectAltName IPAddress fields were found") 283 | 284 | 285 | class InvalidCertificate(Exception): 286 | pass 287 | -------------------------------------------------------------------------------- /src/seedbox/update_watcher.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import json 4 | import logging 5 | import configparser 6 | 7 | import requests 8 | 9 | from seedbox import config 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | coreos_channels = ('stable', 'beta', 'alpha') 14 | coreos_version_url = 'https://{channel}.release.core-os.net/amd64-usr/current/version.txt' 15 | quay_io_tags_url = 'https://quay.io/api/v1/repository/{repo_path}/tag/' 16 | unstable_tag_regexps = [re.compile(r'[^a-zA-Z]' + w + r'[^a-zA-Z]') for w in ('rc', 'beta', 'alpha')] 17 | 18 | 19 | def watch(): 20 | while True: 21 | data = fetch() 22 | log.info('Current versions: %s', data) 23 | with open(config.update_state_file, 'w') as fp: 24 | json.dump(data, fp) 25 | log.info('Saved to %s', config.update_state_file) 26 | log.info('Waiting for %s seconds before next update', config.update_check_interval_sec) 27 | time.sleep(config.update_check_interval_sec) 28 | 29 | 30 | def fetch(): 31 | versions = {} 32 | 33 | coreos_versions = {} 34 | for coreos_channel in coreos_channels: 35 | try: 36 | coreos_versions[coreos_channel] = fetch_coreos(coreos_channel) 37 | except Exception: 38 | log.exception('Failed to fetch latest CoreOS version on %s channel', coreos_channel) 39 | versions['coreos'] = coreos_versions 40 | 41 | try: 42 | versions['etcd'] = fetch_from_quay(config.etcd_image) 43 | except Exception: 44 | log.exception('Failed to fetch latest etcd version') 45 | 46 | try: 47 | versions['hyperkube'] = fetch_from_quay(config.k8s_hyperkube_image) 48 | except Exception: 49 | log.exception('Failed to fetch latest hyperkube version') 50 | 51 | return versions 52 | 53 | 54 | def fetch_coreos(channel): 55 | resp = requests.get(coreos_version_url.format(channel=channel)) 56 | resp.raise_for_status() 57 | config = configparser.ConfigParser() 58 | config.read_string('[a]\n' + resp.content.decode('utf-8')) 59 | return config['a']['COREOS_VERSION'] 60 | 61 | 62 | def fetch_from_quay(image_url): 63 | quay_io_image_prefix = 'quay.io/' 64 | 65 | if not image_url.startswith(quay_io_image_prefix): 66 | raise Exception('Unsupported image URL', image_url) 67 | 68 | repo_path = image_url[len(quay_io_image_prefix):] 69 | resp = requests.get(quay_io_tags_url.format(repo_path=repo_path)) 70 | resp.raise_for_status() 71 | 72 | tags = [(tag['name'], tag2version(tag['name'])) for tag in resp.json()['tags'] if is_stable_tag(tag['name'])] 73 | latest = tags[0] 74 | 75 | for tag in tags: 76 | if tag[1] > latest[1]: 77 | latest = tag 78 | 79 | return latest[0] 80 | 81 | 82 | def tag2version(tag): 83 | from packaging import version 84 | return version.parse(tag) 85 | 86 | 87 | def is_stable_tag(tag): 88 | for r in unstable_tag_regexps: 89 | if r.search(tag): 90 | return False 91 | return True 92 | -------------------------------------------------------------------------------- /src/seedbox/utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import tarfile 3 | 4 | 5 | class TarFile(tarfile.TarFile): 6 | def adddata(self, path, data): 7 | info = tarfile.TarInfo(path) 8 | info.size = len(data) 9 | self.addfile(info, io.BytesIO(data)) 10 | --------------------------------------------------------------------------------