├── .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 | [](https://travis-ci.org/weaveworks/footloose)
6 | [](https://goreportcard.com/report/github.com/weaveworks/footloose)
7 | [](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 | [](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 | [](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 | }
--------------------------------------------------------------------------------