├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── RELEASING.md ├── config.go ├── config_create.go ├── config_get.go ├── create.go ├── defaults.go ├── delete.go ├── examples ├── ansible │ ├── README.md │ ├── ansible.cfg │ ├── example1.yml │ └── inventory.txt ├── apache │ ├── Dockerfile │ ├── README.md │ └── index.html ├── docker-in-docker │ ├── README.md │ └── footloose.yaml ├── fedora29-htop │ ├── Dockerfile │ └── README.md ├── simple-hostPort │ ├── README.md │ └── footloose.yaml └── user-defined-network │ ├── README.md │ └── footloose.yaml ├── footloose.go ├── go.mod ├── go.sum ├── images ├── amazonlinux2 │ └── Dockerfile ├── centos7 │ └── Dockerfile ├── clearlinux │ └── Dockerfile ├── debian10 │ └── Dockerfile ├── fedora29 │ └── Dockerfile ├── ubuntu16.04 │ └── Dockerfile ├── ubuntu18.04 │ └── Dockerfile └── ubuntu20.04 │ └── Dockerfile ├── make-image.sh ├── pkg ├── api │ ├── api.go │ ├── cluster.go │ ├── db.go │ ├── key.go │ ├── machine.go │ └── response.go ├── client │ ├── client.go │ └── client_test.go ├── cluster │ ├── cluster.go │ ├── cluster_test.go │ ├── formatter.go │ ├── key_store.go │ ├── machine.go │ ├── run.go │ ├── runtime_network.go │ └── runtime_network_test.go ├── config │ ├── cluster.go │ ├── get.go │ ├── get_test.go │ ├── key.go │ └── machine.go ├── docker │ ├── archive.go │ ├── cp.go │ ├── create.go │ ├── doc.go │ ├── exec.go │ ├── inspect.go │ ├── kill.go │ ├── network_connect.go │ ├── pull.go │ ├── run.go │ ├── save.go │ ├── start.go │ ├── stop.go │ └── userns_remap.go ├── exec │ ├── exec.go │ └── local.go ├── ignite │ ├── create.go │ ├── doc.go │ ├── ignite.go │ ├── inspect.go │ ├── rm.go │ ├── start.go │ └── stop.go └── version │ └── release.go ├── serve.go ├── serve_test.go ├── show.go ├── ssh.go ├── start.go ├── stop.go ├── tests ├── .gitignore ├── README.md ├── e2e_test.go ├── test-basic-commands-%image.cmd ├── test-basic-commands-clearlinux.skip ├── test-config-get-ubuntu18.04.cmd ├── test-config-get-ubuntu18.04.golden.output ├── test-create-config-already-exists-fedora29.cmd ├── test-create-config-already-exists-fedora29.error ├── test-create-delete-%image.cmd ├── test-create-delete-%image.golden.output ├── test-create-delete-idempotent-%image.cmd ├── test-create-delete-persistent-%image.cmd ├── test-create-delete-persistent-%image.golden.output ├── test-create-invalid-fedora29.cmd ├── test-create-invalid-fedora29.error ├── test-create-invalid-fedora29.static ├── test-create-stop-start-%image.cmd ├── test-create-stop-start-%image.golden.output ├── test-docker-in-docker-amazonlinux2.cmd ├── test-docker-in-docker-amazonlinux2.golden.output ├── test-docker-in-docker-amazonlinux2.long ├── test-docker-in-docker-amazonlinux2.yaml ├── test-docker-in-docker-centos7.cmd ├── test-docker-in-docker-centos7.golden.output ├── test-docker-in-docker-centos7.long ├── test-docker-in-docker-centos7.yaml ├── test-docker-in-docker-debian10.cmd ├── test-docker-in-docker-debian10.golden.output ├── test-docker-in-docker-debian10.long ├── test-docker-in-docker-debian10.yaml ├── test-docker-in-docker-fedora29.cmd ├── test-docker-in-docker-fedora29.golden.output ├── test-docker-in-docker-fedora29.long ├── test-docker-in-docker-fedora29.skip ├── test-docker-in-docker-fedora29.yaml ├── test-docker-in-docker-ubuntu16.04.cmd ├── test-docker-in-docker-ubuntu16.04.golden.output ├── test-docker-in-docker-ubuntu16.04.long ├── test-docker-in-docker-ubuntu16.04.yaml ├── test-docker-in-docker-ubuntu18.04.cmd ├── test-docker-in-docker-ubuntu18.04.golden.output ├── test-docker-in-docker-ubuntu18.04.long ├── test-docker-in-docker-ubuntu18.04.yaml ├── test-show-ubuntu18.04.cmd ├── test-show-ubuntu18.04.golden.output ├── test-ssh-remote-command-%image.cmd ├── test-ssh-remote-command-%image.golden.output ├── test-ssh-user-%image.cmd ├── test-ssh-user-%image.golden.output ├── test-start-stop-specific-%image.cmd ├── test-start-stop-specific-%image.golden.output └── variables.json └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | /footloose.yaml 2 | /pkg/client/testcluster-key 3 | /pkg/client/testcluster-key.pub 4 | /vendor 5 | cluster-key 6 | cluster-key.pub 7 | footloose 8 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | archives: 8 | - id: binary 9 | format: binary 10 | format_overrides: 11 | - goos: darwin 12 | format: tar.gz 13 | name_template: "footloose-{{ .Version }}-{{ .Os }}-{{ .Arch }}" 14 | replacements: 15 | 386: i386 16 | amd64: x86_64 17 | files: 18 | - none* 19 | checksum: 20 | name_template: 'checksums.txt' 21 | snapshot: 22 | name_template: "{{ .Tag }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - '^docs:' 28 | - '^test:' 29 | brews: 30 | - 31 | tap: 32 | owner: weaveworks 33 | name: homebrew-tap 34 | commit_author: 35 | name: weaveworksbot 36 | email: team+gitbot@weave.works 37 | folder: Formula 38 | homepage: https://github.com/weaveworks/footloose 39 | description: Containers that look like Virtual Machines 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - '1.12.x' 5 | 6 | env: 7 | global: 8 | - GO111MODULE=on 9 | 10 | services: 11 | - docker 12 | 13 | before_script: 14 | - curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin v1.17.1 15 | - curl -sfL https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | sh 16 | 17 | matrix: 18 | include: 19 | - script: 20 | - ./make-image.sh build all 21 | - go install 22 | - golangci-lint run ./... 23 | - go test -timeout 0 -v . 24 | - go test -timeout 0 -v ./pkg/... 25 | - go test -timeout 0 -v ./tests 26 | - ./bin/goreleaser check 27 | 28 | deploy: 29 | - provider: script 30 | skip_cleanup: true 31 | script: docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD" quay.io && ./make-image.sh push all 32 | - provider: script 33 | skip_cleanup: true 34 | script: docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD" quay.io && ./make-image.sh tag all $TRAVIS_TAG && ./make-image.sh push all $TRAVIS_TAG 35 | on: 36 | tags: true 37 | - provider: script 38 | skip_cleanup: true 39 | script: ./bin/goreleaser release 40 | on: 41 | tags: true 42 | -------------------------------------------------------------------------------- /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 2019 Weaveworks Ltd. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | UID_GID?=$(shell id -u):$(shell id -g) 2 | GO_VERSION="1.12.6" 3 | 4 | all: binary 5 | 6 | binary: vendor 7 | docker run -it --rm -v $(shell pwd):/build -w /build golang:${GO_VERSION} sh -c "\ 8 | make footloose && \ 9 | chown -R ${UID_GID} bin" 10 | 11 | footloose: bin/footloose 12 | bin/footloose: 13 | CGO_ENABLED=0 go build -mod=vendor -o bin/footloose . 14 | 15 | D := $(shell go env GOPATH)/bin 16 | install: bin/footloose 17 | mkdir -p $(D) 18 | cp $^ $(D) 19 | 20 | vendor: 21 | go mod vendor 22 | 23 | .PHONY: bin/footloose install binary footloose vendor 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | This repository is no longer maintained. For a more up-to-date way to manage microVMs, please take a look at [Flintlock](https://github.com/weaveworks-liquidmetal/flintlock). 4 | 5 | [![Build Status](https://travis-ci.org/weaveworks/footloose.svg?branch=master)](https://travis-ci.org/weaveworks/footloose) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/weaveworks/footloose)](https://goreportcard.com/report/github.com/weaveworks/footloose) 7 | [![GoDoc](https://godoc.org/github.com/weaveworks/footloose?status.svg)](https://godoc.org/github.com/weaveworks/footloose) 8 | 9 | # footloose 10 | 11 | `footloose` creates containers that look like virtual machines. Those 12 | containers run `systemd` as PID 1 and a ssh daemon that can be used to login 13 | into the container. Such "machines" behave very much like a VM, it's even 14 | possible to run [`dockerd` in them][readme-did] :) 15 | 16 | `footloose` can be used for a variety of tasks, wherever you'd like virtual 17 | machines but want fast boot times or need many of them. An easy way to think 18 | about it is: [Vagrant](https://www.vagrantup.com/), but with containers. 19 | 20 | `footloose` in action: 21 | 22 | [![asciicast](https://asciinema.org/a/226185.svg)](https://asciinema.org/a/226185) 23 | 24 | [readme-did]: ./examples/docker-in-docker/README.md 25 | 26 | ## Install 27 | 28 | `footloose` binaries can be downloaded from the [release page][gh-release]: 29 | 30 | ### Linux 31 | 32 | ```console 33 | curl -Lo footloose https://github.com/weaveworks/footloose/releases/download/0.6.3/footloose-0.6.3-linux-x86_64 34 | chmod +x footloose 35 | sudo mv footloose /usr/local/bin/ 36 | ``` 37 | 38 | ### macOS 39 | 40 | On macOS we provide a direct download and a homebrew tap: 41 | 42 | ```console 43 | curl --silent --location https://github.com/weaveworks/footloose/releases/download/0.6.3/footloose-0.6.3-darwin-x86_64.tar.gz | tar xz 44 | sudo mv footloose /usr/local/bin 45 | ``` 46 | 47 | or 48 | 49 | ```console 50 | brew tap weaveworks/tap 51 | brew install weaveworks/tap/footloose 52 | ``` 53 | 54 | ### From source 55 | 56 | Alternatively, build and install `footloose` from source. It requires having 57 | `go >= 1.11` installed: 58 | 59 | ```console 60 | GO111MODULE=on go get github.com/weaveworks/footloose 61 | ``` 62 | 63 | [gh-release]: https://github.com/weaveworks/footloose/releases 64 | 65 | ## Usage 66 | 67 | `footloose` reads a description of the *Cluster* of *Machines* to create from a 68 | file, by default named `footloose.yaml`. An alternate name can be specified on 69 | the command line with the `--config` option or through the `FOOTLOOSE_CONFIG` 70 | environment variable. 71 | 72 | The `config` command helps with creating the initial config file: 73 | 74 | ```console 75 | # Create a footloose.yaml config file. Instruct we want to create 3 machines. 76 | footloose config create --replicas 3 77 | ``` 78 | 79 | Start the cluster: 80 | 81 | ```console 82 | $ footloose create 83 | INFO[0000] Pulling image: quay.io/footloose/centos7 ... 84 | INFO[0007] Creating machine: cluster-node0 ... 85 | INFO[0008] Creating machine: cluster-node1 ... 86 | INFO[0008] Creating machine: cluster-node2 ... 87 | ``` 88 | 89 | > It only takes a second to create those machines. The first time `create` 90 | runs, it will pull the docker image used by the `footloose` containers so it 91 | will take a tiny bit longer. 92 | 93 | SSH into a machine with: 94 | 95 | ```console 96 | $ footloose ssh root@node1 97 | [root@1665288855f6 ~]# ps fx 98 | PID TTY STAT TIME COMMAND 99 | 1 ? Ss 0:00 /sbin/init 100 | 23 ? Ss 0:00 /usr/lib/systemd/systemd-journald 101 | 58 ? Ss 0:00 /usr/sbin/sshd -D 102 | 59 ? Ss 0:00 \_ sshd: root@pts/1 103 | 63 pts/1 Ss 0:00 \_ -bash 104 | 82 pts/1 R+ 0:00 \_ ps fx 105 | 62 ? Ss 0:00 /usr/lib/systemd/systemd-logind 106 | ``` 107 | 108 | ## Choosing the OS image to run 109 | 110 | `footloose` will default to running a centos 7 container image. The `--image` 111 | argument of `config create` can be used to configure the OS image. Valid OS 112 | images are: 113 | 114 | - `quay.io/footloose/centos7` 115 | - `quay.io/footloose/fedora29` 116 | - `quay.io/footloose/ubuntu16.04` 117 | - `quay.io/footloose/ubuntu18.04` 118 | - `quay.io/footloose/ubuntu20.04` 119 | - `quay.io/footloose/amazonlinux2` 120 | - `quay.io/footloose/debian10` 121 | - `quay.io/footloose/clearlinux` 122 | 123 | For example: 124 | 125 | ```console 126 | footloose config create --replicas 3 --image quay.io/footloose/fedora29 127 | ``` 128 | 129 | Ubuntu images need the `--privileged` flag: 130 | 131 | ```console 132 | footloose config create --replicas 1 --image quay.io/footloose/ubuntu16.04 --privileged 133 | ``` 134 | 135 | ## `footloose.yaml` 136 | 137 | `footloose config create` creates a `footloose.yaml` configuration file that is then 138 | used by subsequent commands such as `create`, `delete` or `ssh`. If desired, 139 | the configuration file can be named differently and supplied with the 140 | `-c, --config` option. 141 | 142 | ```console 143 | $ footloose config create --replicas 3 144 | $ cat footloose.yaml 145 | cluster: 146 | name: cluster 147 | privateKey: cluster-key 148 | machines: 149 | - count: 3 150 | backend: docker 151 | spec: 152 | image: quay.io/footloose/centos7 153 | name: node%d 154 | portMappings: 155 | - containerPort: 22 156 | ``` 157 | 158 | If you want to use [Ignite](https://github.com/weaveworks/ignite) as the backend in order 159 | to run real VMs, change to `backend: ignite`. 160 | 161 | ```yaml 162 | cluster: 163 | name: cluster 164 | privateKey: cluster-key 165 | machines: 166 | - count: 3 167 | backend: ignite 168 | spec: 169 | image: weaveworks/ignite-centos:7 170 | name: node%d 171 | portMappings: 172 | - containerPort: 22 173 | # All Ignite options shown below here are optional and can be omitted. 174 | # These are the defaults: 175 | ignite: 176 | cpus: 2 177 | memory: 1GB 178 | diskSize: 4GB 179 | kernel: weaveworks/ignite-ubuntu:4.19.47 180 | ``` 181 | 182 | This configuration can naturally be edited by hand. The full list of 183 | available parameters are in [the reference documentation][pkg-config]. 184 | 185 | [pkg-config]: https://godoc.org/github.com/weaveworks/footloose/pkg/config 186 | 187 | ## Examples 188 | 189 | Interesting things can be done with `footloose`! 190 | 191 | - [Customize the OS image](./examples/fedora29-htop/README.md) 192 | - [Run Apache](./examples/apache/README.md) 193 | - [Specify which ports on the hosts should be bound to services](examples/simple-hostPort/README.md) 194 | - [Use Ansible to provision machines](./examples/ansible/README.md) 195 | - [Run Docker inside `footloose` machines!](./examples/docker-in-docker/README.md) 196 | - [Isolation and DNS resolution with custom docker networks](./examples/user-defined-network/README.md) 197 | - [OpenShift with footloose](https://github.com/carlosedp/openshift-on-footloose) 198 | 199 | ## Under the hood 200 | 201 | Under the hood, *Container Machines* are just containers. They can be 202 | inspected with `docker`: 203 | 204 | ```console 205 | $ docker ps 206 | CONTAINER ID IMAGE COMMAND NAMES 207 | 04c27967f76e quay.io/footloose/centos7 "/sbin/init" cluster-node2 208 | 1665288855f6 quay.io/footloose/centos7 "/sbin/init" cluster-node1 209 | 5134f80b733e quay.io/footloose/centos7 "/sbin/init" cluster-node0 210 | ``` 211 | 212 | The container names are derived from `cluster.name` and 213 | `cluster.machines[].name`. 214 | 215 | They run `systemd` as PID 1, it's even possible to inspect the boot messages: 216 | 217 | ```console 218 | $ docker logs cluster-node1 219 | systemd 219 running in system mode. 220 | Detected virtualization docker. 221 | Detected architecture x86-64. 222 | 223 | Welcome to CentOS Linux 7 (Core)! 224 | 225 | Set hostname to <1665288855f6>. 226 | Initializing machine ID from random generator. 227 | Failed to install release agent, ignoring: File exists 228 | [ OK ] Created slice Root Slice. 229 | [ OK ] Created slice System Slice. 230 | [ OK ] Reached target Slices. 231 | [ OK ] Listening on Journal Socket. 232 | [ OK ] Reached target Local File Systems. 233 | Starting Create Volatile Files and Directories... 234 | [ OK ] Listening on Delayed Shutdown Socket. 235 | [ OK ] Reached target Swap. 236 | [ OK ] Reached target Paths. 237 | Starting Journal Service... 238 | [ OK ] Started Create Volatile Files and Directories. 239 | [ OK ] Started Journal Service. 240 | [ OK ] Reached target System Initialization. 241 | [ OK ] Started Daily Cleanup of Temporary Directories. 242 | [ OK ] Reached target Timers. 243 | [ OK ] Listening on D-Bus System Message Bus Socket. 244 | [ OK ] Reached target Sockets. 245 | [ OK ] Reached target Basic System. 246 | Starting OpenSSH Server Key Generation... 247 | Starting Cleanup of Temporary Directories... 248 | [ OK ] Started Cleanup of Temporary Directories. 249 | [ OK ] Started OpenSSH Server Key Generation. 250 | Starting OpenSSH server daemon... 251 | [ OK ] Started OpenSSH server daemon. 252 | [ OK ] Reached target Multi-User System. 253 | ``` 254 | 255 | ### Run real VMs with Ignite 256 | 257 | [![asciicast](https://asciinema.org/a/HRrgSAjhc0gFGOCnjuqKDwIoN.svg)](https://asciinema.org/a/HRrgSAjhc0gFGOCnjuqKDwIoN) 258 | 259 | ## FAQ 260 | 261 | ### Is `footloose` just like LXD? 262 | In principle yes, but it will also work with Docker container images and 263 | on MacOS as well. 264 | 265 | ## Help 266 | 267 | We are a very friendly community and love questions, help and feedback. 268 | 269 | If you have any questions, feedback, or problems with `footloose`: 270 | 271 | - Check out the [examples](examples). 272 | - Join the discussion 273 | - Invite yourself to the Weave community Slack. 274 | - Ask a question on the [#footloose](https://weave-community.slack.com/messages/footloose/) Slack channel. 275 | - Join the [Weave User Group](https://www.meetup.com/pro/Weave/) and get invited to online talks, hands-on training and meetups in your area. 276 | - [File an issue](https://github.com/weaveworks/footloose/issues/new). 277 | 278 | Weaveworks follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a Weaveworks project maintainer, or Alexis Richardson (alexis@weave.works). 279 | 280 | Your feedback is always welcome! 281 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing Footloose 2 | 1. [ ] `README.md` update 4 references (two per link) of the version number in `Install.Linux` and `Install.macOS` 3 | 1. [ ] `examples/user-defined-network` update reference to version number in `Using user-defined network example`'s `cat footloose.yaml` output. 4 | 1. [ ] `examples/user-defined-network/footloose.yaml` update image version number in `machines[0].spec.image 5 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var configCmd = &cobra.Command{ 8 | Use: "config", 9 | Short: "Manage cluster configuration", 10 | } 11 | 12 | func init() { 13 | footloose.AddCommand(configCmd) 14 | } 15 | -------------------------------------------------------------------------------- /config_create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/weaveworks/footloose/pkg/cluster" 10 | ) 11 | 12 | var configCreateCmd = &cobra.Command{ 13 | Use: "create", 14 | Short: "Create a cluster configuration", 15 | RunE: configCreate, 16 | } 17 | 18 | var configCreateOptions struct { 19 | override bool 20 | file string 21 | } 22 | 23 | func init() { 24 | configCreateCmd.Flags().StringVarP(&configCreateOptions.file, "config", "c", Footloose, "Cluster configuration file") 25 | configCreateCmd.Flags().BoolVar(&configCreateOptions.override, "override", false, "Override configuration file if it exists") 26 | 27 | name := &defaultConfig.Cluster.Name 28 | configCreateCmd.PersistentFlags().StringVarP(name, "name", "n", *name, "Name of the cluster") 29 | 30 | private := &defaultConfig.Cluster.PrivateKey 31 | configCreateCmd.PersistentFlags().StringVarP(private, "key", "k", *private, "Name of the private and public key files") 32 | 33 | networks := &defaultConfig.Machines[0].Spec.Networks 34 | configCreateCmd.PersistentFlags().StringSliceVar(networks, "networks", *networks, "Networks names the machines are assigned to") 35 | 36 | replicas := &defaultConfig.Machines[0].Count 37 | configCreateCmd.PersistentFlags().IntVarP(replicas, "replicas", "r", *replicas, "Number of machine replicas") 38 | 39 | image := &defaultConfig.Machines[0].Spec.Image 40 | configCreateCmd.PersistentFlags().StringVarP(image, "image", "i", *image, "Docker image to use in the containers") 41 | 42 | privileged := &defaultConfig.Machines[0].Spec.Privileged 43 | configCreateCmd.PersistentFlags().BoolVar(privileged, "privileged", *privileged, "Create privileged containers") 44 | 45 | cmd := &defaultConfig.Machines[0].Spec.Cmd 46 | configCreateCmd.PersistentFlags().StringVarP(cmd, "cmd", "d", *cmd, "The command to execute on the container") 47 | 48 | configCmd.AddCommand(configCreateCmd) 49 | } 50 | 51 | // configExists checks whether a configuration file has already been created. 52 | // Returns false if not true if it already exists. 53 | func configExists(path string) bool { 54 | info, err := os.Stat(path) 55 | if os.IsNotExist(err) || os.IsPermission(err) { 56 | return false 57 | } 58 | return !info.IsDir() 59 | } 60 | 61 | func configCreate(cmd *cobra.Command, args []string) error { 62 | opts := &configCreateOptions 63 | cluster, err := cluster.New(defaultConfig) 64 | if err != nil { 65 | return err 66 | } 67 | if configExists(configFile(opts.file)) && !opts.override { 68 | return fmt.Errorf("configuration file at %s already exists", opts.file) 69 | } 70 | return cluster.Save(configFile(opts.file)) 71 | } 72 | -------------------------------------------------------------------------------- /config_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "reflect" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/weaveworks/footloose/pkg/config" 11 | ) 12 | 13 | var getConfigCmd = &cobra.Command{ 14 | Use: "get", 15 | Short: "Get config file information", 16 | RunE: getConfig, 17 | } 18 | 19 | var getOptions struct { 20 | config string 21 | } 22 | 23 | func init() { 24 | getConfigCmd.Flags().StringVarP(&getOptions.config, "config", "c", Footloose, "Cluster configuration file") 25 | configCmd.AddCommand(getConfigCmd) 26 | } 27 | 28 | func getConfig(cmd *cobra.Command, args []string) error { 29 | c, err := config.NewConfigFromFile(configFile(getOptions.config)) 30 | if err != nil { 31 | return err 32 | } 33 | var detail interface{} 34 | if len(args) > 0 { 35 | detail, err = config.GetValueFromConfig(args[0], c) 36 | if err != nil { 37 | log.Println(err) 38 | return fmt.Errorf("Failed to get config detail") 39 | } 40 | } else { 41 | detail = c 42 | } 43 | if reflect.ValueOf(detail).Kind() != reflect.String { 44 | res, err := json.MarshalIndent(detail, "", " ") 45 | if err != nil { 46 | log.Println(err) 47 | return fmt.Errorf("Cannot convert result to json") 48 | } 49 | fmt.Printf("%s", res) 50 | } else { 51 | fmt.Printf("%s", detail) 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/weaveworks/footloose/pkg/cluster" 7 | ) 8 | 9 | var createCmd = &cobra.Command{ 10 | Use: "create", 11 | Short: "Create a cluster", 12 | RunE: create, 13 | } 14 | 15 | var createOptions struct { 16 | config string 17 | } 18 | 19 | func init() { 20 | createCmd.Flags().StringVarP(&createOptions.config, "config", "c", Footloose, "Cluster configuration file") 21 | footloose.AddCommand(createCmd) 22 | } 23 | 24 | func create(cmd *cobra.Command, args []string) error { 25 | cluster, err := cluster.NewFromFile(configFile(createOptions.config)) 26 | if err != nil { 27 | return err 28 | } 29 | return cluster.Create() 30 | } 31 | -------------------------------------------------------------------------------- /defaults.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/weaveworks/footloose/pkg/config" 4 | 5 | // imageTag computes the docker image tag given the footloose version. 6 | func imageTag(v string) string { 7 | if v == "git" { 8 | return "latest" 9 | } 10 | return v 11 | } 12 | 13 | // defaultKeyStore is the path where to store the public keys. 14 | const defaultKeyStorePath = "keys" 15 | 16 | var defaultConfig = config.Config{ 17 | Cluster: config.Cluster{ 18 | Name: "cluster", 19 | PrivateKey: "cluster-key", 20 | }, 21 | Machines: []config.MachineReplicas{{ 22 | Count: 1, 23 | Spec: config.Machine{ 24 | Name: "node%d", 25 | Image: "quay.io/footloose/centos7:" + imageTag(version), 26 | PortMappings: []config.PortMapping{{ 27 | ContainerPort: 22, 28 | }}, 29 | Backend: "docker", 30 | }, 31 | }}, 32 | } 33 | -------------------------------------------------------------------------------- /delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/weaveworks/footloose/pkg/cluster" 7 | ) 8 | 9 | var deleteCmd = &cobra.Command{ 10 | Use: "delete", 11 | Short: "Delete a cluster", 12 | RunE: delete, 13 | } 14 | 15 | var deleteOptions struct { 16 | config string 17 | } 18 | 19 | func init() { 20 | deleteCmd.Flags().StringVarP(&deleteOptions.config, "config", "c", Footloose, "Cluster configuration file") 21 | footloose.AddCommand(deleteCmd) 22 | } 23 | 24 | func delete(cmd *cobra.Command, args []string) error { 25 | cluster, err := cluster.NewFromFile(configFile(deleteOptions.config)) 26 | if err != nil { 27 | return err 28 | } 29 | return cluster.Delete() 30 | } 31 | -------------------------------------------------------------------------------- /examples/ansible/README.md: -------------------------------------------------------------------------------- 1 | # Ansible provisioned machine 2 | 3 | create a new environment configuration: 4 | 5 | ```console 6 | $ footloose config create --replicas 1 7 | ``` 8 | 9 | deploy container images: 10 | 11 | ```console 12 | $ footloose create 13 | INFO[0000] Pulling image: quay.io/footloose/centos7 ... 14 | INFO[0007] Creating machine: cluster-node0 ... 15 | ``` 16 | 17 | 18 | test the ansible setup: 19 | 20 | ```console 21 | $ ansible -m ping all 22 | cluster-node0 | SUCCESS => { 23 | "changed": false, 24 | "ping": "pong" 25 | } 26 | ``` 27 | 28 | run the provisioning playbook: 29 | 30 | ```console 31 | $ ansible-playbook example1.yml 32 | 33 | PLAY [Install nginx] **************************** 34 | 35 | TASK [Gathering Facts] ************************** 36 | ok: [cluster-node0] 37 | 38 | TASK [Add epel-release repo] ******************** 39 | changed: [cluster-node0] 40 | 41 | TASK [Install nginx] **************************** 42 | changed: [cluster-node0] 43 | 44 | TASK [Insert Index Page] ************************ 45 | changed: [cluster-node0] 46 | 47 | TASK [Start NGiNX] ****************************** 48 | changed: [cluster-node0] 49 | 50 | PLAY RECAP ************************************** 51 | cluster-node0 : ok=5 changed=0 unreachable=0 failed=0 52 | ``` -------------------------------------------------------------------------------- /examples/ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory=inventory.txt 3 | remote_user=root 4 | debug=no 5 | 6 | [privilege_escalation] 7 | become=no 8 | 9 | -------------------------------------------------------------------------------- /examples/ansible/example1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install nginx 3 | hosts: cluster-node0 4 | 5 | tasks: 6 | - name: Add epel-release repo 7 | yum: 8 | name: epel-release 9 | state: present 10 | 11 | - name: Install nginx 12 | yum: 13 | name: nginx 14 | state: present 15 | 16 | - name: Insert Index Page 17 | copy: 18 | content: "welcome to footloose nginx ansible example" 19 | dest: /usr/share/nginx/html/index.html 20 | 21 | - name: Start NGiNX 22 | service: 23 | name: nginx 24 | state: started 25 | 26 | -------------------------------------------------------------------------------- /examples/ansible/inventory.txt: -------------------------------------------------------------------------------- 1 | [all] 2 | cluster-node0 ansible_connection=docker 3 | 4 | -------------------------------------------------------------------------------- /examples/apache/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/footloose/ubuntu18.04 2 | 3 | RUN apt-get update && apt-get install -y apache2 4 | COPY index.html /var/www/html 5 | 6 | RUN systemctl enable apache2.service 7 | 8 | EXPOSE 80 9 | -------------------------------------------------------------------------------- /examples/apache/README.md: -------------------------------------------------------------------------------- 1 | # Run Apache with Footloose 2 | 3 | Using the footloose base like above, create a docker file which installs Apache and 4 | exposes a port like 80 or 443: 5 | 6 | ```Dockerfile 7 | FROM quay.io/footloose/ubuntu18.04 8 | 9 | RUN apt-get update && apt-get install -y apache2 10 | COPY index.html /var/www/html 11 | 12 | RUN systemctl enable apache2.service 13 | 14 | EXPOSE 80 15 | ``` 16 | 17 | Build that image: 18 | 19 | ```console 20 | $ docker built -t apache:test01 . 21 | ``` 22 | 23 | Create a footloose configuration file. 24 | 25 | ```console 26 | $ footloose config create --image apache:test01 27 | ``` 28 | 29 | Now, create a machine! 30 | 31 | ```console 32 | $ footloose create 33 | ``` 34 | 35 | Once the machine is ready, you should be able to access apache on the exposed port. 36 | 37 | ```console 38 | $ docker port cluster-node0 80 39 | 0.0.0.0:32824 40 | $ curl 0.0.0.0:32824 41 | 42 | 43 | Footloose 44 | 45 | Hello, from footloose! 46 | 47 | 48 | ``` 49 | 50 | In case of multiple machines the port will be different on each machine. 51 | 52 | ```console 53 | $ docker port cluster-node1 80 54 | 0.0.0.0:32828 55 | 56 | $ docker port cluster-node0 80 57 | 0.0.0.0:32826 58 | ``` 59 | -------------------------------------------------------------------------------- /examples/apache/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Footloose 4 | 5 | Hello, from footloose! 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/docker-in-docker/README.md: -------------------------------------------------------------------------------- 1 | # Running `dockerd` in Container Machines 2 | 3 | To run `dockerd` inside a docker container, two things are needed: 4 | 5 | - Run the container as privileged (we could probably do better! expose 6 | capabilities instead). 7 | - Mount `/var/lib/docker` as volume, here an anonymous volume. This is 8 | because of [limitations][dind] of what you can do with the overlay system 9 | docker is setup to use. 10 | 11 | ```yaml 12 | cluster: 13 | name: cluster 14 | privateKey: cluster-key 15 | machines: 16 | - count: 1 17 | spec: 18 | image: quay.io/footloose/centos7 19 | name: node%d 20 | portMappings: 21 | - containerPort: 22 22 | privileged: true 23 | volumes: 24 | - type: volume 25 | destination: /var/lib/docker 26 | ``` 27 | 28 | You can then install and run docker on the machine: 29 | 30 | ```console 31 | $ footloose create 32 | $ footloose ssh root@node0 33 | # yum install -y docker iptables 34 | [...] 35 | # systemctl start docker 36 | # docker run busybox echo 'Hello, World!' 37 | Hello, World! 38 | ``` 39 | 40 | [dind]: https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ 41 | -------------------------------------------------------------------------------- /examples/docker-in-docker/footloose.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: cluster 3 | privateKey: cluster-key 4 | machines: 5 | - count: 1 6 | spec: 7 | image: quay.io/footloose/centos7 8 | name: node%d 9 | portMappings: 10 | - containerPort: 22 11 | privileged: true 12 | volumes: 13 | - type: volume 14 | destination: /var/lib/docker 15 | -------------------------------------------------------------------------------- /examples/fedora29-htop/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/footloose/fedora29 2 | 3 | # Pre-seed the htop package 4 | RUN dnf -y install htop && dnf clean all 5 | -------------------------------------------------------------------------------- /examples/fedora29-htop/README.md: -------------------------------------------------------------------------------- 1 | # Customize the OS image 2 | 3 | It is possible to create docker images that specialize a [`footloose` base 4 | image](https://github.com/weaveworks/footloose#choosing-the-os-image-to-run) to 5 | suit your needs. 6 | 7 | For instance, if we want the created machines to run `fedora29` with the 8 | `htop` package already pre-installed: 9 | 10 | ```Dockerfile 11 | FROM quay.io/footloose/fedora29 12 | 13 | # Pre-seed the htop package 14 | RUN dnf -y install htop && dnf clean all 15 | 16 | ``` 17 | 18 | Build that image: 19 | 20 | ```console 21 | docker build -t fedora29-htop . 22 | ``` 23 | 24 | Configure `footloose.yaml` to use that image by either editing the file or running: 25 | 26 | ```console 27 | footloose config create --image fedora29-htop 28 | ```` 29 | 30 | `htop` will be available on the newly created machines! 31 | 32 | ```console 33 | $ footloose create 34 | $ footloose ssh root@node0 35 | # htop 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/simple-hostPort/README.md: -------------------------------------------------------------------------------- 1 | # Simple port mapping example 2 | 3 | First prepare your deploy setup. Notice the last two lines, which means that 4 | node0 port 22 will get mapped to host port 2222, and node1 port 22 will get 5 | mapped to host port 2223: 6 | 7 | ```console 8 | $ cat footloose.yaml 9 | cluster: 10 | name: cluster 11 | privateKey: cluster-key 12 | machines: 13 | - count: 2 14 | spec: 15 | image: quay.io/footloose/centos7 16 | name: node%d 17 | portMappings: 18 | - containerPort: 22 19 | hostPort: 2222 20 | ``` 21 | 22 | Now you can deploy your cluster: 23 | 24 | ```console 25 | $ footloose create 26 | INFO[0000] Image: quay.io/footloose/centos7 present locally 27 | INFO[0000] Creating machine: cluster-node0 ... 28 | INFO[0001] Creating machine: cluster-node1 ... 29 | 30 | ``` 31 | 32 | You now have two container running, listening on SSH port 2222 and 2223 of the host: 33 | 34 | 35 | ```console 36 | $ ssh root@127.0.0.1 -p 2222 -i cluster-key hostname 37 | The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established. 38 | ECDSA key fingerprint is SHA256:rUXnIB9Nmpy8bzEOcr2MWLVOdkzs9dLSXh7mfP/v7Po. 39 | Are you sure you want to continue connecting (yes/no)? yes 40 | Warning: Permanently added '[127.0.0.1]:2222' (ECDSA) to the list of known hosts. 41 | node0 42 | 43 | $ ssh root@127.0.0.1 -p 2223 -i cluster-key hostname 44 | The authenticity of host '[127.0.0.1]:2223 ([127.0.0.1]:2223)' can't be established. 45 | ECDSA key fingerprint is SHA256:0vFd0G655FY1PA/04MZKbT/4dmxP8O+hrzMJs/83uaw. 46 | Are you sure you want to continue connecting (yes/no)? yes 47 | Warning: Permanently added '[127.0.0.1]:2223' (ECDSA) to the list of known hosts. 48 | node1 49 | ``` 50 | 51 | When finished, clean up: 52 | 53 | ```console 54 | $ footloose delete 55 | INFO[0000] Deleting machine: cluster-node0 ... 56 | INFO[0000] Deleting machine: cluster-node1 ... 57 | ``` 58 | 59 | -------------------------------------------------------------------------------- /examples/simple-hostPort/footloose.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: cluster 3 | privateKey: cluster-key 4 | machines: 5 | - count: 2 6 | spec: 7 | image: quay.io/footloose/centos7 8 | name: node%d 9 | portMappings: 10 | - containerPort: 22 11 | hostPort: 2222 -------------------------------------------------------------------------------- /examples/user-defined-network/README.md: -------------------------------------------------------------------------------- 1 | # Using user-defined network example 2 | 3 | Using a user-defined network enables DNS name resolution of the container names, so you can talk 4 | to each container of the cluster just using the hostname. 5 | 6 | First prepare your deploy setup. Notice the line 'network' which specifies which user-defined network the containers should be attached to. 7 | 8 | ```console 9 | $ cat footloose.yaml 10 | cluster: 11 | name: cluster 12 | privateKey: cluster-key 13 | machines: 14 | - count: 3 15 | spec: 16 | image: quay.io/footloose/centos7:0.6.4 17 | name: node%d 18 | networks: 19 | - footloose-cluster 20 | portMappings: 21 | - containerPort: 22 22 | ``` 23 | 24 | The user-defined network has to be created manually before deploying your cluster: 25 | 26 | ```console 27 | $ docker network create footloose-cluster 28 | c558b7218393a2e4c89b19f7904d244192664997f46eb6edfc3217e187472afc 29 | ``` 30 | 31 | Now you can deploy your cluster: 32 | 33 | ```console 34 | $ footloose create 35 | INFO[0000] Image: quay.io/footloose/centos7 present locally 36 | INFO[0000] Creating machine: cluster-node0 ... 37 | INFO[0001] Creating machine: cluster-node1 ... 38 | INFO[0002] Creating machine: cluster-node2 ... 39 | 40 | ``` 41 | 42 | You now have three containers running, which can talk to each other using their hostnames: 43 | 44 | ```console 45 | $ footloose ssh root@node0 46 | [root@node0 ~]# ping -c 4 node1 47 | PING node1 (172.25.0.3) 56(84) bytes of data. 48 | 64 bytes from cluster-node1.footloose-cluster (172.25.0.3): icmp_seq=1 ttl=64 time=0.240 ms 49 | 64 bytes from cluster-node1.footloose-cluster (172.25.0.3): icmp_seq=2 ttl=64 time=0.289 ms 50 | 64 bytes from cluster-node1.footloose-cluster (172.25.0.3): icmp_seq=3 ttl=64 time=0.193 ms 51 | 64 bytes from cluster-node1.footloose-cluster (172.25.0.3): icmp_seq=4 ttl=64 time=0.205 ms 52 | 53 | --- node1 ping statistics --- 54 | 4 packets transmitted, 4 received, 0% packet loss, time 3044ms 55 | rtt min/avg/max/mdev = 0.193/0.231/0.289/0.041 ms 56 | [root@node0 ~]# ping -c 4 node2 57 | PING node2 (172.25.0.4) 56(84) bytes of data. 58 | 64 bytes from cluster-node2.footloose-cluster (172.25.0.4): icmp_seq=1 ttl=64 time=0.109 ms 59 | 64 bytes from cluster-node2.footloose-cluster (172.25.0.4): icmp_seq=2 ttl=64 time=0.184 ms 60 | 64 bytes from cluster-node2.footloose-cluster (172.25.0.4): icmp_seq=3 ttl=64 time=0.143 ms 61 | 62 | --- node2 ping statistics --- 63 | 3 packets transmitted, 3 received, 0% packet loss, time 2059ms 64 | rtt min/avg/max/mdev = 0.109/0.145/0.184/0.032 ms 65 | 66 | ``` 67 | 68 | When finished, clean up: 69 | 70 | ```console 71 | $ footloose delete 72 | INFO[0000] Deleting machine: cluster-node0 ... 73 | INFO[0000] Deleting machine: cluster-node1 ... 74 | INFO[0001] Deleting machine: cluster-node2 ... 75 | ``` 76 | 77 | -------------------------------------------------------------------------------- /examples/user-defined-network/footloose.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: cluster 3 | privateKey: cluster-key 4 | machines: 5 | - count: 3 6 | spec: 7 | image: quay.io/footloose/centos7:0.6.4 8 | name: node%d 9 | networks: 10 | - footloose-cluster 11 | portMappings: 12 | - containerPort: 22 13 | -------------------------------------------------------------------------------- /footloose.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // Footloose is the default name of the footloose file. 10 | const Footloose = "footloose.yaml" 11 | 12 | var footloose = &cobra.Command{ 13 | Use: "footloose", 14 | Short: "footloose - Container Machines", 15 | SilenceUsage: true, 16 | SilenceErrors: true, 17 | } 18 | 19 | func configFile(f string) string { 20 | env := os.Getenv("FOOTLOOSE_CONFIG") 21 | if env != "" && f == Footloose{ 22 | return env 23 | } 24 | return f 25 | } 26 | 27 | func main() { 28 | if err := footloose.Execute(); err != nil { 29 | log.Fatal(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/weaveworks/footloose 2 | 3 | require ( 4 | github.com/blang/semver v3.5.1+incompatible 5 | github.com/docker/docker v1.13.1 6 | github.com/docker/go-connections v0.4.0 // indirect 7 | github.com/docker/go-units v0.3.3 // indirect 8 | github.com/ghodss/yaml v1.0.0 9 | github.com/google/go-github/v24 v24.0.1 10 | github.com/gorilla/mux v1.7.3 11 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 12 | github.com/mitchellh/go-homedir v1.1.0 13 | github.com/pkg/errors v0.8.1 14 | github.com/sirupsen/logrus v1.3.0 15 | github.com/spf13/cobra v0.0.3 16 | github.com/spf13/pflag v1.0.3 // indirect 17 | github.com/stretchr/testify v1.2.2 18 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 // indirect 19 | gopkg.in/yaml.v2 v2.2.2 20 | ) 21 | 22 | go 1.13 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 2 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= 6 | github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 7 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 8 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 9 | github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= 10 | github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 11 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 12 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 13 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 14 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 15 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 16 | github.com/google/go-github/v24 v24.0.1 h1:KCt1LjMJEey1qvPXxa9SjaWxwTsCWSq6p2Ju57UR4Q4= 17 | github.com/google/go-github/v24 v24.0.1/go.mod h1:CRqaW1Uns1TCkP0wqTpxYyRxRjxwvKU/XSS44u6X74M= 18 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 19 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 20 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= 21 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 22 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 23 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 24 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 25 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 26 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 27 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 28 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 29 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= 33 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 34 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 35 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 36 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 37 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 38 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 40 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 41 | golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 42 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= 43 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 45 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 46 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 47 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 48 | golang.org/x/sys v0.0.0-20180824143301-4910a1d54f87/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 49 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= 50 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 51 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 52 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 53 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 57 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 58 | -------------------------------------------------------------------------------- /images/amazonlinux2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM amazonlinux:2 2 | 3 | ENV container docker 4 | 5 | RUN yum -y install sudo systemd hostname procps-ng net-tools iproute iputils wget && yum clean all 6 | 7 | RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == \ 8 | systemd-tmpfiles-setup.service ] || rm -f $i; done); \ 9 | rm -f /lib/systemd/system/multi-user.target.wants/*;\ 10 | rm -f /etc/systemd/system/*.wants/*;\ 11 | rm -f /lib/systemd/system/local-fs.target.wants/*; \ 12 | rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ 13 | rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ 14 | rm -f /lib/systemd/system/basic.target.wants/*;\ 15 | rm -f /lib/systemd/system/anaconda.target.wants/*;\ 16 | rm -f /lib/systemd/system/*.wants/*update-utmp*; 17 | 18 | RUN yum -y install openssh-server && yum clean all 19 | 20 | EXPOSE 22 21 | 22 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 23 | STOPSIGNAL SIGRTMIN+3 24 | 25 | CMD ["/bin/bash"] 26 | -------------------------------------------------------------------------------- /images/centos7/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos:7 2 | 3 | ENV container docker 4 | 5 | RUN yum -y install sudo procps-ng net-tools iproute iputils wget && yum clean all 6 | 7 | RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == \ 8 | systemd-tmpfiles-setup.service ] || rm -f $i; done); \ 9 | rm -f /lib/systemd/system/multi-user.target.wants/*;\ 10 | rm -f /etc/systemd/system/*.wants/*;\ 11 | rm -f /lib/systemd/system/local-fs.target.wants/*; \ 12 | rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ 13 | rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ 14 | rm -f /lib/systemd/system/basic.target.wants/*;\ 15 | rm -f /lib/systemd/system/anaconda.target.wants/*;\ 16 | rm -f /lib/systemd/system/*.wants/*update-utmp*; 17 | 18 | RUN yum -y install openssh-server && yum clean all 19 | 20 | EXPOSE 22 21 | 22 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 23 | STOPSIGNAL SIGRTMIN+3 24 | 25 | CMD ["/bin/bash"] 26 | -------------------------------------------------------------------------------- /images/clearlinux/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clearlinux:latest 2 | 3 | ENV container docker 4 | 5 | RUN swupd bundle-add openssh-server vim network-basic sudo 6 | RUN echo 'root:*:17995::::::' > /etc/shadow 7 | 8 | EXPOSE 22 9 | 10 | STOPSIGNAL SIGRTMIN+3 11 | 12 | CMD ["/bin/bash"] 13 | -------------------------------------------------------------------------------- /images/debian10/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:buster 2 | 3 | ENV container docker 4 | 5 | # Don't start any optional services except for the few we need. 6 | RUN find /etc/systemd/system \ 7 | /lib/systemd/system \ 8 | -path '*.wants/*' \ 9 | -not -name '*journald*' \ 10 | -not -name '*systemd-tmpfiles*' \ 11 | -not -name '*systemd-user-sessions*' \ 12 | -exec rm \{} \; 13 | 14 | RUN apt-get update && \ 15 | apt-get install -y \ 16 | dbus systemd openssh-server net-tools iproute2 iputils-ping curl wget vim-tiny sudo && \ 17 | apt-get clean && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | EXPOSE 22 21 | 22 | RUN systemctl set-default multi-user.target 23 | RUN systemctl mask \ 24 | dev-hugepages.mount \ 25 | sys-fs-fuse-connections.mount \ 26 | systemd-update-utmp.service \ 27 | systemd-tmpfiles-setup.service \ 28 | console-getty.service 29 | 30 | # This container image doesn't have locales installed. Disable forwarding the 31 | # user locale env variables or we get warnings such as: 32 | # bash: warning: setlocale: LC_ALL: cannot change locale 33 | RUN sed -i -e 's/^AcceptEnv LANG LC_\*$/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config 34 | 35 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 36 | STOPSIGNAL SIGRTMIN+3 37 | 38 | CMD ["/bin/bash"] 39 | -------------------------------------------------------------------------------- /images/fedora29/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fedora:29 2 | 3 | ENV container docker 4 | 5 | RUN dnf -y install sudo openssh-server procps-ng hostname net-tools iproute iputils wget && dnf clean all 6 | 7 | EXPOSE 22 8 | 9 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 10 | STOPSIGNAL SIGRTMIN+3 11 | 12 | CMD ["/bin/bash"] 13 | -------------------------------------------------------------------------------- /images/ubuntu16.04/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | ENV container docker 4 | ENV LC_ALL C 5 | ENV DEBIAN_FRONTEND noninteractive 6 | 7 | RUN sed -i 's/# deb/deb/g' /etc/apt/sources.list 8 | 9 | RUN apt-get update \ 10 | && apt-get install -y systemd dbus openssh-client openssh-server net-tools iproute2 iputils-ping curl wget vim-tiny sudo \ 11 | && apt-get clean \ 12 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 13 | 14 | RUN find /etc/systemd/system \ 15 | /lib/systemd/system \ 16 | -path '*.wants/*' \ 17 | -not -name '*journald*' \ 18 | -not -name '*systemd-tmpfiles*' \ 19 | -not -name '*systemd-user-sessions*' \ 20 | -exec rm \{} \; 21 | 22 | RUN >/etc/machine-id 23 | RUN >/var/lib/dbus/machine-id 24 | 25 | EXPOSE 22 26 | 27 | RUN systemctl set-default multi-user.target 28 | RUN systemctl mask \ 29 | dev-hugepages.mount \ 30 | sys-fs-fuse-connections.mount \ 31 | systemd-update-utmp.service \ 32 | systemd-tmpfiles-setup.service \ 33 | console-getty.service 34 | 35 | # This container image doesn't have locales installed. Disable forwarding the 36 | # user locale env variables or we get warnings such as: 37 | # bash: warning: setlocale: LC_ALL: cannot change locale 38 | RUN sed -i -e 's/^AcceptEnv LANG LC_\*$/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config 39 | 40 | RUN systemctl enable ssh.service 41 | 42 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 43 | STOPSIGNAL SIGRTMIN+3 44 | 45 | # Read this described bug here: https://askubuntu.com/questions/1110828/ssh-failed-to-start-missing-privilege-separation-directory-var-run-sshd 46 | # And here: https://bugs.launchpad.net/ubuntu/+source/systemd/+bug/1811580 47 | VOLUME [ "/var/run/sshd" ] 48 | 49 | CMD ["/lib/systemd/systemd"] 50 | -------------------------------------------------------------------------------- /images/ubuntu18.04/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | ENV container docker 4 | 5 | # Don't start any optional services except for the few we need. 6 | RUN find /etc/systemd/system \ 7 | /lib/systemd/system \ 8 | -path '*.wants/*' \ 9 | -not -name '*journald*' \ 10 | -not -name '*systemd-tmpfiles*' \ 11 | -not -name '*systemd-user-sessions*' \ 12 | -exec rm \{} \; 13 | 14 | RUN apt-get update && \ 15 | apt-get install -y \ 16 | dbus systemd openssh-server net-tools iproute2 iputils-ping curl wget vim-tiny sudo && \ 17 | apt-get clean && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | RUN >/etc/machine-id 21 | RUN >/var/lib/dbus/machine-id 22 | 23 | EXPOSE 22 24 | 25 | RUN systemctl set-default multi-user.target 26 | RUN systemctl mask \ 27 | dev-hugepages.mount \ 28 | sys-fs-fuse-connections.mount \ 29 | systemd-update-utmp.service \ 30 | systemd-tmpfiles-setup.service \ 31 | console-getty.service 32 | RUN systemctl disable \ 33 | networkd-dispatcher.service 34 | 35 | # This container image doesn't have locales installed. Disable forwarding the 36 | # user locale env variables or we get warnings such as: 37 | # bash: warning: setlocale: LC_ALL: cannot change locale 38 | RUN sed -i -e 's/^AcceptEnv LANG LC_\*$/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config 39 | 40 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 41 | STOPSIGNAL SIGRTMIN+3 42 | 43 | CMD ["/bin/bash"] 44 | -------------------------------------------------------------------------------- /images/ubuntu20.04/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV container docker 4 | 5 | # Don't start any optional services except for the few we need. 6 | RUN find /etc/systemd/system \ 7 | /lib/systemd/system \ 8 | -path '*.wants/*' \ 9 | -not -name '*journald*' \ 10 | -not -name '*systemd-tmpfiles*' \ 11 | -not -name '*systemd-user-sessions*' \ 12 | -exec rm \{} \; 13 | 14 | RUN apt-get update && \ 15 | apt-get install -y \ 16 | dbus systemd openssh-server net-tools iproute2 iputils-ping curl wget vim-tiny sudo && \ 17 | apt-get clean && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | RUN >/etc/machine-id 21 | RUN >/var/lib/dbus/machine-id 22 | 23 | EXPOSE 22 24 | 25 | RUN systemctl set-default multi-user.target 26 | RUN systemctl mask \ 27 | dev-hugepages.mount \ 28 | sys-fs-fuse-connections.mount \ 29 | systemd-update-utmp.service \ 30 | systemd-tmpfiles-setup.service \ 31 | console-getty.service 32 | RUN systemctl disable \ 33 | networkd-dispatcher.service 34 | 35 | # This container image doesn't have locales installed. Disable forwarding the 36 | # user locale env variables or we get warnings such as: 37 | # bash: warning: setlocale: LC_ALL: cannot change locale 38 | RUN sed -i -e 's/^AcceptEnv LANG LC_\*$/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config 39 | 40 | # https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/ 41 | STOPSIGNAL SIGRTMIN+3 42 | 43 | CMD ["/bin/bash"] 44 | -------------------------------------------------------------------------------- /make-image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | scriptdir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 6 | org=quay.io/footloose 7 | 8 | if [ $# -lt 2 ]; then 9 | echo "Usage: make-image.sh VERB IMAGE [ARGS]" 10 | echo 11 | echo VERB is one of: 12 | echo " • build" 13 | echo " • tag" 14 | echo " • push" 15 | echo 16 | echo IMAGE is one of: 17 | echo " • all (build all images)" 18 | for d in `ls $scriptdir/images`; do 19 | echo " • $d" 20 | done 21 | echo 22 | echo "ARGS are VERB-specific optional arguments" 23 | echo " • tag requires a version" 24 | echo " • push takes an optional version, defaults to 'latest' " 25 | echo 26 | echo "Examples:" 27 | echo " • $0 build all" 28 | echo " • $0 tag all 0.2.0" 29 | echo " • $0 push all 0.2.0" 30 | exit 1 31 | fi 32 | 33 | verb=$1 34 | case $verb in 35 | build|tag|push) 36 | ;; 37 | *) 38 | echo "error: unknown verb '$verb'" 39 | exit 1 40 | esac 41 | 42 | images=$2 43 | if [ "$images" == "all" ]; then 44 | images="" 45 | for d in `ls $scriptdir/images`; do 46 | images+=" $d" 47 | done 48 | fi 49 | 50 | shift 51 | shift 52 | 53 | case $verb in 54 | 55 | build) 56 | for image in $images; do 57 | echo " • Building $org/$image" 58 | (cd $scriptdir/images/$image && docker build -t $org/$image .) 59 | done 60 | ;; 61 | 62 | tag) 63 | if [ $# != 1 ]; then 64 | echo "error: usage tag IMAGE VERSION" 65 | exit 1 66 | fi 67 | version=$1 68 | for image in $images; do 69 | echo " • Tagging $org/$image:$version" 70 | docker tag $org/$image:latest $org/$image:$version 71 | done 72 | ;; 73 | 74 | push) 75 | version=latest 76 | [ $# == 1 ] && version=$1 77 | for image in $images; do 78 | echo " • Pushing $org/$image:$version" 79 | docker push $org/$image:$version 80 | done 81 | ;; 82 | 83 | esac 84 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/weaveworks/footloose/pkg/cluster" 11 | ) 12 | 13 | // API represents the footloose REST API. 14 | type API struct { 15 | BaseURI string 16 | db db 17 | keyStore *cluster.KeyStore 18 | router *mux.Router 19 | } 20 | 21 | // New creates a new object able to answer footloose REST API. 22 | func New(baseURI string, keyStore *cluster.KeyStore, debug bool) *API { 23 | if debug { 24 | log.SetLevel(log.DebugLevel) 25 | } 26 | 27 | api := &API{ 28 | BaseURI: baseURI, 29 | keyStore: keyStore, 30 | router: mux.NewRouter(), 31 | } 32 | api.db.init() 33 | return api 34 | } 35 | 36 | func httpLogger(next http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | log.Debugln(r.RequestURI, r.Method) 39 | next.ServeHTTP(w, r) 40 | }) 41 | } 42 | 43 | func (a *API) createDocs(w http.ResponseWriter, r *http.Request) { 44 | w.Header().Set("Content-Type", "application/json") 45 | 46 | response := map[string][]string{} 47 | 48 | _ = a.router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { 49 | path, _ := route.GetPathTemplate() 50 | methods, _ := route.GetMethods() 51 | 52 | if _, ok := response[path]; ok { 53 | response[path] = append(response[path], methods[0]) 54 | } else { 55 | response[path] = methods 56 | } 57 | 58 | return nil 59 | }) 60 | 61 | payload, err := json.Marshal(response) 62 | if err != nil { 63 | log.Fatalln(err) 64 | } 65 | 66 | sendResponse(w, payload) 67 | } 68 | 69 | func (a *API) initRouter() { 70 | a.router.HandleFunc("/api/keys", a.createPublicKey).Methods("POST") 71 | a.router.HandleFunc("/api/keys/{key}", a.getPublicKey).Methods("GET") 72 | a.router.HandleFunc("/api/keys/{key}", a.deletePublicKey).Methods("DELETE") 73 | a.router.HandleFunc("/api/clusters", a.createCluster).Methods("POST") 74 | a.router.HandleFunc("/api/clusters/{cluster}", a.deleteCluster).Methods("DELETE") 75 | a.router.HandleFunc("/api/clusters/{cluster}/machines", a.createMachine).Methods("POST") 76 | a.router.HandleFunc("/api/clusters/{cluster}/machines/{machine}", a.getMachine).Methods("GET") 77 | a.router.HandleFunc("/api/clusters/{cluster}/machines/{machine}", a.deleteMachine).Methods("DELETE") 78 | 79 | a.router.HandleFunc("/", a.createDocs).Methods("GET") 80 | 81 | a.router.Use(httpLogger) 82 | } 83 | 84 | // Router returns the API request router. 85 | func (a *API) Router() *mux.Router { 86 | a.initRouter() 87 | return a.router 88 | } 89 | -------------------------------------------------------------------------------- /pkg/api/cluster.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/pkg/errors" 10 | "github.com/weaveworks/footloose/pkg/cluster" 11 | "github.com/weaveworks/footloose/pkg/config" 12 | ) 13 | 14 | // ClusterURI returns the URI identifying a cluster in the REST API. 15 | func (a *API) ClusterURI(c *cluster.Cluster) string { 16 | return fmt.Sprintf("%s/api/clusters/%s", a.BaseURI, c.Name()) 17 | } 18 | 19 | // createCluster creates a cluster. 20 | func (a *API) createCluster(w http.ResponseWriter, r *http.Request) { 21 | var def config.Cluster 22 | if err := json.NewDecoder(r.Body).Decode(&def); err != nil { 23 | sendError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode body")) 24 | return 25 | } 26 | if def.Name == "" { 27 | sendError(w, http.StatusBadRequest, errors.New("no cluster name provided")) 28 | return 29 | } 30 | 31 | cluster, err := cluster.New(config.Config{Cluster: def}) 32 | if err != nil { 33 | sendError(w, http.StatusInternalServerError, err) 34 | return 35 | } 36 | cluster.SetKeyStore(a.keyStore) 37 | 38 | if err := a.db.addCluster(def.Name, cluster); err != nil { 39 | sendError(w, http.StatusBadRequest, err) 40 | return 41 | } 42 | 43 | if err := cluster.Create(); err != nil { 44 | _, _ = a.db.removeCluster(def.Name) 45 | sendError(w, http.StatusInternalServerError, err) 46 | return 47 | } 48 | sendCreated(w, a.ClusterURI((cluster))) 49 | } 50 | 51 | // deleteCluster deletes a cluster. 52 | func (a *API) deleteCluster(w http.ResponseWriter, r *http.Request) { 53 | vars := mux.Vars(r) 54 | c, err := a.db.cluster(vars["cluster"]) 55 | if err != nil { 56 | sendError(w, http.StatusBadRequest, err) 57 | return 58 | } 59 | 60 | // Starts by deleting the machines associated with the cluster. 61 | machines, err := a.db.machines(vars["cluster"]) 62 | if err != nil { 63 | sendError(w, http.StatusBadRequest, err) 64 | return 65 | } 66 | for _, m := range machines { 67 | if err := c.DeleteMachine(m, 0); err != nil { 68 | sendError(w, http.StatusInternalServerError, err) 69 | return 70 | } 71 | if _, err := a.db.removeMachine(vars["cluster"], m.Hostname()); err != nil { 72 | sendError(w, http.StatusInternalServerError, err) 73 | return 74 | } 75 | } 76 | 77 | // Delete cluster. 78 | if err := c.Delete(); err != nil { 79 | sendError(w, http.StatusInternalServerError, err) 80 | return 81 | } 82 | 83 | _, err = a.db.removeCluster(vars["cluster"]) 84 | if err != nil { 85 | sendError(w, http.StatusInternalServerError, err) 86 | return 87 | } 88 | 89 | sendOK(w) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/api/db.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/weaveworks/footloose/pkg/cluster" 8 | ) 9 | 10 | type entry struct { 11 | cluster *cluster.Cluster 12 | machines map[string]*cluster.Machine 13 | } 14 | 15 | type db struct { 16 | sync.Mutex 17 | 18 | clusters map[string]entry 19 | } 20 | 21 | func (db *db) init() { 22 | db.clusters = make(map[string]entry) 23 | } 24 | 25 | func (db *db) entry(name string) *entry { 26 | db.Lock() 27 | defer db.Unlock() 28 | 29 | entry, ok := db.clusters[name] 30 | if !ok { 31 | return nil 32 | } 33 | return &entry 34 | } 35 | 36 | func (db *db) cluster(name string) (*cluster.Cluster, error) { 37 | entry := db.entry(name) 38 | if entry == nil { 39 | return nil, errors.Errorf("unknown cluster '%s'", name) 40 | } 41 | return entry.cluster, nil 42 | } 43 | 44 | func (db *db) addCluster(name string, c *cluster.Cluster) error { 45 | db.Lock() 46 | defer db.Unlock() 47 | 48 | if _, ok := db.clusters[name]; ok { 49 | return errors.Errorf("cluster '%s' has already been added", name) 50 | } 51 | db.clusters[name] = entry{ 52 | cluster: c, 53 | machines: make(map[string]*cluster.Machine), 54 | } 55 | return nil 56 | } 57 | 58 | func (db *db) removeCluster(name string) (*cluster.Cluster, error) { 59 | db.Lock() 60 | defer db.Unlock() 61 | 62 | var entry entry 63 | var ok bool 64 | if entry, ok = db.clusters[name]; !ok { 65 | return nil, errors.Errorf("unknown cluster '%s'", name) 66 | } 67 | // It is an error to remove the cluster from the db before removing all of its 68 | // machines. 69 | if len(entry.machines) != 0 { 70 | return nil, errors.Errorf("cluster has machines associated with it") 71 | } 72 | delete(db.clusters, name) 73 | return entry.cluster, nil 74 | } 75 | 76 | func (db *db) machine(clusterName, machineName string) (*cluster.Machine, error) { 77 | entry := db.entry(clusterName) 78 | if entry == nil { 79 | return nil, errors.Errorf("unknown cluster '%s'", clusterName) 80 | } 81 | 82 | db.Lock() 83 | defer db.Unlock() 84 | 85 | var m *cluster.Machine 86 | var ok bool 87 | if m, ok = entry.machines[machineName]; !ok { 88 | return nil, errors.Errorf("unknown machine '%s' for cluster '%s'", machineName, clusterName) 89 | } 90 | return m, nil 91 | } 92 | 93 | func (db *db) machines(clusterName string) ([]*cluster.Machine, error) { 94 | entry := db.entry(clusterName) 95 | if entry == nil { 96 | return nil, errors.Errorf("unknown cluster '%s'", clusterName) 97 | } 98 | 99 | db.Lock() 100 | defer db.Unlock() 101 | 102 | var machines []*cluster.Machine 103 | for _, m := range entry.machines { 104 | machines = append(machines, m) 105 | } 106 | return machines, nil 107 | } 108 | 109 | func (db *db) addMachine(cluster string, m *cluster.Machine) error { 110 | entry := db.entry(cluster) 111 | if entry == nil { 112 | return errors.Errorf("unknown cluster '%s'", cluster) 113 | } 114 | 115 | db.Lock() 116 | defer db.Unlock() 117 | 118 | // Hostname is really the machine unique name as we don't allow setting a 119 | // different hostname. 120 | if _, ok := entry.machines[m.Hostname()]; ok { 121 | return errors.Errorf("machine '%s' has already been added", m.Hostname()) 122 | 123 | } 124 | entry.machines[m.Hostname()] = m 125 | return nil 126 | } 127 | 128 | func (db *db) removeMachine(clusterName, machineName string) (*cluster.Machine, error) { 129 | entry := db.entry(clusterName) 130 | if entry == nil { 131 | return nil, errors.Errorf("unknown cluster '%s'", clusterName) 132 | } 133 | 134 | db.Lock() 135 | defer db.Unlock() 136 | 137 | var m *cluster.Machine 138 | var ok bool 139 | if m, ok = entry.machines[machineName]; !ok { 140 | return nil, errors.Errorf("unknown machine '%s' for cluster '%s'", machineName, clusterName) 141 | } 142 | delete(entry.machines, machineName) 143 | return m, nil 144 | } 145 | -------------------------------------------------------------------------------- /pkg/api/key.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/pkg/errors" 10 | "github.com/weaveworks/footloose/pkg/config" 11 | ) 12 | 13 | func (a *API) keyURI(name string) string { 14 | return fmt.Sprintf("%s/keys/%s", a.BaseURI, name) 15 | } 16 | 17 | func (a *API) createPublicKey(w http.ResponseWriter, r *http.Request) { 18 | var def config.PublicKey 19 | if err := json.NewDecoder(r.Body).Decode(&def); err != nil { 20 | sendError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode body")) 21 | return 22 | } 23 | if def.Name == "" { 24 | sendError(w, http.StatusBadRequest, errors.New("no key name provided")) 25 | return 26 | } 27 | 28 | if err := a.keyStore.Store(def.Name, def.Key); err != nil { 29 | sendError(w, http.StatusBadRequest, err) 30 | return 31 | } 32 | 33 | sendCreated(w, a.keyURI(def.Name)) 34 | } 35 | 36 | func (a *API) getPublicKey(w http.ResponseWriter, r *http.Request) { 37 | vars := mux.Vars(r) 38 | data, err := a.keyStore.Get(vars["key"]) 39 | if err != nil { 40 | sendError(w, http.StatusBadRequest, err) 41 | return 42 | } 43 | 44 | key := config.PublicKey{ 45 | Name: vars["key"], 46 | Key: string(data), 47 | } 48 | if err := json.NewEncoder(w).Encode(&key); err != nil { 49 | sendError(w, http.StatusInternalServerError, err) 50 | return 51 | } 52 | } 53 | 54 | func (a *API) deletePublicKey(w http.ResponseWriter, r *http.Request) { 55 | vars := mux.Vars(r) 56 | if err := a.keyStore.Remove(vars["key"]); err != nil { 57 | sendError(w, http.StatusBadRequest, err) 58 | return 59 | } 60 | sendOK(w) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/api/machine.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/pkg/errors" 10 | "github.com/weaveworks/footloose/pkg/cluster" 11 | "github.com/weaveworks/footloose/pkg/config" 12 | ) 13 | 14 | // MachineURI returns the URI identifying a machine in the REST API. 15 | func (a *API) MachineURI(c *cluster.Cluster, m *cluster.Machine) string { 16 | return fmt.Sprintf("%s/api/clusters/%s/machines/%s", a.BaseURI, c.Name(), m.Hostname()) 17 | } 18 | 19 | // createMachine creates a machine. 20 | func (a *API) createMachine(w http.ResponseWriter, r *http.Request) { 21 | var def config.Machine 22 | if err := json.NewDecoder(r.Body).Decode(&def); err != nil { 23 | sendError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode body")) 24 | return 25 | } 26 | if def.Name == "" { 27 | sendError(w, http.StatusBadRequest, errors.New("no machine name provided")) 28 | return 29 | } 30 | 31 | vars := mux.Vars(r) 32 | c, err := a.db.cluster(vars["cluster"]) 33 | if err != nil { 34 | sendError(w, http.StatusBadRequest, err) 35 | return 36 | } 37 | 38 | m := c.NewMachine(&def) 39 | 40 | if err := c.CreateMachine(m, 0); err != nil { 41 | sendError(w, http.StatusInternalServerError, err) 42 | return 43 | } 44 | 45 | if err := a.db.addMachine(vars["cluster"], m); err != nil { 46 | sendError(w, http.StatusInternalServerError, err) 47 | return 48 | } 49 | 50 | sendCreated(w, a.MachineURI(c, m)) 51 | } 52 | 53 | // getMachine returns a machine object 54 | func (a *API) getMachine(w http.ResponseWriter, r *http.Request) { 55 | vars := mux.Vars(r) 56 | m, err := a.db.machine(vars["cluster"], vars["machine"]) 57 | if err != nil { 58 | sendError(w, http.StatusBadRequest, err) 59 | return 60 | } 61 | 62 | formatter := new(cluster.JSONFormatter) 63 | if err := formatter.FormatSingle(w, m); err != nil { 64 | sendError(w, http.StatusInternalServerError, err) 65 | return 66 | } 67 | } 68 | 69 | // deleteMachine deletes a machine. 70 | func (a *API) deleteMachine(w http.ResponseWriter, r *http.Request) { 71 | vars := mux.Vars(r) 72 | c, err := a.db.cluster(vars["cluster"]) 73 | if err != nil { 74 | sendError(w, http.StatusBadRequest, err) 75 | return 76 | } 77 | m, err := a.db.machine(vars["cluster"], vars["machine"]) 78 | if err != nil { 79 | sendError(w, http.StatusBadRequest, err) 80 | return 81 | } 82 | 83 | if err := c.DeleteMachine(m, 0); err != nil { 84 | sendError(w, http.StatusInternalServerError, err) 85 | return 86 | } 87 | 88 | _, err = a.db.removeMachine(vars["cluster"], vars["machine"]) 89 | if err != nil { 90 | sendError(w, http.StatusInternalServerError, err) 91 | return 92 | } 93 | 94 | sendOK(w) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/api/response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | func sendOK(w http.ResponseWriter) { 9 | w.WriteHeader(http.StatusOK) 10 | } 11 | 12 | // DocsResponse returns basic API docs. 13 | type DocsResponse struct { 14 | DOCS []byte `json:"docs"` 15 | } 16 | 17 | func sendResponse(w http.ResponseWriter, body []byte) { 18 | w.Header().Set("Content-Type", "application/json") 19 | w.WriteHeader(http.StatusOK) 20 | 21 | resp := DocsResponse{ 22 | DOCS: body, 23 | } 24 | 25 | _ = json.NewEncoder(w).Encode(&resp) 26 | } 27 | 28 | // ErrorResponse is the response API entry points return when they encountered an error. 29 | type ErrorResponse struct { 30 | Error string `json:"error"` 31 | } 32 | 33 | func sendError(w http.ResponseWriter, status int, err error) { 34 | w.Header().Set("Content-Type", "application/json") 35 | w.WriteHeader(status) 36 | resp := ErrorResponse{ 37 | Error: err.Error(), 38 | } 39 | _ = json.NewEncoder(w).Encode(&resp) 40 | } 41 | 42 | // CreatedResponse is the response POST entry points return when a resource has been 43 | // successfully created. 44 | type CreatedResponse struct { 45 | URI string `json:"uri"` 46 | } 47 | 48 | func sendCreated(w http.ResponseWriter, URI string) { 49 | w.Header().Set("Content-Type", "application/json") 50 | w.WriteHeader(http.StatusCreated) 51 | resp := CreatedResponse{ 52 | URI: URI, 53 | } 54 | _ = json.NewEncoder(w).Encode(&resp) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/weaveworks/footloose/pkg/api" 12 | "github.com/weaveworks/footloose/pkg/cluster" 13 | "github.com/weaveworks/footloose/pkg/config" 14 | ) 15 | 16 | // Client is a object able to talk a remote footloose API server. 17 | type Client struct { 18 | baseURI *url.URL 19 | client *http.Client 20 | } 21 | 22 | // New creates a new Client. 23 | func New(baseURI string) *Client { 24 | u, err := url.Parse(baseURI) 25 | if err != nil { 26 | panic(err) 27 | } 28 | return &Client{ 29 | baseURI: u, 30 | client: &http.Client{}, 31 | } 32 | } 33 | 34 | func (c *Client) uriFromPath(path string) string { 35 | u, err := url.Parse(path) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return c.baseURI.ResolveReference(u).String() 40 | } 41 | 42 | func (c *Client) publicKeyURI(name string) string { 43 | return c.uriFromPath(fmt.Sprintf("/api/keys/%s", name)) 44 | } 45 | 46 | func (c *Client) clusterURI(name string) string { 47 | return c.uriFromPath(fmt.Sprintf("/api/clusters/%s", name)) 48 | } 49 | 50 | func (c *Client) machineURI(clusterName, name string) string { 51 | return c.uriFromPath(fmt.Sprintf("/api/clusters/%s/machines/%s", clusterName, name)) 52 | } 53 | 54 | func apiError(resp *http.Response) error { 55 | e := api.ErrorResponse{} 56 | if err := json.NewDecoder(resp.Body).Decode(&e); err != nil { 57 | return errors.New("could not decode error response") 58 | } 59 | return errors.New(e.Error) 60 | } 61 | 62 | func (c *Client) create(uri string, data interface{}) error { 63 | jsonData, err := json.Marshal(data) 64 | if err != nil { 65 | return errors.Wrap(err, "json marshal") 66 | } 67 | 68 | req, err := http.NewRequest("POST", uri, bytes.NewBuffer(jsonData)) 69 | if err != nil { 70 | return errors.Wrapf(err, "new POST request to %q", uri) 71 | } 72 | req.Header.Set("Content-Type", "application/json") 73 | 74 | resp, err := c.client.Do(req) 75 | if err != nil { 76 | return errors.Wrap(err, "http request") 77 | } 78 | defer resp.Body.Close() 79 | 80 | if resp.StatusCode != http.StatusCreated { 81 | return errors.Wrapf(apiError(resp), "POST status %d", resp.StatusCode) 82 | } 83 | return nil 84 | } 85 | 86 | func (c *Client) get(uri string, data interface{}) error { 87 | req, err := http.NewRequest("GET", uri, http.NoBody) 88 | if err != nil { 89 | return errors.Wrapf(err, "new GET request to %q", uri) 90 | } 91 | 92 | resp, err := c.client.Do(req) 93 | if err != nil { 94 | return errors.Wrap(err, "http request") 95 | } 96 | 97 | if resp.StatusCode != http.StatusOK { 98 | return errors.Wrapf(apiError(resp), "GET status %d", resp.StatusCode) 99 | } 100 | 101 | if err := json.NewDecoder(resp.Body).Decode(data); err != nil { 102 | return errors.Errorf("could not decode GET response: %v", err) 103 | } 104 | return nil 105 | } 106 | 107 | func (c *Client) delete(uri string) error { 108 | req, err := http.NewRequest("DELETE", uri, http.NoBody) 109 | if err != nil { 110 | return errors.Wrapf(err, "new DELETE request to %q", uri) 111 | } 112 | 113 | resp, err := c.client.Do(req) 114 | if err != nil { 115 | return errors.Wrap(err, "http request") 116 | } 117 | 118 | if resp.StatusCode != http.StatusOK { 119 | return errors.Wrapf(apiError(resp), "DELETE status %d", resp.StatusCode) 120 | } 121 | return nil 122 | } 123 | 124 | // CreatePublicKey creates a new public key. 125 | func (c *Client) CreatePublicKey(def *config.PublicKey) error { 126 | return c.create(c.uriFromPath("/api/keys"), def) 127 | } 128 | 129 | // GetPublicKey retrieves a public key. 130 | func (c *Client) GetPublicKey(name string) (*config.PublicKey, error) { 131 | data := config.PublicKey{} 132 | err := c.get(c.publicKeyURI(name), &data) 133 | return &data, err 134 | } 135 | 136 | // DeletePublicKey deletes a public key. 137 | func (c *Client) DeletePublicKey(name string) error { 138 | return c.delete(c.publicKeyURI(name)) 139 | } 140 | 141 | // CreateCluster creates a new cluster. 142 | func (c *Client) CreateCluster(def *config.Cluster) error { 143 | return c.create(c.uriFromPath("/api/clusters"), def) 144 | } 145 | 146 | // DeleteCluster deletes a cluster and all its associated machines. 147 | func (c *Client) DeleteCluster(name string) error { 148 | return c.delete(c.clusterURI(name)) 149 | } 150 | 151 | // CreateMachine creates a new machine. 152 | func (c *Client) CreateMachine(cluster string, def *config.Machine) error { 153 | return c.create(c.uriFromPath(fmt.Sprintf("/api/clusters/%s/machines", cluster)), def) 154 | } 155 | 156 | // GetMachine retrieves the machine details. 157 | // 158 | // XXX: This API isn't stable and will change in the future as we refine what 159 | // the machine spec and status objects should be. 160 | func (c *Client) GetMachine(clusterName, machine string) (*cluster.MachineStatus, error) { 161 | status := cluster.MachineStatus{} 162 | err := c.get(c.machineURI(clusterName, machine), &status) 163 | return &status, err 164 | } 165 | 166 | // DeleteMachine deletes a machine. 167 | func (c *Client) DeleteMachine(cluster, machine string) error { 168 | return c.delete(c.machineURI(cluster, machine)) 169 | } 170 | -------------------------------------------------------------------------------- /pkg/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/weaveworks/footloose/pkg/api" 10 | "github.com/weaveworks/footloose/pkg/cluster" 11 | "github.com/weaveworks/footloose/pkg/config" 12 | ) 13 | 14 | type env struct { 15 | server *httptest.Server 16 | client Client 17 | } 18 | 19 | func (e *env) Close() { 20 | e.server.Close() 21 | } 22 | 23 | func newEnv() *env { 24 | // Create an API server 25 | server := httptest.NewUnstartedServer(nil) 26 | baseURI := "http://" + server.Listener.Addr().String() 27 | keyStore := cluster.NewKeyStore(".") 28 | api := api.New(baseURI, keyStore, false) 29 | server.Config.Handler = api.Router() 30 | server.Start() 31 | 32 | u, _ := url.Parse(server.URL) 33 | 34 | return &env{ 35 | server: server, 36 | client: Client{ 37 | baseURI: u, 38 | client: server.Client(), 39 | }, 40 | } 41 | } 42 | 43 | const publicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDT3IG4sRIpLaoAtQSXBYaVLZTXh3Pl95ONm9oe9+nJ08qrUOFEJuKMTnqSgbC+R6v3T6fcgu1HgZtQyqB15rlA5U6rybKEa631+2Y+STBdCtBover2/c59QqfEyXWoPeq0EWRCt/ixVJdcTZqxNpZQUBoUQAIl1T/+lqEsefI4H/fFCeuqDyZfjWQXpoIh8fTpYleS6rmzvKTBhxg149LdmI96mo8Wzh2nSuXxxrk4ItvjUkNP/+s/I1xBZ6OKkO5a1Ngjuv4Yi0HM3SwZcIEP4P8QnFJtTUZjz7NyyPUthJy7QPIRMmimCg+yyRwkMhnbb6bNY6QIbQmrRw4rbGyd31eY/xXXLk6DLVGaoacVD5VuPjSEVjn9lzgaQoO1HJLYnAfgJB+3L/eKG5C8iE4gwnNbKMazLr2iVa6VdeACqyzTyx3uv/4TY2Q3Aqq+LPzOda0nbeaeIaq6xpA1iBsdNM/j88SOGJtYufUngVMql7nZGsxHt4oEw0OOGtshWcR27bKMJsuOkghnHJzs9o9uRBvBStZFLpEyA6TEIeNfTn6Mzdag/T+0NeisXUKSEvrMaxEVAnX7uvkMr5UNUeT/TDbVhAtFHm4YDFEnSupmMsAKiuiTA+XhBuY+FzsGTDGcVZRj6ERZl6u0A+Oo8p/h7TizP3ct7dXVD02dmfJGAQ== cluster@footloose.mail" 44 | 45 | func TestCreateDeletePublicKey(t *testing.T) { 46 | env := newEnv() 47 | defer env.Close() 48 | 49 | err := env.client.CreatePublicKey(&config.PublicKey{ 50 | Name: "testpublickey", 51 | Key: publicKey, 52 | }) 53 | assert.NoError(t, err) 54 | 55 | data, err := env.client.GetPublicKey("testpublickey") 56 | assert.Equal(t, "testpublickey", data.Name) 57 | assert.Equal(t, publicKey, data.Key) 58 | assert.NoError(t, err) 59 | 60 | err = env.client.DeletePublicKey("testpublickey") 61 | assert.NoError(t, err) 62 | } 63 | 64 | func TestCreateDeleteCluster(t *testing.T) { 65 | env := newEnv() 66 | defer env.Close() 67 | 68 | err := env.client.CreateCluster(&config.Cluster{ 69 | Name: "testcluster", 70 | PrivateKey: "testcluster-key", 71 | }) 72 | assert.NoError(t, err) 73 | 74 | err = env.client.DeleteCluster("testcluster") 75 | assert.NoError(t, err) 76 | } 77 | 78 | func TestCreateDeleteMachine(t *testing.T) { 79 | env := newEnv() 80 | defer env.Close() 81 | 82 | err := env.client.CreateCluster(&config.Cluster{ 83 | Name: "testcluster", 84 | PrivateKey: "testcluster-key", 85 | }) 86 | assert.NoError(t, err) 87 | 88 | err = env.client.CreateMachine("testcluster", &config.Machine{ 89 | Name: "testmachine", 90 | Image: "quay.io/footloose/centos7:latest", 91 | PortMappings: []config.PortMapping{ 92 | {ContainerPort: 22}, 93 | }, 94 | }) 95 | assert.NoError(t, err) 96 | 97 | status, err := env.client.GetMachine("testcluster", "testmachine") 98 | assert.NoError(t, err) 99 | assert.Equal(t, "testmachine", status.Spec.Name) 100 | 101 | err = env.client.DeleteMachine("testcluster", "testmachine") 102 | assert.NoError(t, err) 103 | 104 | err = env.client.DeleteCluster("testcluster") 105 | assert.NoError(t, err) 106 | } 107 | -------------------------------------------------------------------------------- /pkg/cluster/cluster.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/docker/docker/api/types" 15 | "github.com/ghodss/yaml" 16 | "github.com/mitchellh/go-homedir" 17 | "github.com/pkg/errors" 18 | log "github.com/sirupsen/logrus" 19 | "github.com/weaveworks/footloose/pkg/config" 20 | "github.com/weaveworks/footloose/pkg/docker" 21 | "github.com/weaveworks/footloose/pkg/exec" 22 | "github.com/weaveworks/footloose/pkg/ignite" 23 | ) 24 | 25 | // Container represents a running machine. 26 | type Container struct { 27 | ID string 28 | } 29 | 30 | // Cluster is a running cluster. 31 | type Cluster struct { 32 | spec config.Config 33 | keyStore *KeyStore 34 | } 35 | 36 | // New creates a new cluster. It takes as input the description of the cluster 37 | // and its machines. 38 | func New(conf config.Config) (*Cluster, error) { 39 | if err := conf.Validate(); err != nil { 40 | return nil, err 41 | } 42 | return &Cluster{ 43 | spec: conf, 44 | }, nil 45 | } 46 | 47 | // NewFromYAML creates a new Cluster from a YAML serialization of its 48 | // configuration available in the provided string. 49 | func NewFromYAML(data []byte) (*Cluster, error) { 50 | spec := config.Config{} 51 | if err := yaml.Unmarshal(data, &spec); err != nil { 52 | return nil, err 53 | } 54 | return New(spec) 55 | } 56 | 57 | // NewFromFile creates a new Cluster from a YAML serialization of its 58 | // configuration available in the provided file. 59 | func NewFromFile(path string) (*Cluster, error) { 60 | data, err := ioutil.ReadFile(path) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return NewFromYAML(data) 65 | } 66 | 67 | // SetKeyStore provides a store where to persist public keys for this Cluster. 68 | func (c *Cluster) SetKeyStore(keyStore *KeyStore) *Cluster { 69 | c.keyStore = keyStore 70 | return c 71 | } 72 | 73 | // Name returns the cluster name. 74 | func (c *Cluster) Name() string { 75 | return c.spec.Cluster.Name 76 | } 77 | 78 | // Save writes the Cluster configure to a file. 79 | func (c *Cluster) Save(path string) error { 80 | data, err := yaml.Marshal(c.spec) 81 | if err != nil { 82 | return err 83 | } 84 | return ioutil.WriteFile(path, data, 0666) 85 | } 86 | 87 | func f(format string, args ...interface{}) string { 88 | return fmt.Sprintf(format, args...) 89 | } 90 | 91 | func (c *Cluster) containerName(machine *config.Machine) string { 92 | return fmt.Sprintf("%s-%s", c.spec.Cluster.Name, machine.Name) 93 | } 94 | 95 | func (c *Cluster) containerNameWithIndex(machine *config.Machine, i int) string { 96 | format := "%s-" + machine.Name 97 | return f(format, c.spec.Cluster.Name, i) 98 | } 99 | 100 | // NewMachine creates a new Machine in the cluster. 101 | func (c *Cluster) NewMachine(spec *config.Machine) *Machine { 102 | return &Machine{ 103 | spec: spec, 104 | name: c.containerName(spec), 105 | hostname: spec.Name, 106 | } 107 | } 108 | 109 | func (c *Cluster) machine(spec *config.Machine, i int) *Machine { 110 | return &Machine{ 111 | spec: spec, 112 | name: c.containerNameWithIndex(spec, i), 113 | hostname: f(spec.Name, i), 114 | } 115 | } 116 | 117 | func (c *Cluster) forEachMachine(do func(*Machine, int) error) error { 118 | machineIndex := 0 119 | for _, template := range c.spec.Machines { 120 | for i := 0; i < template.Count; i++ { 121 | // machine name indexed with i 122 | machine := c.machine(&template.Spec, i) 123 | // but to prevent port collision, we use machineIndex for the real machine creation 124 | if err := do(machine, machineIndex); err != nil { 125 | return err 126 | } 127 | machineIndex++ 128 | } 129 | } 130 | return nil 131 | } 132 | 133 | func (c *Cluster) forSpecificMachines(do func(*Machine, int) error, machineNames []string) error { 134 | // machineToStart map is used to track machines to make actions and non existing machines 135 | machineToStart := make(map[string]bool) 136 | for _, machine := range machineNames { 137 | machineToStart[machine] = false 138 | } 139 | for _, template := range c.spec.Machines { 140 | for i := 0; i < template.Count; i++ { 141 | machine := c.machine(&template.Spec, i) 142 | _, ok := machineToStart[machine.name] 143 | if ok { 144 | if err := do(machine, i); err != nil { 145 | return err 146 | } 147 | machineToStart[machine.name] = true 148 | } 149 | } 150 | } 151 | // log warning for non existing machines 152 | for key, value := range machineToStart { 153 | if !value { 154 | log.Warnf("machine %v does not exist", key) 155 | } 156 | } 157 | return nil 158 | } 159 | 160 | func (c *Cluster) ensureSSHKey() error { 161 | if c.spec.Cluster.PrivateKey == "" { 162 | return nil 163 | } 164 | path, _ := homedir.Expand(c.spec.Cluster.PrivateKey) 165 | if _, err := os.Stat(path); err == nil { 166 | return nil 167 | } 168 | 169 | log.Infof("Creating SSH key: %s ...", path) 170 | return run( 171 | "ssh-keygen", "-q", 172 | "-t", "rsa", 173 | "-b", "4096", 174 | "-C", f("%s@footloose.mail", c.spec.Cluster.Name), 175 | "-f", path, 176 | "-N", "", 177 | ) 178 | } 179 | 180 | const initScript = ` 181 | set -e 182 | rm -f /run/nologin 183 | sshdir=/root/.ssh 184 | mkdir $sshdir; chmod 700 $sshdir 185 | touch $sshdir/authorized_keys; chmod 600 $sshdir/authorized_keys 186 | ` 187 | 188 | func (c *Cluster) publicKey(machine *Machine) ([]byte, error) { 189 | // Prefer the machine public key over the cluster-wide key. 190 | if machine.spec.PublicKey != "" && c.keyStore != nil { 191 | data, err := c.keyStore.Get(machine.spec.PublicKey) 192 | if err != nil { 193 | return nil, err 194 | } 195 | data = append(data, byte('\n')) 196 | return data, err 197 | } 198 | 199 | // Cluster global key 200 | if c.spec.Cluster.PrivateKey == "" { 201 | return nil, errors.New("no SSH key provided") 202 | } 203 | 204 | path, err := homedir.Expand(c.spec.Cluster.PrivateKey) 205 | if err != nil { 206 | return nil, errors.Wrap(err, "public key expand") 207 | } 208 | return ioutil.ReadFile(path + ".pub") 209 | } 210 | 211 | // CreateMachine creates and starts a new machine in the cluster. 212 | func (c *Cluster) CreateMachine(machine *Machine, i int) error { 213 | name := machine.ContainerName() 214 | 215 | publicKey, err := c.publicKey(machine) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | // Start the container. 221 | log.Infof("Creating machine: %s ...", name) 222 | 223 | if machine.IsCreated() { 224 | log.Infof("Machine %s is already created...", name) 225 | return nil 226 | } 227 | 228 | cmd := "/sbin/init" 229 | if machine.spec.Cmd != "" { 230 | cmd = machine.spec.Cmd 231 | } 232 | 233 | if machine.IsIgnite() { 234 | pubKeyPath := c.spec.Cluster.PrivateKey + ".pub" 235 | if !filepath.IsAbs(pubKeyPath) { 236 | wd, err := os.Getwd() 237 | if err != nil { 238 | return err 239 | } 240 | pubKeyPath = filepath.Join(wd, pubKeyPath) 241 | } 242 | 243 | if _, err := ignite.Create(machine.name, machine.spec, pubKeyPath); err != nil { 244 | return err 245 | } 246 | } else { 247 | runArgs := c.createMachineRunArgs(machine, name, i) 248 | _, err := docker.Create(machine.spec.Image, 249 | runArgs, 250 | []string{cmd}, 251 | ) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | if len(machine.spec.Networks) > 1 { 257 | for _, network := range machine.spec.Networks[1:] { 258 | log.Infof("Connecting %s to the %s network...", name, network) 259 | if network == "bridge" { 260 | if err := docker.ConnectNetwork(name, network); err != nil { 261 | return err 262 | } 263 | } else { 264 | if err := docker.ConnectNetworkWithAlias(name, network, machine.Hostname()); err != nil { 265 | return err 266 | } 267 | } 268 | } 269 | } 270 | 271 | if err := docker.Start(name); err != nil { 272 | return err 273 | } 274 | 275 | // Initial provisioning. 276 | if err := containerRunShell(name, initScript); err != nil { 277 | return err 278 | } 279 | if err := copy(name, publicKey, "/root/.ssh/authorized_keys"); err != nil { 280 | return err 281 | } 282 | } 283 | 284 | return nil 285 | } 286 | 287 | func (c *Cluster) createMachineRunArgs(machine *Machine, name string, i int) []string { 288 | runArgs := []string{ 289 | "-it", 290 | "--label", "works.weave.owner=footloose", 291 | "--label", "works.weave.cluster=" + c.spec.Cluster.Name, 292 | "--name", name, 293 | "--hostname", machine.Hostname(), 294 | "--tmpfs", "/run", 295 | "--tmpfs", "/run/lock", 296 | "--tmpfs", "/tmp:exec,mode=777", 297 | "-v", "/sys/fs/cgroup:/sys/fs/cgroup:ro", 298 | } 299 | 300 | for _, volume := range machine.spec.Volumes { 301 | mount := f("type=%s", volume.Type) 302 | if volume.Source != "" { 303 | mount += f(",src=%s", volume.Source) 304 | } 305 | mount += f(",dst=%s", volume.Destination) 306 | if volume.ReadOnly { 307 | mount += ",readonly" 308 | } 309 | runArgs = append(runArgs, "--mount", mount) 310 | } 311 | 312 | for _, mapping := range machine.spec.PortMappings { 313 | publish := "" 314 | if mapping.Address != "" { 315 | publish += f("%s:", mapping.Address) 316 | } 317 | if mapping.HostPort != 0 { 318 | publish += f("%d:", int(mapping.HostPort)+i) 319 | } 320 | publish += f("%d", mapping.ContainerPort) 321 | if mapping.Protocol != "" { 322 | publish += f("/%s", mapping.Protocol) 323 | } 324 | runArgs = append(runArgs, "-p", publish) 325 | } 326 | 327 | if machine.spec.Privileged { 328 | runArgs = append(runArgs, "--privileged") 329 | } 330 | 331 | if len(machine.spec.Networks) > 0 { 332 | network := machine.spec.Networks[0] 333 | log.Infof("Connecting %s to the %s network...", name, network) 334 | runArgs = append(runArgs, "--network", machine.spec.Networks[0]) 335 | if network != "bridge" { 336 | runArgs = append(runArgs, "--network-alias", machine.Hostname()) 337 | } 338 | } 339 | 340 | return runArgs 341 | } 342 | 343 | // Create creates the cluster. 344 | func (c *Cluster) Create() error { 345 | if err := c.ensureSSHKey(); err != nil { 346 | return err 347 | } 348 | if err := docker.IsRunning(); err != nil { 349 | return err 350 | } 351 | for _, template := range c.spec.Machines { 352 | if _, err := docker.PullIfNotPresent(template.Spec.Image, 2); err != nil { 353 | return err 354 | } 355 | } 356 | return c.forEachMachine(c.CreateMachine) 357 | } 358 | 359 | // DeleteMachine remove a Machine from the cluster. 360 | func (c *Cluster) DeleteMachine(machine *Machine, i int) error { 361 | name := machine.ContainerName() 362 | if !machine.IsCreated() { 363 | log.Infof("Machine %s hasn't been created...", name) 364 | return nil 365 | } 366 | 367 | if machine.IsIgnite() { 368 | log.Infof("Deleting machine: %s ...", name) 369 | return ignite.Remove(machine.name) 370 | } 371 | 372 | if machine.IsStarted() { 373 | log.Infof("Machine %s is started, stopping and deleting machine...", name) 374 | err := docker.Kill("KILL", name) 375 | if err != nil { 376 | return err 377 | } 378 | cmd := exec.Command( 379 | "docker", "rm", "--volumes", 380 | name, 381 | ) 382 | return cmd.Run() 383 | } 384 | log.Infof("Deleting machine: %s ...", name) 385 | cmd := exec.Command( 386 | "docker", "rm", "--volumes", 387 | name, 388 | ) 389 | return cmd.Run() 390 | } 391 | 392 | // Delete deletes the cluster. 393 | func (c *Cluster) Delete() error { 394 | if err := docker.IsRunning(); err != nil { 395 | return err 396 | } 397 | return c.forEachMachine(c.DeleteMachine) 398 | } 399 | 400 | // Inspect will generate information about running or stopped machines. 401 | func (c *Cluster) Inspect(hostnames []string) ([]*Machine, error) { 402 | if err := docker.IsRunning(); err != nil { 403 | return nil, err 404 | } 405 | machines, err := c.gatherMachines() 406 | if err != nil { 407 | return nil, err 408 | } 409 | if len(hostnames) > 0 { 410 | return c.machineFilering(machines, hostnames), nil 411 | } 412 | return machines, nil 413 | } 414 | 415 | func (c *Cluster) machineFilering(machines []*Machine, hostnames []string) []*Machine { 416 | // machinesToKeep map is used to know not found machines 417 | machinesToKeep := make(map[string]bool) 418 | for _, machine := range hostnames { 419 | machinesToKeep[machine] = false 420 | } 421 | // newMachines is the filtered list 422 | newMachines := make([]*Machine, 0) 423 | for _, m := range machines { 424 | if _, ok := machinesToKeep[m.hostname]; ok { 425 | machinesToKeep[m.hostname] = true 426 | newMachines = append(newMachines, m) 427 | } 428 | } 429 | for hostname, found := range machinesToKeep { 430 | if !found { 431 | log.Warnf("machine with hostname %s not found", hostname) 432 | } 433 | } 434 | return newMachines 435 | } 436 | 437 | func (c *Cluster) gatherMachines() (machines []*Machine, err error) { 438 | // Footloose has no machines running. Falling back to display 439 | // cluster related data. 440 | machines = c.gatherMachinesByCluster() 441 | for _, m := range machines { 442 | if !m.IsCreated() { 443 | continue 444 | } 445 | if m.IsIgnite() { 446 | continue 447 | } 448 | 449 | var inspect types.ContainerJSON 450 | if err := docker.InspectObject(m.name, ".", &inspect); err != nil { 451 | return machines, err 452 | } 453 | 454 | // Set Ports 455 | ports := make([]config.PortMapping, 0) 456 | for k, v := range inspect.NetworkSettings.Ports { 457 | if len(v) < 1 { 458 | continue 459 | } 460 | p := config.PortMapping{} 461 | hostPort, _ := strconv.Atoi(v[0].HostPort) 462 | p.HostPort = uint16(hostPort) 463 | p.ContainerPort = uint16(k.Int()) 464 | p.Address = v[0].HostIP 465 | ports = append(ports, p) 466 | } 467 | m.spec.PortMappings = ports 468 | // Volumes 469 | var volumes []config.Volume 470 | for _, mount := range inspect.Mounts { 471 | v := config.Volume{ 472 | Type: string(mount.Type), 473 | Source: mount.Source, 474 | Destination: mount.Destination, 475 | ReadOnly: mount.RW, 476 | } 477 | volumes = append(volumes, v) 478 | } 479 | m.spec.Volumes = volumes 480 | m.spec.Cmd = strings.Join(inspect.Config.Cmd, ",") 481 | m.ip = inspect.NetworkSettings.IPAddress 482 | m.runtimeNetworks = NewRuntimeNetworks(inspect.NetworkSettings.Networks) 483 | 484 | } 485 | return 486 | } 487 | 488 | func (c *Cluster) gatherMachinesByCluster() (machines []*Machine) { 489 | for _, template := range c.spec.Machines { 490 | for i := 0; i < template.Count; i++ { 491 | s := template.Spec 492 | machine := c.machine(&s, i) 493 | machines = append(machines, machine) 494 | } 495 | } 496 | return 497 | } 498 | 499 | func (c *Cluster) startMachine(machine *Machine, i int) error { 500 | name := machine.ContainerName() 501 | if !machine.IsCreated() { 502 | log.Infof("Machine %s hasn't been created...", name) 503 | return nil 504 | } 505 | if machine.IsStarted() { 506 | log.Infof("Machine %s is already started...", name) 507 | return nil 508 | } 509 | log.Infof("Starting machine: %s ...", name) 510 | 511 | if machine.IsIgnite() { 512 | return ignite.Start(name) 513 | } 514 | 515 | // Run command while sigs.k8s.io/kind/pkg/container/docker doesn't 516 | // have a start command 517 | cmd := exec.Command( 518 | "docker", "start", 519 | name, 520 | ) 521 | return cmd.Run() 522 | } 523 | 524 | // Start starts the machines in cluster. 525 | func (c *Cluster) Start(machineNames []string) error { 526 | if err := docker.IsRunning(); err != nil { 527 | return err 528 | } 529 | if len(machineNames) < 1 { 530 | return c.forEachMachine(c.startMachine) 531 | } 532 | return c.forSpecificMachines(c.startMachine, machineNames) 533 | } 534 | 535 | // StartMachines starts specific machines(s) in cluster 536 | func (c *Cluster) StartMachines(machineNames []string) error { 537 | return c.forSpecificMachines(c.startMachine, machineNames) 538 | } 539 | 540 | func (c *Cluster) stopMachine(machine *Machine, i int) error { 541 | name := machine.ContainerName() 542 | 543 | if !machine.IsCreated() { 544 | log.Infof("Machine %s hasn't been created...", name) 545 | return nil 546 | } 547 | if !machine.IsStarted() { 548 | log.Infof("Machine %s is already stopped...", name) 549 | return nil 550 | } 551 | log.Infof("Stopping machine: %s ...", name) 552 | 553 | // Run command while sigs.k8s.io/kind/pkg/container/docker doesn't 554 | // have a start command 555 | cmd := exec.Command( 556 | "docker", "stop", 557 | name, 558 | ) 559 | return cmd.Run() 560 | } 561 | 562 | // Stop stops the machines in cluster. 563 | func (c *Cluster) Stop(machineNames []string) error { 564 | if err := docker.IsRunning(); err != nil { 565 | return err 566 | } 567 | if len(machineNames) < 1 { 568 | return c.forEachMachine(c.stopMachine) 569 | } 570 | return c.forSpecificMachines(c.stopMachine, machineNames) 571 | } 572 | 573 | // io.Writer filter that writes that it receives to writer. Keeps track if it 574 | // has seen a write matching regexp. 575 | type matchFilter struct { 576 | writer io.Writer 577 | writeMatched bool // whether the filter should write the matched value or not. 578 | 579 | regexp *regexp.Regexp 580 | matched bool 581 | } 582 | 583 | func (f *matchFilter) Write(p []byte) (n int, err error) { 584 | // Assume the relevant log line is flushed in one write. 585 | if match := f.regexp.Match(p); match { 586 | f.matched = true 587 | if !f.writeMatched { 588 | return len(p), nil 589 | } 590 | } 591 | return f.writer.Write(p) 592 | } 593 | 594 | // Matches: 595 | // ssh_exchange_identification: read: Connection reset by peer 596 | var connectRefused = regexp.MustCompile("^ssh_exchange_identification: ") 597 | 598 | // Matches: 599 | // Warning:Permanently added '172.17.0.2' (ECDSA) to the list of known hosts 600 | var knownHosts = regexp.MustCompile("^Warning: Permanently added .* to the list of known hosts.") 601 | 602 | // ssh returns true if the command should be tried again. 603 | func ssh(args []string) (bool, error) { 604 | cmd := exec.Command("ssh", args...) 605 | 606 | refusedFilter := &matchFilter{ 607 | writer: os.Stderr, 608 | writeMatched: false, 609 | regexp: connectRefused, 610 | } 611 | 612 | errFilter := &matchFilter{ 613 | writer: refusedFilter, 614 | writeMatched: false, 615 | regexp: knownHosts, 616 | } 617 | 618 | cmd.SetStdin(os.Stdin) 619 | cmd.SetStdout(os.Stdout) 620 | cmd.SetStderr(errFilter) 621 | 622 | err := cmd.Run() 623 | if err != nil && refusedFilter.matched { 624 | return true, err 625 | } 626 | return false, err 627 | } 628 | 629 | func (c *Cluster) machineFromHostname(hostname string) (*Machine, error) { 630 | for _, template := range c.spec.Machines { 631 | for i := 0; i < template.Count; i++ { 632 | if hostname == f(template.Spec.Name, i) { 633 | return c.machine(&template.Spec, i), nil 634 | } 635 | } 636 | } 637 | return nil, fmt.Errorf("%s: invalid machine hostname", hostname) 638 | } 639 | 640 | func mappingFromPort(spec *config.Machine, containerPort int) (*config.PortMapping, error) { 641 | for i := range spec.PortMappings { 642 | if int(spec.PortMappings[i].ContainerPort) == containerPort { 643 | return &spec.PortMappings[i], nil 644 | } 645 | } 646 | return nil, fmt.Errorf("unknown containerPort %d", containerPort) 647 | } 648 | 649 | // SSH logs into the name machine with SSH. 650 | func (c *Cluster) SSH(nodename string, username string, remoteArgs ...string) error { 651 | machine, err := c.machineFromHostname(nodename) 652 | if err != nil { 653 | return err 654 | } 655 | 656 | hostPort, err := machine.HostPort(22) 657 | if err != nil { 658 | return err 659 | } 660 | mapping, err := mappingFromPort(machine.spec, 22) 661 | if err != nil { 662 | return err 663 | } 664 | remote := "localhost" 665 | if mapping.Address != "" { 666 | remote = mapping.Address 667 | } 668 | path, _ := homedir.Expand(c.spec.Cluster.PrivateKey) 669 | args := []string{ 670 | "-o", "UserKnownHostsFile=/dev/null", 671 | "-o", "StrictHostKeyChecking=no", 672 | "-o", "IdentitiesOnly=yes", 673 | "-i", path, 674 | "-p", f("%d", hostPort), 675 | "-l", username, 676 | remote, 677 | } 678 | args = append(args, remoteArgs...) 679 | // If we ssh in a bit too quickly after the container creation, ssh errors out 680 | // with: 681 | // ssh_exchange_identification: read: Connection reset by peer 682 | // Let's loop a few times if we receive this message. 683 | retries := 25 684 | var retry bool 685 | for retries > 0 { 686 | retry, err = ssh(args) 687 | if !retry { 688 | break 689 | } 690 | retries-- 691 | time.Sleep(200 * time.Millisecond) 692 | } 693 | 694 | return err 695 | } 696 | -------------------------------------------------------------------------------- /pkg/cluster/cluster_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMatchFilter(t *testing.T) { 11 | const refused = "ssh: connect to host 172.17.0.2 port 22: Connection refused" 12 | 13 | filter := matchFilter{ 14 | writer: ioutil.Discard, 15 | regexp: connectRefused, 16 | } 17 | 18 | _, err := filter.Write([]byte("foo\n")) 19 | assert.NoError(t, err) 20 | assert.Equal(t, false, filter.matched) 21 | 22 | _, err = filter.Write([]byte(refused)) 23 | assert.NoError(t, err) 24 | assert.Equal(t, false, filter.matched) 25 | } 26 | 27 | func TestNewClusterWithHostPort(t *testing.T) { 28 | cluster, err := NewFromYAML([]byte(`cluster: 29 | name: cluster 30 | privateKey: cluster-key 31 | machines: 32 | - count: 2 33 | spec: 34 | image: quay.io/footloose/centos7 35 | name: node%d 36 | portMappings: 37 | - containerPort: 22 38 | hostPort: 2222 39 | `)) 40 | assert.NoError(t, err) 41 | assert.NotNil(t, cluster) 42 | assert.Equal(t, 1, len(cluster.spec.Machines)) 43 | template := cluster.spec.Machines[0] 44 | assert.Equal(t, 2, template.Count) 45 | assert.Equal(t, 1, len(template.Spec.PortMappings)) 46 | portMapping := template.Spec.PortMappings[0] 47 | assert.Equal(t, uint16(22), portMapping.ContainerPort) 48 | assert.Equal(t, uint16(2222), portMapping.HostPort) 49 | 50 | machine0 := cluster.machine(&template.Spec, 0) 51 | args0 := cluster.createMachineRunArgs(machine0, machine0.ContainerName(), 0) 52 | i := indexOf("-p", args0) 53 | assert.NotEqual(t, -1, i) 54 | assert.Equal(t, "2222:22", args0[i+1]) 55 | 56 | machine1 := cluster.machine(&template.Spec, 1) 57 | args1 := cluster.createMachineRunArgs(machine1, machine1.ContainerName(), 1) 58 | i = indexOf("-p", args1) 59 | assert.NotEqual(t, -1, i) 60 | assert.Equal(t, "2223:22", args1[i+1]) 61 | } 62 | 63 | func indexOf(element string, array []string) int { 64 | for k, v := range array { 65 | if element == v { 66 | return k 67 | } 68 | } 69 | return -1 // element not found. 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cluster/formatter.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | "github.com/weaveworks/footloose/pkg/config" 11 | ) 12 | 13 | // Formatter formats a slice of machines and outputs the result 14 | // in a given format. 15 | type Formatter interface { 16 | Format(io.Writer, []*Machine) error 17 | } 18 | 19 | // JSONFormatter formats a slice of machines into a JSON and 20 | // outputs it to stdout. 21 | type JSONFormatter struct{} 22 | 23 | // TableFormatter formats a slice of machines into a colored 24 | // table like output and prints that to stdout. 25 | type TableFormatter struct{} 26 | 27 | type port struct { 28 | Guest int `json:"guest"` 29 | Host int `json:"host"` 30 | } 31 | 32 | const ( 33 | // NotCreated status of a machine 34 | NotCreated = "Not created" 35 | // Stopped status of a machine 36 | Stopped = "Stopped" 37 | // Running status of a machine 38 | Running = "Running" 39 | ) 40 | 41 | // MachineStatus is the runtime status of a Machine. 42 | type MachineStatus struct { 43 | Container string `json:"container"` 44 | State string `json:"state"` 45 | Spec *config.Machine `json:"spec,omitempty"` 46 | Ports []port `json:"ports"` 47 | Hostname string `json:"hostname"` 48 | Image string `json:"image"` 49 | Command string `json:"cmd"` 50 | IP string `json:"ip"` 51 | RuntimeNetworks []*RuntimeNetwork `json:"runtimeNetworks,omitempty"` 52 | } 53 | 54 | // Format will output to stdout in JSON format. 55 | func (JSONFormatter) Format(w io.Writer, machines []*Machine) error { 56 | var statuses []MachineStatus 57 | for _, m := range machines { 58 | statuses = append(statuses, *m.Status()) 59 | } 60 | 61 | m := struct { 62 | Machines []MachineStatus `json:"machines"` 63 | }{ 64 | Machines: statuses, 65 | } 66 | ms, err := json.MarshalIndent(m, "", " ") 67 | if err != nil { 68 | return err 69 | } 70 | ms = append(ms, '\n') 71 | _, err = w.Write(ms) 72 | return err 73 | } 74 | 75 | // FormatSingle is a json formatter for a single machine. 76 | func (JSONFormatter) FormatSingle(w io.Writer, m *Machine) error { 77 | status, err := json.MarshalIndent(m.Status(), "", " ") 78 | if err != nil { 79 | return err 80 | } 81 | _, err = w.Write(status) 82 | return err 83 | } 84 | 85 | // writer contains writeColumns' error value to clean-up some error handling 86 | type writer struct { 87 | err error 88 | } 89 | 90 | // writerColumns is a no-op if there was an error already 91 | func (wr writer) writeColumns(w io.Writer, cols []string) { 92 | if wr.err != nil { 93 | return 94 | } 95 | _, err := fmt.Fprintln(w, strings.Join(cols, "\t")) 96 | wr.err = err 97 | } 98 | 99 | // Format will output to stdout in table format. 100 | func (TableFormatter) Format(w io.Writer, machines []*Machine) error { 101 | const padding = 3 102 | wr := new(writer) 103 | var statuses []MachineStatus 104 | for _, m := range machines { 105 | statuses = append(statuses, *m.Status()) 106 | } 107 | 108 | table := tabwriter.NewWriter(w, 0, 0, padding, ' ', 0) 109 | wr.writeColumns(table, []string{"NAME", "HOSTNAME", "PORTS", "IP", "IMAGE", "CMD", "STATE", "BACKEND"}) 110 | // we bail early here if there was an error so we don't process the below loop 111 | if wr.err != nil { 112 | return wr.err 113 | } 114 | for _, s := range statuses { 115 | var ports []string 116 | for k, v := range s.Ports { 117 | p := fmt.Sprintf("%d->%d", k, v) 118 | ports = append(ports, p) 119 | } 120 | if len(ports) < 1 { 121 | for _, p := range s.Spec.PortMappings { 122 | port := fmt.Sprintf("%d->%d", p.HostPort, p.ContainerPort) 123 | ports = append(ports, port) 124 | } 125 | } 126 | ps := strings.Join(ports, ",") 127 | wr.writeColumns(table, []string{s.Container, s.Hostname, ps, s.IP, s.Image, s.Command, s.State, s.Spec.Backend}) 128 | } 129 | 130 | if wr.err != nil { 131 | return wr.err 132 | } 133 | return table.Flush() 134 | } 135 | -------------------------------------------------------------------------------- /pkg/cluster/key_store.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // KeyStore is a store for public keys. 12 | type KeyStore struct { 13 | basePath string 14 | } 15 | 16 | // NewKeyStore creates a new KeyStore 17 | func NewKeyStore(basePath string) *KeyStore { 18 | return &KeyStore{ 19 | basePath: basePath, 20 | } 21 | } 22 | 23 | // Init initializes the key store, creating the store directory if needed. 24 | func (s *KeyStore) Init() error { 25 | return os.MkdirAll(s.basePath, 0760) 26 | } 27 | 28 | func fileExists(path string) bool { 29 | // XXX: There's a subtle bug: if stat fails for another reason that the file 30 | // not existing, we return the file exists. 31 | _, err := os.Stat(path) 32 | return !os.IsNotExist(err) 33 | } 34 | 35 | func (s *KeyStore) keyPath(name string) string { 36 | return filepath.Join(s.basePath, name) 37 | } 38 | 39 | func (s *KeyStore) keyExists(name string) bool { 40 | return fileExists(s.keyPath(name)) 41 | } 42 | 43 | // Store adds the key to the store. 44 | func (s *KeyStore) Store(name, key string) error { 45 | if s.keyExists(name) { 46 | return errors.Errorf("key store: store: key '%s' already exists", name) 47 | } 48 | 49 | if err := ioutil.WriteFile(s.keyPath(name), []byte(key), 0644); err != nil { 50 | return errors.Wrap(err, "key store: write") 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // Get retrieves a key from the store. 57 | func (s *KeyStore) Get(name string) ([]byte, error) { 58 | if !s.keyExists(name) { 59 | return nil, errors.Errorf("key store: get: unknown key '%s'", name) 60 | } 61 | return ioutil.ReadFile(s.keyPath(name)) 62 | } 63 | 64 | // Remove removes a key from the store. 65 | func (s *KeyStore) Remove(name string) error { 66 | if !s.keyExists(name) { 67 | return errors.Errorf("key store: remove: unknown key '%s'", name) 68 | } 69 | if err := os.Remove(s.keyPath(name)); err != nil { 70 | return errors.Wrap(err, "key store: remove") 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/cluster/machine.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "syscall" 8 | 9 | "github.com/docker/docker/api/types/network" 10 | "github.com/pkg/errors" 11 | log "github.com/sirupsen/logrus" 12 | "github.com/weaveworks/footloose/pkg/config" 13 | "github.com/weaveworks/footloose/pkg/docker" 14 | "github.com/weaveworks/footloose/pkg/exec" 15 | "github.com/weaveworks/footloose/pkg/ignite" 16 | ) 17 | 18 | // Machine is a single machine. 19 | type Machine struct { 20 | spec *config.Machine 21 | 22 | // container name. 23 | name string 24 | // container hostname. 25 | hostname string 26 | // container ip. 27 | ip string 28 | 29 | runtimeNetworks []*RuntimeNetwork 30 | // Fields that are cached from the docker daemon. 31 | 32 | ports map[int]int 33 | // maps containerPort -> hostPort. 34 | } 35 | 36 | // ContainerName is the name of the running container corresponding to this 37 | // Machine. 38 | func (m *Machine) ContainerName() string { 39 | if m.IsIgnite() { 40 | filter := fmt.Sprintf(`label=ignite.name=%s`, m.name) 41 | cid, err := exec.ExecuteCommand("docker", "ps", "-q", "-f", filter) 42 | if err != nil || len(cid) == 0 { 43 | return m.name 44 | } 45 | return cid 46 | } 47 | return m.name 48 | } 49 | 50 | // Hostname is the machine hostname. 51 | func (m *Machine) Hostname() string { 52 | return m.hostname 53 | } 54 | 55 | // IsCreated returns if a machine is has been created. A created machine could 56 | // either be running or stopped. 57 | func (m *Machine) IsCreated() bool { 58 | if m.IsIgnite() { 59 | return ignite.IsCreated(m.name) 60 | } 61 | 62 | res, _ := docker.Inspect(m.name, "{{.Name}}") 63 | if len(res) > 0 && len(res[0]) > 0 { 64 | return true 65 | } 66 | return false 67 | } 68 | 69 | // IsStarted returns if a machine is currently started or not. 70 | func (m *Machine) IsStarted() bool { 71 | if m.IsIgnite() { 72 | return ignite.IsStarted(m.name) 73 | } 74 | 75 | res, _ := docker.Inspect(m.name, "{{.State.Running}}") 76 | parsed, _ := strconv.ParseBool(strings.Trim(res[0], `'`)) 77 | return parsed 78 | } 79 | 80 | // HostPort returns the host port corresponding to the given container port. 81 | func (m *Machine) HostPort(containerPort int) (int, error) { 82 | // Use the cached version first 83 | if hostPort, ok := m.ports[containerPort]; ok { 84 | return hostPort, nil 85 | } 86 | 87 | var hostPort int 88 | 89 | // Handle Ignite VMs 90 | if m.IsIgnite() { 91 | // Retrieve the machine details 92 | vm, err := ignite.PopulateMachineDetails(m.name) 93 | if err != nil { 94 | return -1, errors.Wrap(err, "failed to populate VM details") 95 | } 96 | 97 | // Find the host port for the given VM port 98 | var found = false 99 | for _, p := range vm.Spec.Network.Ports { 100 | if int(p.VMPort) == containerPort { 101 | hostPort = int(p.HostPort) 102 | found = true 103 | break 104 | } 105 | } 106 | 107 | if !found { 108 | return -1, fmt.Errorf("invalid VM port queried: %d", containerPort) 109 | } 110 | } else { 111 | // retrieve the specific port mapping using docker inspect 112 | lines, err := docker.Inspect(m.ContainerName(), fmt.Sprintf("{{(index (index .NetworkSettings.Ports \"%d/tcp\") 0).HostPort}}", containerPort)) 113 | if err != nil { 114 | return -1, errors.Wrapf(err, "hostport: failed to inspect container: %v", lines) 115 | } 116 | if len(lines) != 1 { 117 | return -1, errors.Errorf("hostport: should only be one line, got %d lines", len(lines)) 118 | } 119 | 120 | port := strings.Replace(lines[0], "'", "", -1) 121 | if hostPort, err = strconv.Atoi(port); err != nil { 122 | return -1, errors.Wrap(err, "hostport: failed to parse string to int") 123 | } 124 | } 125 | 126 | if m.ports == nil { 127 | m.ports = make(map[int]int) 128 | } 129 | 130 | // Cache the result 131 | m.ports[containerPort] = hostPort 132 | return hostPort, nil 133 | } 134 | 135 | func (m *Machine) networks() ([]*RuntimeNetwork, error) { 136 | if len(m.runtimeNetworks) != 0 { 137 | return m.runtimeNetworks, nil 138 | } 139 | 140 | var networks map[string]*network.EndpointSettings 141 | if err := docker.InspectObject(m.name, ".NetworkSettings.Networks", &networks); err != nil { 142 | return nil, err 143 | } 144 | m.runtimeNetworks = NewRuntimeNetworks(networks) 145 | return m.runtimeNetworks, nil 146 | } 147 | 148 | func (m *Machine) igniteStatus(s *MachineStatus) error { 149 | vm, err := ignite.PopulateMachineDetails(m.name) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | // Set Ports 155 | var ports []port 156 | for _, p := range vm.Spec.Network.Ports { 157 | ports = append(ports, port{ 158 | Host: int(p.HostPort), 159 | Guest: int(p.VMPort), 160 | }) 161 | } 162 | s.Ports = ports 163 | if vm.Status.IpAddresses != nil && len(vm.Status.IpAddresses) > 0 { 164 | m.ip = vm.Status.IpAddresses[0] 165 | } 166 | 167 | s.RuntimeNetworks = NewIgniteRuntimeNetwork(&vm.Status) 168 | 169 | return nil 170 | } 171 | 172 | func (m *Machine) dockerStatus(s *MachineStatus) error { 173 | var ports []port 174 | if m.IsCreated() { 175 | for _, v := range m.spec.PortMappings { 176 | hPort, err := m.HostPort(int(v.ContainerPort)) 177 | if err != nil { 178 | hPort = 0 179 | } 180 | p := port{ 181 | Host: hPort, 182 | Guest: int(v.ContainerPort), 183 | } 184 | ports = append(ports, p) 185 | } 186 | } 187 | if len(ports) < 1 { 188 | for _, p := range m.spec.PortMappings { 189 | ports = append(ports, port{Host: 0, Guest: int(p.ContainerPort)}) 190 | } 191 | } 192 | s.Ports = ports 193 | 194 | s.RuntimeNetworks, _ = m.networks() 195 | 196 | return nil 197 | } 198 | 199 | // Status returns the machine status. 200 | func (m *Machine) Status() *MachineStatus { 201 | s := MachineStatus{} 202 | s.Container = m.ContainerName() 203 | s.Image = m.spec.Image 204 | s.Command = m.spec.Cmd 205 | s.Spec = m.spec 206 | s.Hostname = m.Hostname() 207 | s.IP = m.ip 208 | state := NotCreated 209 | 210 | if m.IsCreated() { 211 | state = Stopped 212 | if m.IsStarted() { 213 | state = Running 214 | } 215 | } 216 | s.State = state 217 | 218 | if m.IsIgnite() { 219 | _ = m.igniteStatus(&s) 220 | } else { 221 | _ = m.dockerStatus(&s) 222 | } 223 | 224 | return &s 225 | } 226 | 227 | // Only check for Ignite prerequisites once 228 | var igniteChecked bool 229 | 230 | // IsIgnite returns if the backend is Ignite 231 | func (m *Machine) IsIgnite() (b bool) { 232 | b = m.spec.Backend == ignite.BackendName 233 | 234 | if !igniteChecked && b { 235 | if syscall.Getuid() != 0 { 236 | log.Fatalf("Footloose needs to run as root to use the %q backend", ignite.BackendName) 237 | } 238 | 239 | ignite.CheckVersion() 240 | igniteChecked = true 241 | } 242 | 243 | return 244 | } 245 | -------------------------------------------------------------------------------- /pkg/cluster/run.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/weaveworks/footloose/pkg/docker" 10 | "github.com/weaveworks/footloose/pkg/exec" 11 | ) 12 | 13 | // run runs a command. It will output the combined stdout/error on failure. 14 | func run(name string, args ...string) error { 15 | cmd := exec.Command(name, args...) 16 | output, err := exec.CombinedOutputLines(cmd) 17 | if err != nil { 18 | // log error output if there was any 19 | for _, line := range output { 20 | log.Error(line) 21 | } 22 | } 23 | return err 24 | } 25 | 26 | // Run a command in a container. It will output the combined stdout/error on failure. 27 | func containerRun(nameOrID string, name string, args ...string) error { 28 | exe := docker.ContainerCmder(nameOrID) 29 | cmd := exe.Command(name, args...) 30 | output, err := exec.CombinedOutputLines(cmd) 31 | if err != nil { 32 | // log error output if there was any 33 | for _, line := range output { 34 | log.WithField("machine", nameOrID).Error(line) 35 | } 36 | } 37 | return err 38 | } 39 | 40 | func containerRunShell(nameOrID string, script string) error { 41 | return containerRun(nameOrID, "/bin/bash", "-c", script) 42 | } 43 | 44 | func copy(nameOrID string, content []byte, path string) error { 45 | buf := bytes.Buffer{} 46 | buf.WriteString(fmt.Sprintf("cat <<__EOF | tee -a %s\n", path)) 47 | buf.Write(content) 48 | buf.WriteString("__EOF") 49 | return containerRunShell(nameOrID, buf.String()) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/cluster/runtime_network.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/docker/docker/api/types/network" 7 | "github.com/weaveworks/footloose/pkg/ignite" 8 | ) 9 | 10 | const ( 11 | ipv4Length = 32 12 | ) 13 | 14 | // NewRuntimeNetworks returns a slice of networks 15 | func NewRuntimeNetworks(networks map[string]*network.EndpointSettings) []*RuntimeNetwork { 16 | rnList := make([]*RuntimeNetwork, 0, len(networks)) 17 | for key, value := range networks { 18 | mask := net.CIDRMask(value.IPPrefixLen, ipv4Length) 19 | maskIP := net.IP(mask).String() 20 | rnNetwork := &RuntimeNetwork{ 21 | Name: key, 22 | IP: value.IPAddress, 23 | Mask: maskIP, 24 | Gateway: value.Gateway, 25 | } 26 | rnList = append(rnList, rnNetwork) 27 | } 28 | return rnList 29 | } 30 | 31 | // NewIgniteRuntimeNetwork creates reports network status for the ignite backend. 32 | func NewIgniteRuntimeNetwork(status *ignite.Status) []*RuntimeNetwork { 33 | networks := make([]*RuntimeNetwork, 0, len(status.IpAddresses)) 34 | for _, ip := range status.IpAddresses { 35 | networks = append(networks, &RuntimeNetwork{ 36 | IP: ip, 37 | }) 38 | } 39 | 40 | return networks 41 | } 42 | 43 | // RuntimeNetwork contains information about the network 44 | type RuntimeNetwork struct { 45 | // Name of the network 46 | Name string `json:"name,omitempty"` 47 | // IP of the container 48 | IP string `json:"ip,omitempty"` 49 | // Mask of the network 50 | Mask string `json:"mask,omitempty"` 51 | // Gateway of the network 52 | Gateway string `json:"gateway,omitempty"` 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cluster/runtime_network_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/network" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewRuntimeNetworks(t *testing.T) { 11 | t.Run("Success", func(t *testing.T) { 12 | networks := map[string]*network.EndpointSettings{} 13 | networks["mynetwork"] = &network.EndpointSettings{ 14 | Gateway: "172.17.0.1", 15 | IPAddress: "172.17.0.4", 16 | IPPrefixLen: 16, 17 | } 18 | res := NewRuntimeNetworks(networks) 19 | 20 | expectedRuntimeNetworks := []*RuntimeNetwork{ 21 | &RuntimeNetwork{Name: "mynetwork", Gateway: "172.17.0.1", IP: "172.17.0.4", Mask: "255.255.0.0"}} 22 | assert.Equal(t, expectedRuntimeNetworks, res) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/config/cluster.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "gopkg.in/yaml.v2" 9 | ) 10 | 11 | func NewConfigFromYAML(data []byte) (*Config, error) { 12 | spec := Config{} 13 | if err := yaml.Unmarshal(data, &spec); err != nil { 14 | return nil, err 15 | } 16 | return &spec, nil 17 | } 18 | 19 | func NewConfigFromFile(path string) (*Config, error) { 20 | data, err := ioutil.ReadFile(path) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return NewConfigFromYAML(data) 25 | } 26 | 27 | // MachineReplicas are a number of machine following the same specification. 28 | type MachineReplicas struct { 29 | Spec Machine `json:"spec"` 30 | Count int `json:"count"` 31 | } 32 | 33 | // Cluster is a set of Machines. 34 | type Cluster struct { 35 | // Name is the cluster name. Defaults to "cluster". 36 | Name string `json:"name"` 37 | 38 | // PrivateKey is the path to the private SSH key used to login into the cluster 39 | // machines. Can be expanded to user homedir if ~ is found. Ex. ~/.ssh/id_rsa. 40 | // 41 | // This field is optional. If absent, machines are expected to have a public 42 | // key defined. 43 | PrivateKey string `json:"privateKey,omitempty"` 44 | } 45 | 46 | // Config is the top level config object. 47 | type Config struct { 48 | // Cluster describes cluster-wide configuration. 49 | Cluster Cluster `json:"cluster"` 50 | // Machines describe the machines we want created for this cluster. 51 | Machines []MachineReplicas `json:"machines"` 52 | } 53 | 54 | // validate checks basic rules for MachineReplicas's fields 55 | func (conf MachineReplicas) validate() error { 56 | return conf.Spec.validate() 57 | } 58 | 59 | // Validate checks basic rules for Config's fields 60 | func (conf Config) Validate() error { 61 | valid := true 62 | for _, machine := range conf.Machines { 63 | err := machine.validate() 64 | if err != nil { 65 | valid = false 66 | log.Fatalf(err.Error()) 67 | } 68 | } 69 | if !valid { 70 | return fmt.Errorf("Configuration file non valid") 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/config/get.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func pathSplit(r rune) bool { 11 | return r == '.' || r == '[' || r == ']' || r == '"' 12 | } 13 | 14 | // GetValueFromConfig returns specific value from object given a string path 15 | func GetValueFromConfig(stringPath string, object interface{}) (interface{}, error) { 16 | keyPath := strings.FieldsFunc(stringPath, pathSplit) 17 | v := reflect.ValueOf(object) 18 | for _, key := range keyPath { 19 | keyUpper := strings.Title(key) 20 | for v.Kind() == reflect.Ptr { 21 | v = v.Elem() 22 | } 23 | if v.Kind() == reflect.Struct { 24 | v = v.FieldByName(keyUpper) 25 | if !v.IsValid() { 26 | return nil, fmt.Errorf("%v key does not exist", keyUpper) 27 | } 28 | } else if v.Kind() == reflect.Slice { 29 | index, errConv := strconv.Atoi(keyUpper) 30 | if errConv != nil { 31 | return nil, fmt.Errorf("%v is not an index", key) 32 | } 33 | v = v.Index(index) 34 | } else { 35 | return nil, fmt.Errorf("%v is neither a slice or a struct", v) 36 | } 37 | } 38 | return v.Interface(), nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/config/get_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetValueFromConfig(t *testing.T) { 10 | config := Config{ 11 | Cluster: Cluster{Name: "clustername", PrivateKey: "privatekey"}, 12 | Machines: []MachineReplicas{ 13 | MachineReplicas{ 14 | Count: 3, 15 | Spec: Machine{ 16 | Image: "myImage", 17 | Name: "myName", 18 | Privileged: true, 19 | }, 20 | }, 21 | }, 22 | } 23 | 24 | tests := []struct { 25 | name string 26 | stringPath string 27 | config Config 28 | expectedOutput interface{} 29 | }{ 30 | { 31 | "simple path select string", 32 | "cluster.name", 33 | Config{ 34 | Cluster: Cluster{Name: "clustername", PrivateKey: "privatekey"}, 35 | Machines: []MachineReplicas{MachineReplicas{Count: 3, Spec: Machine{}}}, 36 | }, 37 | "clustername", 38 | }, 39 | { 40 | "array path select global", 41 | "machines[0].spec", 42 | config, 43 | Machine{ 44 | Image: "myImage", 45 | Name: "myName", 46 | Privileged: true, 47 | }, 48 | }, 49 | { 50 | "array path select bool", 51 | "machines[0].spec.Privileged", 52 | config, 53 | true, 54 | }, 55 | } 56 | 57 | for _, utest := range tests { 58 | t.Run(utest.name, func(t *testing.T) { 59 | res, err := GetValueFromConfig(utest.stringPath, utest.config) 60 | assert.Nil(t, err) 61 | assert.Equal(t, utest.expectedOutput, res) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/config/key.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // PublicKey is a public SSH key. 4 | type PublicKey struct { 5 | Name string `json:"name"` 6 | // Key is the public key textual representation. Begins with Begins with 7 | // 'ssh-rsa', 'ssh-dss', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 8 | // 'ecdsa-sha2-nistp384', or 'ecdsa-sha2-nistp521'. 9 | Key string `json:"key"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/config/machine.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Volume is a volume that can be attached to a Machine. 11 | type Volume struct { 12 | // Type is the volume type. One of "bind" or "volume". 13 | Type string `json:"type"` 14 | // Source is the volume source. 15 | // With type=bind, the volume source is a directory or a file in the host 16 | // filesystem. 17 | // With type=volume, source is either the name of a docker volume or "" for 18 | // anonymous volumes. 19 | Source string `json:"source"` 20 | // Destination is the mount point inside the container. 21 | Destination string `json:"destination"` 22 | // ReadOnly specifies if the volume should be read-only or not. 23 | ReadOnly bool `json:"readOnly"` 24 | } 25 | 26 | // PortMapping describes mapping a port from the machine onto the host. 27 | type PortMapping struct { 28 | // Protocol is the layer 4 protocol for this mapping. One of "tcp" or "udp". 29 | // Defaults to "tcp". 30 | Protocol string `json:"protocol,omitempty"` 31 | // Address is the host address to bind to. Defaults to "0.0.0.0". 32 | Address string `json:"address,omitempty"` 33 | // HostPort is the base host port to map the containers ports to. As we 34 | // configure a number of machine replicas, each machine will use HostPort+i 35 | // where i is between 0 and N-1, N being the number of machine replicas. If 0, 36 | // a local port will be automatically allocated. 37 | HostPort uint16 `json:"hostPort,omitempty"` 38 | // ContainerPort is the container port to map. 39 | ContainerPort uint16 `json:"containerPort"` 40 | } 41 | 42 | // Machine is the machine configuration. 43 | type Machine struct { 44 | // Name is the machine name. 45 | // 46 | // When used in a MachineReplicas object, eg. in footloose.yaml config files, 47 | // this field a format string. This format string needs to have a '%d', which 48 | // is populated by the machine index, a number between 0 and N-1, N being the 49 | // Count field of MachineReplicas. Name will default to "node%d" 50 | // 51 | // This name will also be used as the machine hostname. 52 | Name string `json:"name"` 53 | // Image is the container image to use for this machine. 54 | Image string `json:"image"` 55 | // Privileged controls whether to start the Machine as a privileged container 56 | // or not. Defaults to false. 57 | Privileged bool `json:"privileged,omitempty"` 58 | // Volumes is the list of volumes attached to this machine. 59 | Volumes []Volume `json:"volumes,omitempty"` 60 | // Networks is the list of user-defined docker networks this machine is 61 | // attached to. These networks have to be created manually before creating the 62 | // containers via "docker network create mynetwork" 63 | Networks []string `json:"networks,omitempty"` 64 | // PortMappings is the list of ports to expose to the host. 65 | PortMappings []PortMapping `json:"portMappings,omitempty"` 66 | // Cmd is a cmd which will be run in the container. 67 | Cmd string `json:"cmd,omitempty"` 68 | // PublicKey is the name of the public key to upload onto the machine for root 69 | // SSH access. 70 | PublicKey string `json:"publicKey,omitempty"` 71 | 72 | // Backend specifies the runtime backend for this machine 73 | Backend string `json:"backend,omitempty"` 74 | // Ignite specifies ignite-specific options 75 | Ignite *Ignite `json:"ignite,omitempty"` 76 | } 77 | 78 | func (m *Machine) IgniteConfig() Ignite { 79 | i := Ignite{} 80 | if m.Ignite != nil { 81 | i = *m.Ignite 82 | } 83 | if i.CPUs == 0 { 84 | i.CPUs = 2 85 | } 86 | if len(i.Memory) == 0 { 87 | i.Memory = "1GB" 88 | } 89 | if len(i.DiskSize) == 0 { 90 | i.DiskSize = "4GB" 91 | } 92 | if len(i.Kernel) == 0 { 93 | i.Kernel = "weaveworks/ignite-kernel:4.19.47" 94 | } 95 | return i 96 | } 97 | 98 | // Ignite holds the ignite-specific configuration 99 | type Ignite struct { 100 | // CPUs specify the number of vCPUs to use. Default: 2 101 | CPUs uint64 `json:"cpus,omitempty"` 102 | // Memory specifies the amount of RAM the VM should have. Default: 1GB 103 | Memory string `json:"memory,omitempty"` 104 | // DiskSize specifies the amount of disk space the VM should have. Default: 4GB 105 | DiskSize string `json:"diskSize,omitempty"` 106 | // Kernel specifies an OCI image to use for the kernel overlay 107 | Kernel string `json:"kernel,omitempty"` 108 | // Files to copy to the VM 109 | CopyFiles map[string]string `json:"copyFiles,omitempty"` 110 | } 111 | 112 | // validate checks basic rules for Machine's fields 113 | func (conf Machine) validate() error { 114 | validName := strings.Contains(conf.Name, "%d") 115 | if !validName { 116 | log.Warnf("Machine conf validation: machine name %v is not valid, it should contains %%d", conf.Name) 117 | return fmt.Errorf("Machine configuration not valid") 118 | } 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /pkg/docker/archive.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package docker contains helpers for working with docker 18 | // This package has no stability guarantees whatsoever! 19 | package docker 20 | 21 | import ( 22 | "archive/tar" 23 | "encoding/json" 24 | "fmt" 25 | "io" 26 | "io/ioutil" 27 | "os" 28 | 29 | "github.com/pkg/errors" 30 | ) 31 | 32 | // GetArchiveTags obtains a list of "repo:tag" docker image tags from a 33 | // given docker image archive (tarball) path 34 | // compatible with all known specs: 35 | // https://github.com/moby/moby/blob/master/image/spec/v1.0.md 36 | // https://github.com/moby/moby/blob/master/image/spec/v1.1.md 37 | // https://github.com/moby/moby/blob/master/image/spec/v1.2.md 38 | func GetArchiveTags(path string) ([]string, error) { 39 | // open the archive and find the repositories entry 40 | f, err := os.Open(path) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer f.Close() 45 | tr := tar.NewReader(f) 46 | var hdr *tar.Header 47 | for { 48 | hdr, err = tr.Next() 49 | if err == io.EOF { 50 | return nil, errors.New("could not find image metadata") 51 | } 52 | if err != nil { 53 | return nil, err 54 | } 55 | if hdr.Name == "repositories" { 56 | break 57 | } 58 | } 59 | // read and parse the tags 60 | b, err := ioutil.ReadAll(tr) 61 | if err != nil { 62 | return nil, err 63 | } 64 | var repoTags map[string]map[string]string 65 | if err := json.Unmarshal(b, &repoTags); err != nil { 66 | return nil, err 67 | } 68 | res := []string{} 69 | for repo, tags := range repoTags { 70 | for tag := range tags { 71 | res = append(res, fmt.Sprintf("%s:%s", repo, tag)) 72 | } 73 | } 74 | return res, nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/docker/cp.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/weaveworks/footloose/pkg/exec" 21 | ) 22 | 23 | // CopyTo copies the file at hostPath to the container at destPath 24 | func CopyTo(hostPath, containerNameOrID, destPath string) error { 25 | cmd := exec.Command( 26 | "docker", "cp", 27 | hostPath, // from the source file 28 | containerNameOrID+":"+destPath, // to the node, at dest 29 | ) 30 | return cmd.Run() 31 | } 32 | 33 | // CopyFrom copies the file or dir in the container at srcPath to the host at hostPath 34 | func CopyFrom(containerNameOrID, srcPath, hostPath string) error { 35 | cmd := exec.Command( 36 | "docker", "cp", 37 | containerNameOrID+":"+srcPath, // from the node, at src 38 | hostPath, // to the host 39 | ) 40 | return cmd.Run() 41 | } 42 | -------------------------------------------------------------------------------- /pkg/docker/create.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/pkg/errors" 21 | log "github.com/sirupsen/logrus" 22 | 23 | "github.com/weaveworks/footloose/pkg/exec" 24 | ) 25 | 26 | // Create creates a container with "docker create", with some error handling 27 | // it will return the ID of the created container if any, even on error 28 | func Create(image string, runArgs []string, containerArgs []string) (id string, err error) { 29 | args := []string{"create"} 30 | args = append(args, runArgs...) 31 | args = append(args, image) 32 | args = append(args, containerArgs...) 33 | cmd := exec.Command("docker", args...) 34 | output, err := exec.CombinedOutputLines(cmd) 35 | if err != nil { 36 | // log error output if there was any 37 | for _, line := range output { 38 | log.Error(line) 39 | } 40 | return "", err 41 | } 42 | // if docker created a container the id will be the first line and match 43 | // validate the output and get the id 44 | if len(output) < 1 { 45 | return "", errors.New("failed to get container id, received no output from docker run") 46 | } 47 | if !containerIDRegex.MatchString(output[0]) { 48 | return "", errors.Errorf("failed to get container id, output did not match: %v", output) 49 | } 50 | return output[0], nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/docker/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package docker contains helpers for working with docker 18 | // This package has no stability guarantees whatsoever! 19 | package docker 20 | -------------------------------------------------------------------------------- /pkg/docker/exec.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "io" 21 | 22 | "github.com/weaveworks/footloose/pkg/exec" 23 | ) 24 | 25 | // containerCmder implements exec.Cmder for docker containers 26 | type containerCmder struct { 27 | nameOrID string 28 | } 29 | 30 | // ContainerCmder creates a new exec.Cmder against a docker container 31 | func ContainerCmder(containerNameOrID string) exec.Cmder { 32 | return &containerCmder{ 33 | nameOrID: containerNameOrID, 34 | } 35 | } 36 | 37 | func (c *containerCmder) Command(command string, args ...string) exec.Cmd { 38 | return &containerCmd{ 39 | nameOrID: c.nameOrID, 40 | command: command, 41 | args: args, 42 | } 43 | } 44 | 45 | // containerCmd implements exec.Cmd for docker containers 46 | type containerCmd struct { 47 | nameOrID string // the container name or ID 48 | command string 49 | args []string 50 | env []string 51 | stdin io.Reader 52 | stdout io.Writer 53 | stderr io.Writer 54 | } 55 | 56 | func (c *containerCmd) Run() error { 57 | args := []string{ 58 | "exec", 59 | // run with priviliges so we can remount etc.. 60 | // this might not make sense in the most general sense, but it is 61 | // important to many kind commands 62 | "--privileged", 63 | } 64 | if c.stdin != nil { 65 | args = append(args, 66 | "-i", // interactive so we can supply input 67 | ) 68 | } 69 | if c.stderr != nil || c.stdout != nil { 70 | args = append(args, 71 | "-t", // use a tty so we can get output 72 | ) 73 | } 74 | // set env 75 | for _, env := range c.env { 76 | args = append(args, "-e", env) 77 | } 78 | // specify the container and command, after this everything will be 79 | // args the the command in the container rather than to docker 80 | args = append( 81 | args, 82 | c.nameOrID, // ... against the container 83 | c.command, // with the command specified 84 | ) 85 | args = append( 86 | args, 87 | // finally, with the caller args 88 | c.args..., 89 | ) 90 | cmd := exec.Command("docker", args...) 91 | if c.stdin != nil { 92 | cmd.SetStdin(c.stdin) 93 | } 94 | if c.stderr != nil { 95 | cmd.SetStderr(c.stderr) 96 | } 97 | if c.stdout != nil { 98 | cmd.SetStdout(c.stdout) 99 | } 100 | return cmd.Run() 101 | } 102 | 103 | func (c *containerCmd) SetEnv(env ...string) { 104 | c.env = env 105 | } 106 | 107 | func (c *containerCmd) SetStdin(r io.Reader) { 108 | c.stdin = r 109 | } 110 | 111 | func (c *containerCmd) SetStdout(w io.Writer) { 112 | c.stdout = w 113 | } 114 | 115 | func (c *containerCmd) SetStderr(w io.Writer) { 116 | c.stderr = w 117 | } 118 | -------------------------------------------------------------------------------- /pkg/docker/inspect.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "strings" 23 | 24 | "github.com/weaveworks/footloose/pkg/exec" 25 | ) 26 | 27 | // Inspect return low-level information on containers 28 | func Inspect(containerNameOrID, format string) ([]string, error) { 29 | cmd := exec.Command("docker", "inspect", 30 | "-f", // format 31 | fmt.Sprintf("'%s'", format), 32 | containerNameOrID, // ... against the "node" container 33 | ) 34 | 35 | return exec.CombinedOutputLines(cmd) 36 | 37 | } 38 | 39 | // InspectObject is similar to Inspect but deserializes the JSON output to a struct. 40 | func InspectObject(containerNameOrID, format string, out interface{}) error { 41 | res, err := Inspect(containerNameOrID, fmt.Sprintf("{{json %s}}", format)) 42 | if err != nil { 43 | return err 44 | } 45 | data := []byte(strings.Trim(res[0], "'")) 46 | err = json.Unmarshal(data, out) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/docker/kill.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/weaveworks/footloose/pkg/exec" 21 | ) 22 | 23 | // Kill sends the named signal to the container 24 | func Kill(signal, containerNameOrID string) error { 25 | cmd := exec.Command( 26 | "docker", "kill", 27 | "-s", signal, 28 | containerNameOrID, 29 | ) 30 | return cmd.Run() 31 | } 32 | -------------------------------------------------------------------------------- /pkg/docker/network_connect.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/weaveworks/footloose/pkg/exec" 21 | ) 22 | 23 | // ConnectNetwork connects network to container. 24 | func ConnectNetwork(container, network string) error { 25 | cmd := exec.Command("docker", "network", "connect", network, container) 26 | return runWithLogging(cmd) 27 | } 28 | 29 | // ConnectNetworkWithAlias connects network to container adding a network-scoped 30 | // alias for the container. 31 | func ConnectNetworkWithAlias(container, network, alias string) error { 32 | cmd := exec.Command("docker", "network", "connect", network, container, "--alias", alias) 33 | return runWithLogging(cmd) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/docker/pull.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "os" 21 | "time" 22 | 23 | log "github.com/sirupsen/logrus" 24 | 25 | "github.com/weaveworks/footloose/pkg/exec" 26 | ) 27 | 28 | // PullIfNotPresent will pull an image if it is not present locally 29 | // retrying up to retries times 30 | // it returns true if it attempted to pull, and any errors from pulling 31 | func PullIfNotPresent(image string, retries int) (pulled bool, err error) { 32 | // TODO(bentheelder): switch most (all) of the logging here to debug level 33 | // once we have configurable log levels 34 | // if this did not return an error, then the image exists locally 35 | cmd := exec.Command("docker", "inspect", "--type=image", image) 36 | if err := cmd.Run(); err == nil { 37 | log.Infof("Docker Image: %s present locally", image) 38 | return false, nil 39 | } 40 | // otherwise try to pull it 41 | return true, Pull(image, retries) 42 | } 43 | 44 | // Pull pulls an image, retrying up to retries times 45 | func Pull(image string, retries int) error { 46 | log.Infof("Pulling image: %s ...", image) 47 | err := setPullCmd(image).Run() 48 | // retry pulling up to retries times if necessary 49 | if err != nil { 50 | for i := 0; i < retries; i++ { 51 | time.Sleep(time.Second * time.Duration(i+1)) 52 | log.WithError(err).Infof("Trying again to pull image: %s ...", image) 53 | // TODO(bentheelder): add some backoff / sleep? 54 | if err = setPullCmd(image).Run(); err == nil { 55 | break 56 | } 57 | } 58 | } 59 | if err != nil { 60 | log.WithError(err).Infof("Failed to pull image: %s", image) 61 | } 62 | return err 63 | } 64 | 65 | // IsRunning checks if Docker is running properly 66 | func IsRunning() error { 67 | cmd := exec.Command("docker", "info") 68 | if err := cmd.Run(); err != nil { 69 | log.WithError(err).Infoln("Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?") 70 | return err 71 | } 72 | return nil 73 | } 74 | 75 | func setPullCmd(image string) exec.Cmd { 76 | cmd := exec.Command("docker", "pull", image) 77 | cmd.SetStderr(os.Stderr) 78 | return cmd 79 | } 80 | -------------------------------------------------------------------------------- /pkg/docker/run.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "regexp" 21 | 22 | "github.com/pkg/errors" 23 | log "github.com/sirupsen/logrus" 24 | 25 | "github.com/weaveworks/footloose/pkg/exec" 26 | ) 27 | 28 | // Docker container IDs are hex, more than one character, and on their own line 29 | var containerIDRegex = regexp.MustCompile("^[a-f0-9]+$") 30 | 31 | // Run creates a container with "docker run", with some error handling 32 | // it will return the ID of the created container if any, even on error 33 | func Run(image string, runArgs []string, containerArgs []string) (id string, err error) { 34 | args := []string{"run"} 35 | args = append(args, runArgs...) 36 | args = append(args, image) 37 | args = append(args, containerArgs...) 38 | cmd := exec.Command("docker", args...) 39 | output, err := exec.CombinedOutputLines(cmd) 40 | if err != nil { 41 | // log error output if there was any 42 | for _, line := range output { 43 | log.Error(line) 44 | } 45 | return "", err 46 | } 47 | // if docker created a container the id will be the first line and match 48 | // validate the output and get the id 49 | if len(output) < 1 { 50 | return "", errors.New("failed to get container id, received no output from docker run") 51 | } 52 | if !containerIDRegex.MatchString(output[0]) { 53 | return "", errors.Errorf("failed to get container id, output did not match: %v", output) 54 | } 55 | return output[0], nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/docker/save.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/weaveworks/footloose/pkg/exec" 21 | ) 22 | 23 | // Save saves image to dest, as in `docker save` 24 | func Save(image, dest string) error { 25 | return exec.Command("docker", "save", "-o", dest, image).Run() 26 | } 27 | -------------------------------------------------------------------------------- /pkg/docker/start.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | log "github.com/sirupsen/logrus" 21 | "github.com/weaveworks/footloose/pkg/exec" 22 | ) 23 | 24 | func runWithLogging(cmd exec.Cmd) error { 25 | output, err := exec.CombinedOutputLines(cmd) 26 | if err != nil { 27 | // log error output if there was any 28 | for _, line := range output { 29 | log.Error(line) 30 | } 31 | } 32 | return err 33 | 34 | } 35 | 36 | // Start starts a container. 37 | func Start(container string) error { 38 | cmd := exec.Command("docker", "start", container) 39 | return runWithLogging(cmd) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/docker/stop.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "github.com/weaveworks/footloose/pkg/exec" 21 | ) 22 | 23 | // Stop stops a container. 24 | func Stop(container string) error { 25 | cmd := exec.Command("docker", "stop", container) 26 | return runWithLogging(cmd) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/docker/userns_remap.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package docker 18 | 19 | import ( 20 | "strings" 21 | 22 | "github.com/weaveworks/footloose/pkg/exec" 23 | ) 24 | 25 | // UsernsRemap checks if userns-remap is enabled in dockerd 26 | func UsernsRemap() bool { 27 | cmd := exec.Command("docker", "info", "--format", "'{{json .SecurityOptions}}'") 28 | lines, err := exec.CombinedOutputLines(cmd) 29 | if err != nil { 30 | return false 31 | } 32 | if len(lines) > 0 { 33 | if strings.Contains(lines[0], "name=userns") { 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /pkg/exec/exec.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package exec contains an interface for executing commands, along with helpers 18 | // TODO(bentheelder): add standardized timeout functionality & a default timeout 19 | // so that commands cannot hang indefinitely (!) 20 | package exec 21 | 22 | import ( 23 | "bufio" 24 | "bytes" 25 | "io" 26 | "os" 27 | 28 | log "github.com/sirupsen/logrus" 29 | ) 30 | 31 | // Cmd abstracts over running a command somewhere, this is useful for testing 32 | type Cmd interface { 33 | Run() error 34 | // Each entry should be of the form "key=value" 35 | SetEnv(...string) 36 | SetStdin(io.Reader) 37 | SetStdout(io.Writer) 38 | SetStderr(io.Writer) 39 | } 40 | 41 | // Cmder abstracts over creating commands 42 | type Cmder interface { 43 | // command, args..., just like os/exec.Cmd 44 | Command(string, ...string) Cmd 45 | } 46 | 47 | // DefaultCmder is a LocalCmder instance used for convienience, packages 48 | // originally using os/exec.Command can instead use pkg/kind/exec.Command 49 | // which forwards to this instance 50 | // TODO(bentheelder): swap this for testing 51 | // TODO(bentheelder): consider not using a global for this :^) 52 | var DefaultCmder = &LocalCmder{} 53 | 54 | // Command is a convience wrapper over DefaultCmder.Command 55 | func Command(command string, args ...string) Cmd { 56 | return DefaultCmder.Command(command, args...) 57 | } 58 | 59 | // CommandWithLogging is a convience wrapper over Command 60 | // display any errors received by the executed command 61 | func CommandWithLogging(command string, args ...string) error { 62 | cmd := Command(command, args...) 63 | output, err := CombinedOutputLines(cmd) 64 | if err != nil { 65 | // log error output if there was any 66 | for _, line := range output { 67 | log.Error(line) 68 | } 69 | } 70 | return err 71 | 72 | } 73 | 74 | // CombinedOutputLines is like os/exec's cmd.CombinedOutput(), 75 | // but over our Cmd interface, and instead of returning the byte buffer of 76 | // stderr + stdout, it scans these for lines and returns a slice of output lines 77 | func CombinedOutputLines(cmd Cmd) (lines []string, err error) { 78 | var buff bytes.Buffer 79 | cmd.SetStdout(&buff) 80 | cmd.SetStderr(&buff) 81 | err = cmd.Run() 82 | scanner := bufio.NewScanner(&buff) 83 | for scanner.Scan() { 84 | lines = append(lines, scanner.Text()) 85 | } 86 | return lines, err 87 | } 88 | 89 | // InheritOutput sets cmd's output to write to the current process's stdout and stderr 90 | func InheritOutput(cmd Cmd) { 91 | cmd.SetStderr(os.Stderr) 92 | cmd.SetStdout(os.Stdout) 93 | } 94 | 95 | // RunLoggingOutputOnFail runs the cmd, logging error output if Run returns an error 96 | func RunLoggingOutputOnFail(cmd Cmd) error { 97 | var buff bytes.Buffer 98 | cmd.SetStdout(&buff) 99 | cmd.SetStderr(&buff) 100 | err := cmd.Run() 101 | if err != nil { 102 | log.Error("failed with:") 103 | scanner := bufio.NewScanner(&buff) 104 | for scanner.Scan() { 105 | log.Error(scanner.Text()) 106 | } 107 | } 108 | return err 109 | } 110 | -------------------------------------------------------------------------------- /pkg/exec/local.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package exec 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | osexec "os/exec" 24 | "strings" 25 | 26 | "github.com/pkg/errors" 27 | log "github.com/sirupsen/logrus" 28 | ) 29 | 30 | // LocalCmd wraps os/exec.Cmd, implementing the kind/pkg/exec.Cmd interface 31 | type LocalCmd struct { 32 | *osexec.Cmd 33 | } 34 | 35 | var _ Cmd = &LocalCmd{} 36 | 37 | // LocalCmder is a factory for LocalCmd, implementing Cmder 38 | type LocalCmder struct{} 39 | 40 | var _ Cmder = &LocalCmder{} 41 | 42 | // Command returns a new exec.Cmd backed by Cmd 43 | func (c *LocalCmder) Command(name string, arg ...string) Cmd { 44 | return &LocalCmd{ 45 | Cmd: osexec.Command(name, arg...), 46 | } 47 | } 48 | 49 | // SetEnv sets env 50 | func (cmd *LocalCmd) SetEnv(env ...string) { 51 | cmd.Env = env 52 | } 53 | 54 | // SetStdin sets stdin 55 | func (cmd *LocalCmd) SetStdin(r io.Reader) { 56 | cmd.Stdin = r 57 | } 58 | 59 | // SetStdout set stdout 60 | func (cmd *LocalCmd) SetStdout(w io.Writer) { 61 | cmd.Stdout = w 62 | } 63 | 64 | // SetStderr sets stderr 65 | func (cmd *LocalCmd) SetStderr(w io.Writer) { 66 | cmd.Stderr = w 67 | } 68 | 69 | // Run runs 70 | func (cmd *LocalCmd) Run() error { 71 | log.Debugf("Running: %v %v", cmd.Path, cmd.Args) 72 | return cmd.Cmd.Run() 73 | } 74 | 75 | func ExecuteCommand(command string, args ...string) (string, error) { 76 | cmd := osexec.Command(command, args...) 77 | out, err := cmd.CombinedOutput() 78 | cmdArgs := strings.Join(cmd.Args, " ") 79 | //log.Debugf("Command %q returned %q\n", cmdArgs, out) 80 | if err != nil { 81 | return "", errors.Wrapf(err, "command %q exited with %q", cmdArgs, out) 82 | } 83 | 84 | // TODO: strings.Builder? 85 | return strings.TrimSpace(string(out)), nil 86 | } 87 | 88 | func ExecForeground(command string, args ...string) (int, error) { 89 | cmd := osexec.Command(command, args...) 90 | cmd.Stdin = os.Stdin 91 | cmd.Stdout = os.Stdout 92 | cmd.Stderr = os.Stderr 93 | err := cmd.Run() 94 | cmdArgs := strings.Join(cmd.Args, " ") 95 | 96 | var cmdErr error 97 | var exitCode int 98 | 99 | if err != nil { 100 | cmdErr = fmt.Errorf("external command %q exited with an error: %v", cmdArgs, err) 101 | 102 | if exitError, ok := err.(*osexec.ExitError); ok { 103 | exitCode = exitError.ExitCode() 104 | } else { 105 | cmdErr = fmt.Errorf("failed to get exit code for external command %q", cmdArgs) 106 | } 107 | } 108 | 109 | return exitCode, cmdErr 110 | } 111 | -------------------------------------------------------------------------------- /pkg/ignite/create.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "path/filepath" 7 | 8 | "github.com/weaveworks/footloose/pkg/config" 9 | "github.com/weaveworks/footloose/pkg/exec" 10 | ) 11 | 12 | const ( 13 | BackendName = "ignite" 14 | ) 15 | 16 | // This offset is incremented for each port so we avoid 17 | // duplicate port bindings (and hopefully port collisions) 18 | var portOffset uint16 19 | 20 | // Create creates an Ignite VM using "ignite run", it doesn't return a container ID 21 | func Create(name string, spec *config.Machine, pubKeyPath string) (id string, err error) { 22 | runArgs := []string{ 23 | "run", 24 | spec.Image, 25 | fmt.Sprintf("--name=%s", name), 26 | fmt.Sprintf("--cpus=%d", spec.IgniteConfig().CPUs), 27 | fmt.Sprintf("--memory=%s", spec.IgniteConfig().Memory), 28 | fmt.Sprintf("--size=%s", spec.IgniteConfig().DiskSize), 29 | fmt.Sprintf("--kernel-image=%s", spec.IgniteConfig().Kernel), 30 | fmt.Sprintf("--ssh=%s", pubKeyPath), 31 | } 32 | 33 | if copyFiles := spec.IgniteConfig().CopyFiles; copyFiles != nil { 34 | runArgs = append(runArgs, setupCopyFiles(copyFiles)...) 35 | } 36 | 37 | for _, mapping := range spec.PortMappings { 38 | if mapping.HostPort == 0 { 39 | // If not defined, set the host port to a random free ephemeral port 40 | var err error 41 | if mapping.HostPort, err = freePort(); err != nil { 42 | return "", err 43 | } 44 | } else { 45 | // If defined, apply an offset so all VMs won't use the same port 46 | mapping.HostPort += portOffset 47 | } 48 | 49 | runArgs = append(runArgs, fmt.Sprintf("--ports=%d:%d", int(mapping.HostPort), mapping.ContainerPort)) 50 | } 51 | 52 | // Increment portOffset per-machine 53 | portOffset++ 54 | 55 | _, err = exec.ExecuteCommand(execName, runArgs...) 56 | return "", err 57 | } 58 | 59 | // setupCopyFiles formats the files to copy over to Ignite flags 60 | func setupCopyFiles(copyFiles map[string]string) []string { 61 | ret := make([]string, 0, len(copyFiles)) 62 | for k, v := range copyFiles { 63 | ret = append(ret, fmt.Sprintf("--copy-files=%s:%s", toAbs(k), v)) 64 | } 65 | 66 | return ret 67 | } 68 | 69 | func toAbs(p string) string { 70 | if ap, err := filepath.Abs(p); err == nil { 71 | return ap 72 | } 73 | 74 | // If Abs reports an error, just return the given path as-is 75 | return p 76 | } 77 | 78 | // IsCreated checks if the VM with the given name is created 79 | func IsCreated(name string) bool { 80 | return exec.Command(execName, "inspect", "vm", name).Run() == nil 81 | } 82 | 83 | // IsStarted checks if the VM with the given name is running 84 | func IsStarted(name string) bool { 85 | vm, err := PopulateMachineDetails(name) 86 | if err != nil { 87 | return false 88 | } 89 | 90 | return vm.Status.Running 91 | } 92 | 93 | // freePort requests a free/open ephemeral port from the kernel 94 | // Heavily inspired by https://github.com/phayes/freeport/blob/master/freeport.go 95 | func freePort() (uint16, error) { 96 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 97 | if err != nil { 98 | return 0, err 99 | } 100 | 101 | l, err := net.ListenTCP("tcp", addr) 102 | if err != nil { 103 | return 0, err 104 | } 105 | defer l.Close() 106 | 107 | return uint16(l.Addr().(*net.TCPAddr).Port), nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/ignite/doc.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | -------------------------------------------------------------------------------- /pkg/ignite/ignite.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/blang/semver" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/weaveworks/footloose/pkg/exec" 9 | ) 10 | 11 | const execName = "ignite" 12 | 13 | var minVersion = semver.MustParse("0.5.2") // Require v0.5.2 or higher 14 | 15 | func CheckVersion() { 16 | 17 | lines, err := exec.CombinedOutputLines(exec.Command(execName, "version", "-o", "short")) 18 | if err == nil && len(lines) == 0 { 19 | err = fmt.Errorf("no output") 20 | } 21 | 22 | if err != nil { 23 | verParseFail(err) 24 | } 25 | 26 | // Use ParseTolerant as Ignite's version has a leading "v" 27 | version, err := semver.ParseTolerant(lines[0]) 28 | if err != nil { 29 | verParseFail(err) 30 | } 31 | 32 | if minVersion.Compare(version) > 0 { 33 | verParseFail(fmt.Errorf("minimum version is v%s, detected older v%s", minVersion, version)) 34 | } 35 | 36 | if len(version.Build) > 0 { 37 | log.Warnf("Continuing with a dirty build of Ignite (v%s), here be dragons", version) 38 | } 39 | } 40 | 41 | func verParseFail(err error) { 42 | log.Fatalf("Failed to verify Ignite version: %v", err) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/ignite/inspect.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/weaveworks/footloose/pkg/exec" 9 | ) 10 | 11 | type Metadata struct { 12 | Name string 13 | UID string 14 | Created string 15 | } 16 | 17 | type Port struct { 18 | HostPort uint16 19 | VMPort uint16 20 | Protocol string 21 | } 22 | 23 | type Network struct { 24 | Ports []Port 25 | } 26 | 27 | type Spec struct { 28 | Network Network 29 | Cpus uint 30 | Memory string 31 | DiskSize string 32 | } 33 | 34 | type Status struct { 35 | Running bool 36 | StartTime string 37 | IpAddresses []string 38 | } 39 | 40 | type VM struct { 41 | Metadata Metadata 42 | Spec Spec 43 | Status Status 44 | } 45 | 46 | // PopulateMachineDetails returns the details of the VM identified by the given name 47 | func PopulateMachineDetails(name string) (*VM, error) { 48 | cmd := exec.Command(execName, "inspect", "vm", name) 49 | lines, err := exec.CombinedOutputLines(cmd) 50 | if err != nil { 51 | log.Errorf("Ignite.IsStarted error: %v\n", err) 52 | return nil, err 53 | } 54 | 55 | var sb strings.Builder 56 | for _, s := range lines { 57 | sb.WriteString(s) 58 | } 59 | 60 | return toVM([]byte(sb.String())) 61 | } 62 | 63 | // toVM unmarshals the given data to a VM object 64 | func toVM(data []byte) (*VM, error) { 65 | obj := &VM{} 66 | if err := json.Unmarshal(data, obj); err != nil { 67 | log.Errorf("Ignite.toVM error: %v\n", err) 68 | return nil, err 69 | } 70 | 71 | return obj, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/ignite/rm.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import "github.com/weaveworks/footloose/pkg/exec" 4 | 5 | // Remove removes an Ignite VM 6 | func Remove(name string) error { 7 | return exec.CommandWithLogging(execName, "rm", "-f", name) 8 | } 9 | -------------------------------------------------------------------------------- /pkg/ignite/start.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import ( 4 | "github.com/weaveworks/footloose/pkg/exec" 5 | ) 6 | 7 | // Start starts an Ignite VM 8 | func Start(name string) error { 9 | return exec.CommandWithLogging(execName, "start", name) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/ignite/stop.go: -------------------------------------------------------------------------------- 1 | package ignite 2 | 3 | import ( 4 | "github.com/weaveworks/footloose/pkg/exec" 5 | ) 6 | 7 | // Stop stops an Ignite VM 8 | func Stop(name string) error { 9 | return exec.CommandWithLogging(execName, "stop", name) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/version/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-github/v24/github" 8 | ) 9 | 10 | const ( 11 | owner = "weaveworks" 12 | repo = "footloose" 13 | ) 14 | 15 | // FindLastRelease searches latest release of the project 16 | func FindLastRelease() (*github.RepositoryRelease, error) { 17 | githubclient := github.NewClient(nil) 18 | repoRelease, _, err := githubclient.Repositories.GetLatestRelease(context.Background(), owner, repo) 19 | if err != nil { 20 | return nil, fmt.Errorf("Failed to get latest release information") 21 | } 22 | return repoRelease, nil 23 | } 24 | -------------------------------------------------------------------------------- /serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "github.com/weaveworks/footloose/pkg/api" 13 | "github.com/weaveworks/footloose/pkg/cluster" 14 | ) 15 | 16 | var serveCmd = &cobra.Command{ 17 | Use: "serve", 18 | Short: "Launch a footloose server", 19 | RunE: serve, 20 | } 21 | 22 | var serveOptions struct { 23 | listen string 24 | keyStorePath string 25 | debug bool 26 | } 27 | 28 | func baseURI(addr string) (string, error) { 29 | host, port, err := net.SplitHostPort(addr) 30 | if err != nil { 31 | return "", err 32 | } 33 | if host == "" || host == "0.0.0.0" || host == "[::]" { 34 | host = "localhost" 35 | } 36 | return fmt.Sprintf("http://%s:%s", host, port), nil 37 | } 38 | 39 | func init() { 40 | serveCmd.Flags().StringVarP(&serveOptions.listen, "listen", "l", ":2444", "Cluster configuration file") 41 | serveCmd.Flags().StringVar(&serveOptions.keyStorePath, "keystore-path", defaultKeyStorePath, "Path of the public keys store") 42 | serveCmd.Flags().BoolVar(&serveOptions.debug, "debug", false, "Enable debug") 43 | footloose.AddCommand(serveCmd) 44 | } 45 | 46 | func serve(cmd *cobra.Command, args []string) error { 47 | opts := &serveOptions 48 | 49 | baseURI, err := baseURI(opts.listen) 50 | if err != nil { 51 | return errors.Wrapf(err, "invalid listen address '%s'", opts.listen) 52 | } 53 | 54 | log.Infof("Starting server on: %s\n", opts.listen) 55 | 56 | keyStore := cluster.NewKeyStore(opts.keyStorePath) 57 | if err := keyStore.Init(); err != nil { 58 | return errors.Wrapf(err, "could not init keystore") 59 | } 60 | 61 | log.Infof("Key store successfully initialized in path: %s\n", opts.keyStorePath) 62 | 63 | api := api.New(baseURI, keyStore, opts.debug) 64 | router := api.Router() 65 | 66 | err = http.ListenAndServe(opts.listen, router) 67 | if err != nil { 68 | log.Fatalf("Unable to start server: %s", err) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /serve_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBaseURI(t *testing.T) { 10 | tests := []struct { 11 | valid bool 12 | input, expected string 13 | }{ 14 | {true, ":2444", "http://localhost:2444"}, 15 | } 16 | 17 | for _, test := range tests { 18 | uri, err := baseURI(test.input) 19 | if !test.valid { 20 | assert.Error(t, err) 21 | } 22 | assert.Equal(t, test.expected, uri) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /show.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/weaveworks/footloose/pkg/cluster" 9 | ) 10 | 11 | var showCmd = &cobra.Command{ 12 | Use: "show [HOSTNAME]", 13 | Aliases: []string{"status"}, 14 | Short: "Show all running machines or a single machine with a given hostname.", 15 | Long: `Provides information about machines created by footloose in JSON or Table format. 16 | Optionally, provide show with a hostname to look for a specific machine. Exp: 'show node0'.`, 17 | RunE: show, 18 | Args: cobra.MaximumNArgs(1), 19 | } 20 | 21 | var showOptions struct { 22 | output string 23 | config string 24 | } 25 | 26 | func init() { 27 | showCmd.Flags().StringVarP(&showOptions.config, "config", "c", Footloose, "Cluster configuration file") 28 | showCmd.Flags().StringVarP(&showOptions.output, "output", "o", "table", "Output formatting options: {json,table}.") 29 | footloose.AddCommand(showCmd) 30 | } 31 | 32 | // show will show all machines in a given cluster. 33 | func show(cmd *cobra.Command, args []string) error { 34 | c, err := cluster.NewFromFile(configFile(showOptions.config)) 35 | if err != nil { 36 | return err 37 | } 38 | var formatter cluster.Formatter 39 | switch showOptions.output { 40 | case "json": 41 | formatter = new(cluster.JSONFormatter) 42 | case "table": 43 | formatter = new(cluster.TableFormatter) 44 | default: 45 | return fmt.Errorf("unknown formatter '%s'", showOptions.output) 46 | } 47 | machines, err := c.Inspect(args) 48 | if err != nil { 49 | return err 50 | } 51 | return formatter.Format(os.Stdout, machines) 52 | } 53 | -------------------------------------------------------------------------------- /ssh.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/user" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/weaveworks/footloose/pkg/cluster" 12 | ) 13 | 14 | var sshCmd = &cobra.Command{ 15 | Use: "ssh", 16 | Short: "SSH into a machine", 17 | Args: validateArgs, 18 | RunE: ssh, 19 | } 20 | 21 | var sshOptions struct { 22 | config string 23 | } 24 | 25 | func init() { 26 | sshCmd.Flags().StringVarP(&sshOptions.config, "config", "c", Footloose, "Cluster configuration file") 27 | footloose.AddCommand(sshCmd) 28 | } 29 | 30 | func ssh(cmd *cobra.Command, args []string) error { 31 | cluster, err := cluster.NewFromFile(configFile(sshOptions.config)) 32 | if err != nil { 33 | return err 34 | } 35 | var node string 36 | var username string 37 | if strings.Contains(args[0], "@") { 38 | items := strings.Split(args[0], "@") 39 | if len(items) != 2 { 40 | return fmt.Errorf("bad syntax for user@node: %v", items) 41 | } 42 | username = items[0] 43 | node = items[1] 44 | } else { 45 | node = args[0] 46 | user, err := user.Current() 47 | if err != nil { 48 | return errors.New("error in getting current user") 49 | } 50 | username = user.Username 51 | } 52 | return cluster.SSH(node, username, args[1:]...) 53 | } 54 | 55 | func validateArgs(cmd *cobra.Command, args []string) error { 56 | if len(args) < 1 { 57 | return errors.New("missing machine name argument") 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /start.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/weaveworks/footloose/pkg/cluster" 7 | ) 8 | 9 | var startCmd = &cobra.Command{ 10 | Use: "start", 11 | Short: "Start cluster machines", 12 | RunE: start, 13 | } 14 | 15 | var startOptions struct { 16 | config string 17 | } 18 | 19 | func init() { 20 | startCmd.Flags().StringVarP(&startOptions.config, "config", "c", Footloose, "Cluster configuration file") 21 | footloose.AddCommand(startCmd) 22 | } 23 | 24 | func start(cmd *cobra.Command, args []string) error { 25 | cluster, err := cluster.NewFromFile(configFile(startOptions.config)) 26 | if err != nil { 27 | return err 28 | } 29 | return cluster.Start(args) 30 | } 31 | -------------------------------------------------------------------------------- /stop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/weaveworks/footloose/pkg/cluster" 7 | ) 8 | 9 | var stopCmd = &cobra.Command{ 10 | Use: "stop", 11 | Short: "Stop cluster machines", 12 | RunE: stop, 13 | } 14 | 15 | var stopOptions struct { 16 | config string 17 | } 18 | 19 | func init() { 20 | stopCmd.Flags().StringVarP(&stopOptions.config, "config", "c", Footloose, "Cluster configuration file") 21 | footloose.AddCommand(stopCmd) 22 | } 23 | 24 | func stop(cmd *cobra.Command, args []string) error { 25 | cluster, err := cluster.NewFromFile(configFile(stopOptions.config)) 26 | if err != nil { 27 | return err 28 | } 29 | return cluster.Stop(args) 30 | } 31 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /*-key 2 | /*-key.pub 3 | /*.footloose 4 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # End-to-end tests 2 | 3 | This directory holds `footloose` end-to-end tests. All commands given in this 4 | README are assuming being run from the root of the repository. 5 | 6 | The prerequisites to run to the tests are: 7 | 8 | - `docker` installed on the machine with no container running. This 9 | limitation can be lifted once we can select `footloose` containers better 10 | ([#17][issue-17]). 11 | - `footloose` in the path. 12 | 13 | [issue-17]: https://github.com/weaveworks/footloose/issues/17 14 | 15 | ## Running the tests 16 | 17 | To run all tests: 18 | 19 | ```console 20 | go test -v ./tests 21 | ``` 22 | 23 | To exclude long running tests (useful to smoke test a change before a longer 24 | run in CI): 25 | 26 | ```console 27 | go test -short -v ./tests 28 | ``` 29 | 30 | To run a specific test: 31 | 32 | ```console 33 | go test -v -run TestEndToEnd/test-create-delete-centos7 34 | ``` 35 | 36 | Remember that the `-run` argument is a regex so it's possible to select a 37 | subset of the tests with this: 38 | 39 | ```console 40 | go test -v -run TestEndToEnd/test-create-delete 41 | ``` 42 | 43 | This will match `test-create-delete-centos7`, `test-create-delete-fedora29`, 44 | ... 45 | 46 | ## Writing tests 47 | 48 | `footloose` has a small framework to write end to end tests. The main idea is 49 | to write a `.cmd` file with a list of commands to run and compare the output 50 | (stdout+stderr) of those commands to a golden, expected, output. 51 | 52 | `.cmd` files look like (`test-ssh-remote-command-%image.cmd`): 53 | 54 | ```shell 55 | # Test footloose ssh can execute a remote command 56 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 57 | footloose create --config %testName.footloose 58 | %out footloose --config %testName.footloose ssh root@node0 hostname 59 | footloose delete --config %testName.footloose 60 | ``` 61 | 62 | And the corresponding golden output file (`test-ssh-remote-command-%image.golden.output`): 63 | 64 | ```shell 65 | node0 66 | ``` 67 | 68 | The **--override** flag should be used with the **config create** command because otherwise 69 | the first run of a test will leave a config file behind and additional runs will fail 70 | to avoid overwriting the original config file. The only exception to this rule is in tests 71 | that are intended to validate the override mechanism itself. 72 | 73 | Some variables and directives are supplied by the test framework: 74 | 75 | - **%testName**: The name of the test. This is really the name of the `.cmd` 76 | file without the extension. 77 | 78 | - **%out**: Capture the output of the following command to be compared to the 79 | golden output. In the example above the result of the remote `hostname` 80 | command will be compared to `node0`. 81 | 82 | It is also possible to have user-defined variables, variables that are 83 | specified outside of the test framework. In the example above, `%image` is 84 | such a variable. User-defined variables are kept in `variables.json`: 85 | 86 | 87 | ```json 88 | { 89 | "image": [ 90 | "amazonlinux2", 91 | "centos7", 92 | "fedora29", 93 | "ubuntu16.04", 94 | "ubuntu18.04", 95 | "debian10" 96 | ] 97 | } 98 | ``` 99 | 100 | The test framework will instantiate a separate test case for each value of 101 | the `image` array. For this to work, the `.cmd` file will need to reference 102 | `%image` in its name too in order keep the test name unique. 103 | -------------------------------------------------------------------------------- /tests/e2e_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | type variables map[string][]string 19 | 20 | func (v variables) alternatives(name string) []string { 21 | return v[name] 22 | } 23 | 24 | func (v variables) sortedKeys() []string { 25 | keys := []string{} 26 | for k := range v { 27 | keys = append(keys, k) 28 | } 29 | sort.Strings(keys) 30 | return keys 31 | } 32 | 33 | func copyArray(a []string) []string { 34 | tmp := make([]string, len(a)) 35 | copy(tmp, a) 36 | return tmp 37 | } 38 | 39 | type expandedItem struct { 40 | expanded string 41 | combination []string 42 | } 43 | 44 | func uniqueItems(slice []expandedItem) []expandedItem { 45 | m := make(map[string]struct{}) 46 | r := []expandedItem{} 47 | for _, i := range slice { 48 | if _, ok := m[i.expanded]; ok { 49 | continue 50 | } 51 | m[i.expanded] = struct{}{} 52 | r = append(r, i) 53 | } 54 | return r 55 | } 56 | 57 | func fixupSingleCombination(s []expandedItem) []expandedItem { 58 | // When the expansion result in a single string, it's really not the result of 59 | // a combination of vars, so clear up the combination field. 60 | if len(s) == 1 { 61 | s[0].combination = nil 62 | } 63 | return s 64 | } 65 | 66 | func (v variables) expand(s string) []expandedItem { 67 | expanded := []expandedItem{} 68 | 69 | if len(v) == 0 { 70 | return []expandedItem{ 71 | {expanded: s}, 72 | } 73 | } 74 | 75 | args := [][]string{} 76 | for _, k := range v.sortedKeys() { 77 | alts := v.alternatives(k) 78 | 79 | if len(args) == 0 { 80 | // Populate args for the first time 81 | cur := [][]string{} 82 | for _, alt := range alts { 83 | cur = append(cur, []string{"%" + k, alt}) 84 | } 85 | args = cur 86 | continue 87 | } 88 | 89 | cur := [][]string{} 90 | for _, a := range args { 91 | for _, alt := range alts { 92 | tmp := copyArray(a) 93 | cur = append(cur, append(tmp, "%"+k, alt)) 94 | } 95 | } 96 | args = cur 97 | } 98 | 99 | for _, a := range args { 100 | replacer := strings.NewReplacer(a...) 101 | expanded = append(expanded, expandedItem{ 102 | expanded: replacer.Replace(s), 103 | combination: a, 104 | }) 105 | } 106 | return fixupSingleCombination(uniqueItems(expanded)) 107 | } 108 | 109 | func TestVariableExpansion(t *testing.T) { 110 | v := make(variables) 111 | v["foo"] = []string{"foo1", "foo2"} 112 | v["bar"] = []string{"bar1", "bar2", "bar3"} 113 | 114 | // Test a string expansion 115 | assert.Equal(t, []expandedItem{ 116 | {"foo1-bar1", []string{"%bar", "bar1", "%foo", "foo1"}}, 117 | {"foo2-bar1", []string{"%bar", "bar1", "%foo", "foo2"}}, 118 | {"foo1-bar2", []string{"%bar", "bar2", "%foo", "foo1"}}, 119 | {"foo2-bar2", []string{"%bar", "bar2", "%foo", "foo2"}}, 120 | {"foo1-bar3", []string{"%bar", "bar3", "%foo", "foo1"}}, 121 | {"foo2-bar3", []string{"%bar", "bar3", "%foo", "foo2"}}, 122 | }, v.expand("%foo-%bar")) 123 | 124 | // When a string doesn't need expansion. 125 | assert.Equal(t, []expandedItem{ 126 | {"foo", nil}, 127 | }, v.expand("foo")) 128 | } 129 | 130 | func find(dir string) ([]string, error) { 131 | var files []string 132 | 133 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 134 | switch { 135 | case err != nil: 136 | return err 137 | case info.IsDir(): 138 | return nil 139 | case strings.HasSuffix(path, "~"): 140 | return nil 141 | } 142 | files = append(files, strings.TrimPrefix(path, dir)) 143 | return nil 144 | }) 145 | 146 | return files, err 147 | } 148 | 149 | // test is a end to end test, corresponding to one test-$testname.cmd file. 150 | type test struct { 151 | testname string // test name, after variable resolution. 152 | file string // name of the test file (test-*.cmd), without the extentension. 153 | vars []string // user-defined variables list of key, value pairs. 154 | } 155 | 156 | type byName []test 157 | 158 | func (a byName) Len() int { return len(a) } 159 | func (a byName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 160 | func (a byName) Less(i, j int) bool { return a[i].testname < a[j].testname } 161 | 162 | func (t *test) name() string { 163 | return t.testname 164 | } 165 | 166 | func exists(filename string) bool { 167 | _, err := os.Stat(filename) 168 | return err == nil 169 | } 170 | 171 | func (t *test) shouldErrorOut() bool { 172 | return exists(t.testname + ".error") 173 | } 174 | 175 | func (t *test) shouldSkip() bool { 176 | return exists(t.testname + ".skip") 177 | } 178 | 179 | func (t *test) isLong() bool { 180 | return exists(t.testname + ".long") 181 | } 182 | 183 | func (t *test) outputDir() string { 184 | return t.testname + ".got" 185 | } 186 | 187 | type cmd struct { 188 | name string 189 | args []string 190 | // should we capture the command output to be tested against the golden 191 | // output? 192 | captureOutput bool 193 | } 194 | 195 | func (t *test) expandVars(s string) string { 196 | replacements := copyArray(t.vars) 197 | replacements = append(replacements, 198 | "%testOutputDir", t.outputDir(), 199 | "%testName", t.name(), 200 | ) 201 | replacer := strings.NewReplacer(replacements...) 202 | return replacer.Replace(s) 203 | } 204 | 205 | func (t *test) parseCmd(line string) cmd { 206 | parts := strings.Split(line, " ") 207 | 208 | // Replace special strings 209 | for i := range parts { 210 | parts[i] = t.expandVars(parts[i]) 211 | } 212 | 213 | cmd := cmd{} 214 | switch parts[0] { 215 | case "%out": 216 | cmd.captureOutput = true 217 | parts = parts[1:] 218 | } 219 | 220 | cmd.name = parts[0] 221 | cmd.args = parts[1:] 222 | return cmd 223 | 224 | } 225 | 226 | func (t *test) run() (string, error) { 227 | f, err := os.Open(t.file) 228 | if err != nil { 229 | return "", err 230 | } 231 | defer f.Close() 232 | 233 | var capturedOutput strings.Builder 234 | 235 | scanner := bufio.NewScanner(f) 236 | for scanner.Scan() { 237 | line := scanner.Text() 238 | if line[0] == '#' { 239 | continue 240 | } 241 | testCmd := t.parseCmd(line) 242 | cmd := exec.Command(testCmd.name, testCmd.args...) 243 | if testCmd.captureOutput { 244 | output, err := cmd.CombinedOutput() 245 | if err != nil { 246 | // Display the captured output in case of failure. 247 | fmt.Print(string(output)) 248 | return "", err 249 | } 250 | capturedOutput.Write(output) 251 | } else { 252 | cmd.Stdout = os.Stdout 253 | cmd.Stderr = os.Stderr 254 | if err := cmd.Run(); err != nil { 255 | return "", err 256 | } 257 | } 258 | } 259 | 260 | if err := scanner.Err(); err != nil { 261 | return "", err 262 | } 263 | 264 | return capturedOutput.String(), nil 265 | } 266 | 267 | func (t *test) goldenOutput() string { 268 | // testname.golden.output takes precedence. 269 | golden, err := ioutil.ReadFile(t.testname + ".golden.output") 270 | if err == nil { 271 | return string(golden) 272 | } 273 | 274 | // Expand a generic golden output. 275 | baseFilename := t.file[:len(t.file)-len(".cmd")] 276 | data, err := ioutil.ReadFile(baseFilename + ".golden.output") 277 | if err != nil { 278 | // not having any golden output isn't an error, it just means the test 279 | // shouldn't output anything. 280 | return "" 281 | } 282 | 283 | return t.expandVars(string(data)) 284 | } 285 | 286 | func runTest(t *testing.T, test *test) { 287 | base := test.file 288 | goldenDir := base + ".golden" 289 | gotDir := base + ".got" 290 | 291 | if test.shouldSkip() { 292 | return 293 | } 294 | 295 | output, err := test.run() 296 | 297 | // 0. Check process exit code. 298 | if test.shouldErrorOut() { 299 | _, ok := err.(*exec.ExitError) 300 | assert.True(t, ok, err.Error()) 301 | } else { 302 | if err != nil { 303 | fmt.Print(string(output)) 304 | } 305 | assert.NoError(t, err) 306 | } 307 | 308 | // 1. Compare stdout/err. 309 | assert.Equal(t, test.goldenOutput(), string(output)) 310 | 311 | // 2. Compare produced files. 312 | goldenFiles, _ := find(goldenDir) 313 | gotFiles, _ := find(gotDir) 314 | 315 | // 2. a) Compare the list of files. 316 | if !assert.Equal(t, goldenFiles, gotFiles) { 317 | assert.FailNow(t, "generated files not equivalent; bail") 318 | } 319 | 320 | // 2. b) Compare file content. 321 | for i := range goldenFiles { 322 | golden, err := ioutil.ReadFile(goldenDir + goldenFiles[i]) 323 | assert.NoError(t, err) 324 | got, err := ioutil.ReadFile(gotDir + gotFiles[i]) 325 | assert.NoError(t, err) 326 | 327 | assert.Equal(t, string(golden), string(got)) 328 | } 329 | } 330 | 331 | func loadVariables(t *testing.T) variables { 332 | data, err := ioutil.ReadFile("variables.json") 333 | if err != nil { 334 | // it's allowed to not have any variable! 335 | return nil 336 | } 337 | 338 | vars := make(variables) 339 | if err := json.Unmarshal(data, &vars); err != nil { 340 | t.Fatalf("variables.json: %v", err) 341 | } 342 | 343 | return vars 344 | } 345 | 346 | func listTests(t *testing.T, vars variables) []test { 347 | files, err := filepath.Glob("test-*.cmd") 348 | assert.NoError(t, err) 349 | 350 | // expand variables in file names. 351 | expanded := []test{} 352 | for _, f := range files { 353 | items := vars.expand(f) 354 | for _, item := range items { 355 | ext := filepath.Ext(item.expanded) 356 | testname := item.expanded[:len(item.expanded)-len(ext)] 357 | expanded = append(expanded, test{ 358 | testname: testname, 359 | file: f, 360 | vars: item.combination, 361 | }) 362 | } 363 | } 364 | 365 | sort.Sort(byName(expanded)) 366 | return expanded 367 | } 368 | 369 | func TestEndToEnd(t *testing.T) { 370 | vars := loadVariables(t) 371 | tests := listTests(t, vars) 372 | 373 | for _, test := range tests { 374 | t.Run(test.name(), func(t *testing.T) { 375 | if test.isLong() && testing.Short() { 376 | t.Skip("Skipping long running test in short mode") 377 | } 378 | runTest(t, &test) 379 | }) 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /tests/test-basic-commands-%image.cmd: -------------------------------------------------------------------------------- 1 | # Test that common utilities are present in the base images 2 | footloose config create --config %testName.footloose --override --name %testName --key %testName-key --image quay.io/footloose/%image 3 | footloose create --config %testName.footloose 4 | footloose --config %testName.footloose ssh root@node0 hostname 5 | footloose --config %testName.footloose ssh root@node0 ps 6 | footloose --config %testName.footloose ssh root@node0 ifconfig 7 | footloose --config %testName.footloose ssh root@node0 ip route 8 | footloose --config %testName.footloose ssh root@node0 -- netstat -n -l 9 | footloose --config %testName.footloose ssh root@node0 -- ping -V 10 | footloose --config %testName.footloose ssh root@node0 -- curl --version 11 | footloose --config %testName.footloose ssh root@node0 -- wget --version 12 | footloose --config %testName.footloose ssh root@node0 -- vi --help 13 | footloose --config %testName.footloose ssh root@node0 -- sudo true 14 | footloose delete --config %testName.footloose 15 | -------------------------------------------------------------------------------- /tests/test-basic-commands-clearlinux.skip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-basic-commands-clearlinux.skip -------------------------------------------------------------------------------- /tests/test-config-get-ubuntu18.04.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --networks=net1,net2 --image quay.io/footloose/%image 2 | %out footloose config get --config %testName.footloose machines[0].spec 3 | -------------------------------------------------------------------------------- /tests/test-config-get-ubuntu18.04.golden.output: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node%d", 3 | "image": "quay.io/footloose/%image", 4 | "networks": [ 5 | "net1", 6 | "net2" 7 | ], 8 | "backend": "docker" 9 | } -------------------------------------------------------------------------------- /tests/test-create-config-already-exists-fedora29.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose config create --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 3 | -------------------------------------------------------------------------------- /tests/test-create-config-already-exists-fedora29.error: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-create-config-already-exists-fedora29.error -------------------------------------------------------------------------------- /tests/test-create-delete-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | %out docker ps --format {{.Names}} -f label=works.weave.cluster=%testName 4 | footloose delete --config %testName.footloose 5 | %out docker ps --format {{.Names}} -f label=works.weave.cluster=%testName 6 | -------------------------------------------------------------------------------- /tests/test-create-delete-%image.golden.output: -------------------------------------------------------------------------------- 1 | test-create-delete-%image-node0 2 | -------------------------------------------------------------------------------- /tests/test-create-delete-idempotent-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | footloose create --config %testName.footloose 4 | footloose delete --config %testName.footloose 5 | footloose delete --config %testName.footloose -------------------------------------------------------------------------------- /tests/test-create-delete-persistent-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | %out docker ps --format {{.Names}} -f label=works.weave.cluster=%testName 4 | %out docker inspect %testName-node0 -f "{{.HostConfig.AutoRemove}}" 5 | footloose delete --config %testName.footloose 6 | %out docker ps --format {{.Names}} -f label=works.weave.cluster=%testName 7 | -------------------------------------------------------------------------------- /tests/test-create-delete-persistent-%image.golden.output: -------------------------------------------------------------------------------- 1 | %testName-node0 2 | "false" 3 | -------------------------------------------------------------------------------- /tests/test-create-invalid-fedora29.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.static -------------------------------------------------------------------------------- /tests/test-create-invalid-fedora29.error: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-create-invalid-fedora29.error -------------------------------------------------------------------------------- /tests/test-create-invalid-fedora29.static: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-create-invalid-fedora29 3 | privateKey: test-create-invalid-fedora29-key 4 | machines: 5 | - count: 3 6 | spec: 7 | image: quay.io/footloose/centos7:latest 8 | name: node 9 | portMappings: 10 | - containerPort: 22 -------------------------------------------------------------------------------- /tests/test-create-stop-start-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | %out docker ps --format {{.Names}} -f label=works.weave.cluster=%testName 4 | footloose stop --config %testName.footloose 5 | %out docker inspect %testName-node0 -f "{{.State.Running}}" 6 | footloose start --config %testName.footloose 7 | %out docker inspect %testName-node0 -f "{{.State.Running}}" 8 | footloose delete --config %testName.footloose 9 | -------------------------------------------------------------------------------- /tests/test-create-stop-start-%image.golden.output: -------------------------------------------------------------------------------- 1 | %testName-node0 2 | "false" 3 | "true" 4 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-amazonlinux2.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- amazon-linux-extras install -y docker 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml 7 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-amazonlinux2.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-amazonlinux2.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-amazonlinux2.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-amazonlinux2.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-amazonlinux2 3 | privateKey: test-docker-in-docker-amazonlinux2-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/amazonlinux2 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-centos7.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- yum install -y docker iptables 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml 7 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-centos7.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-centos7.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-centos7.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-centos7.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-centos7 3 | privateKey: test-docker-in-docker-centos7-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/centos7 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-debian10.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- apt update && apt install -y docker.io 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml 7 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-debian10.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-debian10.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-debian10.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-debian10.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-debian10 3 | privateKey: test-docker-in-docker-debian10-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/debian10 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-fedora29.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- yum install -y docker iptables 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml 7 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-fedora29.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-fedora29.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-fedora29.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-fedora29.skip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-fedora29.skip -------------------------------------------------------------------------------- /tests/test-docker-in-docker-fedora29.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-fedora29 3 | privateKey: test-docker-in-docker-fedora29-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/fedora29 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu16.04.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- apt-get update && apt-get install -y docker.io 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu16.04.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu16.04.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-ubuntu16.04.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu16.04.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-ubuntu16.04 3 | privateKey: test-docker-in-docker-ubuntu16.04-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/ubuntu16.04 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu18.04.cmd: -------------------------------------------------------------------------------- 1 | footloose create --config %testName.yaml 2 | footloose --config %testName.yaml ssh root@node0 -- apt-get update && apt-get install -y docker.io 3 | footloose --config %testName.yaml ssh root@node0 systemctl start docker 4 | footloose --config %testName.yaml ssh root@node0 docker pull busybox 5 | %out footloose --config %testName.yaml ssh root@node0 docker run busybox echo success 6 | footloose delete --config %testName.yaml 7 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu18.04.golden.output: -------------------------------------------------------------------------------- 1 | success 2 | -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu18.04.long: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weaveworks/footloose/df593d549c8e95eff042610c7734c917e902a2c4/tests/test-docker-in-docker-ubuntu18.04.long -------------------------------------------------------------------------------- /tests/test-docker-in-docker-ubuntu18.04.yaml: -------------------------------------------------------------------------------- 1 | cluster: 2 | name: test-docker-in-docker-ubuntu18.04 3 | privateKey: test-docker-in-docker-ubuntu18.04-key 4 | machines: 5 | - count: 1 6 | spec: 7 | volumes: 8 | - type: volume 9 | destination: /var/lib/docker 10 | image: quay.io/footloose/ubuntu18.04 11 | name: node%d 12 | portMappings: 13 | - containerPort: 22 14 | privileged: true 15 | -------------------------------------------------------------------------------- /tests/test-show-ubuntu18.04.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/ubuntu18.04 2 | footloose create --config %testName.footloose 3 | footloose delete --config %testName.footloose 4 | %out footloose show --config %testName.footloose 5 | %out footloose show -o json --config %testName.footloose 6 | -------------------------------------------------------------------------------- /tests/test-show-ubuntu18.04.golden.output: -------------------------------------------------------------------------------- 1 | NAME HOSTNAME PORTS IP IMAGE CMD STATE BACKEND 2 | test-show-ubuntu18.04-node0 node0 0->{22 0} quay.io/footloose/ubuntu18.04 Not created docker 3 | { 4 | "machines": [ 5 | { 6 | "container": "test-show-ubuntu18.04-node0", 7 | "state": "Not created", 8 | "spec": { 9 | "name": "node%d", 10 | "image": "quay.io/footloose/ubuntu18.04", 11 | "portMappings": [ 12 | { 13 | "containerPort": 22 14 | } 15 | ], 16 | "backend": "docker" 17 | }, 18 | "ports": [ 19 | { 20 | "guest": 22, 21 | "host": 0 22 | } 23 | ], 24 | "hostname": "node0", 25 | "image": "quay.io/footloose/ubuntu18.04", 26 | "cmd": "", 27 | "ip": "" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tests/test-ssh-remote-command-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | %out footloose --config %testName.footloose ssh root@node0 hostname 4 | footloose delete --config %testName.footloose 5 | -------------------------------------------------------------------------------- /tests/test-ssh-remote-command-%image.golden.output: -------------------------------------------------------------------------------- 1 | node0 2 | -------------------------------------------------------------------------------- /tests/test-ssh-user-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image 2 | footloose create --config %testName.footloose 3 | %out footloose --config %testName.footloose ssh root@node0 whoami 4 | footloose delete --config %testName.footloose 5 | -------------------------------------------------------------------------------- /tests/test-ssh-user-%image.golden.output: -------------------------------------------------------------------------------- 1 | root 2 | -------------------------------------------------------------------------------- /tests/test-start-stop-specific-%image.cmd: -------------------------------------------------------------------------------- 1 | footloose config create --override --config %testName.footloose --name %testName --key %testName-key --image quay.io/footloose/%image --replicas 3 2 | footloose create --config %testName.footloose 3 | footloose stop %testName-node1 --config %testName.footloose 4 | %out docker inspect %testName-node0 -f "{{.State.Running}}" 5 | %out docker inspect %testName-node1 -f "{{.State.Running}}" 6 | footloose start %testName-node1 --config %testName.footloose 7 | %out docker inspect %testName-node1 -f "{{.State.Running}}" 8 | footloose stop %testName-node0 --config %testName.footloose 9 | footloose stop --config %testName.footloose 10 | %out docker inspect %testName-node0 -f "{{.State.Running}}" 11 | %out docker inspect %testName-node1 -f "{{.State.Running}}" 12 | %out docker inspect %testName-node2 -f "{{.State.Running}}" 13 | footloose delete --config %testName.footloose 14 | -------------------------------------------------------------------------------- /tests/test-start-stop-specific-%image.golden.output: -------------------------------------------------------------------------------- 1 | "true" 2 | "false" 3 | "true" 4 | "false" 5 | "false" 6 | "false" 7 | -------------------------------------------------------------------------------- /tests/variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": [ 3 | "amazonlinux2", 4 | "centos7", 5 | "fedora29", 6 | "ubuntu18.04", 7 | "ubuntu20.04", 8 | "debian10", 9 | "clearlinux" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | release "github.com/weaveworks/footloose/pkg/version" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var versionCmd = &cobra.Command{ 13 | Use: "version", 14 | Short: "Print footloose version", 15 | Run: showVersion, 16 | } 17 | 18 | func init() { 19 | footloose.AddCommand(versionCmd) 20 | } 21 | 22 | var version = "git" 23 | 24 | func showVersion(cmd *cobra.Command, args []string) { 25 | fmt.Println("version:", version) 26 | if version == "git" { 27 | return 28 | } 29 | release, err := release.FindLastRelease() 30 | if err != nil { 31 | fmt.Println("version: failed to check for new versions. You may want to check yourself at https://github.com/weaveworks/footloose/releases.") 32 | return 33 | } 34 | if strings.Compare(version, *release.TagName) != 0 { 35 | fmt.Printf("New version %v is available. More information at: %v\n", *release.TagName, *release.HTMLURL) 36 | } 37 | } --------------------------------------------------------------------------------