├── .github
├── labeler.yml
└── workflows
│ └── label.yml
├── .gitignore
├── .travis.yml
├── API.md
├── CHANGELOG.txt
├── Dockerfile
├── LICENSE
├── README.md
├── config
├── config.go
└── config_test.go
├── docker-compose.yml
├── examples
├── README.md
├── config.yml
├── groups
│ └── example.com.yml
├── machines
│ └── dns02.example.com.yml
└── templates
│ ├── finish.j2
│ ├── messages
│ ├── build.j2
│ ├── cancel.j2
│ ├── done.j2
│ ├── pxe-event.j2
│ └── stale.j2
│ ├── motd.j2
│ ├── partitioning
│ ├── default.j2
│ ├── raid1.j2
│ └── uefi.j2
│ └── preseed.j2
├── generate_markdown_doc.sh
├── go.mod
├── inventoryplugins
├── factory.go
├── factory_test.go
├── file.go
├── groups.go
└── netbox.go
├── machine
└── machine.go
├── main.go
├── main_test.go
└── waitron
├── filters.go
├── waitron.go
└── waitron_test.go
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | tests:
2 | - "**/*test*"
3 |
4 | factory:
5 | - "**/factory*"
6 |
7 | examples:
8 | - "examples/**/*"
9 |
10 | api:
11 | - main.go
12 |
13 | config:
14 | - config/**/*
15 |
16 | machine:
17 | - machine/**/*
18 |
19 | plugins:
20 | - inventoryplugins/**/*
21 |
22 | waitron:
23 | - waitron/**/*
24 |
25 | repo:
26 | - ./*
27 |
--------------------------------------------------------------------------------
/.github/workflows/label.yml:
--------------------------------------------------------------------------------
1 |
2 | # This workflow will triage pull requests and apply a label based on the
3 | # paths that are modified in the pull request.
4 | #
5 | # To use this workflow, you will need to set up a .github/labeler.yml
6 | # file with configuration. For more information, see:
7 | # https://github.com/actions/labeler/blob/master/README.md
8 |
9 | name: Labeler
10 | on: [pull_request]
11 |
12 | jobs:
13 | label:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/labeler@v2
19 | with:
20 | repo-token: "${{ secrets.GITHUB_TOKEN }}"
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /bin/
2 | /docs/
3 | vp
4 | vendor
5 | go.sum
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 |
3 | go:
4 | - tip
5 |
6 |
--------------------------------------------------------------------------------
/API.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # Waitron
5 | Endpoints for server provisioning
6 |
7 |
8 | ## Informations
9 |
10 | ### Version
11 |
12 | 2
13 |
14 | ### Contact
15 |
16 |
17 |
18 | ## Content negotiation
19 |
20 | ### URI Schemes
21 | * http
22 |
23 | ### Consumes
24 | * application/json
25 |
26 | ### Produces
27 | * application/json
28 |
29 | ## All endpoints
30 |
31 | ### operations
32 |
33 | | Method | URI | Name | Summary |
34 | |---------|---------|--------|---------|
35 | | GET | /definition/{hostname}/{type} | [get definition hostname type](#get-definition-hostname-type) | Return the waitron configuration details for a machine. Note that "build type" is technically not required, depending on your config. |
36 | | GET | /done/{hostname}/{token} | [get done hostname token](#get-done-hostname-token) | Remove the server from build mode |
37 | | GET | /health | [get health](#get-health) | Check that Waitron is running |
38 | | GET | /job/{token} | [get job token](#get-job-token) | Return details for the specified job token |
39 | | GET | /status | [get status](#get-status) | Dictionary with jobs and status |
40 | | GET | /status/{hostname} | [get status hostname](#get-status-hostname) | Build status of the server |
41 | | GET | /template/{template}/{hostname}/{token} | [get template template hostname token](#get-template-template-hostname-token) | Render either the finish or the preseed template |
42 | | GET | /v1/boot/{macaddr} | [get v1 boot macaddr](#get-v1-boot-macaddr) | Dictionary with kernel, intrd(s) and commandline for pixiecore |
43 | | PUT | /build/{hostname}/{type} | [put build hostname type](#put-build-hostname-type) | Put the server in build mode |
44 | | PUT | /cancel/{hostname}/{token} | [put cancel hostname token](#put-cancel-hostname-token) | Remove the server from build mode |
45 | | PUT | /cleanhistory | [put cleanhistory](#put-cleanhistory) | Clear all completed jobs from the in-memory history of Waitron |
46 |
47 |
48 |
49 | ## Paths
50 |
51 | ### Return the waitron configuration details for a machine. Note that "build type" is technically not required, depending on your config. (*GetDefinitionHostnameType*)
52 |
53 | ```
54 | GET /definition/{hostname}/{type}
55 | ```
56 |
57 | Return the waitron configuration details for a machine
58 |
59 | #### Parameters
60 |
61 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
62 | |------|--------|------|---------|-----------| :------: |---------|-------------|
63 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
64 | | type | `path` | string | `string` | | ✓ | | Build Type |
65 |
66 | #### All responses
67 | | Code | Status | Description | Has headers | Schema |
68 | |------|--------|-------------|:-----------:|--------|
69 | | [200](#get-definition-hostname-type-200) | OK | Machine config in JSON format. | | [schema](#get-definition-hostname-type-200-schema) |
70 | | [404](#get-definition-hostname-type-404) | Not Found | Unable to find host definition for '' '' '' | | [schema](#get-definition-hostname-type-404-schema) |
71 | | [500](#get-definition-hostname-type-500) | Internal Server Error | Bad machine data for '' '' '' | | [schema](#get-definition-hostname-type-500-schema) |
72 |
73 | #### Responses
74 |
75 |
76 | ##### 200 - Machine config in JSON format.
77 | Status: OK
78 |
79 | ###### Schema
80 |
81 |
82 |
83 |
84 |
85 | ##### 404 - Unable to find host definition for '' '' ''
86 | Status: Not Found
87 |
88 | ###### Schema
89 |
90 |
91 |
92 |
93 |
94 | ##### 500 - Bad machine data for '' '' ''
95 | Status: Internal Server Error
96 |
97 | ###### Schema
98 |
99 |
100 |
101 |
102 |
103 | ### Remove the server from build mode (*GetDoneHostnameToken*)
104 |
105 | ```
106 | GET /done/{hostname}/{token}
107 | ```
108 |
109 | Remove the server from build mode
110 |
111 | #### Parameters
112 |
113 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
114 | |------|--------|------|---------|-----------| :------: |---------|-------------|
115 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
116 | | token | `path` | string | `string` | | ✓ | | Token |
117 |
118 | #### All responses
119 | | Code | Status | Description | Has headers | Schema |
120 | |------|--------|-------------|:-----------:|--------|
121 | | [200](#get-done-hostname-token-200) | OK | {"State": "OK"} | | [schema](#get-done-hostname-token-200-schema) |
122 | | [500](#get-done-hostname-token-500) | Internal Server Error | Failed to finish build mode | | [schema](#get-done-hostname-token-500-schema) |
123 |
124 | #### Responses
125 |
126 |
127 | ##### 200 - {"State": "OK"}
128 | Status: OK
129 |
130 | ###### Schema
131 |
132 |
133 |
134 |
135 |
136 | ##### 500 - Failed to finish build mode
137 | Status: Internal Server Error
138 |
139 | ###### Schema
140 |
141 |
142 |
143 |
144 |
145 | ### Check that Waitron is running (*GetHealth*)
146 |
147 | ```
148 | GET /health
149 | ```
150 |
151 | Check that Waitron is running
152 |
153 | #### All responses
154 | | Code | Status | Description | Has headers | Schema |
155 | |------|--------|-------------|:-----------:|--------|
156 | | [200](#get-health-200) | OK | {"State": "OK"} | | [schema](#get-health-200-schema) |
157 |
158 | #### Responses
159 |
160 |
161 | ##### 200 - {"State": "OK"}
162 | Status: OK
163 |
164 | ###### Schema
165 |
166 |
167 |
168 |
169 |
170 | ### Return details for the specified job token (*GetJobToken*)
171 |
172 | ```
173 | GET /job/{token}
174 | ```
175 |
176 | Return details for the specified job token
177 |
178 | #### Parameters
179 |
180 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
181 | |------|--------|------|---------|-----------| :------: |---------|-------------|
182 | | token | `path` | string | `string` | | ✓ | | Token |
183 |
184 | #### All responses
185 | | Code | Status | Description | Has headers | Schema |
186 | |------|--------|-------------|:-----------:|--------|
187 | | [200](#get-job-token-200) | OK | Job details in JSON format. | | [schema](#get-job-token-200-schema) |
188 | | [404](#get-job-token-404) | Not Found | Job not found | | [schema](#get-job-token-404-schema) |
189 |
190 | #### Responses
191 |
192 |
193 | ##### 200 - Job details in JSON format.
194 | Status: OK
195 |
196 | ###### Schema
197 |
198 |
199 |
200 |
201 |
202 | ##### 404 - Job not found
203 | Status: Not Found
204 |
205 | ###### Schema
206 |
207 |
208 |
209 |
210 |
211 | ### Dictionary with jobs and status (*GetStatus*)
212 |
213 | ```
214 | GET /status
215 | ```
216 |
217 | Dictionary with jobs and status
218 |
219 | #### All responses
220 | | Code | Status | Description | Has headers | Schema |
221 | |------|--------|-------------|:-----------:|--------|
222 | | [200](#get-status-200) | OK | Dictionary with jobs and status | | [schema](#get-status-200-schema) |
223 | | [500](#get-status-500) | Internal Server Error | The error encountered | | [schema](#get-status-500-schema) |
224 |
225 | #### Responses
226 |
227 |
228 | ##### 200 - Dictionary with jobs and status
229 | Status: OK
230 |
231 | ###### Schema
232 |
233 |
234 |
235 |
236 |
237 | ##### 500 - The error encountered
238 | Status: Internal Server Error
239 |
240 | ###### Schema
241 |
242 |
243 |
244 |
245 |
246 | ### Build status of the server (*GetStatusHostname*)
247 |
248 | ```
249 | GET /status/{hostname}
250 | ```
251 |
252 | Build status of the server
253 |
254 | #### Parameters
255 |
256 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
257 | |------|--------|------|---------|-----------| :------: |---------|-------------|
258 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
259 |
260 | #### All responses
261 | | Code | Status | Description | Has headers | Schema |
262 | |------|--------|-------------|:-----------:|--------|
263 | | [200](#get-status-hostname-200) | OK | The status: (installing or installed) | | [schema](#get-status-hostname-200-schema) |
264 | | [404](#get-status-hostname-404) | Not Found | Failed to find active job for host | | [schema](#get-status-hostname-404-schema) |
265 |
266 | #### Responses
267 |
268 |
269 | ##### 200 - The status: (installing or installed)
270 | Status: OK
271 |
272 | ###### Schema
273 |
274 |
275 |
276 |
277 |
278 | ##### 404 - Failed to find active job for host
279 | Status: Not Found
280 |
281 | ###### Schema
282 |
283 |
284 |
285 |
286 |
287 | ### Render either the finish or the preseed template (*GetTemplateTemplateHostnameToken*)
288 |
289 | ```
290 | GET /template/{template}/{hostname}/{token}
291 | ```
292 |
293 | Render either the finish or the preseed template
294 |
295 | #### Parameters
296 |
297 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
298 | |------|--------|------|---------|-----------| :------: |---------|-------------|
299 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
300 | | template | `path` | string | `string` | | ✓ | | The template to be rendered |
301 | | token | `path` | string | `string` | | ✓ | | Token |
302 |
303 | #### All responses
304 | | Code | Status | Description | Has headers | Schema |
305 | |------|--------|-------------|:-----------:|--------|
306 | | [200](#get-template-template-hostname-token-200) | OK | Rendered template | | [schema](#get-template-template-hostname-token-200-schema) |
307 | | [400](#get-template-template-hostname-token-400) | Bad Request | Unable to render template | | [schema](#get-template-template-hostname-token-400-schema) |
308 |
309 | #### Responses
310 |
311 |
312 | ##### 200 - Rendered template
313 | Status: OK
314 |
315 | ###### Schema
316 |
317 |
318 |
319 |
320 |
321 | ##### 400 - Unable to render template
322 | Status: Bad Request
323 |
324 | ###### Schema
325 |
326 |
327 |
328 |
329 |
330 | ### Dictionary with kernel, intrd(s) and commandline for pixiecore (*GetV1BootMacaddr*)
331 |
332 | ```
333 | GET /v1/boot/{macaddr}
334 | ```
335 |
336 | Dictionary with kernel, intrd(s) and commandline for pixiecore
337 |
338 | #### Parameters
339 |
340 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
341 | |------|--------|------|---------|-----------| :------: |---------|-------------|
342 | | macaddr | `path` | string | `string` | | ✓ | | MacAddress |
343 |
344 | #### All responses
345 | | Code | Status | Description | Has headers | Schema |
346 | |------|--------|-------------|:-----------:|--------|
347 | | [200](#get-v1-boot-macaddr-200) | OK | Dictionary with kernel, intrd(s) and commandline for pixiecore | | [schema](#get-v1-boot-macaddr-200-schema) |
348 | | [500](#get-v1-boot-macaddr-500) | Internal Server Error | failed to get pxe config: | | [schema](#get-v1-boot-macaddr-500-schema) |
349 |
350 | #### Responses
351 |
352 |
353 | ##### 200 - Dictionary with kernel, intrd(s) and commandline for pixiecore
354 | Status: OK
355 |
356 | ###### Schema
357 |
358 |
359 |
360 |
361 |
362 | ##### 500 - failed to get pxe config:
363 | Status: Internal Server Error
364 |
365 | ###### Schema
366 |
367 |
368 |
369 |
370 |
371 | ### Put the server in build mode (*PutBuildHostnameType*)
372 |
373 | ```
374 | PUT /build/{hostname}/{type}
375 | ```
376 |
377 | Put the server in build mode
378 |
379 | #### Consumes
380 | * application/json
381 |
382 | #### Produces
383 | * application/json
384 |
385 | #### Parameters
386 |
387 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
388 | |------|--------|------|---------|-----------| :------: |---------|-------------|
389 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
390 | | type | `path` | string | `string` | | ✓ | | Build Type |
391 | | {object} | `body` | string | `string` | | ✓ | | Machine definition if desired. Can be used to override nearly all properties of a compiled machine. See examples directory for machine definition. |
392 |
393 | #### All responses
394 | | Code | Status | Description | Has headers | Schema |
395 | |------|--------|-------------|:-----------:|--------|
396 | | [200](#put-build-hostname-type-200) | OK | {"State": "OK", "Token": } | | [schema](#put-build-hostname-type-200-schema) |
397 | | [500](#put-build-hostname-type-500) | Internal Server Error | Failed to set build mode on hostname | | [schema](#put-build-hostname-type-500-schema) |
398 |
399 | #### Responses
400 |
401 |
402 | ##### 200 - {"State": "OK", "Token": }
403 | Status: OK
404 |
405 | ###### Schema
406 |
407 |
408 |
409 |
410 |
411 | ##### 500 - Failed to set build mode on hostname
412 | Status: Internal Server Error
413 |
414 | ###### Schema
415 |
416 |
417 |
418 |
419 |
420 | ### Remove the server from build mode (*PutCancelHostnameToken*)
421 |
422 | ```
423 | PUT /cancel/{hostname}/{token}
424 | ```
425 |
426 | Remove the server from build mode
427 |
428 | #### Consumes
429 | * application/json
430 |
431 | #### Produces
432 | * application/json
433 |
434 | #### Parameters
435 |
436 | | Name | Source | Type | Go type | Separator | Required | Default | Description |
437 | |------|--------|------|---------|-----------| :------: |---------|-------------|
438 | | hostname | `path` | string | `string` | | ✓ | | Hostname |
439 | | token | `path` | string | `string` | | ✓ | | Token |
440 | | {object} | `body` | string | `string` | | ✓ | | Machine definition if desired. Can be used to override nearly all properties of a compiled machine. See examples directory for machine definition. |
441 |
442 | #### All responses
443 | | Code | Status | Description | Has headers | Schema |
444 | |------|--------|-------------|:-----------:|--------|
445 | | [200](#put-cancel-hostname-token-200) | OK | {"State": "OK"} | | [schema](#put-cancel-hostname-token-200-schema) |
446 | | [500](#put-cancel-hostname-token-500) | Internal Server Error | Failed to cancel build mode | | [schema](#put-cancel-hostname-token-500-schema) |
447 |
448 | #### Responses
449 |
450 |
451 | ##### 200 - {"State": "OK"}
452 | Status: OK
453 |
454 | ###### Schema
455 |
456 |
457 |
458 |
459 |
460 | ##### 500 - Failed to cancel build mode
461 | Status: Internal Server Error
462 |
463 | ###### Schema
464 |
465 |
466 |
467 |
468 |
469 | ### Clear all completed jobs from the in-memory history of Waitron (*PutCleanhistory*)
470 |
471 | ```
472 | PUT /cleanhistory
473 | ```
474 |
475 | Clear all completed jobs from the in-memory history of Waitron
476 |
477 | #### All responses
478 | | Code | Status | Description | Has headers | Schema |
479 | |------|--------|-------------|:-----------:|--------|
480 | | [200](#put-cleanhistory-200) | OK | {"State": "OK"} | | [schema](#put-cleanhistory-200-schema) |
481 | | [500](#put-cleanhistory-500) | Internal Server Error | Failed to clean history | | [schema](#put-cleanhistory-500-schema) |
482 |
483 | #### Responses
484 |
485 |
486 | ##### 200 - {"State": "OK"}
487 | Status: OK
488 |
489 | ###### Schema
490 |
491 |
492 |
493 |
494 |
495 | ##### 500 - Failed to clean history
496 | Status: Internal Server Error
497 |
498 | ###### Schema
499 |
500 |
501 |
502 |
503 |
504 | ## Models
505 |
--------------------------------------------------------------------------------
/CHANGELOG.txt:
--------------------------------------------------------------------------------
1 | Current
2 | -------
3 | * Near complete rewrite of codebase.
4 | * Reorganized packages/directories.
5 | * Added more fields to machine and interface structs. (Tags, description, vlan info, z-side endpoint details)
6 | * Added simple inventory plug-in framework with weighting.
7 | * Added netbox inventory plug-in.
8 | * Moved group yml and file yml inventory handling to separate inventory plug-ins.
9 | * Added build types and removed rescue mode since this can just be a build type now.
10 | * Added regex_replace tag and from_yaml filter to pongo2.
11 | * Moved build commands to use temp files.
12 | * Added build commands config option for PXE requests.
13 | * Added build commands config option for requests from unknown MACs.
14 | * Added caching job history.
15 | * Added endpoints for retrieving and cleaning job history.
16 | * Added better status tracking.
17 | * Added ability to handle unknown MACs via.
18 | * Normalizing MACs.
19 | * Now associating all MACs of a machine with a job.
20 | * Separated endpoints for job details and machine details.
21 | * Added super simple leveled, buffered logging until a logger is chosen.
22 | * Updated responses from some of the API endpoints.
23 | * Updated docker-compose.yml and Dockerfile
24 | * Updated README and examples.
25 |
26 |
27 | v2.0.0
28 | -------
29 | * Added in build commands.
30 | * Removed the ability to name a machine within its config. The config file name now must match the device.
31 | * Added cancel endpoint.
32 | * Added config merging: machines now inherit things from the main config.yml as defaults.
33 | * Created groups as a place to store config between main config and machine config.
34 | * Added rescue build mode.
35 |
36 | v1.0.0
37 | -------
38 |
39 | * Just a branch to store the original Waitron.
40 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.15-buster as builder
2 |
3 | ENV GOPATH=/opt/go
4 |
5 | RUN mkdir -p /opt/go/src/github.com/ns1/waitron
6 | COPY . /opt/go/src/github.com/ns1/waitron/
7 | RUN cd /opt/go/src/github.com/ns1/waitron \
8 | && go build -o bin/waitron . \
9 | && mv bin/waitron /usr/local/bin/waitron
10 |
11 | FROM debian:buster-slim
12 | # Install some basic tools for use in build commands.
13 | RUN apt-get -y update && apt-get -y install wget curl ipmitool strace openssh-client iputils-ping dnsutils httpie iptables
14 | COPY --from=builder /usr/local/bin/waitron /usr/local/bin/waitron
15 |
16 | ENTRYPOINT [ "waitron", "--config", "/etc/waitron/config.yml"]
17 |
18 | HEALTHCHECK --interval=10s --timeout=5s --start-period=30s CMD curl -X GET http://localhost/health
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2015 Johan Haals
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Historical info and credits
2 | The original Waitron, found on the version 1.0.0 branch in this repo, was originally written by [jhaals](https://github.com/jhaals).
3 | We at NS1 needed an internal build system that would allow us to meet a specific set of requirements and found [pixiecore](https://github.com/danderson/pixiecore), and eventually Waitron in an unmaintained state. Jhaals was kind enough to let NS1 take over the project, and we've continued maintaining it since.
4 |
5 | The 2.0.0 branch of this repo still has a large portion of the original from Jhaals, with a few additions we needed at the time. However, the current main branch (representing post 2.0.0) is an almost complete rewrite of the original Waitron code, but the spirit of the original Waitron lives on!
6 |
7 |
8 | # Waitron
9 | > This project is in [maintenance](https://github.com/ns1/community/blob/master/project_status/MAINTENANCE.md) status.
10 |
11 | [](https://travis-ci.org/ns1/waitron)
12 |
13 | Waitron is used to build machines (primarily bare-metal, but anything that understands PXE booting will work) based on definitions from any number of specified inventory sources.
14 |
15 | When a server is set in _build mode_, Waitron will deliver a kernel/initrd/commandline that can be used by [pixiecore](https://github.com/danderson/pixiecore) (in API mode) to boot and install the machine.
16 |
17 | Try it out in a docker:
18 |
19 | ```
20 | docker build -t waitron . && docker-compose -f ./docker-compose.yml up
21 | ```
22 |
23 | ```
24 | $ curl -X PUT http://localhost/build/dns02.example.com
25 | {"Token":"fb300739-b4ce-4740-af26-80a99326ee05"
26 |
27 | $ curl -X GET http://localhost/status/dns02.example.com
28 | pending
29 |
30 | curl -X PUT http://localhost/cancel/dns02.example.com/fb300739-b4ce-4740-af26-80a99326ee05
31 | {"State":"OK"}
32 |
33 | ```
34 |
35 | ### Config file
36 | See the example [config](examples/config.yml) for descriptions and examples of configuration options.
37 |
38 | ### API
39 |
40 | See [API.md](API.md) file in the repo
41 |
42 | Contributions
43 | ---
44 | Pull Requests and issues are welcome. See the [NS1 Contribution Guidelines](https://github.com/ns1/community) for more information.
45 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "io/ioutil"
5 | "strings"
6 |
7 | "gopkg.in/yaml.v2"
8 | )
9 |
10 | type LogLevel int
11 |
12 | const (
13 | LogLevelError LogLevel = iota
14 | LogLevelWarning
15 | LogLevelInfo
16 | LogLevelDebug
17 | )
18 |
19 | func (l LogLevel) String() string {
20 | return [...]string{"ERROR", "WARN", "INFO", "DEBUG"}[l]
21 | }
22 |
23 | var ll = map[string]LogLevel{
24 | "ERROR": LogLevelError,
25 | "WARN": LogLevelWarning,
26 | "INFO": LogLevelInfo,
27 | "DEBUG": LogLevelDebug,
28 | }
29 |
30 | type BuildCommand struct {
31 | Command string
32 | TimeoutSeconds int `yaml:"timeout_seconds"`
33 | ErrorsFatal bool `yaml:"errors_fatal"`
34 | ShouldLog bool `yaml:"should_log"`
35 | }
36 |
37 | type BuildType struct {
38 | Cmdline string `yaml:"cmdline,omitempty"`
39 | Kernel string `yaml:"kernel,omitempty"`
40 | Initrd []string `yaml:"initrd,omitempty"`
41 | ImageURL string `yaml:"image_url,omitempty"`
42 |
43 | OperatingSystem string `yaml:"operatingsystem,omitempty"`
44 | Finish string `yaml:"finish,omitempty"`
45 | Preseed string `yaml:"preseed,omitempty"`
46 | Params map[string]string `yaml:"params,omitempty"`
47 |
48 | StaleBuildThresholdSeconds int `yaml:"stale_build_threshold_secs,omitempty"`
49 |
50 | StaleBuildCommands []BuildCommand `yaml:"stalebuild_commands,omitempty"`
51 | PreBuildCommands []BuildCommand `yaml:"prebuild_commands,omitempty"`
52 | PostBuildCommands []BuildCommand `yaml:"postbuild_commands,omitempty"`
53 | CancelBuildCommands []BuildCommand `yaml:"cancelbuild_commands,omitempty"`
54 | UnknownBuildCommands []BuildCommand `yaml:"unknownbuild_commands,omitempty"`
55 | PxeEventCommands []BuildCommand `yaml:"pxeevent_commands,omitempty"`
56 |
57 | Tags []string `yaml:"tags`
58 | Description string `yaml:"description`
59 | }
60 |
61 | /*
62 | All the wacky marshal/unmarshal stuff being done internall uses the yaml lib,
63 | and we only start doing JSON when we want to respond to API calls.
64 | That means, for now, we can easily hide password values with a custom MarshalJSON.
65 | */
66 | type Password string
67 |
68 | func (pw *Password) MarshalJSON() ([]byte, error) {
69 | return []byte{'"', '*', '*', '*', '"'}, nil
70 | }
71 |
72 | type MachineInventoryPluginSettings struct {
73 | Name string `yaml:"name"`
74 | Type string `yaml:"type"`
75 | Source string `yaml:"source"`
76 | AuthUser string `yaml:"auth_user"`
77 | AuthPassword Password `yaml:"auth_password"`
78 | AuthToken Password `yaml:"auth_token"`
79 | AdditionalOptions map[string]interface{} `yaml:"additional_options"`
80 | Weight int `yaml:"weight"`
81 | WriteEnabled bool `yaml:"writable"`
82 | Disabled bool `yaml:"disabled"`
83 | SupplementalOnly bool `yaml:"supplemental_only"`
84 | }
85 |
86 | // Config is our global configuration file
87 | /*
88 | The omitempty's need to be cleaned up. They're mostly there to let someone see the state of things when they requested a build.
89 | If they try to override some of the values in a machine definition from an inventory plugin, it'll show in the JSON
90 | response that the API endpoints provide, but it'll be a lie because they won't have actually changed the config.
91 | */
92 | type Config struct {
93 | TempPath string `yaml:"temp_path,omitempty"`
94 | TemplatePath string `yaml:"templatepath,omitempty"`
95 | StaticFilesPath string `yaml:"staticspath,omitempty"`
96 | BaseURL string `yaml:"baseurl,omitempty"`
97 |
98 | MachineInventoryPlugins []MachineInventoryPluginSettings `yaml:"inventory_plugins,omitempty"`
99 | BuildTypes map[string]BuildType `yaml:"build_types,omitempty"`
100 | StaleBuildCheckFrequency int `yaml:"stale_build_check_frequency_secs,omitempty"`
101 | HistoryCacheSeconds int `yaml:"history_cache_seconds,omitempty"`
102 | LogLevelName string `yaml:"log_level,omitempty"`
103 | LogLevel LogLevel `yaml:"-,omitempty"`
104 |
105 | BuildType `yaml:",inline"`
106 | }
107 |
108 | // Loads config.yaml and returns a Config struct
109 | func LoadConfig(configPath string) (*Config, error) {
110 |
111 | var c Config
112 |
113 | data, err := ioutil.ReadFile(configPath)
114 | if err != nil {
115 | return nil, err
116 | }
117 |
118 | err = yaml.Unmarshal(data, &c)
119 | if err != nil {
120 | return nil, err
121 | }
122 |
123 | c.LogLevel = ll[strings.ToUpper(c.LogLevelName)]
124 |
125 | return &c, nil
126 | }
127 |
--------------------------------------------------------------------------------
/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestLoadConfig(t *testing.T) {
8 | _, err := LoadConfig("../examples/config.yml")
9 | if err != nil {
10 | t.Errorf("Failed to load test configuration")
11 | }
12 | }
13 |
14 | func TestInvalidConfig(t *testing.T) {
15 | _, err := LoadConfig("invalid.yml")
16 | if err == nil {
17 | t.Errorf("No error presented when invalid configuration is loaded")
18 | }
19 | }
20 |
21 | func TestInvalidYAMLConfig(t *testing.T) {
22 | _, err := LoadConfig("README.md")
23 | if err == nil {
24 | t.Errorf("No error presented when invalid configuration is loaded")
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | waitron:
2 | #cap_add:
3 | #- NET_ADMIN # Uncomment if you expect to have waitron manipulate local iptables configs with build commands.
4 | command: ' --port 80 '
5 | image: waitron:latest
6 | log_driver: syslog
7 | log_opt:
8 | tag: '{{ .ImageName }}/{{ .Name }}'
9 | net: host
10 | volumes:
11 | - ./examples/:/etc/waitron/
12 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | ### WIP: A (very) brief how-to for a simple Waitron set-up
2 |
3 | These steps assume you already have have a DHCP server handing out IP addresses. Waitron and Pixiecore work along-side _existing_ DHCP servers. It's possible to hand out temporary, local IPs with DHCP and then switch to public v4 or v6 for the rest of the install process.
4 |
5 | As long as your DHCP server is handing out addresses that will allow you to reach your local pixiecore installation, things should just work. If you're planning to do a full OS install from netboot, which is what the examples in the repo attempt, you'll only need to ensure that the IP settings in dns02.example.com.yml can reach the outside world.
6 |
7 | In the examples/machines directory, make sure to update dns02.example.com.yml with the MAC of your installing machine and the IP details you'd like it to have. If the server running Waitron has access to run IPMI commands on your target device, set the IPMI details in dns02.example.com.yml and uncomment the ipmitool lines in the examples/templates/messages/*.j2 files.
8 |
9 | Also, change `SOME_PASSWORD_THAT_YOU_SHOULD_CHANGE` in the examples/templates/preseed.j2 file. :)
10 |
11 | # pixiecore
12 |
13 | You'll need to run pixiecore on a machine in the same network where you plan to boot your new machine.
14 |
15 | First, build pixiecore:
16 |
17 | ```
18 | (GOROOT=/usr/local/go; cd /tmp/ \
19 | && git clone https://github.com/google/netboot.git \
20 | && mkdir -p $GOROOT/src/go.universe.tf \
21 | && ln -s /tmp/netboot $GOROOT/src/go.universe.tf/netboot \
22 | && go get golang.org/x/net/ipv4 \
23 | && go get golang.org/x/net/ipv6 \
24 | && go get golang.org/x/net/bpf \
25 | && go get golang.org/x/crypto/nacl/secretbox \
26 | && go get github.com/spf13/cobra \
27 | && go get github.com/spf13/viper \
28 | && cd netboot/cmd/pixiecore \
29 | && CGO_ENABLED=0 go build -o pixiecore main.go \
30 | && mv pixiecore /usr/local/bin/pixiecore)
31 | ```
32 |
33 | Next, run pixiecore:
34 |
35 | ```
36 | pixiecore api http://my_waitron_location --dhcp-no-bind --log-timestamps --debug --port 5058 --status-port 5058
37 | ```
38 |
39 | # Waitron
40 |
41 | Next, run an instance of Waitron at any location that would be reachable by the machine running pixiecore via http:
42 |
43 | ```
44 | git clone https://github.com/ns1/waitron.git && cd waitron
45 | docker build -t waitron . && docker-compose -f ./docker-compose.yml up
46 | ```
47 |
48 | Then put your machine into build mode:
49 |
50 | ```
51 | curl -X PUT http://my_waitron_location/build/dns02.example.com
52 | ```
53 |
54 | If you've configured IPMI, Waitron and the example files will handle putting your new machine into PXE-mode on next boot, and it will handle power cycling your target machine.
55 |
56 | If you _haven't_ configued IPMI, you'll need to power cycle the target machine to kick off the boot process, and you might also have to use the BIOS or a start-up key to force it to PXE boot.
57 |
58 | From that point, Waitron should be able to handle the rest of the install process, and you can check status periodically if you like:
59 |
60 | ```
61 | curl -X GET http://my_waitron_location/status/dns02.example.com
62 | ```
63 |
64 |
--------------------------------------------------------------------------------
/examples/config.yml:
--------------------------------------------------------------------------------
1 | # The URL of your Waitron service
2 | baseurl: http://waitron.example.com:7078
3 |
4 | # A directory that can be used by Waitron and plugins for temporary data/files.
5 | # Plugins _should_ respect this setting.
6 | temp_path: /tmp
7 |
8 | # During an active build, anything in here can be requested and will be rendered and returned in the API response.
9 | # preseed/cloud-init, finish, and any other templates used in your build should go here.
10 | templatepath: /etc/waitron/templates
11 |
12 | # Any files that your build depends on, or if you just want to host some of your own images,
13 | # such as a small rescue kernel+initrd, can be stored here and will be accessible at [baseurl]/files/
14 | staticspath: /etc/waitron/files
15 |
16 | # In order of increasing verbosity: ERROR, WARN, INFO, DEBUG
17 | log_level: INFO
18 |
19 | # For how long do you want the job history json blog to be cached once requested?
20 | history_cache_seconds: 20
21 |
22 | # During builds, inventory plugins will be checked for machine details in the order below.
23 | # Details found will me merged according to the details for the [weight] option below.
24 | inventory_plugins:
25 | - name: groups
26 | type: groups
27 | # Only use the details returned from this plugin if the device is found in another plugig.
28 | # I.e., if this plugin is the only place we found the machine, treat it as not found.
29 | supplemental_only: True
30 | # [weight] is used to determine how inventory data should be merged. The default is 0.
31 | # Plugins of the same weight can be merged.
32 | # Plugins of greater weight will COMPLETELY overwrite data of plugins with lower weights that had been compiled prior to their execution.
33 | #weight: 0
34 | additional_options:
35 | # [grouppath] is required for this plugin, but the existence of group files within the path is not required.
36 | # group data is optional data that can be used to include "group-wide" config details.
37 | # For example, a host named dns02.example.com.yml would be seen as belonging to the group "example.com"
38 | # During builds, /etc/waitron/groups/example.com.yml would be searched and have its config details used if found.
39 | grouppath: /etc/waitron/groups/
40 | - name: file
41 | type: file
42 | additional_options:
43 | # [machinepath] is a required path for this plugin.
44 | # If a build is requested for hostname "dns02.example.com",
45 | # this path would be searched for dns02.example.com.yml.
46 | machinepath: /etc/waitron/machines/
47 |
48 | # type:netbox will let you pull inventory data from a netbox API server.
49 | # It's possible to tag things in netbox in order to have the plugin attempt to fill out fields for you.
50 | # The tag waitron_gateway on an IP address will let the plugin attempt to set the Gateway4 and Gateway6 values for the interface in machine.Network[].
51 | # For example, you could then access it in a template with {{ machine.Network[0].Gateway6 }}
52 | # The tag waitron_ipmi on an interface in netbox will let the plugin pull any attached IP addresses on the interface
53 | # to populate the machine.IpmiAddressRaw value for use in templates.
54 | # The plugin will also attach any IP and interface tags to the Tags value of that object in Waitron for use in templates. Example: {% if "fallback_interface" in machine.Network[0].Tags %}
55 | # The plugin will also store the netbox "rendered config context" of the machine in machine.Params.config_context
56 | # which can then be converted to a template object with Waitron's custom from_yaml filter.
57 | # {% with configcontext = machine.Params.config_context|from_yaml %} {{ configcontext.some_netbox_context_value }} {% endwith %}
58 | - name: netbox
59 | disabled: True
60 | type: netbox
61 | source: "https://netbox.example.com/api"
62 | auth_token: "some_netbox_api_token"
63 | additional_options:
64 | enabled_assets_only: False # Do you want to restrict netbox query results to enabled devices/interfaces/IPs only?
65 |
66 | #############################################################################
67 | # New build types can be specified here. #
68 | # Any option that exists in the "DEFAULTS" section below can be overridden. #
69 | #############################################################################
70 | build_types:
71 | rescue:
72 | image_url: http://waitron.example.com:7078/files/ # See "staticspath" above for more details about the value used here.
73 | kernel: vmlinuz64
74 | initrd: [corepure64.gz]
75 | cmdline: "{% with configcontext = machine.Params.config_context|from_yaml %}{% for interface in machine.Network %}{% if 'waitron_provisioning' in interface.Tags %} loglevel=3 nameservers=2001:4860:4860::8888 ipv6_address={{interface.Addresses6.0.IPAddress}} ipv6_gateway={{interface.Gateway6}} ipv6_cidr={{interface.Addresses6.0.Cidr}}{% endif %}{% endfor %}{% endwith %}"
76 | stale_build_threshold_secs: 9000
77 | params:
78 | nameservers: "8.8.8.8"
79 | os_version_name: "rescue-image"
80 | # For "power users," _unknown_ is a special, optional build type that will be invoked when Waitron receives a MAC that it doesn't know about.
81 | # After checking all inventory plugins using the incoming MAC, if no matching device is found, it will use the _unknown_ build type.
82 | # There is a corresponding [unknownbuild_commands] option below that can be used to run any desired commands when an unknown MAC is seen.
83 | # Note that any templating in cmdline or in any build commands will have limited information and should only expect to have "{{ Token }}" available,
84 | # which will hold the MAC of the unknown device.
85 | # Also, this WILL NOT work well with the file plugin because the file plugin cannot currently search by MAC and will trigger
86 | # an _unknown_ for any machine not in build mode if it is the only plugin in use.
87 | _unknown_:
88 | image_url: http://waitron.example.com:7078/files/
89 | kernel: vmlinuz64
90 | initrd: [corepure64.gz]
91 | cmdline: " loglevel=3 "
92 | stale_build_threshold_secs: 9000
93 | params:
94 | nameservers: "8.8.8.8"
95 | os_version_name: "discovery-image"
96 |
97 |
98 | ######################################## HOW DETAILS ARE MERGED ###############################################
99 | # During builds, the order of merging looks like this [base config (config.yml)] -> [build type] -> [machine] #
100 | # Details specified in machine details have the highest precedence. #
101 | # Array/lists are merged as details are merged. #
102 | # Dictionaries are merged but existing simple values are replaced. #
103 | # Simple values get replaced. #
104 | ###############################################################################################################
105 |
106 | ################################# DEFAULTS ############################################
107 | # Everything below will function as "default" build options. #
108 | # If no build type is specified during the build request, these options will be used. #
109 | # The can be overridden in whole or in part in a build-type specification #
110 | #######################################################################################
111 | # NOTE: It's possible to use iPXE variables such as ${netX/mac} in the cmdline.
112 | # For example, rather than use interface and ksdevice values below, some users may be able
113 | # to simply use netcfg/choose_interface=${netX/mac} to let the netboot process
114 | # automatically select the interface that triggered the PXE process.
115 | cmdline: >-
116 | {% with configcontext = machine.Params.config_context|from_yaml %}{% for interface in machine.Network %}{% if 'waitron_provisioning' in interface.Tags %}netcfg/choose_interface=${netX/mac} netcfg/get_nameservers="{{ configcontext.nameservers | default: machine.Params.nameservers }}" netcfg/disable_dhcp=true netcfg/get_ipaddress={{interface.Addresses6.0.IPAddress}} netcfg/get_gateway={{interface.Gateway6}} netcfg/get_netmask={{interface.Addresses6.0.Netmask}} url={{ BaseURL }}/template/preseed/{{ Hostname }}/{{ Token }} ramdisk_size=10800 root=/dev/rd/0 rw auto hostname={{ Hostname }} console-setup/ask_detect=false console-setup/layout=USA console-setup/variant=USA keyboard-configuration/layoutcode=us localechooser/translation/warn-light=true localechooser/translation/warn-severe=true locale=en_US{% endif %}{% endfor %}{% endwith %}
117 |
118 | operatingsystem: "18.04"
119 | kernel: linux
120 | image_url: http://archive.ubuntu.com/ubuntu/dists/bionic-updates/main/installer-amd64/current/images/netboot/ubuntu-installer/amd64/
121 | initrd: [initrd.gz]
122 | preseed: preseed.j2
123 | finish: finish.j2
124 |
125 | stale_build_threshold_secs: 900
126 | stale_build_check_frequency_secs: 300
127 |
128 | # These are example params and could be any extra details that you want to access in your templates.
129 | # For eaxmple, {{ machine.Params.apt_hostname }}
130 | params:
131 | apt_hostname: "archive.ubuntu.com"
132 | apt_path: "/ubuntu/"
133 | nameservers: "8.8.8.8"
134 | ntp_server: "pool.ntp.org"
135 | include_packages: "python2.7 ipmitool lsb-release openssh-server vim ifenslave vlan lldpd secure-delete curl wget strace"
136 | os_version_name: "bionic"
137 |
138 | # All "command" content in the build commands below have access to the following additional filters/tags:
139 | # from_yaml:
140 | # Accepts: A single string containing valid YAML
141 | # Returns: A template object according to the YAML passed in
142 | # Example: {% with configcontext = machine.Params.config_context|from_yaml %} {{ configcontext.some_netbox_context_value }} {% endwith %}
143 | # regex_replace:
144 | # Accepts: 3 arguments: , , and
145 | # Returns: The original string with all instances of in replaced with
146 | # To use escape characters, you'll need to escape them. See example below.
147 | # Example: {% regex_replace interface.Description "\\d+" "" %}
148 |
149 | # Any of the commands below can be written inline directly in the config file or can be included from additional templates.
150 | # [stalebuild_commands] will be run when the build has taken longer than [stale_build_threshold_secs]
151 | stalebuild_commands:
152 | - command: |
153 | {% include "/etc/waitron/templates/messages/stale.j2" %}
154 | errors_fatal: true # Should errors be returned and cause any further commands to be skipped?
155 | timeout_seconds: 10 # How long should the command be allowed to run?
156 | should_log: true # Should the command be logged?
157 |
158 | # [prebuild_commands] will be run when the machine is requested but before the machine is put into build mode.
159 | prebuild_commands:
160 | - command: |
161 | {% include "/etc/waitron/templates/messages/build.j2" %}
162 | errors_fatal: true
163 | timeout_seconds: 10
164 | should_log: false
165 |
166 | # [postbuild_commands] will be run once the "done" api endpoint has been hit but before the job is cleaned up and marked as "completed"
167 | postbuild_commands:
168 | - command: |
169 | {% include "/etc/waitron/templates/messages/done.j2" %}
170 | errors_fatal: true
171 | timeout_seconds: 10
172 | should_log: false
173 |
174 | # [cancelbuild_commands] will be run once the "cancel" api endpoint has been hit but before the job is cleaned up and marked as "terminated"
175 | cancelbuild_commands:
176 | - command: |
177 | {% include "/etc/waitron/templates/messages/cancel.j2" %}
178 | errors_fatal: true
179 | timeout_seconds: 10
180 | should_log: false
181 |
182 | # [unknownbuild_commands] will be run when only when Waitron receives a pxe request for a MAC it cannot find in any inventory plugin.
183 | # These will only be run if the "_unknown_" build type has been added to the "build_types" section of the config.
184 | unknownbuild_commands:
185 | - command: |
186 | {% include "/etc/waitron/templates/messages/unknown.j2" %}
187 | errors_fatal: true
188 | timeout_seconds: 10
189 | should_log: false
190 |
191 | # [pxeevent_commands] will be run when a PXE boot request is received for a valid/active job
192 | # errors_fatal only controls whether or not subsequent commands will run but it will not prevent an
193 | # install from continuing.
194 | pxeevent_commands:
195 | - command: |
196 | {% include "/etc/waitron/templates/messages/pxe-event.j2" %}
197 | errors_fatal: false
198 | timeout_seconds: 10
199 | should_log: false
200 |
201 |
--------------------------------------------------------------------------------
/examples/groups/example.com.yml:
--------------------------------------------------------------------------------
1 | params:
2 | nameservers: "8.8.4.4 8.8.8.8"
3 |
--------------------------------------------------------------------------------
/examples/machines/dns02.example.com.yml:
--------------------------------------------------------------------------------
1 |
2 | ipmi_address: "10.55.24.2"
3 | ipmi_user: "my_ipmi_username"
4 | ipmi_password: "my_ipmi_password"
5 |
6 |
7 | network:
8 | - name: provisioning
9 | tags: ["waitron_provisioning"] # Tags can be helpful for later use in templates. Build-types, machines, interfaces, and addresses can all be tagged separately.
10 | gateway6: "fe80::0004:cf80:100b:0003:0001"
11 | addresses6:
12 | - ipaddress: "fe80::0004:cf80:100b:0003:0002"
13 | netmask: "ffff:ffff:ffff:ffff::"
14 | cidr: "64"
15 |
16 | - name: eno1
17 | tags: ["waitron_primary"]
18 | gateway4: "10.35.25.1"
19 | addresses4:
20 | - ipaddress: 10.35.25.5
21 | netmask: 255.255.255.0
22 | cidr: 24
23 |
24 | gateway6: "fe80::5555:cf80:100b:0003:0001"
25 | addresses6:
26 | - ipaddress: "fe80::5555:cf80:100b:0003:0002"
27 | netmask: "ffff:ffff:ffff:ffff::"
28 | cidr: 64
29 | macaddress: "de:ad:c0:de:ca:fe"
30 |
--------------------------------------------------------------------------------
/examples/templates/finish.j2:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | /bin/mkdir -p /root/.ssh;
4 | /bin/sh -c "echo 'ssh-rsa SOME_PUBLIC_KEY bootstrap' >> /root/.ssh/authorized_keys";
5 | chmod 600 /root/.ssh/authorized_keys;
6 |
7 | rm -rf /etc/netplan/*
8 |
9 | # We're going to assign things based on MAC so that we don't have to care about how interfaces were named.
10 | # We could also rename interfaces with udev rules, or indirectly with netplan.
11 |
12 | cat < /etc/netplan/netplan-cfg.yml
13 | network:
14 | ethernets:
15 | {% for interface in machine.Network %}
16 | {% if "waitron_primary" in interface.Tags %}
17 | # {{interface.Name}}
18 | primary:
19 | match:
20 | macaddress: {{ job.TriggerMacRaw }}
21 | addresses:
22 | {% for ipconfig in interface.Addresses4 %}
23 | - {{ ipconfig.IPAddress}}/{{ipconfig.Cidr}}
24 | {% endfor %}
25 | {% for ipconfig in interface.Addresses6 %}
26 | - {{ ipconfig.IPAddress}}/{{ipconfig.Cidr}}
27 | {% endfor %}
28 | dhcp4: false
29 | dhcp6: false
30 | accept-ra: false
31 | gateway4: {{interface.Gateway4}}
32 | gateway6: {{interface.Gateway6}}
33 | nameservers:
34 | addresses:
35 | - 8.8.8.8
36 | - 8.8.4.4
37 | - 2001:4860:4860::8888
38 | search:
39 | - example.com
40 | renderer: networkd
41 | version: 2
42 | {% endif %}
43 | {% endfor %}
44 | ENDNETPLAN
45 |
46 | cat < /etc/rc.local
47 | #!/bin/sh
48 |
49 | rm -f /etc/rc.local
50 |
51 | wget -O /dev/null -q '{{machine.BaseURL}}/done/{{machine.Hostname}}/{{job.Token}}';
52 |
53 | FINALTRIGGER
54 |
55 | chmod 0755 /etc/rc.local
56 |
--------------------------------------------------------------------------------
/examples/templates/messages/build.j2:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Uncomment if you've set up ipmi access
4 | #ipmitool -H {{ machine.IpmiAddressRaw }} -U {{ machine.IpmiUser }} -P {{ machine.IpmiPassword }} -I lanplus chassis power on; sleep 5;
5 | #ipmitool -H {{ machine.IpmiAddressRaw }} -U {{ machine.IpmiUser }} -P {{ machine.IpmiPassword }} -I lanplus chassis bootdev pxe
6 | #ipmitool -H {{ machine.IpmiAddressRaw }} -U {{ machine.IpmiUser }} -P {{ machine.IpmiPassword }} -I lanplus chassis power cycle
7 |
8 | echo "Maybe I'll tell slack that I'm going to build."
9 |
--------------------------------------------------------------------------------
/examples/templates/messages/cancel.j2:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Uncomment if you want your machines rebooted when installs are terminated.
4 | #ipmitool -H {{ machine.IpmiAddressRaw }} -U {{ machine.IpmiUser }} -P {{ machine.IpmiPassword }} -I lanplus chassis power cycle
5 |
6 | echo "Maybe I'll tell slack that someone terminated the build."
7 |
--------------------------------------------------------------------------------
/examples/templates/messages/done.j2:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Uncomment if you need/want one last final reboot after all the work is done, assuming you're calling the "done" endpoint at some point in your install steps or templates.
4 | #ipmitool -H {{ machine.IpmiAddressRaw }} -U {{ machine.IpmiUser }} -P {{ machine.IpmiPassword }} -I lanplus chassis power cycle
5 |
6 | echo "Maybe I'll tell slack that the build finished."
7 |
--------------------------------------------------------------------------------
/examples/templates/messages/pxe-event.j2:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "Maybe I'll tell slack I just got a PXE request."
4 |
--------------------------------------------------------------------------------
/examples/templates/messages/stale.j2:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "Maybe I'll tell slack that things are stale :("
4 |
--------------------------------------------------------------------------------
/examples/templates/motd.j2:
--------------------------------------------------------------------------------
1 | All your base are belong to us
2 |
--------------------------------------------------------------------------------
/examples/templates/partitioning/default.j2:
--------------------------------------------------------------------------------
1 | {% with configcontext = machine.Params.config_context|from_yaml %}
2 | # My default partitioning template
3 | # Disks
4 | d-i partman-auto/disk string {{ configcontext.primary_disk | default: machine.Params.primary_disk | default:"/dev/sda" }}
5 | d-i partman-auto/method string regular
6 | d-i partman-auto/choose_recipe select simple_recipe
7 | d-i partman-basicfilesystems/no_swap boolean false
8 |
9 | # clean up if something is there
10 | d-i partman-auto/purge_lvm_from_device boolean true
11 | d-i partman-lvm/device_remove_lvm boolean true
12 | d-i partman-md/device_remove_md boolean true
13 | d-i partman-lvm/confirm boolean true
14 | d-i partman-auto/expert_recipe string \
15 | simple_recipe :: \
16 | 2048 1000000000 2048 ext4 \
17 | \$primary{ } \$bootable{ } \
18 | method{ format } format{ } \
19 | use_filesystem{ } filesystem{ ext4 } \
20 | mountpoint{ /boot } \
21 | . \
22 | 2048 500 1000000000 ext4 \
23 | method{ format } format{ } \
24 | use_filesystem{ } filesystem{ ext4 } \
25 | mountpoint{ / } \
26 | .
27 | # confirm write
28 | d-i partman-partitioning/confirm_write_new_label boolean true
29 | d-i partman/choose_partition select finish
30 | d-i partman/confirm boolean true
31 | d-i partman/confirm_nooverwrite boolean true
32 | {% endwith %}
33 |
--------------------------------------------------------------------------------
/examples/templates/partitioning/raid1.j2:
--------------------------------------------------------------------------------
1 | {% with configcontext = machine.Params.config_context|from_yaml %}
2 | # My default partitioning template
3 | # Disks
4 | d-i partman-auto/disk string {{ configcontext.primary_disk | default: machine.Params.primary_disk | default:"/dev/sda /dev/sdb" }}
5 | d-i partman-auto/method string raid
6 | d-i partman-lvm/device_remove_lvm boolean true
7 | d-i partman-md/device_remove_md boolean true
8 | d-i partman-lvm/confirm boolean true
9 | d-i partman-auto/choose_recipe select raid_recipe
10 | d-i partman-auto-lvm/new_vg_name string vg00
11 | d-i partman-auto-lvm/guided_size string max
12 | d-i partman-basicfilesystems/no_swap boolean false
13 |
14 | # clean up if something is there
15 | d-i partman-auto/purge_lvm_from_device boolean true
16 | d-i partman-lvm/device_remove_lvm boolean true
17 | d-i partman-md/device_remove_md boolean true
18 | d-i partman-lvm/confirm boolean true
19 | d-i partman-auto/expert_recipe string \
20 | raid_recipe :: \
21 | 2048 1000000000 2048 ext4 \
22 | \$primary{ } \$bootable{ } \
23 | method{ raid } \
24 | mountpoint{ /boot } \
25 | . \
26 | 2048 500 1000000000 ext4 \
27 | method{ raid } \
28 | mountpoint{ / } \
29 | .
30 |
31 | d-i partman-auto-raid/recipe string \
32 | 1 2 0 ext4 /boot \
33 | /dev/sda1#/dev/sdb1 \
34 | . \
35 | 1 2 0 ext4 / \
36 | /dev/sda5#/dev/sdb5 \
37 | .
38 |
39 | # confirm write
40 | d-i partman-partitioning/confirm_write_new_label boolean true
41 | d-i partman/choose_partition select finish
42 | d-i partman/confirm boolean true
43 | d-i partman/confirm_nooverwrite boolean true
44 | d-i partman-lvm/confirm boolean true
45 | d-i partman-md/confirm boolean true
46 | d-i partman-md/confirm_nooverwrite boolean true
47 |
48 | d-i partman/mount_style select label
49 |
50 | d-i mdadm/boot_degraded boolean false
51 | {% endwith %}
52 |
--------------------------------------------------------------------------------
/examples/templates/partitioning/uefi.j2:
--------------------------------------------------------------------------------
1 | {% with configcontext = machine.Params.config_context|from_yaml %}
2 | # auto method must be lvm
3 | d-i partman-auto/method string lvm
4 | d-i partman-lvm/device_remove_lvm boolean true
5 | d-i partman-md/device_remove_md boolean true
6 | d-i partman-lvm/confirm boolean true
7 | d-i partman-lvm/confirm_nooverwrite boolean true
8 | d-i partman-basicfilesystems/no_swap boolean false
9 |
10 | # Keep that one set to true so we end up with a UEFI enabled
11 | # system. If set to false, /var/lib/partman/uefi_ignore will be touched
12 | d-i partman-efi/non_efi_system boolean true
13 |
14 | # enforce usage of GPT - a must have to use EFI!
15 | d-i partman-basicfilesystems/choose_label string gpt
16 | d-i partman-basicfilesystems/default_label string gpt
17 | d-i partman-partitioning/choose_label string gpt
18 | d-i partman-partitioning/default_label string gpt
19 | d-i partman/choose_label string gpt
20 | d-i partman/default_label string gpt
21 |
22 | d-i partman-auto/choose_recipe select boot-root-all
23 | d-i partman-auto/expert_recipe string \
24 |
25 | simple_recipe :: \
26 | 538 538 1075 free \
27 | \$iflabel{ gpt } \
28 | \$reusemethod{ } \
29 | method{ efi } \
30 | format{ } \
31 | . \
32 | 2048 1000000000 2048 ext4 \
33 | \$primary{ } \$bootable{ } \
34 | \$defaultignore{ } \
35 | method{ format } format{ } \
36 | use_filesystem{ } filesystem{ ext4 } \
37 | mountpoint{ /boot } \
38 | . \
39 | 2048 500 1000000000 ext4 \
40 | \$lvmok{ } \
41 | method{ format } format{ } \
42 | use_filesystem{ } filesystem{ ext4 } \
43 | mountpoint{ / } \
44 | .
45 |
46 | # confirm write
47 | d-i partman-partitioning/confirm_write_new_label boolean true
48 | d-i partman/choose_partition select finish
49 | d-i partman-md/confirm boolean true
50 | d-i partman/confirm boolean true
51 | d-i partman/confirm_nooverwrite boolean true
52 | {% endwith %}
53 |
--------------------------------------------------------------------------------
/examples/templates/preseed.j2:
--------------------------------------------------------------------------------
1 | {% with configcontext = machine.Params.config_context|from_yaml %}
2 | d-i debian-installer/locale string en_US.UTF-8
3 | d-i keyboard-configuration/xkb-keymap seen true
4 | d-i console-keymaps-at/keymap seen true
5 | d-i console-setup/ask_detect boolean false
6 | d-i console-setup/layoutcode string gb
7 |
8 | ### Network configuration
9 | # Static network configuration.
10 | d-i preseed/early_command string kill-all-dhcp; /bin/netcfg
11 | d-i netcfg/disable_dhcp boolean true
12 | d-i netcfg/disable_autoconfig boolean true
13 | #d-i netcfg/link_wait_timeout string 5
14 |
15 |
16 | d-i netcfg/choose_interface select {{ job.TriggerMacRaw }}
17 | d-i netcfg/confirm_static boolean true
18 | d-i netcfg/get_hostname string {{machine.Hostname}}
19 | d-i netcfg/get_domain string {{machine.Domain}}
20 |
21 | # Here, we're assuming that the first address is the one we want to use, but similar logic that checks tags could be used to select the IP address as well.
22 | {% for interface in machine.Network %}
23 | {% if 'waitron_provisioning' in interface.Tags %}
24 | d-i netcfg/get_ipaddress string {{interface.Addresses6.0.IPAddress}}
25 | d-i netcfg/get_netmask string {{interface.Addresses6.0.Netmask}}
26 | d-i netcfg/get_gateway string {{interface.Gateway6}}
27 | {% endif %}
28 | {% endfor %}
29 |
30 | d-i netcfg/get_nameservers string {{configcontext.nameservers | default: machine.Params.nameservers}}
31 | d-i netcfg/confirm_static boolean true
32 |
33 | d-i netcfg/target_network_config select ifupdown
34 | d-i hw-detect/load_firmware boolean true
35 |
36 | ### Mirror settings
37 | d-i mirror/country string manual
38 | d-i mirror/http/hostname string {{machine.Params.apt_hostname}}
39 | d-i mirror/http/directory string {{machine.Params.apt_path}}
40 | d-i mirror/http/proxy string
41 | d-i mirror/codename string {{ machine.Params.os_version_name }}
42 | d-i mirror/suite string {{ machine.Params.os_version_name }}
43 | d-i mirror/udeb/suite string {{ machine.Params.os_version_name }}
44 |
45 | ### Clock and time zone setup
46 | d-i clock-setup/utc boolean true
47 | d-i time/zone string UTC
48 | d-i clock-setup/ntp boolean true
49 | d-i clock-setup/ntp-server string {{machine.Params.ntp_server}}
50 |
51 | ### Apt configuration
52 | d-i apt-setup/security_host string {{machine.Params.apt_hostname}}
53 | d-i apt-setup/security_path string /ubuntu
54 |
55 | ### Partitioning
56 | {% include configcontext.disklayout | default: machine.Params.disklayout | default:"partitioning/default.j2" %}
57 |
58 | # Accounts
59 | d-i passwd/root-login boolean true
60 | d-i passwd/make-user boolean false
61 | d-i passwd/root-password password SOME_PASSWORD_THAT_YOU_SHOULD_CHANGE
62 | d-i passwd/root-password-again password SOME_PASSWORD_THAT_YOU_SHOULD_CHANGE
63 | d-i user-setup/encrypt-home boolean false
64 | d-i user-setup/allow-password-weak boolean true
65 |
66 | # Install some base packages
67 | tasksel tasksel/first multiselect server, openssh-server
68 | d-i pkgsel/include string {{ machine.Params.include_packages }}
69 | d-i pkgsel/upgrade select safe-upgrade
70 | d-i pkgsel/update-policy select none
71 |
72 | popularity-contest popularity-contest/participate boolean false
73 |
74 | ### Boot loader installation
75 | d-i grub-installer/only_debian boolean true
76 | d-i grub-installer/bootdev string {{ configcontext.primary_disk | default: machine.Params.primary_disk | default:"/dev/sda" }}
77 |
78 | ### Finishing up the installation
79 | d-i finish-install/reboot_in_progress note
80 |
81 | # Fetch and run finish script from waitron
82 | d-i preseed/late_command string wget -q -O /target/tmp/{{job.Token}}-finish.sh '{{machine.BaseURL}}/template/finish/{{machine.Hostname}}/{{job.Token}}' \
83 | && in-target /bin/sh /tmp/{{job.Token}}-finish.sh \
84 | && in-target rm -f /tmp/{{job.Token}}-finish.sh
85 | {% endwith %}
86 |
--------------------------------------------------------------------------------
/generate_markdown_doc.sh:
--------------------------------------------------------------------------------
1 | go get -u github.com/swaggo/swag/cmd/swag
2 | go get -u github.com/go-swagger/go-swagger
3 |
4 | go build -o $GOPATH/bin/ $GOPATH/src/github.com/go-swagger/go-swagger/cmd/swagger
5 | $GOPATH/bin/swag init
6 | $GOPATH/bin/swagger generate markdown -f docs/swagger.json --output API.md
7 | mv api.md API.md 2>/dev/null
8 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module waitron
2 |
3 | require (
4 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3
5 | github.com/google/uuid v1.2.0
6 | github.com/gorilla/handlers v1.5.1
7 | github.com/julienschmidt/httprouter v1.3.0
8 | gopkg.in/yaml.v2 v2.4.0
9 | )
10 |
11 | go 1.15
12 |
--------------------------------------------------------------------------------
/inventoryplugins/factory.go:
--------------------------------------------------------------------------------
1 | package inventoryplugins
2 |
3 | import (
4 | "errors"
5 |
6 | "waitron/config"
7 | "waitron/machine"
8 | )
9 |
10 | var machineInventoryPlugins map[string]func(*config.MachineInventoryPluginSettings, *config.Config, func(string, config.LogLevel) bool) MachineInventoryPlugin = make(map[string]func(*config.MachineInventoryPluginSettings, *config.Config, func(string, config.LogLevel) bool) MachineInventoryPlugin)
11 |
12 | type MachineInventoryPlugin interface {
13 | Init() error
14 | GetMachine(string, string) (*machine.Machine, error)
15 | PutMachine(*machine.Machine) error
16 | Deinit() error
17 | }
18 |
19 | func AddMachineInventoryPlugin(t string, f func(*config.MachineInventoryPluginSettings, *config.Config, func(string, config.LogLevel) bool) MachineInventoryPlugin) error {
20 | if _, found := machineInventoryPlugins[t]; found {
21 | return errors.New("plugin type already exists: " + t)
22 | }
23 |
24 | machineInventoryPlugins[t] = f
25 |
26 | return nil
27 | }
28 |
29 | func GetPlugin(t string, s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) (MachineInventoryPlugin, error) {
30 | pNew, found := machineInventoryPlugins[t]
31 |
32 | if !found {
33 | return nil, errors.New("plugin type not found: " + t)
34 | }
35 |
36 | plf := func(ls string, ll config.LogLevel) bool {
37 | return lf("[plugin:"+t+"] "+ls, ll)
38 | }
39 |
40 | return pNew(s, c, plf), nil
41 | }
42 |
--------------------------------------------------------------------------------
/inventoryplugins/factory_test.go:
--------------------------------------------------------------------------------
1 | package inventoryplugins_test
2 |
3 | import (
4 | "testing"
5 |
6 | "waitron/config"
7 | "waitron/inventoryplugins"
8 | "waitron/machine"
9 | )
10 |
11 | type TestPlugin struct {
12 | }
13 |
14 | func (t *TestPlugin) Init() error {
15 | return nil
16 | }
17 |
18 | func (t *TestPlugin) GetMachine(s string, m string) (*machine.Machine, error) {
19 |
20 | return &machine.Machine{}, nil
21 | }
22 |
23 | func (t *TestPlugin) PutMachine(m *machine.Machine) error {
24 | return nil
25 | }
26 |
27 | func (t *TestPlugin) Deinit() error {
28 | return nil
29 | }
30 |
31 | func TestNew(t *testing.T) {
32 |
33 | if _, err := inventoryplugins.GetPlugin("test", &config.MachineInventoryPluginSettings{}, &config.Config{}, func(s string, i config.LogLevel) bool { return true }); err == nil {
34 | t.Errorf("Plugin factory did not return error for unknown type.")
35 | }
36 |
37 | if err := inventoryplugins.AddMachineInventoryPlugin("test", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
38 | return &TestPlugin{}
39 | }); err != nil {
40 | t.Errorf("Plugin factory failed to add new type.")
41 | }
42 |
43 | if err := inventoryplugins.AddMachineInventoryPlugin("test", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
44 | return &TestPlugin{}
45 | }); err == nil {
46 | t.Errorf("Plugin factory allowed duplicate type.")
47 | }
48 |
49 | if _, err := inventoryplugins.GetPlugin("test", &config.MachineInventoryPluginSettings{}, &config.Config{}, func(s string, i config.LogLevel) bool { return true }); err != nil {
50 | t.Errorf("Plugin factory failed to return known type.")
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/inventoryplugins/file.go:
--------------------------------------------------------------------------------
1 | package inventoryplugins
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "os"
7 | "path"
8 | "strings"
9 |
10 | "waitron/config"
11 | "waitron/machine"
12 |
13 | "gopkg.in/yaml.v2"
14 | )
15 |
16 | func init() {
17 | if err := AddMachineInventoryPlugin("file", NewFileInventoryPlugin); err != nil {
18 | panic(err)
19 | }
20 | }
21 |
22 | type FileInventoryPlugin struct {
23 | settings *config.MachineInventoryPluginSettings
24 | waitronConfig *config.Config
25 | Log func(string, config.LogLevel) bool
26 |
27 | machinePath string
28 | }
29 |
30 | func NewFileInventoryPlugin(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) MachineInventoryPlugin {
31 |
32 | p := &FileInventoryPlugin{
33 | settings: s, // Plugin settings
34 | waitronConfig: c, // Global waitron config
35 | Log: lf,
36 | }
37 |
38 | return p
39 |
40 | }
41 |
42 | func (p *FileInventoryPlugin) Init() error {
43 | if p.machinePath, _ = p.settings.AdditionalOptions["machinepath"].(string); p.machinePath == "" {
44 | return fmt.Errorf("machine path not found in config of file plugin")
45 | }
46 |
47 | p.machinePath = strings.TrimRight(p.machinePath, "/") + "/"
48 |
49 | return nil
50 | }
51 |
52 | func (p *FileInventoryPlugin) Deinit() error {
53 | return nil
54 | }
55 |
56 | func (p *FileInventoryPlugin) PutMachine(m *machine.Machine) error {
57 | return nil
58 | }
59 |
60 | func (p *FileInventoryPlugin) GetMachine(hostname string, macaddress string) (*machine.Machine, error) {
61 | hostname = strings.ToLower(hostname)
62 |
63 | m, err := machine.New(hostname)
64 |
65 | if err != nil {
66 | return nil, err
67 | }
68 |
69 | p.Log(fmt.Sprintf("looking for %s.[yml|yaml] in %s", hostname, p.machinePath), config.LogLevelDebug)
70 |
71 | // Then load the machine definition.
72 | data, err := ioutil.ReadFile(path.Join(p.machinePath, hostname+".yaml")) // compute01.apc03.prod.yaml
73 |
74 | p.Log(fmt.Sprintf("first attempt at slurping %s.[yml|yaml] in %s", hostname, p.machinePath), config.LogLevelDebug)
75 |
76 | if err != nil {
77 | if os.IsNotExist(err) {
78 |
79 | data, err = ioutil.ReadFile(path.Join(p.machinePath, hostname+".yml")) // One more try but look for .yml
80 | p.Log(fmt.Sprintf("second attempt at slurping %s.[yml|yaml] in %s", hostname, p.machinePath), config.LogLevelDebug)
81 |
82 | if err != nil {
83 | if os.IsNotExist(err) { // Whether the error was due to non-existence or something else, report it. Machine definitions are must.
84 | p.Log(fmt.Sprintf("%s.[yml|yaml] not found in %s", hostname, p.machinePath), config.LogLevelDebug)
85 | return nil, nil
86 | } else {
87 | p.Log(fmt.Sprintf("%v", err), config.LogLevelDebug)
88 | return nil, err // Some error beyond just "not found"
89 | }
90 | }
91 | } else {
92 | p.Log(fmt.Sprintf("%v", err), config.LogLevelDebug)
93 | return nil, err // Some error beyond just "not found"
94 | }
95 | }
96 |
97 | p.Log(fmt.Sprintf("%s.[yml|yaml] slurped in from %s", hostname, p.machinePath), config.LogLevelDebug)
98 |
99 | err = yaml.Unmarshal(data, m)
100 | if err != nil {
101 | // Don't blow everything up on bad data. Only truly critical errors should be passed back.
102 | // Log and return "nothing" so that the next inventory plugin can do stuff.
103 | // If this was the only inventory plugin used, then the build request will fail, anyway.
104 | p.Log(fmt.Sprintf("unable to unmarshal %s.[yml|yaml]: %v", hostname, err), config.LogLevelError)
105 | return nil, nil
106 | }
107 |
108 | p.Log(fmt.Sprintf("got machine from plugin in: %v", m), config.LogLevelDebug)
109 |
110 | return m, nil
111 | }
112 |
--------------------------------------------------------------------------------
/inventoryplugins/groups.go:
--------------------------------------------------------------------------------
1 | package inventoryplugins
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "os"
7 | "path"
8 | "strings"
9 |
10 | "waitron/config"
11 | "waitron/machine"
12 |
13 | "gopkg.in/yaml.v2"
14 | )
15 |
16 | func init() {
17 | if err := AddMachineInventoryPlugin("groups", NewGroupsInventoryPlugin); err != nil {
18 | panic(err)
19 | }
20 | }
21 |
22 | type GroupsInventoryPlugin struct {
23 | settings *config.MachineInventoryPluginSettings
24 | waitronConfig *config.Config
25 | Log func(string, config.LogLevel) bool
26 |
27 | groupPath string
28 | }
29 |
30 | func NewGroupsInventoryPlugin(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) MachineInventoryPlugin {
31 |
32 | p := &GroupsInventoryPlugin{
33 | settings: s, // Plugin settings
34 | waitronConfig: c, // Global waitron config
35 | Log: lf,
36 | }
37 |
38 | return p
39 |
40 | }
41 |
42 | func (p *GroupsInventoryPlugin) Init() error {
43 | if p.groupPath, _ = p.settings.AdditionalOptions["grouppath"].(string); p.groupPath == "" {
44 | return fmt.Errorf("group path not found in config of file plugin")
45 | }
46 |
47 | p.groupPath = strings.TrimRight(p.groupPath, "/") + "/"
48 |
49 | return nil
50 | }
51 |
52 | func (p *GroupsInventoryPlugin) Deinit() error {
53 | return nil
54 | }
55 |
56 | func (p *GroupsInventoryPlugin) PutMachine(m *machine.Machine) error {
57 | return nil
58 | }
59 |
60 | func (p *GroupsInventoryPlugin) GetMachine(hostname string, macaddress string) (*machine.Machine, error) {
61 | hostname = strings.ToLower(hostname)
62 |
63 | m, err := machine.New(hostname)
64 |
65 | if err != nil {
66 | return nil, err
67 | }
68 |
69 | p.Log(fmt.Sprintf("first attempt at slurping %s.[yml|yaml] in %s", m.Domain, p.groupPath), config.LogLevelDebug)
70 |
71 | data, err := ioutil.ReadFile(path.Join(p.groupPath, m.Domain+".yaml")) // apc03.prod.yaml
72 |
73 | if os.IsNotExist(err) {
74 | p.Log(fmt.Sprintf("second attempt at slurping %s.[yml|yaml] in %s", m.Domain, p.groupPath), config.LogLevelDebug)
75 | data, err = ioutil.ReadFile(path.Join(p.groupPath, m.Domain+".yml")) // Try .yml
76 | if err != nil && !os.IsNotExist(err) { // We should expect the file to not exist, but if it did exist, err happened for a different reason, then it should be reported.
77 | return nil, err // Some error beyond just "not found"
78 | }
79 | } else {
80 | return nil, err // Some error beyond just "not found"
81 | }
82 |
83 | p.Log(fmt.Sprintf("%s.[yml|yaml] slurped in from %s", m.Domain, p.groupPath), config.LogLevelDebug)
84 |
85 | if len(data) > 0 { // Group Files are optional. So we shouldn't be failing unless they were requested and found.
86 | if err = yaml.Unmarshal(data, m); err != nil {
87 | return nil, err
88 | }
89 | }
90 |
91 | p.Log(fmt.Sprintf("got machine from plugin in: %v", m), config.LogLevelDebug)
92 |
93 | return m, nil
94 | }
95 |
--------------------------------------------------------------------------------
/inventoryplugins/netbox.go:
--------------------------------------------------------------------------------
1 | package inventoryplugins
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "net"
7 | "net/http"
8 | "strings"
9 | "time"
10 |
11 | "waitron/config"
12 | "waitron/machine"
13 |
14 | "gopkg.in/yaml.v2"
15 | )
16 |
17 | func init() {
18 | if err := AddMachineInventoryPlugin("netbox", NewNetboxInventoryPlugin); err != nil {
19 | panic(err)
20 | }
21 | }
22 |
23 | type netboxInterfaceResults struct {
24 | Results []struct {
25 | ID int `yaml:"id"`
26 | Name string `yaml:"name"`
27 | MacAddress string `yaml:"mac_address"`
28 | Description string `yaml:"description"`
29 | ParentDevice struct {
30 | Name string `yaml:"name"`
31 | } `yaml:"device"`
32 | ConnectedEndpoint struct {
33 | Name string `yaml:"name"`
34 | Device struct {
35 | ID int `yaml:"id"`
36 | Name string `yaml:"name"`
37 | } `yaml:"device"`
38 | } `yaml:"connected_endpoint"`
39 | UntaggedVlan struct {
40 | Vid int `yaml:"vid"`
41 | Name string `yaml:"name"`
42 | } `yaml:"untagged_vlan"`
43 | Tags []struct {
44 | Name string `yaml:"name"`
45 | } `yaml:"tags"`
46 | } `yaml:"results"`
47 | }
48 |
49 | type netboxIpAddressResults struct {
50 | Results []struct {
51 | Family struct {
52 | Value int `yaml:"value"`
53 | } `yaml:"family"`
54 | AssignedObjectID int `yaml:"assigned_object_id"`
55 | Address string `yaml:"address"`
56 | } `yaml:"results"`
57 | }
58 |
59 | type netboxDeviceResults struct {
60 | Results []struct {
61 | ConfigContext map[string]interface{} `yaml:"config_context"`
62 | } `yaml:"results"`
63 | }
64 |
65 | type annotatedIface struct {
66 | iface *machine.Interface
67 | isIpmi bool
68 | }
69 |
70 | type NetboxInventoryPlugin struct {
71 | settings *config.MachineInventoryPluginSettings
72 | waitronConfig *config.Config
73 | Log func(string, config.LogLevel) bool
74 |
75 | enabledAssetsFilter string
76 | machinePath string
77 | }
78 |
79 | func NewNetboxInventoryPlugin(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) MachineInventoryPlugin {
80 |
81 | p := &NetboxInventoryPlugin{
82 | settings: s, // Plugin settings
83 | waitronConfig: c, // Global waitron config
84 | Log: lf,
85 | }
86 |
87 | return p
88 |
89 | }
90 |
91 | func (p *NetboxInventoryPlugin) Init() error {
92 | if p.settings.Source == "" {
93 | return fmt.Errorf("source for netbox plugin must not be empty")
94 | }
95 |
96 | if p.settings.AuthToken == "" {
97 | return fmt.Errorf("auth token for netbox plugin must not be empty")
98 | }
99 |
100 | return nil
101 | }
102 |
103 | func (p *NetboxInventoryPlugin) Deinit() error {
104 | return nil
105 | }
106 |
107 | func (p *NetboxInventoryPlugin) PutMachine(m *machine.Machine) error {
108 | return nil
109 | }
110 |
111 | func (p *NetboxInventoryPlugin) GetMachine(hostname string, macaddress string) (*machine.Machine, error) {
112 | hostname = strings.ToLower(hostname)
113 |
114 | m, err := machine.New(hostname)
115 |
116 | m.Params = make(map[string]string)
117 |
118 | if err != nil {
119 | return nil, err
120 | }
121 |
122 | if _, ok := p.settings.AdditionalOptions["enabled_assets_only"]; ok {
123 | if enabledAssetsOnly, ok := p.settings.AdditionalOptions["enabled_assets_only"].(bool); ok && enabledAssetsOnly {
124 | p.enabledAssetsFilter = "&enabled=true"
125 | }
126 | }
127 |
128 | // Let hostname win, but if it's not present, then we'll try to pull it from an interface that matchces the MAC passed. in.
129 | if hostname == "" && macaddress != "" {
130 |
131 | macResults := &netboxInterfaceResults{}
132 |
133 | response, err := p.queryNetbox(p.settings.Source + "/dcim/interfaces/?mac_address=" + macaddress)
134 | p.Log(fmt.Sprintf("retrieved interface data from netbox: %v", string(response)), config.LogLevelDebug)
135 |
136 | if err != nil {
137 | return nil, err
138 | }
139 |
140 | if err = yaml.Unmarshal(response, macResults); err != nil {
141 | return nil, err
142 | }
143 |
144 | // It wasn't an error, but it didn't result in finding a hostname.
145 | if macResults.Results[0].ParentDevice.Name == "" {
146 | p.Log(fmt.Sprintf("MAC '%s' used for netbox query, but no related hostname found", macaddress), config.LogLevelInfo)
147 | return nil, nil
148 | }
149 | hostname = macResults.Results[0].ParentDevice.Name
150 | }
151 |
152 | /*
153 | Try to grab the host and pull its config_context so that we can stuff it into a params value for people
154 | to use later with the from_yaml filter if they want.
155 | We need to do it this way because nested json data will blow up JSON marshalling in API responses.
156 | */
157 | deviceResults := &netboxDeviceResults{}
158 |
159 | response, err := p.queryNetbox(p.settings.Source + "/dcim/devices/?name=" + hostname)
160 |
161 | if err != nil {
162 | return nil, err
163 | }
164 |
165 | if err = yaml.Unmarshal(response, &deviceResults); err != nil {
166 | return nil, err
167 | p.Log(fmt.Sprintf("ignoring config_context beacause unmarshal of content is somehow bad for '%s'", hostname), config.LogLevelError)
168 | } else {
169 |
170 | if len(deviceResults.Results) == 0 {
171 | p.Log(fmt.Sprintf("no matching device results for netbox query with '%s'", hostname), config.LogLevelInfo)
172 | return nil, nil
173 | }
174 |
175 | if len(deviceResults.Results) > 1 {
176 | p.Log(fmt.Sprintf("more than one device named '%s' found, so using the first one", hostname), config.LogLevelWarning)
177 | }
178 |
179 | // There should be only one.
180 | cc, err := yaml.Marshal(deviceResults.Results[0].ConfigContext)
181 |
182 | if err != nil {
183 | p.Log(fmt.Sprintf("ignoring config_context beacause re-marshal of content is somehow bad for '%s'", hostname), config.LogLevelError)
184 | } else {
185 | m.Params["config_context"] = string(cc)
186 | }
187 | }
188 |
189 | results := &netboxInterfaceResults{}
190 |
191 | response, err = p.queryNetbox(p.settings.Source + "/dcim/interfaces/?device=" + hostname)
192 | p.Log(fmt.Sprintf("retrieved interface data from netbox: %v", string(response)), config.LogLevelDebug)
193 |
194 | if err != nil {
195 | return nil, err
196 | }
197 |
198 | if err = yaml.Unmarshal(response, results); err != nil {
199 | return nil, err
200 | }
201 |
202 | /*
203 | We're implicitly saying that netbox as a datasource is only meant to provide a machine
204 | that has at least one interface.
205 | This doesn't have to be the case, but the only real job of this plugin is to provide interface/IP details.
206 | If they don't exist, then it shouldn't return a machine.
207 | */
208 |
209 | if len(results.Results) == 0 {
210 | p.Log(fmt.Sprintf("no matching interface results for netbox query with '%v'", results), config.LogLevelInfo)
211 | return nil, nil
212 | }
213 |
214 | p.Log(fmt.Sprintf("have interface structure from netbox interface: %v", results), config.LogLevelDebug)
215 |
216 | annotatedInterfaces := make(map[int]*annotatedIface)
217 |
218 | netboxIfaces := results.Results
219 |
220 | // Making sure the array underneath doesn't change so that I can just grab references to the entries as needed.
221 | m.Network = make([]machine.Interface, len(netboxIfaces))
222 |
223 | // Grab all the interfaces for the device
224 | for idx, iface := range netboxIfaces {
225 |
226 | p.Log(fmt.Sprintf("found netbox interface: %v", iface), config.LogLevelDebug)
227 |
228 | m.Network[idx] = machine.Interface{Name: iface.Name, MacAddress: iface.MacAddress}
229 | newIface := &m.Network[idx]
230 |
231 | // We'll need to attach IP addresses to the interface in a little bit.
232 | annotatedInterfaces[iface.ID] = &annotatedIface{iface: newIface}
233 |
234 | newIface.ZSideDeviceInterface = iface.ConnectedEndpoint.Name
235 | newIface.ZSideDevice = iface.ConnectedEndpoint.Device.Name
236 |
237 | newIface.VlanName = iface.UntaggedVlan.Name
238 | newIface.VlanID = iface.UntaggedVlan.Vid
239 | newIface.Description = iface.Description
240 |
241 | for _, tag := range iface.Tags {
242 |
243 | newIface.Tags = append(newIface.Tags, tag.Name)
244 |
245 | if tag.Name == "waitron_ipmi" {
246 | annotatedInterfaces[iface.ID].isIpmi = true
247 | p.Log(fmt.Sprintf("found ipmi interface: %v", m.Network[idx]), config.LogLevelDebug)
248 | }
249 | }
250 |
251 | }
252 |
253 | ipResults := &netboxIpAddressResults{}
254 |
255 | response, err = p.queryNetbox(p.settings.Source + "/ipam/ip-addresses/?device=" + hostname)
256 |
257 | if err != nil {
258 | return nil, err
259 | }
260 |
261 | if err = yaml.Unmarshal(response, ipResults); err != nil {
262 | return nil, err
263 | }
264 |
265 | p.Log(fmt.Sprintf("retrieved ip address data from netbox: %v", ipResults), config.LogLevelDebug)
266 |
267 | addrs := ipResults.Results
268 |
269 | // Grab all the ip addresses for the device
270 | for _, addr := range addrs {
271 |
272 | annotatedIface := annotatedInterfaces[addr.AssignedObjectID]
273 | iface := annotatedIface.iface
274 |
275 | _, ipNet, err := net.ParseCIDR(addr.Address)
276 |
277 | if err != nil {
278 | p.Log(fmt.Sprintf("skipping unparseable address '%s' for interface %s", addr.Address, iface.Name), config.LogLevelWarning)
279 | continue
280 | }
281 |
282 | addressParts := strings.Split(addr.Address, "/")
283 |
284 | // Watch out! We're assuming there's only a single IPMI address of either v4 or v6.
285 | // Operators can always get around this by passing in IPMI details other ways.
286 | if annotatedIface.isIpmi {
287 | m.IpmiAddressRaw = addressParts[0]
288 | p.Log(fmt.Sprintf("added ipmi address to interface %s for %s: %s", iface.Name, hostname, addressParts[0]), config.LogLevelDebug)
289 | p.Log(fmt.Sprintf("interface %v", *iface), config.LogLevelDebug)
290 | }
291 |
292 | if addr.Family.Value == 4 {
293 | netmask := fmt.Sprintf("%d.%d.%d.%d", ipNet.Mask[0], ipNet.Mask[1], ipNet.Mask[2], ipNet.Mask[3])
294 |
295 | // Update the list of addresses in the related interface
296 | iface.Addresses4 = append(iface.Addresses4, machine.IPConfig{IPAddress: addressParts[0], Cidr: addressParts[1], Netmask: netmask})
297 | p.Log(fmt.Sprintf("added ipv4 address to interface %s for %s: %s", iface.Name, hostname, addressParts[0]), config.LogLevelDebug)
298 |
299 | if iface.Gateway4 == "" {
300 |
301 | gw, err := p.getGateway(iface, addr.Address)
302 |
303 | if gw == "" || err != nil {
304 | p.Log(fmt.Sprintf("no gateway address found for '%s' for interface %s: %v", addr.Address, iface.Name, err), config.LogLevelWarning)
305 | if err != nil {
306 | return nil, err
307 | }
308 | }
309 | iface.Gateway4 = gw
310 | }
311 |
312 | } else if addr.Family.Value == 6 {
313 | netmask := fmt.Sprintf("%x%x:%x%x:%x%x:%x%x:%x%x:%x%x:%x%x:%x%x",
314 | ipNet.Mask[0], ipNet.Mask[1], ipNet.Mask[2], ipNet.Mask[3],
315 | ipNet.Mask[4], ipNet.Mask[5], ipNet.Mask[6], ipNet.Mask[7],
316 | ipNet.Mask[8], ipNet.Mask[9], ipNet.Mask[10], ipNet.Mask[11],
317 | ipNet.Mask[12], ipNet.Mask[13], ipNet.Mask[14], ipNet.Mask[15])
318 |
319 | // Update the list of addresses in the related interface
320 | iface.Addresses6 = append(iface.Addresses6, machine.IPConfig{IPAddress: addressParts[0], Cidr: addressParts[1], Netmask: netmask})
321 | p.Log(fmt.Sprintf("added ipv6 address to interface %s for %s: %s", iface.Name, hostname, addressParts[0]), config.LogLevelDebug)
322 |
323 | if iface.Gateway6 == "" {
324 |
325 | gw, err := p.getGateway(iface, addr.Address)
326 |
327 | if gw == "" || err != nil {
328 | p.Log(fmt.Sprintf("no gateway address found for '%s' for interface %s: %v", addr.Address, iface.Name, err), config.LogLevelWarning)
329 | if err != nil {
330 | return nil, err
331 | }
332 | }
333 | iface.Gateway6 = gw
334 | }
335 | }
336 |
337 | }
338 |
339 | return m, nil
340 |
341 | }
342 |
343 | func (p *NetboxInventoryPlugin) getGateway(iface *machine.Interface, addr string) (string, error) {
344 |
345 | gwResponse, err := p.queryNetbox(p.settings.Source + "/ipam/ip-addresses/?tag=waitron_gateway&parent=" + addr)
346 |
347 | if err != nil {
348 | return "", err
349 | }
350 |
351 | gwResults := &netboxIpAddressResults{}
352 |
353 | if err := yaml.Unmarshal(gwResponse, gwResults); err != nil {
354 | return "", err
355 | }
356 |
357 | gateways := gwResults.Results
358 | if len(gateways) > 1 {
359 | p.Log(fmt.Sprintf("multiple gateways found for '%s' for interface %s", addr, iface.Name), config.LogLevelWarning)
360 | } else if len(gateways) == 0 {
361 | p.Log(fmt.Sprintf("no gateways found for '%s' for interface %s", addr, iface.Name), config.LogLevelWarning)
362 | return "", nil
363 | }
364 |
365 | for _, gateway := range gateways {
366 | if gateway.Address != "" {
367 | return strings.Split(gateway.Address, "/")[0], nil
368 | }
369 | }
370 | return "", nil
371 | }
372 |
373 | func (p *NetboxInventoryPlugin) queryNetbox(q string) ([]byte, error) {
374 |
375 | q = q + p.enabledAssetsFilter
376 |
377 | p.Log(fmt.Sprintf("going to query %s", q), config.LogLevelDebug)
378 |
379 | tr := &http.Transport{
380 | ResponseHeaderTimeout: 10 * time.Second,
381 | }
382 |
383 | client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
384 |
385 | req, err := http.NewRequest("GET", q, nil)
386 |
387 | if err != nil {
388 | p.Log(fmt.Sprintf("unable to create request for querying %s: %v", q, err), config.LogLevelDebug)
389 | return nil, err
390 | }
391 |
392 | req.Header.Add("Authorization", "Token "+string(p.settings.AuthToken))
393 |
394 | resp, err := client.Do(req)
395 |
396 | if err != nil {
397 | p.Log(fmt.Sprintf("error while querying %s: %v", q, err), config.LogLevelDebug)
398 | return nil, err
399 | }
400 |
401 | if resp.StatusCode >= 400 {
402 | p.Log(fmt.Sprintf("error while querying %s: %v", q, err), config.LogLevelDebug)
403 | return nil, err
404 | }
405 |
406 | response, err := ioutil.ReadAll(resp.Body)
407 |
408 | if err != nil {
409 | p.Log(fmt.Sprintf("error while reading body of query to %s: %v", q, err), config.LogLevelDebug)
410 | return nil, err
411 | }
412 |
413 | return response, nil
414 | }
415 |
--------------------------------------------------------------------------------
/machine/machine.go:
--------------------------------------------------------------------------------
1 | package machine
2 |
3 | import (
4 | "strings"
5 | "waitron/config"
6 | )
7 |
8 | // Machine configuration
9 | type Machine struct {
10 | config.Config `yaml:",inline"`
11 |
12 | Hostname string `yaml:"hostname,omitempty"`
13 | ShortName string `yaml:"shortname,omitempty"`
14 | Domain string `yaml:"domain,omitempty"`
15 | Network []Interface `yaml:"network,omitempty"`
16 |
17 | IpmiAddressRaw string `yaml:"ipmi_address"`
18 | IpmiUser string `yaml:"ipmi_user"`
19 | IpmiPassword config.Password `yaml:"ipmi_password"`
20 |
21 | BuildTypeName string `yaml:"build_type,omitempty"`
22 | }
23 |
24 | type IPConfig struct {
25 | IPAddress string `yaml:"ipaddress"`
26 | Netmask string `yaml:"netmask"`
27 | Cidr string `yaml:"cidr"`
28 | Tags []string `yaml:"tags`
29 | Description string `yaml:"description"`
30 | }
31 |
32 | // Interface Configuration
33 | type Interface struct {
34 | Name string `yaml:"name"`
35 | Addresses4 []IPConfig `yaml:"addresses4"`
36 | Addresses6 []IPConfig `yaml:"addresses6"`
37 | MacAddress string `yaml:"macaddress"`
38 | VlanID int `yaml:"vlan_id"`
39 | VlanName string `yaml:"vlan_name"`
40 | Gateway4 string `yaml:"gateway4"`
41 | Gateway6 string `yaml:"gateway6"`
42 | ZSideDevice string `yaml:"zside_device"`
43 | ZSideDeviceInterface string `yaml:"zside_device_port"`
44 | Tags []string `yaml:"tags`
45 | Description string `yaml:"description"`
46 | }
47 |
48 | func New(hostname string) (*Machine, error) {
49 | hostname = strings.ToLower(hostname)
50 | hostSlice := strings.Split(hostname, ".")
51 |
52 | m := &Machine{
53 | Hostname: hostname,
54 | ShortName: hostSlice[0],
55 | Domain: strings.Join(hostSlice[1:], "."),
56 | }
57 |
58 | return m, nil
59 | }
60 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | // @Title Waitron
4 | // @Version 2
5 | // @Description Endpoints for server provisioning
6 | // @License BSD
7 | // @LicenseUrl http://opensource.org/licenses/BSD-2-Clause
8 | import (
9 | "encoding/json"
10 | "flag"
11 | "fmt"
12 | "io/ioutil"
13 | "log"
14 | "net/http"
15 | "os"
16 |
17 | "waitron/config"
18 | "waitron/waitron"
19 |
20 | "github.com/gorilla/handlers"
21 | "github.com/julienschmidt/httprouter"
22 | )
23 |
24 | type result struct {
25 | Token string `json:",omitempty"`
26 | Error string `json:",omitempty"`
27 | State string `json:",omitempty"`
28 | }
29 |
30 | // @Title definitionHandler
31 | // @Description Return the waitron configuration details for a machine
32 | // @Summary Return the waitron configuration details for a machine. Note that "build type" is technically not required, depending on your config.
33 | // @Param hostname path string true "Hostname"
34 | // @Param type path string true "Build Type"
35 | // @Success 200 {object} string "Machine config in JSON format."
36 | // @Failure 404 {object} string "Unable to find host definition for '' '' ''"
37 | // @Failure 500 {object} string "Bad machine data for '' '' ''"
38 | // @Router /definition/{hostname}/{type} [GET]
39 | func definitionHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
40 |
41 | hostname := ps.ByName("hostname")
42 | btype := ps.ByName("type")
43 |
44 | m, err := w.GetMergedMachine(hostname, "", btype, nil)
45 | if err != nil || m == nil {
46 | http.Error(response, fmt.Sprintf("Unable to find host definition for '%s' '%s'. %s", hostname, btype, err.Error()), 404)
47 | return
48 | }
49 |
50 | result, err := json.Marshal(m)
51 |
52 | if err != nil {
53 | http.Error(response, fmt.Sprintf("Bad machine data for '%s' '%s'. %s", hostname, btype, err.Error()), 500)
54 | return
55 | }
56 |
57 | fmt.Fprintf(response, string(result))
58 | }
59 |
60 | // @Title jobDefinitionHandler
61 | // @Description Return details for the specified job token
62 | // @Summary Return details for the specified job token
63 | // @Param token path string true "Token"
64 | // @Success 200 {object} string "Job details in JSON format."
65 | // @Failure 404 {object} string "Job not found"
66 | // @Router /job/{token} [GET]
67 | func jobDefinitionHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
68 |
69 | token := ps.ByName("token")
70 |
71 | jb, err := w.GetJobBlob(token)
72 | if err != nil {
73 | http.Error(response, fmt.Sprintf("Unable to find valid job for %s. %s", token, err.Error()), 404)
74 | return
75 | }
76 |
77 | response.Write(jb)
78 | }
79 |
80 | // @Title templateHandler
81 | // @Description Render either the finish or the preseed template
82 | // @Summary Render either the finish or the preseed template
83 | // @Param hostname path string true "Hostname"
84 | // @Param template path string true "The template to be rendered"
85 | // @Param token path string true "Token"
86 | // @Success 200 {object} string "Rendered template"
87 | // @Failure 400 {object} string "Unable to render template"
88 | // @Router /template/{template}/{hostname}/{token} [GET]
89 | func templateHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
90 |
91 | /* This eventually should to change to a PUT/POST because it causes changes. */
92 |
93 | renderedTemplate, err := w.RenderStageTemplate(ps.ByName("token"), ps.ByName("template"))
94 | if err != nil {
95 | http.Error(response, "Unable to render template", 400)
96 | return
97 | }
98 |
99 | fmt.Fprintf(response, renderedTemplate)
100 | }
101 |
102 | // @Title buildHandler
103 | // @Description Put the server in build mode
104 | // @Summary Put the server in build mode
105 | // @Accept json
106 | // @Produce json
107 | // @Param hostname path string true "Hostname"
108 | // @Param type path string true "Build Type"
109 | // @Param {object} body string true "Machine definition if desired. Can be used to override nearly all properties of a compiled machine. See examples directory for machine definition."
110 | // @Success 200 {object} string "{"State": "OK", "Token": }"
111 | // @Failure 500 {object} string "Failed to set build mode on hostname"
112 | // @Router /build/{hostname}/{type} [PUT]
113 | func buildHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
114 |
115 | hostname := ps.ByName("hostname")
116 | btype := ps.ByName("type")
117 |
118 | body := http.MaxBytesReader(response, request.Body, 1024*1024) // 1MB limit on posted machine definition. Even that seems insane.
119 | machineDefinition, err := ioutil.ReadAll(body)
120 |
121 | if err != nil {
122 | http.Error(response, fmt.Sprintf("Failed to set build mode for %s - %s while reading request body: %s", hostname, btype, err.Error()), 500)
123 | return
124 | }
125 |
126 | token, err := w.Build(hostname, btype, machineDefinition)
127 | if err != nil {
128 | http.Error(response, fmt.Sprintf("Failed to set build mode for %s - %s: %s", hostname, btype, err.Error()), 500)
129 | return
130 | }
131 |
132 | result, _ := json.Marshal(&result{State: "OK", Token: token})
133 |
134 | fmt.Fprintf(response, string(result))
135 | }
136 |
137 | // @Title doneHandler
138 | // @Description Remove the server from build mode
139 | // @Summary Remove the server from build mode
140 | // @Param hostname path string true "Hostname"
141 | // @Param token path string true "Token"
142 | // @Success 200 {object} string "{"State": "OK"}"
143 | // @Failure 500 {object} string "Failed to finish build mode"
144 | // @Router /done/{hostname}/{token} [GET]
145 | func doneHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
146 |
147 | /* This eventually should to change to a PUT/POST because it causes changes. */
148 |
149 | err := w.FinishBuild(ps.ByName("hostname"), ps.ByName("token"))
150 |
151 | if err != nil {
152 | http.Error(response, "Failed to finish build.", 500)
153 | return
154 | }
155 |
156 | result, _ := json.Marshal(&result{State: "OK"})
157 |
158 | fmt.Fprintf(response, string(result))
159 | }
160 |
161 | // @Title cancelHandler
162 | // @Description Remove the server from build mode
163 | // @Summary Remove the server from build mode
164 | // @Accept json
165 | // @Produce json
166 | // @Param hostname path string true "Hostname"
167 | // @Param token path string true "Token"
168 | // @Param {object} body string true "Machine definition if desired. Can be used to override nearly all properties of a compiled machine. See examples directory for machine definition."
169 | // @Success 200 {object} string "{"State": "OK"}"
170 | // @Failure 500 {object} string "Failed to cancel build mode"
171 | // @Router /cancel/{hostname}/{token} [PUT]
172 | func cancelHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
173 |
174 | /* This eventually should to change to a PUT/POST because it causes changes. */
175 |
176 | err := w.CancelBuild(ps.ByName("hostname"), ps.ByName("token"))
177 |
178 | if err != nil {
179 | http.Error(response, "Failed to cancel build mode", 500)
180 | return
181 | }
182 |
183 | result, _ := json.Marshal(&result{State: "OK"})
184 |
185 | fmt.Fprintf(response, string(result))
186 | }
187 |
188 | // @Title hostStatus
189 | // @Description Build status of the server
190 | // @Summary Build status of the server
191 | // @Param hostname path string true "Hostname"
192 | // @Success 200 {object} string "The status: (installing or installed)"
193 | // @Failure 404 {object} string "Failed to find active job for host"
194 | // @Router /status/{hostname} [GET]
195 | func hostStatus(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
196 |
197 | hostname := ps.ByName("hostname")
198 | s, err := w.GetMachineStatus(hostname)
199 |
200 | if err != nil {
201 | http.Error(response, fmt.Sprintf("Failed to find active job for %s. Try search by job ID. %s", hostname, err.Error()), 404)
202 | return
203 | }
204 | fmt.Fprintf(response, s)
205 | }
206 |
207 | // @Title status
208 | // @Description Dictionary with jobs and status
209 | // @Summary Dictionary with jobs and status
210 | // @Success 200 {object} string "Dictionary with jobs and status"
211 | // @Success 500 {object} string "The error encountered"
212 | // @Router /status [GET]
213 | func status(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
214 | result, err := w.GetJobsHistoryBlob()
215 | if err != nil {
216 | http.Error(response, err.Error(), 500)
217 | return
218 | }
219 | response.Write(result)
220 | }
221 |
222 | // @Title cleanHistory
223 | // @Description Clear all completed jobs from the in-memory history of Waitron
224 | // @Summary Clear all completed jobs from the in-memory history of Waitron
225 | // @Success 200 {object} string "{"State": "OK"}"
226 | // @Failure 500 {object} string "Failed to clean history"
227 | // @Router /cleanhistory [PUT]
228 | func cleanHistory(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
229 | err := w.CleanHistory()
230 | if err != nil {
231 | http.Error(response, "Failed to clean history", 500)
232 | return
233 | }
234 | result, _ := json.Marshal(&result{State: "OK"})
235 |
236 | response.Write(result)
237 | }
238 |
239 | // @Title pixieHandler
240 | // @Description Dictionary with kernel, intrd(s) and commandline for pixiecore
241 | // @Summary Dictionary with kernel, intrd(s) and commandline for pixiecore
242 | // @Param macaddr path string true "MacAddress"
243 | // @Success 200 {object} string "Dictionary with kernel, intrd(s) and commandline for pixiecore"
244 | // @Failure 500 {object} string "failed to get pxe config: "
245 | // @Router /v1/boot/{macaddr} [GET]
246 | func pixieHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
247 |
248 | pxeconfig, err := w.GetPxeConfig(ps.ByName("macaddr"))
249 |
250 | if err != nil {
251 | http.Error(response, "failed to get pxe config: "+err.Error(), 500)
252 | return
253 | }
254 |
255 | result, _ := json.Marshal(pxeconfig)
256 | response.Write(result)
257 | }
258 |
259 | // @Title healthHandler
260 | // @Description Check that Waitron is running
261 | // @Summary Check that Waitron is running
262 | // @Success 200 {object} string "{"State": "OK"}"
263 | // @Router /health [GET]
264 | func healthHandler(response http.ResponseWriter, request *http.Request, ps httprouter.Params, w *waitron.Waitron) {
265 |
266 | result, _ := json.Marshal(&result{State: "OK"})
267 |
268 | fmt.Fprintf(response, string(result))
269 | }
270 |
271 | func main() {
272 |
273 | configPath := flag.String("config", "", "Path to config file.")
274 | address := flag.String("address", "", "Address to listen for requests.")
275 | port := flag.String("port", "9090", "Port to listen for requests.")
276 | flag.Parse()
277 |
278 | configFile := *configPath
279 |
280 | if configFile == "" {
281 | if configFile = os.Getenv("CONFIG_FILE"); configFile == "" {
282 | log.Fatal("environment variables CONFIG_FILE must be set or use --config")
283 | }
284 | }
285 |
286 | configuration, err := config.LoadConfig(configFile)
287 | if err != nil {
288 | log.Fatal(err)
289 | }
290 |
291 | w := waitron.New(configuration)
292 | if err := w.Init(); err != nil {
293 | log.Fatal(err)
294 | }
295 |
296 | r := httprouter.New()
297 | r.PUT("/build/:hostname",
298 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
299 | buildHandler(response, request, ps, w)
300 | })
301 | r.PUT("/build/:hostname/:type",
302 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
303 | buildHandler(response, request, ps, w)
304 | })
305 | r.GET("/status/:hostname",
306 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
307 | hostStatus(response, request, ps, w)
308 | })
309 | r.GET("/status",
310 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
311 | status(response, request, ps, w)
312 | })
313 | r.PUT("/cleanhistory",
314 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
315 | cleanHistory(response, request, ps, w)
316 | })
317 | r.GET("/definition/:hostname",
318 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
319 | definitionHandler(response, request, ps, w)
320 | })
321 | r.GET("/definition/:hostname/:type",
322 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
323 | definitionHandler(response, request, ps, w)
324 | })
325 | r.GET("/job/:token",
326 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
327 | jobDefinitionHandler(response, request, ps, w)
328 | })
329 |
330 | r.GET("/done/:hostname/:token",
331 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
332 | doneHandler(response, request, ps, w)
333 | })
334 | r.PUT("/cancel/:hostname/:token",
335 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
336 | cancelHandler(response, request, ps, w)
337 | })
338 | r.GET("/template/:template/:hostname/:token",
339 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
340 | templateHandler(response, request, ps, w)
341 | })
342 | r.GET("/v1/boot/:macaddr",
343 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
344 | pixieHandler(response, request, ps, w)
345 | })
346 | r.GET("/health",
347 | func(response http.ResponseWriter, request *http.Request, ps httprouter.Params) {
348 | healthHandler(response, request, ps, w)
349 | })
350 |
351 | if configuration.StaticFilesPath != "" {
352 | fs := http.FileServer(http.Dir(configuration.StaticFilesPath))
353 | r.Handler("GET", "/files/:filename", http.StripPrefix("/files/", fs))
354 | log.Println("Serving static files from " + configuration.StaticFilesPath)
355 | }
356 |
357 | if err := w.Run(); err != nil {
358 | log.Fatal(fmt.Sprintf("waitron instance failed to run: %v", err))
359 | }
360 |
361 | log.Println("Starting Server on " + *address + ":" + *port)
362 | log.Fatal(http.ListenAndServe(*address+":"+*port, handlers.LoggingHandler(w.GetLogger(), r)))
363 |
364 | // This is practically a lie since nothing is properly catching signals AFAIK, but maybe in
365 | w.Stop()
366 | }
367 |
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "io/ioutil"
6 | "net/http"
7 | "net/http/httptest"
8 | "strings"
9 | "testing"
10 |
11 | "waitron/config"
12 | "waitron/inventoryplugins"
13 | "waitron/machine"
14 | "waitron/waitron"
15 |
16 | "github.com/julienschmidt/httprouter"
17 | )
18 |
19 | // Test plugin #1
20 | type TestPlugin struct {
21 | }
22 |
23 | func (t *TestPlugin) Init() error {
24 | return nil
25 | }
26 |
27 | func (t *TestPlugin) GetMachine(s string, m string) (*machine.Machine, error) {
28 |
29 | if s == "test01.prod" {
30 | return &machine.Machine{Hostname: "test01.prod", ShortName: "test01"}, nil
31 | }
32 |
33 | return nil, nil
34 | }
35 |
36 | func (t *TestPlugin) PutMachine(m *machine.Machine) error {
37 | return nil
38 | }
39 |
40 | func (t *TestPlugin) Deinit() error {
41 | return nil
42 | }
43 |
44 | // Test plugin #2
45 | type TestPlugin2 struct {
46 | }
47 |
48 | func (t *TestPlugin2) Init() error {
49 | return nil
50 | }
51 |
52 | func (t *TestPlugin2) GetMachine(s string, m string) (*machine.Machine, error) {
53 |
54 | mm := &machine.Machine{
55 | Hostname: "test01.prod",
56 | ShortName: "test02",
57 | Domain: "domain02",
58 | IpmiAddressRaw: "original_ipmi_address",
59 | Network: []machine.Interface{
60 | machine.Interface{
61 | MacAddress: "de:ad:be:ef",
62 | },
63 | },
64 | }
65 |
66 | if s == "test01.prod" {
67 | return mm, nil
68 | }
69 |
70 | return nil, nil
71 | }
72 |
73 | func (t *TestPlugin2) PutMachine(m *machine.Machine) error {
74 | return nil
75 | }
76 |
77 | func (t *TestPlugin2) Deinit() error {
78 | return nil
79 | }
80 | func TestPixieHandlerNotInBuildMode(t *testing.T) {
81 |
82 | cf := &config.Config{
83 | BuildType: config.BuildType{
84 | Cmdline: "cmd",
85 | ImageURL: "image.com",
86 | Kernel: "popcorn",
87 | Initrd: []string{"initrd"},
88 | },
89 | MachineInventoryPlugins: []config.MachineInventoryPluginSettings{
90 | config.MachineInventoryPluginSettings{
91 | Name: "test1",
92 | Type: "test1",
93 | },
94 | config.MachineInventoryPluginSettings{
95 | Name: "test2",
96 | Type: "test2",
97 | },
98 | },
99 | }
100 |
101 | w := waitron.New(cf)
102 |
103 | /************** Stand up **************/
104 | if err := inventoryplugins.AddMachineInventoryPlugin("test1", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
105 | return &TestPlugin{}
106 | }); err != nil {
107 | t.Errorf("Plugin factory failed to add test1 type: %v", err)
108 | return
109 | }
110 |
111 | if err := inventoryplugins.AddMachineInventoryPlugin("test2", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
112 | return &TestPlugin2{}
113 | }); err != nil {
114 | t.Errorf("Plugin factory failed to add test1 type: %v", err)
115 | return
116 | }
117 |
118 | if err := w.Init(); err != nil {
119 | t.Errorf("Failed to init: %v", err)
120 | return
121 | }
122 |
123 | if err := w.Run(); err != nil {
124 | t.Errorf("Failed to run: %v", err)
125 | return
126 | }
127 |
128 | /******************************************************************/
129 |
130 | request, _ := http.NewRequest("PUT", "/boot", nil)
131 | request.Body = ioutil.NopCloser(bytes.NewBufferString("{\"ipmi_address\": \"new_ipmi_address\"}"))
132 | response := httptest.NewRecorder()
133 | ps := httprouter.Params{httprouter.Param{Key: "hostname", Value: "test01.prod"}}
134 | buildHandler(response, request, ps, w)
135 |
136 | expected := "\"State\":\"OK\"}"
137 | if !strings.Contains(response.Body.String(), expected) {
138 | t.Errorf("Reponse body is '%s', expected to contain '%s'", response.Body, expected)
139 | }
140 |
141 | request, _ = http.NewRequest("GET", "/status", nil)
142 | response = httptest.NewRecorder()
143 | ps = httprouter.Params{}
144 | status(response, request, ps, w)
145 |
146 | expected = "new_ipmi_address"
147 | if !strings.Contains(response.Body.String(), expected) {
148 | t.Errorf("Reponse body is '%s', expected to contain '%s'", response.Body, expected)
149 | }
150 |
151 | request, _ = http.NewRequest("GET", "/boot", nil)
152 | response = httptest.NewRecorder()
153 | ps = httprouter.Params{httprouter.Param{Key: "macaddr", Value: "cow"}}
154 |
155 | pixieHandler(response, request, ps, w)
156 |
157 | expected = "failed to get pxe config"
158 | if !strings.Contains(response.Body.String(), expected) {
159 | t.Errorf("Reponse body is '%s', expected to contain '%s'", response.Body, expected)
160 | }
161 |
162 | }
163 |
--------------------------------------------------------------------------------
/waitron/filters.go:
--------------------------------------------------------------------------------
1 | package waitron
2 |
3 | import (
4 | "regexp"
5 |
6 | "github.com/flosch/pongo2"
7 | "gopkg.in/yaml.v2"
8 | )
9 |
10 | func FilterFromYaml(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
11 |
12 | s := in.String()
13 |
14 | out := make(map[interface{}]interface{})
15 |
16 | if err := yaml.Unmarshal([]byte(s), out); err != nil {
17 | return nil, &pongo2.Error{Sender: "filter:from_yaml", OrigError: err}
18 | }
19 |
20 | return pongo2.AsSafeValue(out), nil
21 | }
22 |
23 | type tagRegexReplaceNode struct {
24 | position *pongo2.Token
25 | args []pongo2.IEvaluator
26 | }
27 |
28 | func (node tagRegexReplaceNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) *pongo2.Error {
29 |
30 | s, perr := node.args[0].Evaluate(ctx)
31 | if perr != nil {
32 | return perr
33 | }
34 |
35 | rgx, perr := node.args[1].Evaluate(ctx)
36 | if perr != nil {
37 | return perr
38 | }
39 |
40 | rpl, perr := node.args[2].Evaluate(ctx)
41 | if perr != nil {
42 | return perr
43 | }
44 |
45 | re, err := regexp.Compile(rgx.String())
46 |
47 | if err != nil {
48 | return &pongo2.Error{Sender: "tag:regex_replace", OrigError: err}
49 | }
50 |
51 | writer.WriteString(pongo2.AsValue(re.ReplaceAllString(s.String(), rpl.String())).String())
52 |
53 | return nil
54 | }
55 |
56 | func TagRegexReplace(doc *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, *pongo2.Error) {
57 | regexReplaceNode := &tagRegexReplaceNode{
58 | position: start,
59 | }
60 |
61 | for arguments.Remaining() > 0 {
62 | node, err := arguments.ParseExpression()
63 | if err != nil {
64 | return nil, err
65 | }
66 | regexReplaceNode.args = append(regexReplaceNode.args, node)
67 | }
68 |
69 | return regexReplaceNode, nil
70 | }
71 |
72 | func init() {
73 | pongo2.RegisterFilter("from_yaml", FilterFromYaml)
74 | pongo2.RegisterTag("regex_replace", TagRegexReplace)
75 | }
76 |
--------------------------------------------------------------------------------
/waitron/waitron.go:
--------------------------------------------------------------------------------
1 | package waitron
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "log"
10 | "os"
11 | "os/exec"
12 | "path"
13 | "strings"
14 | "sync"
15 | "syscall"
16 | "time"
17 |
18 | "waitron/config"
19 | "waitron/inventoryplugins"
20 | "waitron/machine"
21 |
22 | "github.com/flosch/pongo2"
23 | "github.com/google/uuid"
24 | "gopkg.in/yaml.v2"
25 | )
26 |
27 | /*
28 | TODO:
29 | Figure out logging.
30 |
31 | Take a look at what actually needs to be exported here. Seems like not much, so either
32 | move some of the Job* stuff to a separate package and make the rest of the fields public, or stop exporting the struct and also just make the properties private.
33 | */
34 |
35 | // PixieConfig boot configuration
36 | type PixieConfig struct {
37 | Kernel string `json:"kernel" description:"The kernel file"`
38 | Initrd []string `json:"initrd"`
39 | Cmdline string `json:"cmdline"`
40 | }
41 |
42 | type Jobs struct {
43 | sync.RWMutex `json:"-"`
44 | jobByToken map[string]*Job
45 | jobByMAC map[string]*Job
46 | jobByHostname map[string]*Job
47 | }
48 |
49 | type JobsHistory struct {
50 | sync.RWMutex `json:"-"`
51 | jobByToken map[string]*Job
52 | }
53 |
54 | type Job struct {
55 | Start time.Time
56 | End time.Time
57 |
58 | sync.RWMutex `json:"-"`
59 | Status string
60 | StatusReason string
61 |
62 | BuildTypeName string
63 | Machine *machine.Machine
64 | TriggerMacRaw string // The MAC that actually came in looking for a PXE boot.
65 | TriggerMacNormalized string
66 | Token string
67 | }
68 |
69 | type activePlugin struct {
70 | plugin inventoryplugins.MachineInventoryPlugin
71 | settings *config.MachineInventoryPluginSettings
72 | }
73 |
74 | type Waitron struct {
75 | config *config.Config
76 | jobs Jobs
77 | history JobsHistory
78 |
79 | historyBlobLastCached time.Time
80 | historyBlobCache []byte
81 |
82 | done chan struct{}
83 | wg sync.WaitGroup
84 |
85 | activePlugins []activePlugin
86 |
87 | logs chan string
88 | }
89 |
90 | func New(c *config.Config) *Waitron {
91 | w := &Waitron{
92 | config: c,
93 | jobs: Jobs{},
94 | history: JobsHistory{},
95 | historyBlobLastCached: time.Time{},
96 | done: make(chan struct{}, 1),
97 | wg: sync.WaitGroup{},
98 | activePlugins: make([]activePlugin, 0, 1),
99 | logs: make(chan string, 1000),
100 | }
101 |
102 | w.history.jobByToken = make(map[string]*Job)
103 |
104 | w.jobs.jobByToken = make(map[string]*Job)
105 | w.jobs.jobByMAC = make(map[string]*Job)
106 | w.jobs.jobByHostname = make(map[string]*Job)
107 |
108 | return w
109 | }
110 |
111 | /*
112 | Just some quick and dirty buffered logging. This function can/should/will be passed around to plugins.
113 | */
114 | func (w *Waitron) addLog(s string, l config.LogLevel) bool {
115 |
116 | if l > w.config.LogLevel {
117 | return true
118 | }
119 |
120 | select {
121 | case w.logs <- fmt.Sprintf("[%s] %s", l, s):
122 | return true
123 | default:
124 | return false
125 | }
126 | }
127 |
128 | /*********** super hacky, sorry... *************/
129 | type WaitronLogger struct {
130 | wf func(string, config.LogLevel) bool
131 | }
132 |
133 | func (wl WaitronLogger) Write(b []byte) (int, error) {
134 |
135 | if wl.wf(string(b), config.LogLevelInfo) {
136 | return len(b), nil
137 | } else {
138 | return 0, fmt.Errorf("log channel is full")
139 | }
140 |
141 | }
142 | func (w *Waitron) GetLogger() WaitronLogger {
143 | return WaitronLogger{wf: w.addLog}
144 | }
145 |
146 | /************************************************/
147 |
148 | /*
149 | Create an array of plugin instances. Only enabled/active plugins will be loaded.
150 | */
151 | func (w *Waitron) initPlugins() error {
152 | for idx := 0; idx < len(w.config.MachineInventoryPlugins); idx++ { // for-range and pointers don't mix.
153 |
154 | cp := &(w.config.MachineInventoryPlugins[idx])
155 |
156 | if !cp.Disabled {
157 |
158 | p, err := inventoryplugins.GetPlugin(cp.Name, cp, w.config, w.addLog)
159 |
160 | if err != nil {
161 | return err
162 | }
163 |
164 | if err = p.Init(); err != nil {
165 | return err
166 | }
167 |
168 | w.activePlugins = append(w.activePlugins, activePlugin{plugin: p, settings: cp})
169 | }
170 | }
171 | return nil
172 | }
173 |
174 | /*
175 | Perform any init work that needs to be done before running things.
176 | */
177 | func (w *Waitron) Init() error {
178 |
179 | if err := w.initPlugins(); err != nil {
180 | return err
181 | }
182 |
183 | return nil
184 | }
185 |
186 | /*
187 | Start up any necessary go-routines.
188 | */
189 | func (w *Waitron) Run() error {
190 |
191 | if w.config.StaleBuildCheckFrequency <= 0 {
192 | w.config.StaleBuildCheckFrequency = 300
193 | }
194 |
195 | ticker := time.NewTicker(time.Duration(w.config.StaleBuildCheckFrequency) * time.Second)
196 |
197 | w.wg.Add(1)
198 | go func() {
199 | defer w.wg.Done()
200 | defer ticker.Stop()
201 | for {
202 | select {
203 | case _, _ = <-w.done:
204 | ticker.Stop()
205 | return
206 | case <-ticker.C:
207 | w.checkForStaleJobs()
208 | }
209 | }
210 | }()
211 |
212 | w.wg.Add(1)
213 | go func() {
214 | w.wg.Done()
215 | for lm := range w.logs {
216 | log.Print(lm)
217 | select {
218 | case <-w.done:
219 | return
220 | default:
221 | }
222 | }
223 |
224 | }()
225 |
226 | return nil
227 | }
228 |
229 | /*
230 | Broadcast "done" and wait for any go-routines to return.
231 | */
232 | func (w *Waitron) Stop() error {
233 | close(w.done) // Was going to use <- struct{}{} since the use case is so simple but figured close() will get my attention if we make sync-related changes in the future.
234 | w.wg.Wait()
235 |
236 | return nil
237 | }
238 |
239 | /*
240 | Loop through all active jobs and run stale-commands for any that have crossed their StaleBuildThresholdSeconds
241 | */
242 | func (w *Waitron) checkForStaleJobs() {
243 |
244 | staleJobs := make([]*Job, 0)
245 |
246 | w.jobs.RLock()
247 | for _, j := range w.jobs.jobByToken {
248 | if j.Machine.StaleBuildThresholdSeconds > 0 && int(time.Now().Sub(j.Start).Seconds()) >= j.Machine.StaleBuildThresholdSeconds {
249 | staleJobs = append(staleJobs, j)
250 | }
251 | }
252 | w.jobs.RUnlock()
253 |
254 | for _, j := range staleJobs {
255 | go func() {
256 | if err := w.runBuildCommands(j, j.Machine.StaleBuildCommands); err != nil {
257 | w.addLog(err.Error(), config.LogLevelError)
258 | }
259 | }()
260 | }
261 | }
262 |
263 | /*
264 | This should ensure that even commands that spawn child processes are cleaned up correctly, along with their children.
265 | */
266 | func (w *Waitron) timedCommandOutput(timeout time.Duration, command string) ([]byte, error) {
267 |
268 | tmpfile, err := ioutil.TempFile(w.config.TempPath, "waitron.timedCommandOutput")
269 | if err != nil {
270 | return []byte{}, err
271 | }
272 |
273 | defer os.Remove(tmpfile.Name())
274 |
275 | if _, err = tmpfile.Write([]byte(command)); err != nil {
276 | return []byte{}, err
277 | }
278 |
279 | if err = tmpfile.Close(); err != nil {
280 | return []byte{}, err
281 | }
282 |
283 | if err = os.Chmod(tmpfile.Name(), 0700); err != nil {
284 | return []byte{}, err
285 | }
286 |
287 | // Fair credit: Decided to migrate to a compact version of github user abh's idea for a temp file vs straight to bash -c.
288 | // Not as nice for simple commands but more pleasant for large scripts.
289 | cmd := exec.Command(tmpfile.Name())
290 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
291 |
292 | outP, err := cmd.StdoutPipe() // Set up the stdout pipe
293 |
294 | if err != nil {
295 | return []byte{}, err
296 | }
297 |
298 | // Start the command
299 | if err := cmd.Start(); err != nil {
300 | return []byte{}, err
301 | }
302 |
303 | // Grab the pid now that we've started and set up the timeout function.
304 | pid := cmd.Process.Pid
305 | time.AfterFunc(timeout, func() {
306 | syscall.Kill(-pid, syscall.SIGKILL)
307 | })
308 |
309 | out := make([]byte, 512, 512)
310 | n, err := outP.Read(out)
311 |
312 | if err != nil && err != io.EOF {
313 | return out, err
314 | }
315 |
316 | // Wait for the command to finish/terminate.
317 | if err := cmd.Wait(); err != nil {
318 | return []byte{}, err
319 | }
320 |
321 | return out[:n], nil
322 | }
323 |
324 | /*
325 | Loop through any passed in commands, render them, and execute them.
326 | */
327 | func (w *Waitron) runBuildCommands(j *Job, b []config.BuildCommand) error {
328 | for _, buildCommand := range b {
329 |
330 | if buildCommand.TimeoutSeconds == 0 {
331 | buildCommand.TimeoutSeconds = 5
332 | }
333 |
334 | tpl, err := pongo2.FromString(buildCommand.Command)
335 | if err != nil {
336 | return err
337 | }
338 |
339 | j.RLock()
340 | cmdline, err := tpl.Execute(pongo2.Context{"job": j, "machine": j.Machine, "token": j.Token})
341 | j.RUnlock()
342 |
343 | if err != nil {
344 | return err
345 | }
346 |
347 | if buildCommand.ShouldLog {
348 | w.addLog(cmdline, config.LogLevelInfo)
349 | }
350 |
351 | // Now actually execute the command and return err if ErrorsFatal
352 | out, err := w.timedCommandOutput(time.Duration(buildCommand.TimeoutSeconds)*time.Second, cmdline)
353 |
354 | if err != nil {
355 | if buildCommand.ErrorsFatal {
356 | return errors.New(err.Error() + ":" + string(out))
357 | } else {
358 | w.addLog(err.Error()+":"+string(out), config.LogLevelWarning)
359 | }
360 | }
361 | }
362 |
363 | return nil
364 | }
365 |
366 | /*
367 | Create a register a new job for the specified hostname, and optionally the build type.
368 | */
369 | func (w *Waitron) Build(hostname string, buildTypeName string, machineDefinitionOverride []byte) (string, error) {
370 | /*
371 | Since the details of a BuildType can also exist directly in the root config,
372 | an empty build-type can be assumed to mean we'll use that.
373 |
374 | But, it's important to remember that things will be merged, and using the root config as a "default"
375 | might give you more items in pre/post/stale/cancel command lists than expected.
376 |
377 | Build type is how we will know what specific pre-build commands exist
378 | Machines can also have specific pre-build commands, but this should all be handled by how we merge in the configs starting at config->build-type->machine.
379 |
380 | We can also allow build-type to come from the config of the machine itself.
381 |
382 | If present, we should be merging on top of that build type and not the one passed in here, then we have to "rebase" the machine onto the build type it's requesting.
383 | If not present, then it will be set from buildType - This must happen so that when the macaddress comes in for the pxe config, we will know what to serve.
384 | */
385 |
386 | w.addLog(fmt.Sprintf("looking for already active job for '%s'", hostname), config.LogLevelDebug)
387 |
388 | // Error or not, if an existing job was found, no new job permitted.
389 | if _, found, _ := w.getActiveJob(hostname, ""); found {
390 | return "", fmt.Errorf("active job for '%s' must complete or be terminated before new job", hostname)
391 | }
392 |
393 | // Generate a job token, which can optionally be used to authenticate requests.
394 | token := uuid.New().String()
395 |
396 | w.addLog(fmt.Sprintf("%s job token generated: %s", hostname, token), config.LogLevelInfo)
397 |
398 | hostname = strings.ToLower(hostname)
399 |
400 | w.addLog(fmt.Sprintf("retrieving complied machine details for job %s", token), config.LogLevelDebug)
401 |
402 | // Get the compiled machine details from any config, build type, and plugins being used
403 | foundMachine, err := w.GetMergedMachine(hostname, "", buildTypeName, machineDefinitionOverride)
404 |
405 | if err != nil {
406 | return "", err
407 | }
408 |
409 | // Prep the new Job
410 | j := &Job{
411 | Start: time.Now(),
412 | RWMutex: sync.RWMutex{},
413 | Status: "pending",
414 | StatusReason: "",
415 | Machine: foundMachine,
416 | BuildTypeName: buildTypeName,
417 | Token: token,
418 | }
419 |
420 | w.addLog(fmt.Sprintf("running pre-build commands for job %s", token), config.LogLevelDebug)
421 |
422 | // Perform any desired operations needed prior to setting build mode.
423 | if err := w.runBuildCommands(j, j.Machine.PreBuildCommands); err != nil {
424 | w.addLog(fmt.Sprintf("pre-build commands for %s returned errors %v", token, err), config.LogLevelDebug)
425 | return "", err
426 | }
427 |
428 | w.addLog(fmt.Sprintf("normalizing macs for job %s", token), config.LogLevelDebug)
429 |
430 | // normalize interface MAC addresses
431 | macs := make([]string, 0, len(j.Machine.Network))
432 | r := strings.NewReplacer(":", "", "-", "", ".", "")
433 |
434 | for i := 0; i < len(j.Machine.Network); i++ {
435 | if j.Machine.Network[i].MacAddress != "" {
436 | j.Machine.Network[i].MacAddress = strings.ToLower(r.Replace(j.Machine.Network[i].MacAddress))
437 | macs = append(macs, j.Machine.Network[i].MacAddress)
438 | }
439 | }
440 |
441 | w.addLog(fmt.Sprintf("adding job %s", token), config.LogLevelDebug)
442 |
443 | if err = w.addJob(j, token, hostname, macs); err != nil {
444 | return "", err
445 | }
446 |
447 | w.addLog(fmt.Sprintf("job %s added", token), config.LogLevelInfo)
448 |
449 | return token, nil
450 | }
451 |
452 | /*
453 | This produces a Machine with data compiled from all enabled plugins.
454 | This is not pulling data from Waitron. It's pulling external data,
455 | compiling it, and returning that.
456 | */
457 | func (w *Waitron) getMergedInventoryMachine(hostname string, mac string) (*machine.Machine, error) {
458 | m := &machine.Machine{}
459 |
460 | anyFound := false
461 |
462 | w.addLog(fmt.Sprintf("looping through %d active plugins", len(w.activePlugins)), config.LogLevelInfo)
463 |
464 | /*
465 | Take the hostname and start looping through the inventory plugins
466 | Merge details as you get them into a single, compiled Machine object
467 | */
468 | maxWeightSeen := 0
469 | for _, ap := range w.activePlugins {
470 |
471 | /*
472 | If we've already found details in a higher-precedence plugins, there's no need to even check the current one.
473 | This would mean that a plugin of greater weight was executed AND returned data.
474 | */
475 | if ap.settings.Weight < maxWeightSeen {
476 | continue
477 | }
478 |
479 | pm, err := ap.plugin.GetMachine(hostname, mac)
480 |
481 | if err != nil {
482 | w.addLog(fmt.Sprintf("failed to get machine from plugin in: %v", err), config.LogLevelInfo)
483 | return nil, err
484 | }
485 |
486 | if pm != nil {
487 | // Just keep merging in details that we find
488 | if b, err := yaml.Marshal(pm); err == nil {
489 |
490 | /*
491 | But if we are now working on the response from a plugin with a greater weight than all previous plugins that returned data,
492 | then we need to clobber all the previous data and let this current one replace it all.
493 | */
494 | if ap.settings.Weight > maxWeightSeen {
495 | m = &machine.Machine{}
496 | maxWeightSeen = ap.settings.Weight
497 | }
498 |
499 | if err = yaml.Unmarshal(b, m); err != nil {
500 | return nil, err
501 | }
502 | } else {
503 | // Just log. Don't let one plugin break everything.
504 | w.addLog(fmt.Sprintf("failed to marshal plugin data during machine merging: %v", err), config.LogLevelError)
505 | continue
506 | }
507 |
508 | /*
509 | We found details, but we've been told not to treat them as inidicitive of finding a true machine definition.
510 | I.e., the user probably wants this treated as supplmental information if a machine is found in some other plugin.
511 | */
512 | if !ap.settings.SupplementalOnly {
513 | anyFound = true
514 | }
515 | }
516 | }
517 |
518 | // Bail out if we didn't find the machine anywhere.
519 | if !anyFound {
520 | w.addLog(fmt.Sprintf("machine not found in any non-supplemental plugin"), config.LogLevelDebug)
521 | return nil, nil
522 | }
523 |
524 | return m, nil
525 | }
526 |
527 | /*
528 | This produces the final merge machine with config and build type details.
529 | */
530 | func (w *Waitron) GetMergedMachine(hostname string, mac string, buildTypeName string, machineDefinitionOverride []byte) (*machine.Machine, error) {
531 |
532 | /*
533 | We need the "merge" order to go config -> build type -> machine -> machineDefinition (something passed in from a cli etc that will override everything.)
534 | But a machine can also specify a build type for itself, which could have come from any of the available plugins,
535 | so we need to take the initial machine compile from plugins, then create a new base machine and start merging
536 | things in the order we want because we wouldn't know the true final build type until after the plugins have provided all the details.
537 | */
538 | baseMachine := &machine.Machine{}
539 |
540 | foundMachine, err := w.getMergedInventoryMachine(hostname, mac)
541 |
542 | if err != nil {
543 | return nil, err
544 | }
545 |
546 | if foundMachine == nil {
547 | return nil, fmt.Errorf("'%s' '%s' not found using any active plugin", hostname, mac)
548 | }
549 |
550 | // Merge in the "global" config. The marshal/unmarshal combo looks funny, but we've given up completely on speed at this point.
551 | if c, err := yaml.Marshal(w.config); err == nil {
552 | if err = yaml.Unmarshal(c, baseMachine); err != nil {
553 | return nil, err
554 | }
555 | } else {
556 | return nil, err
557 | }
558 |
559 | // Merge in the build type, but allow machines to select their own build type first.
560 | if foundMachine.BuildTypeName != "" {
561 | buildTypeName = foundMachine.BuildTypeName
562 | }
563 |
564 | if buildTypeName != "" {
565 | buildType, found := w.config.BuildTypes[buildTypeName]
566 |
567 | if !found {
568 | return nil, fmt.Errorf("build type '%s' not found", buildTypeName)
569 | }
570 |
571 | if b, err := yaml.Marshal(buildType); err == nil {
572 | if err = yaml.Unmarshal(b, baseMachine); err != nil {
573 | return nil, err
574 | }
575 | } else {
576 | return nil, err
577 | }
578 | }
579 |
580 | // Merge in the machine-specific details.
581 | if f, err := yaml.Marshal(foundMachine); err == nil {
582 | if err = yaml.Unmarshal(f, baseMachine); err != nil {
583 | return nil, err
584 | }
585 | } else {
586 | return nil, err
587 | }
588 |
589 | // Finally, merge in any overriding machine-specific details that were passed in.
590 | if machineDefinitionOverride != nil {
591 | if err = yaml.Unmarshal(machineDefinitionOverride, baseMachine); err != nil {
592 | return nil, err
593 | }
594 | }
595 |
596 | return baseMachine, nil
597 | }
598 |
599 | /*
600 | Retrieves the current ACTIVE job status for related to a hostname
601 | */
602 | func (w *Waitron) GetMachineStatus(hostname string) (string, error) {
603 | j, _, err := w.getActiveJob(hostname, "")
604 | if err != nil {
605 | return "", err
606 | }
607 |
608 | j.RLock()
609 | defer j.RUnlock()
610 |
611 | return j.Status, nil
612 | }
613 |
614 | /*
615 | Retrieves the job status related to a job token if it's currently active.
616 | */
617 | func (w *Waitron) GetActiveJobStatus(token string) (string, error) {
618 | j, _, err := w.getActiveJob("", token)
619 | if err != nil {
620 | return "", err
621 | }
622 |
623 | j.RLock()
624 | defer j.RUnlock()
625 |
626 | return j.Status, nil
627 | }
628 |
629 | /*
630 | Retrieves the job status related to a job token, whether or not it's current active
631 | */
632 | func (w *Waitron) GetJobStatus(token string) (string, error) {
633 | w.history.RLock()
634 | defer w.history.RUnlock()
635 |
636 | j, found := w.history.jobByToken[token]
637 |
638 | if !found {
639 | return "", fmt.Errorf("job '%s' not found", token)
640 | }
641 |
642 | j.RLock()
643 | defer j.RUnlock()
644 |
645 | return j.Status, nil
646 | }
647 |
648 | /*
649 | Adds a new build job
650 | */
651 | func (w *Waitron) addJob(j *Job, token string, hostname string, macs []string) error {
652 | w.jobs.Lock()
653 | defer w.jobs.Unlock()
654 |
655 | w.jobs.jobByToken[token] = j
656 | w.jobs.jobByHostname[hostname] = j
657 |
658 | for _, mac := range macs {
659 | w.jobs.jobByMAC[mac] = j
660 | }
661 |
662 | w.history.Lock()
663 | w.history.jobByToken[token] = j
664 | w.history.Unlock()
665 |
666 | return nil
667 | }
668 |
669 | /*
670 | Retrieves the job struct to a job token or hostname if it's currently active.
671 | If hostname and token are both passed, they much point to the same job.
672 | */
673 | func (w *Waitron) getActiveJob(hostname string, token string) (*Job, bool, error) {
674 | w.jobs.RLock()
675 | defer w.jobs.RUnlock()
676 |
677 | var j *Job
678 | found := false
679 |
680 | // If both are passed, check that they both point to the same job.
681 |
682 | if hostname != "" {
683 | j, found = w.jobs.jobByHostname[hostname]
684 | }
685 |
686 | if token != "" {
687 | jAgain, foundAgain := w.jobs.jobByToken[token]
688 |
689 | if (found && foundAgain) && j != jAgain {
690 | return nil, found, errors.New("hostname/Job mismatch")
691 | }
692 |
693 | found = foundAgain
694 | j = jAgain
695 | }
696 |
697 | if !found {
698 | return nil, found, fmt.Errorf("job not found: '%s' '%s' ", hostname, token)
699 | }
700 |
701 | return j, found, nil
702 |
703 | }
704 |
705 | /*
706 | This handles special cases if requested by the config used with Waitron.
707 | If there doesn't appear to be any job associated with a MAC but the config contains the '_unknown'_
708 | build type, Waitron will serve what it has, but it won't perform any status tracking.
709 | This is simply a hook to allow power users to load in special "registration" OS images that they can use
710 | to, for example, collect and register machine details for new machines into their inventory management system.
711 | */
712 | func (w *Waitron) getPxeConfigForUnknown(b *config.BuildType, macaddress string) (PixieConfig, error) {
713 |
714 | m, err := w.getMergedInventoryMachine("", macaddress)
715 |
716 | if err != nil {
717 | return PixieConfig{}, err
718 | }
719 |
720 | // _unknown_ is only for machines we don't know about at all.
721 | if m != nil {
722 | return PixieConfig{}, fmt.Errorf("job not found for '%s' and _unknown_ builds not requested", macaddress)
723 | }
724 |
725 | w.addLog(fmt.Sprintf("running unknown-build commands for job %s", macaddress), config.LogLevelDebug)
726 |
727 | // Perform any desired operations when an unknown MAC is seen.
728 | if len(w.config.UnknownBuildCommands) > 0 {
729 | /*
730 | I don't want runBuildCommands to accept an empty interface.
731 | For now, at least, I'd prefer sending in a nearly empty job and repurposing the Token field to send the MAC
732 | */
733 | j := &Job{
734 | Token: macaddress,
735 | }
736 |
737 | if err := w.runBuildCommands(j, w.config.UnknownBuildCommands); err != nil {
738 | w.addLog(fmt.Sprintf("unknown-build commands for %s returned errors %v", macaddress, err), config.LogLevelDebug)
739 | return PixieConfig{}, err
740 | }
741 | }
742 |
743 | w.addLog("going to send _unknown_ details to unknown mac", config.LogLevelInfo)
744 |
745 | pixieConfig := PixieConfig{}
746 |
747 | var cmdline string
748 |
749 | cmdline = b.Cmdline
750 |
751 | tpl, err := pongo2.FromString(cmdline)
752 | if err != nil {
753 | return pixieConfig, err
754 | }
755 |
756 | cmdline, err = tpl.Execute(pongo2.Context{"machine": b, "BaseURL": w.config.BaseURL, "Hostname": macaddress, "MAC": macaddress})
757 |
758 | if err != nil {
759 | return pixieConfig, err
760 | }
761 |
762 | imageURL := strings.TrimRight(b.ImageURL, "/")
763 |
764 | pixieConfig.Kernel = imageURL + "/" + b.Kernel
765 | for _, initrd := range b.Initrd {
766 | pixieConfig.Initrd = append(pixieConfig.Initrd, imageURL+"/"+initrd)
767 | }
768 | pixieConfig.Cmdline = cmdline
769 |
770 | return pixieConfig, nil
771 | }
772 |
773 | /*
774 | Retrieves the PXE config based on the details of the job related to the specified MAC.
775 | This will/should be called when Waitron receives a request from something pixiecore, which is basically forwarding along
776 | the MAC from the DHCP request.
777 | */
778 | func (w *Waitron) GetPxeConfig(macaddress string) (PixieConfig, error) {
779 |
780 | // Normalize the MAC
781 | r := strings.NewReplacer(":", "", "-", "", ".", "")
782 | normMacaddress := strings.ToLower(r.Replace(macaddress))
783 |
784 | // Look up the *Job by MAC
785 | w.jobs.RLock()
786 | j, found := w.jobs.jobByMAC[normMacaddress]
787 | w.jobs.RUnlock()
788 |
789 | if !found {
790 | if uBuild, ok := w.config.BuildTypes["_unknown_"]; ok {
791 | return w.getPxeConfigForUnknown(&uBuild, normMacaddress)
792 | } else {
793 | return PixieConfig{}, fmt.Errorf("job not found for '%s'", normMacaddress)
794 | }
795 | }
796 |
797 | // Build the pxe config based on the compiled machine details.
798 |
799 | pixieConfig := PixieConfig{}
800 |
801 | /*
802 | It's entirely possible for multiple requests to come in for the same MAC, either from retries or because pixiecore/dhcp
803 | has been set up as "cluster" and you have "duplicate" pxe requests, but only one will ultimately be selected.
804 | We'll only want to trigger certain things when we're seeing a PXE for a MAC for the first time, such as when a set a network cards attempt PXE in order.
805 |
806 | Unique is a bit of a lie, though, since e.g. a machine looping endlessly through two network cards would keep toggling this var as it rotates through NICs/MACs
807 | */
808 | uniquePxeRequest := false
809 |
810 | j.RLock()
811 |
812 | cmdline := j.Machine.Cmdline
813 |
814 | tpl, err := pongo2.FromString(cmdline)
815 | if err != nil {
816 | return pixieConfig, err
817 | }
818 |
819 | cmdline, err = tpl.Execute(pongo2.Context{"machine": j.Machine, "BaseURL": j.Machine.BaseURL, "Hostname": j.Machine.Hostname, "Token": j.Token})
820 |
821 | j.RUnlock()
822 | j.Lock()
823 | /*
824 | The deferred unlock was removed. Even though the (read-locking) runBuildCommands call near the end
825 | is happening in a go-routine, having it happen while this function is holding a write-lock
826 | makes me too nervous. It just feels too dead-lockish.
827 | */
828 |
829 | if j.TriggerMacRaw != macaddress {
830 | uniquePxeRequest = true
831 | j.TriggerMacRaw = macaddress
832 | j.TriggerMacNormalized = normMacaddress
833 | }
834 |
835 | if err != nil {
836 | j.Status = "failed"
837 | j.StatusReason = "pxe config build failed"
838 |
839 | j.Unlock()
840 |
841 | return pixieConfig, err
842 | } else {
843 | j.Status = "installing"
844 | j.StatusReason = "pxe config sent"
845 | }
846 |
847 | j.Unlock()
848 |
849 | imageURL := strings.TrimRight(j.Machine.ImageURL, "/")
850 |
851 | pixieConfig.Kernel = imageURL + "/" + j.Machine.Kernel
852 | for _, initrd := range j.Machine.Initrd {
853 | pixieConfig.Initrd = append(pixieConfig.Initrd, imageURL+"/"+initrd)
854 | }
855 | pixieConfig.Cmdline = cmdline
856 |
857 | /*
858 | It can be pretty valuable to be able to run commands when a PXE is received,
859 | but they shouldn't be allowed to block an install at this point.
860 |
861 | This would probably be a good spot where go-routines could leak if a user were to create super-long running commands
862 | that don't, or practically don't, timeout.
863 | */
864 | if uniquePxeRequest {
865 | go func() {
866 | if err := w.runBuildCommands(j, j.Machine.PxeEventCommands); err != nil {
867 | w.addLog(fmt.Sprintf("pxe-event commands for %s returned errors %v", macaddress, err), config.LogLevelError)
868 | }
869 | }()
870 | }
871 |
872 | w.addLog(fmt.Sprintf("PXE config for %s: %v", macaddress, pixieConfig), config.LogLevelDebug)
873 |
874 | return pixieConfig, nil
875 | }
876 |
877 | /*
878 | Clean up the references to the job, excluding from the job history
879 | */
880 | func (w *Waitron) cleanUpJob(j *Job, status string) error {
881 | // Take the list of all macs found in that Jobs Machine->Network
882 | // Use host, token, and list of MACs to clean out the details from Jobs
883 |
884 | j.Lock()
885 | j.Status = status
886 | j.StatusReason = ""
887 | j.End = time.Now()
888 | j.Unlock()
889 |
890 | j.RLock()
891 | defer j.RUnlock()
892 |
893 | w.jobs.Lock()
894 | defer w.jobs.Unlock()
895 |
896 | for _, iface := range j.Machine.Network {
897 | delete(w.jobs.jobByMAC, iface.MacAddress)
898 | }
899 |
900 | delete(w.jobs.jobByToken, j.Token)
901 | delete(w.jobs.jobByHostname, j.Machine.Hostname)
902 |
903 | return nil
904 | }
905 |
906 | /*
907 | Perform any final/post-build actions and then clean up the job refernces.
908 | */
909 | func (w *Waitron) FinishBuild(hostname string, token string) error {
910 |
911 | j, _, err := w.getActiveJob(hostname, token)
912 |
913 | if err != nil {
914 | return err
915 | }
916 |
917 | if err := w.runBuildCommands(j, j.Machine.PostBuildCommands); err != nil {
918 | return err
919 | }
920 |
921 | // Run clean-up if all finish commands were successful (or non-fatal).
922 | return w.cleanUpJob(j, "completed")
923 | }
924 |
925 | /*
926 | Perform any final/cancel actions and then clean up the job references.
927 | */
928 | func (w *Waitron) CancelBuild(hostname string, token string) error {
929 |
930 | j, _, err := w.getActiveJob(hostname, token)
931 |
932 | if err != nil {
933 | return err
934 | }
935 |
936 | if err := w.runBuildCommands(j, j.Machine.CancelBuildCommands); err != nil {
937 | return err
938 | }
939 |
940 | // Run clean-up if all cancel commands were successful (or non-fatal).
941 | return w.cleanUpJob(j, "terminated")
942 | }
943 |
944 | /*
945 | Remove all completed (non-active) jobs from the Job history.
946 | Eventually the in-memory job history will need pruning. This handles that.
947 | */
948 | func (w *Waitron) CleanHistory() error {
949 | // Loop through all items in JobsHistory and check existence in Waitron.jobs.JobByToken
950 | // If not found, it's either completed or terminated and can be cleaned out.
951 | w.history.Lock()
952 | defer w.history.Unlock()
953 |
954 | w.jobs.RLock()
955 | defer w.jobs.RUnlock()
956 |
957 | for token := range w.history.jobByToken {
958 | if _, found := w.jobs.jobByToken[token]; !found {
959 | delete(w.history.jobByToken, token)
960 | }
961 | }
962 |
963 | /*
964 | We're not invalidating the history cache here.
965 | Cleaning history will clean out complete jobs, which doesn't seem much different from
966 | adding in new jobs. If the purpose of the history blob is to reduce load when history
967 | is queried frequently, this holds true even after cleaning since cleaning could still
968 | leave you with a large job history if many jobs are in flight when the cleaning happens.
969 | */
970 |
971 | return nil
972 | }
973 |
974 | /*
975 | Returns a binary-blob representation of the current job history.
976 | */
977 | func (w *Waitron) GetJobsHistoryBlob() ([]byte, error) {
978 | w.history.RLock()
979 | defer w.history.RUnlock()
980 |
981 | // This is the only place that touches historyBlobCache, so the history RLock's above end up working as RW locks for it.
982 |
983 | // Seems efficient...
984 | // https://github.com/golang/go/blob/0bd308ff27822378dc2db77d6dd0ad3c15ed2e08/src/runtime/map.go#L118
985 | if len(w.history.jobByToken) == 0 {
986 | w.addLog("no jobs, so returning empty job history", config.LogLevelInfo)
987 |
988 | /*
989 | If you do a lot of building, then prime the cache, then CleanHistory before ever calling GetJobsHistory again,
990 | you'll end up holding onto the cache until you build things and check history again.
991 | This really just seems like a symptom of the silly way the cache is built, so when someone does something smarter,
992 | this will just go away.
993 | */
994 | if len(w.historyBlobCache) > 2 {
995 | w.historyBlobCache = []byte("[]")
996 | }
997 |
998 | return []byte("[]"), nil
999 | }
1000 |
1001 | // This is simple but seems kind of dumb, but every suggested solution went crazy with marshal and unmarshal,
1002 | // which also seems dumb here but less simple. Did I miss something silly?
1003 | if w.config.HistoryCacheSeconds > 0 && int(time.Now().Sub(w.historyBlobLastCached).Seconds()) < w.config.HistoryCacheSeconds {
1004 | w.addLog("returning valid history cache", config.LogLevelInfo)
1005 | return w.historyBlobCache, nil
1006 | }
1007 |
1008 | w.addLog(fmt.Sprintf("rebuilding stale history blob cache of %d jobs", len(w.history.jobByToken)), config.LogLevelInfo)
1009 | w.historyBlobCache = make([]byte, 1, 256*len(w.history.jobByToken))
1010 | w.historyBlobCache[0] = '['
1011 |
1012 | // Each of the jobs in here needs to be RLock'ed as they are processed.
1013 | // I need to loop through them. Just Marshal'ing the history isn't acceptable. :(
1014 | for _, job := range w.history.jobByToken {
1015 |
1016 | job.RLock()
1017 | b, err := json.Marshal(job)
1018 | job.RUnlock()
1019 |
1020 | if err != nil {
1021 | return b, err
1022 | }
1023 |
1024 | w.historyBlobCache = append(w.historyBlobCache, ',')
1025 | w.historyBlobCache = append(w.historyBlobCache, b...) // So it's not _quite_ as bad as it looks? --> https://stackoverflow.com/questions/16248241/concatenate-two-slices-in-go#comment40751903_16248257
1026 | }
1027 |
1028 | w.historyBlobCache = append(w.historyBlobCache, ']')
1029 | w.historyBlobCache[1] = ' ' // Get rid of that prepended comma of the first item.
1030 |
1031 | w.historyBlobLastCached = time.Now()
1032 |
1033 | return w.historyBlobCache, nil
1034 | }
1035 |
1036 | /*
1037 | Returns a binary-blob representation of the specified job.
1038 | */
1039 | func (w *Waitron) GetJobBlob(token string) ([]byte, error) {
1040 |
1041 | w.history.RLock()
1042 | j, found := w.history.jobByToken[token]
1043 | w.history.RUnlock()
1044 |
1045 | if !found {
1046 | return []byte{}, fmt.Errorf("job '%s' not found", token)
1047 | }
1048 |
1049 | j.RLock()
1050 | b, err := json.Marshal(j)
1051 | j.RUnlock()
1052 |
1053 | if err != nil {
1054 | return []byte{}, err
1055 | }
1056 |
1057 | return b, nil
1058 | }
1059 |
1060 | /*
1061 | Returns a fully rendered template for the ACTIVE job specified by the token.
1062 | */
1063 | func (w *Waitron) RenderStageTemplate(token string, templateStage string) (string, error) {
1064 |
1065 | j, _, err := w.getActiveJob("", token)
1066 | if err != nil {
1067 | return "", err
1068 | }
1069 |
1070 | // Render preseed as default
1071 | templateName := j.Machine.Preseed
1072 |
1073 | if templateStage == "finish" {
1074 | templateName = j.Machine.Finish
1075 | }
1076 |
1077 | return w.renderTemplate(templateName, templateStage, j)
1078 | }
1079 |
1080 | /*
1081 | Performs the actual template rendering for a job and specified template.
1082 | */
1083 | func (w *Waitron) renderTemplate(templateName string, templateStage string, j *Job) (string, error) {
1084 |
1085 | j.Lock()
1086 | j.Status = templateStage
1087 | j.StatusReason = "processing " + templateName
1088 | j.Unlock()
1089 |
1090 | j.RLock()
1091 | defer j.RUnlock()
1092 |
1093 | templateName = path.Join(w.config.TemplatePath, templateName)
1094 | if _, err := os.Stat(templateName); err != nil {
1095 | return "", errors.New("Template does not exist")
1096 | }
1097 |
1098 | var tpl = pongo2.Must(pongo2.FromFile(templateName))
1099 | result, err := tpl.Execute(pongo2.Context{"job": j, "machine": j.Machine, "config": w.config, "Token": j.Token})
1100 | if err != nil {
1101 | return "", err
1102 | }
1103 | return result, err
1104 | }
1105 |
--------------------------------------------------------------------------------
/waitron/waitron_test.go:
--------------------------------------------------------------------------------
1 | package waitron_test
2 |
3 | import (
4 | "testing"
5 |
6 | "waitron/config"
7 | "waitron/inventoryplugins"
8 | "waitron/machine"
9 |
10 | "waitron/waitron"
11 | )
12 |
13 | // Test plugin #1
14 | type TestPlugin struct {
15 | }
16 |
17 | func (t *TestPlugin) Init() error {
18 | return nil
19 | }
20 |
21 | func (t *TestPlugin) GetMachine(s string, m string) (*machine.Machine, error) {
22 |
23 | if s == "test01.prod" {
24 | return &machine.Machine{Hostname: "test01.prod", ShortName: "test01"}, nil
25 | }
26 |
27 | return nil, nil
28 | }
29 |
30 | func (t *TestPlugin) PutMachine(m *machine.Machine) error {
31 | return nil
32 | }
33 |
34 | func (t *TestPlugin) Deinit() error {
35 | return nil
36 | }
37 |
38 | // Test plugin #2
39 | type TestPlugin2 struct {
40 | }
41 |
42 | func (t *TestPlugin2) Init() error {
43 | return nil
44 | }
45 |
46 | func (t *TestPlugin2) GetMachine(s string, m string) (*machine.Machine, error) {
47 |
48 | mm := &machine.Machine{
49 | Hostname: "test01.prod",
50 | ShortName: "test02",
51 | Domain: "domain02",
52 | Network: []machine.Interface{
53 | machine.Interface{
54 | MacAddress: "de:ad:be:ef",
55 | },
56 | },
57 | }
58 |
59 | if s == "test01.prod" {
60 | return mm, nil
61 | }
62 |
63 | return nil, nil
64 | }
65 |
66 | func (t *TestPlugin2) PutMachine(m *machine.Machine) error {
67 | return nil
68 | }
69 |
70 | func (t *TestPlugin2) Deinit() error {
71 | return nil
72 | }
73 |
74 | func TestWaitron(t *testing.T) {
75 | cf := &config.Config{
76 | BuildType: config.BuildType{
77 | Cmdline: "cmd",
78 | ImageURL: "image.com",
79 | Kernel: "popcorn",
80 | Initrd: []string{"initrd"},
81 | },
82 | BuildTypes: make(map[string]config.BuildType),
83 | MachineInventoryPlugins: []config.MachineInventoryPluginSettings{
84 | config.MachineInventoryPluginSettings{
85 | Name: "test1",
86 | Type: "test1",
87 | },
88 | config.MachineInventoryPluginSettings{
89 | Name: "test2",
90 | Type: "test2",
91 | },
92 | },
93 | }
94 |
95 | w := waitron.New(cf)
96 |
97 | /************** Stand up **************/
98 | if err := inventoryplugins.AddMachineInventoryPlugin("test1", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
99 | return &TestPlugin{}
100 | }); err != nil {
101 | t.Errorf("Plugin factory failed to add test1 type: %v", err)
102 | return
103 | }
104 |
105 | if err := inventoryplugins.AddMachineInventoryPlugin("test2", func(s *config.MachineInventoryPluginSettings, c *config.Config, lf func(string, config.LogLevel) bool) inventoryplugins.MachineInventoryPlugin {
106 | return &TestPlugin2{}
107 | }); err != nil {
108 | t.Errorf("Plugin factory failed to add test1 type: %v", err)
109 | return
110 | }
111 |
112 | if err := w.Init(); err != nil {
113 | t.Errorf("Failed to init: %v", err)
114 | return
115 | }
116 |
117 | if err := w.Run(); err != nil {
118 | t.Errorf("Failed to run: %v", err)
119 | return
120 | }
121 |
122 | /******************************************************************/
123 |
124 | m, err := w.GetMergedMachine("", "", "", nil)
125 |
126 | if m != nil {
127 | t.Errorf("Returned merged machine for unknown machine: %v", err)
128 | return
129 | }
130 |
131 | m, err = w.GetMergedMachine("test01.prod", "", "", nil)
132 |
133 | if err != nil {
134 | t.Errorf("Error while getting merge machine: %v", err)
135 | return
136 | }
137 |
138 | if m == nil {
139 | t.Errorf("Known machine not found: %v", err)
140 | return
141 | }
142 |
143 | if m.Domain == "" {
144 | t.Errorf("Machine details were not merged")
145 | return
146 | }
147 |
148 | if m.ShortName != "test02" {
149 | t.Errorf("Plugin ordering was not reserved")
150 | return
151 | }
152 |
153 | /******************************************************************/
154 |
155 | _, err = w.Build("test01.prod", "invalid_build_type", nil)
156 |
157 | if err == nil {
158 | t.Errorf("Allowed build with invalid build type")
159 | return
160 | }
161 |
162 | token, err := w.Build("test01.prod", "", nil)
163 |
164 | if err != nil {
165 | t.Errorf("Failed to set build: %v", err)
166 | return
167 | }
168 |
169 | if token == "" {
170 | t.Errorf("invalid token returned: %s", token)
171 | return
172 | }
173 |
174 | if token == "" {
175 | t.Errorf("invalid token returned: %s", token)
176 | return
177 | }
178 |
179 | if token2, _ := w.Build("test01.prod", "", nil); token2 != "" {
180 | t.Errorf("simultaneous builds for a single host were permitted: %s", token)
181 | return
182 | }
183 |
184 | /******************************************************************/
185 |
186 | status, err := w.GetJobStatus(token)
187 | if err != nil {
188 | t.Errorf("Failed to get job status: %v", err)
189 | return
190 | }
191 |
192 | if status != "pending" {
193 | t.Errorf("Incorrect status returned: %s", status)
194 | return
195 | }
196 |
197 | /******************************************************************/
198 |
199 | status, err = w.GetMachineStatus("test01.prod")
200 | if err != nil {
201 | t.Errorf("Failed to get machine status: %v", err)
202 | return
203 | }
204 |
205 | if status != "pending" {
206 | t.Errorf("Incorrect status returned")
207 | return
208 | }
209 |
210 | /******************************************************************/
211 |
212 | if _, err = w.GetPxeConfig("de:ad:c0:de:ca:fe"); err == nil {
213 | t.Errorf("Returned PXE config for unknown MAC")
214 | return
215 | }
216 |
217 | pCfg, err := w.GetPxeConfig("deadbeef")
218 |
219 | if err != nil {
220 | t.Errorf("Failed to return PXE config for known MAC v3: %v", err)
221 | return
222 | }
223 |
224 | pCfg, err = w.GetPxeConfig("de:ad:be:ef")
225 |
226 | if err != nil {
227 | t.Errorf("Failed to return PXE config for known MAC v2: %v", err)
228 | return
229 | }
230 |
231 | pCfg, err = w.GetPxeConfig("DE-AD-BE-EF")
232 |
233 | if err != nil {
234 | t.Errorf("Failed to return PXE config for known MAC v3: %v", err)
235 | return
236 | }
237 |
238 | if pCfg.Kernel == "" {
239 | t.Errorf("Empty PXE config returned. Machine: %v", m)
240 | return
241 | }
242 |
243 | if pCfg.Kernel != "image.com/popcorn" {
244 | t.Errorf("Unexpected PXE config returned: %s", pCfg.Kernel)
245 | return
246 | }
247 |
248 | /******************************************************************/
249 |
250 | // Sneaky with the pointer usage. Don't do this IRL!
251 | cf.BuildTypes["_unknown_"] = config.BuildType{
252 | Cmdline: "cmd",
253 | ImageURL: "unknown.com",
254 | Kernel: "sanders",
255 | Initrd: []string{"it_is_rd"},
256 | }
257 |
258 | uCfg, err := w.GetPxeConfig("un:kn:ow:nt:hi:ng")
259 |
260 | if err != nil {
261 | t.Errorf("Failed to return PXE config for unknown MAC when _unknown_ exists: %v", err)
262 | return
263 | }
264 |
265 | if uCfg.Kernel != "unknown.com/sanders" {
266 | t.Errorf("Unexpected PXE config returned for _unknown_: %s", pCfg.Kernel)
267 | return
268 | }
269 | delete(cf.BuildTypes, "_unknown_")
270 |
271 | /******************************************************************/
272 |
273 | status, err = w.GetMachineStatus("test01.prod")
274 | if err != nil {
275 | t.Errorf("Failed to get machine status after pxe: %v", err)
276 | return
277 | }
278 |
279 | if status != "installing" {
280 | t.Errorf("Incorrect status returned: %s", status)
281 | return
282 | }
283 |
284 | /******************************************************************/
285 |
286 | if err = w.FinishBuild("test01.prod", token); err != nil {
287 | t.Errorf("Failed to finish build: %v", err)
288 | return
289 | }
290 |
291 | _, err = w.GetActiveJobStatus(token)
292 | if err == nil {
293 | t.Errorf("Job found active after finish")
294 | return
295 | }
296 |
297 | _, err = w.GetMachineStatus(token)
298 | if err == nil {
299 | t.Errorf("Able to trace job status to machine after finish")
300 | return
301 | }
302 |
303 | status, err = w.GetJobStatus(token)
304 | if err != nil {
305 | t.Errorf("Failed to get historical job status after finish: %v", err)
306 | return
307 | }
308 |
309 | if status != "completed" {
310 | t.Errorf("Incorrect status returned: %s", status)
311 | return
312 | }
313 |
314 | if err = w.CancelBuild("test01.prod", token); err == nil {
315 | t.Errorf("Permitted to cancel build after finish")
316 | return
317 | }
318 |
319 | /******************************************************************/
320 |
321 | blob, err := w.GetJobsHistoryBlob()
322 | if err != nil {
323 | t.Errorf("Failed to get jobs history blob: %v", err)
324 | return
325 | }
326 |
327 | if len(blob) == 0 {
328 | t.Errorf("History blob was unexpectedly empty: %v", blob)
329 | return
330 | }
331 |
332 | if string(blob) == "[]" {
333 | t.Errorf("History blob unexpectedly has no jobs: %v", blob)
334 | return
335 | }
336 |
337 | if j, err := w.GetJobBlob(token); err != nil || len(j) == 0 {
338 | t.Errorf("Failed to get job blob for known token: err(%v) job(%v)", err, j)
339 | return
340 | }
341 |
342 | err = w.CleanHistory()
343 | if err != nil {
344 | t.Errorf("Failed to clean history: %v", err)
345 | return
346 | }
347 |
348 | // This comes from history, not history cache blob.
349 | _, err = w.GetJobStatus(token)
350 | if err == nil {
351 | t.Errorf("Able to get historical job status after cleaning")
352 | return
353 | }
354 |
355 | /******************************************************************/
356 |
357 | if err := w.Stop(); err != nil {
358 | t.Errorf("Failed to stop: %v", err)
359 | return
360 | }
361 | }
362 |
--------------------------------------------------------------------------------