├── .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 | [![Build Status](https://travis-ci.org/ns1/waitron.svg?branch=master)](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 | --------------------------------------------------------------------------------