├── .gitignore ├── .gitmodules ├── .hound.yml ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── bin └── broadside ├── broadside.gemspec ├── lib ├── broadside.rb └── broadside │ ├── command.rb │ ├── configuration │ ├── aws_configuration.rb │ ├── configuration.rb │ └── invalid_configuration.rb │ ├── deploy.rb │ ├── ecs │ ├── ecs_deploy.rb │ └── ecs_manager.rb │ ├── error.rb │ ├── gli │ ├── commands.rb │ └── global.rb │ ├── logging_utils.rb │ ├── target.rb │ └── version.rb └── spec ├── broadside ├── command_spec.rb ├── configuration_spec.rb ├── ecs │ ├── ecs_deploy_spec.rb │ └── ecs_manager_spec.rb └── target_spec.rb ├── broadside_spec.rb ├── fixtures ├── .env.rspec ├── .env.rspec.override ├── broadside_app_example.conf.rb └── broadside_system_example.conf.rb ├── spec_helper.rb └── support ├── aws_stub_helper.rb ├── configuration_shared_context.rb └── ecs_shared_contexts.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | *.log 3 | pkg/ 4 | *~ 5 | .env 6 | .DS_Store 7 | 8 | *.gem 9 | Gemfile.lock 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lumoslabs/broadside/222998a008c7c2f68cc23fefd3a9f046494d3b61/.gitmodules -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | config_file: .rubocop.yml 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This is the default configuration file. Enabling and disabling is configured 2 | # in separate files. This file adds all other parameters apart from Enabled. 3 | 4 | inherit_from: 5 | - enabled.yml 6 | - disabled.yml 7 | 8 | # Common configuration. 9 | AllCops: 10 | # Include common Ruby source files. 11 | Include: 12 | - '**/*.builder' 13 | - '**/*.fcgi' 14 | - '**/*.gemspec' 15 | - '**/*.god' 16 | - '**/*.jb' 17 | - '**/*.jbuilder' 18 | - '**/*.mspec' 19 | - '**/*.opal' 20 | - '**/*.pluginspec' 21 | - '**/*.podspec' 22 | - '**/*.rabl' 23 | - '**/*.rake' 24 | - '**/*.rbuild' 25 | - '**/*.rbw' 26 | - '**/*.rbx' 27 | - '**/*.ru' 28 | - '**/*.ruby' 29 | - '**/*.spec' 30 | - '**/*.thor' 31 | - '**/*.watchr' 32 | - '**/.irbrc' 33 | - '**/.pryrc' 34 | - '**/buildfile' 35 | - '**/config.ru' 36 | - '**/Appraisals' 37 | - '**/Berksfile' 38 | - '**/Brewfile' 39 | - '**/Buildfile' 40 | - '**/Capfile' 41 | - '**/Cheffile' 42 | - '**/Dangerfile' 43 | - '**/Deliverfile' 44 | - '**/Fastfile' 45 | - '**/*Fastfile' 46 | - '**/Gemfile' 47 | - '**/Guardfile' 48 | - '**/Jarfile' 49 | - '**/Mavenfile' 50 | - '**/Podfile' 51 | - '**/Puppetfile' 52 | - '**/Rakefile' 53 | - '**/Snapfile' 54 | - '**/Thorfile' 55 | - '**/Vagabondfile' 56 | - '**/Vagrantfile' 57 | Exclude: 58 | - 'node_modules/**/*' 59 | - 'vendor/**/*' 60 | # Default formatter will be used if no `-f/--format` option is given. 61 | DefaultFormatter: progress 62 | # Cop names are not displayed in offense messages by default. Change behavior 63 | # by overriding DisplayCopNames, or by giving the `-D/--display-cop-names` 64 | # option. 65 | DisplayCopNames: false 66 | # Style guide URLs are not displayed in offense messages by default. Change 67 | # behavior by overriding `DisplayStyleGuide`, or by giving the 68 | # `-S/--display-style-guide` option. 69 | DisplayStyleGuide: false 70 | # When specifying style guide URLs, any paths and/or fragments will be 71 | # evaluated relative to the base URL. 72 | StyleGuideBaseURL: https://github.com/bbatsov/ruby-style-guide 73 | # Extra details are not displayed in offense messages by default. Change 74 | # behavior by overriding ExtraDetails, or by giving the 75 | # `-E/--extra-details` option. 76 | ExtraDetails: false 77 | # Additional cops that do not reference a style guide rule may be enabled by 78 | # default. Change behavior by overriding `StyleGuideCopsOnly`, or by giving 79 | # the `--only-guide-cops` option. 80 | StyleGuideCopsOnly: false 81 | # All cops except the ones in disabled.yml are enabled by default. Change 82 | # this behavior by overriding either `DisabledByDefault` or `EnabledByDefault`. 83 | # When `DisabledByDefault` is `true`, all cops in the default configuration 84 | # are disabled, and only cops in user configuration are enabled. This makes 85 | # cops opt-in instead of opt-out. Note that when `DisabledByDefault` is `true`, 86 | # cops in user configuration will be enabled even if they don't set the 87 | # Enabled parameter. 88 | # When `EnabledByDefault` is `true`, all cops, even those in disabled.yml, 89 | # are enabled by default. Cops can still be disabled in user configuration. 90 | # Note that it is invalid to set both EnabledByDefault and DisabledByDefault 91 | # to true in the same configuration. 92 | EnabledByDefault: false 93 | DisabledByDefault: false 94 | # Enables the result cache if `true`. Can be overridden by the `--cache` command 95 | # line option. 96 | UseCache: true 97 | # Threshold for how many files can be stored in the result cache before some 98 | # of the files are automatically removed. 99 | MaxFilesInCache: 20000 100 | # The cache will be stored in "rubocop_cache" under this directory. If 101 | # CacheRootDirectory is ~ (nil), which it is by default, the root will be 102 | # taken from the environment variable `$XDG_CACHE_HOME` if it is set, or if 103 | # `$XDG_CACHE_HOME` is not set, it will be `$HOME/.cache/`. 104 | CacheRootDirectory: ~ 105 | # It is possible for a malicious user to know the location of RuboCop's cache 106 | # directory by looking at CacheRootDirectory, and create a symlink in its 107 | # place that could cause RuboCop to overwrite unintended files, or read 108 | # malicious input. If you are certain that your cache location is secure from 109 | # this kind of attack, and wish to use a symlinked cache location, set this 110 | # value to "true". 111 | AllowSymlinksInCacheRootDirectory: false 112 | # What MRI version of the Ruby interpreter is the inspected code intended to 113 | # run on? (If there is more than one, set this to the lowest version.) 114 | # If a value is specified for TargetRubyVersion then it is used. 115 | # Else if .ruby-version exists and it contains an MRI version it is used. 116 | # Otherwise we fallback to the oldest officially supported Ruby version (2.1). 117 | TargetRubyVersion: ~ 118 | TargetRailsVersion: 5.0 119 | 120 | #################### Layout ########################### 121 | 122 | # Indent private/protected/public as deep as method definitions 123 | Layout/AccessModifierIndentation: 124 | EnforcedStyle: indent 125 | SupportedStyles: 126 | - outdent 127 | - indent 128 | # By default, the indentation width from Layout/IndentationWidth is used 129 | # But it can be overridden by setting this parameter 130 | IndentationWidth: ~ 131 | 132 | # Align the elements of a hash literal if they span more than one line. 133 | Layout/AlignHash: 134 | # Alignment of entries using hash rocket as separator. Valid values are: 135 | # 136 | # key - left alignment of keys 137 | # 'a' => 2 138 | # 'bb' => 3 139 | # separator - alignment of hash rockets, keys are right aligned 140 | # 'a' => 2 141 | # 'bb' => 3 142 | # table - left alignment of keys, hash rockets, and values 143 | # 'a' => 2 144 | # 'bb' => 3 145 | EnforcedHashRocketStyle: key 146 | SupportedHashRocketStyles: 147 | - key 148 | - separator 149 | - table 150 | # Alignment of entries using colon as separator. Valid values are: 151 | # 152 | # key - left alignment of keys 153 | # a: 0 154 | # bb: 1 155 | # separator - alignment of colons, keys are right aligned 156 | # a: 0 157 | # bb: 1 158 | # table - left alignment of keys and values 159 | # a: 0 160 | # bb: 1 161 | EnforcedColonStyle: key 162 | SupportedColonStyles: 163 | - key 164 | - separator 165 | - table 166 | # Select whether hashes that are the last argument in a method call should be 167 | # inspected? Valid values are: 168 | # 169 | # always_inspect - Inspect both implicit and explicit hashes. 170 | # Registers an offense for: 171 | # function(a: 1, 172 | # b: 2) 173 | # Registers an offense for: 174 | # function({a: 1, 175 | # b: 2}) 176 | # always_ignore - Ignore both implicit and explicit hashes. 177 | # Accepts: 178 | # function(a: 1, 179 | # b: 2) 180 | # Accepts: 181 | # function({a: 1, 182 | # b: 2}) 183 | # ignore_implicit - Ignore only implicit hashes. 184 | # Accepts: 185 | # function(a: 1, 186 | # b: 2) 187 | # Registers an offense for: 188 | # function({a: 1, 189 | # b: 2}) 190 | # ignore_explicit - Ignore only explicit hashes. 191 | # Accepts: 192 | # function({a: 1, 193 | # b: 2}) 194 | # Registers an offense for: 195 | # function(a: 1, 196 | # b: 2) 197 | EnforcedLastArgumentHashStyle: always_inspect 198 | SupportedLastArgumentHashStyles: 199 | - always_inspect 200 | - always_ignore 201 | - ignore_implicit 202 | - ignore_explicit 203 | 204 | Layout/AlignParameters: 205 | # Alignment of parameters in multi-line method calls. 206 | # 207 | # The `with_first_parameter` style aligns the following lines along the same 208 | # column as the first parameter. 209 | # 210 | # method_call(a, 211 | # b) 212 | # 213 | # The `with_fixed_indentation` style aligns the following lines with one 214 | # level of indentation relative to the start of the line with the method call. 215 | # 216 | # method_call(a, 217 | # b) 218 | EnforcedStyle: with_first_parameter 219 | SupportedStyles: 220 | - with_first_parameter 221 | - with_fixed_indentation 222 | # By default, the indentation width from Layout/IndentationWidth is used 223 | # But it can be overridden by setting this parameter 224 | IndentationWidth: ~ 225 | 226 | # Indentation of `when`. 227 | Layout/CaseIndentation: 228 | EnforcedStyle: case 229 | SupportedStyles: 230 | - case 231 | - end 232 | IndentOneStep: false 233 | # By default, the indentation width from `Layout/IndentationWidth` is used. 234 | # But it can be overridden by setting this parameter. 235 | # This only matters if `IndentOneStep` is `true` 236 | IndentationWidth: ~ 237 | 238 | # Multi-line method chaining should be done with leading dots. 239 | Layout/DotPosition: 240 | EnforcedStyle: leading 241 | SupportedStyles: 242 | - leading 243 | - trailing 244 | 245 | # Use empty lines between defs. 246 | Layout/EmptyLineBetweenDefs: 247 | # If `true`, this parameter means that single line method definitions don't 248 | # need an empty line between them. 249 | AllowAdjacentOneLineDefs: false 250 | # Can be array to specify minimum and maximum number of empty lines, e.g. [1, 2] 251 | NumberOfEmptyLines: 1 252 | 253 | Layout/EmptyLinesAroundBlockBody: 254 | EnforcedStyle: no_empty_lines 255 | SupportedStyles: 256 | - empty_lines 257 | - no_empty_lines 258 | 259 | Layout/EmptyLinesAroundClassBody: 260 | EnforcedStyle: no_empty_lines 261 | SupportedStyles: 262 | - empty_lines 263 | - empty_lines_except_namespace 264 | - empty_lines_special 265 | - no_empty_lines 266 | 267 | Layout/EmptyLinesAroundModuleBody: 268 | EnforcedStyle: no_empty_lines 269 | SupportedStyles: 270 | - empty_lines 271 | - empty_lines_except_namespace 272 | - empty_lines_special 273 | - no_empty_lines 274 | 275 | Layout/EndOfLine: 276 | # The `native` style means that CR+LF (Carriage Return + Line Feed) is 277 | # enforced on Windows, and LF is enforced on other platforms. The other styles 278 | # mean LF and CR+LF, respectively. 279 | EnforcedStyle: native 280 | SupportedStyles: 281 | - native 282 | - lf 283 | - crlf 284 | 285 | Layout/ExtraSpacing: 286 | # When true, allows most uses of extra spacing if the intent is to align 287 | # things with the previous or next line, not counting empty lines or comment 288 | # lines. 289 | AllowForAlignment: true 290 | # When true, forces the alignment of `=` in assignments on consecutive lines. 291 | ForceEqualSignAlignment: false 292 | 293 | Layout/FirstParameterIndentation: 294 | EnforcedStyle: special_for_inner_method_call_in_parentheses 295 | SupportedStyles: 296 | # The first parameter should always be indented one step more than the 297 | # preceding line. 298 | - consistent 299 | # The first parameter should normally be indented one step more than the 300 | # preceding line, but if it's a parameter for a method call that is itself 301 | # a parameter in a method call, then the inner parameter should be indented 302 | # relative to the inner method. 303 | - special_for_inner_method_call 304 | # Same as `special_for_inner_method_call` except that the special rule only 305 | # applies if the outer method call encloses its arguments in parentheses. 306 | - special_for_inner_method_call_in_parentheses 307 | # By default, the indentation width from `Layout/IndentationWidth` is used 308 | # But it can be overridden by setting this parameter 309 | IndentationWidth: ~ 310 | 311 | Layout/IndentationConsistency: 312 | # The difference between `rails` and `normal` is that the `rails` style 313 | # prescribes that in classes and modules the `protected` and `private` 314 | # modifier keywords shall be indented the same as public methods and that 315 | # protected and private members shall be indented one step more than the 316 | # modifiers. Other than that, both styles mean that entities on the same 317 | # logical depth shall have the same indentation. 318 | EnforcedStyle: normal 319 | SupportedStyles: 320 | - normal 321 | - rails 322 | 323 | Layout/IndentationWidth: 324 | # Number of spaces for each indentation level. 325 | Width: 2 326 | IgnoredPatterns: [] 327 | 328 | # Checks the indentation of the first element in an array literal. 329 | Layout/IndentArray: 330 | # The value `special_inside_parentheses` means that array literals with 331 | # brackets that have their opening bracket on the same line as a surrounding 332 | # opening round parenthesis, shall have their first element indented relative 333 | # to the first position inside the parenthesis. 334 | # 335 | # The value `consistent` means that the indentation of the first element shall 336 | # always be relative to the first position of the line where the opening 337 | # bracket is. 338 | # 339 | # The value `align_brackets` means that the indentation of the first element 340 | # shall always be relative to the position of the opening bracket. 341 | EnforcedStyle: special_inside_parentheses 342 | SupportedStyles: 343 | - special_inside_parentheses 344 | - consistent 345 | - align_brackets 346 | # By default, the indentation width from `Layout/IndentationWidth` is used 347 | # But it can be overridden by setting this parameter 348 | IndentationWidth: ~ 349 | 350 | # Checks the indentation of assignment RHS, when on a different line from LHS 351 | Layout/IndentAssignment: 352 | # By default, the indentation width from `Layout/IndentationWidth` is used 353 | # But it can be overridden by setting this parameter 354 | IndentationWidth: ~ 355 | 356 | # Checks the indentation of the first key in a hash literal. 357 | Layout/IndentHash: 358 | # The value `special_inside_parentheses` means that hash literals with braces 359 | # that have their opening brace on the same line as a surrounding opening 360 | # round parenthesis, shall have their first key indented relative to the 361 | # first position inside the parenthesis. 362 | # 363 | # The value `consistent` means that the indentation of the first key shall 364 | # always be relative to the first position of the line where the opening 365 | # brace is. 366 | # 367 | # The value `align_braces` means that the indentation of the first key shall 368 | # always be relative to the position of the opening brace. 369 | EnforcedStyle: special_inside_parentheses 370 | SupportedStyles: 371 | - special_inside_parentheses 372 | - consistent 373 | - align_braces 374 | # By default, the indentation width from `Layout/IndentationWidth` is used 375 | # But it can be overridden by setting this parameter 376 | IndentationWidth: ~ 377 | 378 | Layout/IndentHeredoc: 379 | EnforcedStyle: auto_detection 380 | SupportedStyles: 381 | - auto_detection 382 | - squiggly 383 | - active_support 384 | - powerpack 385 | - unindent 386 | 387 | Layout/SpaceInLambdaLiteral: 388 | EnforcedStyle: require_no_space 389 | SupportedStyles: 390 | - require_no_space 391 | - require_space 392 | 393 | Layout/MultilineArrayBraceLayout: 394 | EnforcedStyle: symmetrical 395 | SupportedStyles: 396 | # symmetrical: closing brace is positioned in same way as opening brace 397 | # new_line: closing brace is always on a new line 398 | # same_line: closing brace is always on the same line as last element 399 | - symmetrical 400 | - new_line 401 | - same_line 402 | 403 | Layout/MultilineAssignmentLayout: 404 | # The types of assignments which are subject to this rule. 405 | SupportedTypes: 406 | - block 407 | - case 408 | - class 409 | - if 410 | - kwbegin 411 | - module 412 | EnforcedStyle: new_line 413 | SupportedStyles: 414 | # Ensures that the assignment operator and the rhs are on the same line for 415 | # the set of supported types. 416 | - same_line 417 | # Ensures that the assignment operator and the rhs are on separate lines 418 | # for the set of supported types. 419 | - new_line 420 | 421 | Layout/MultilineHashBraceLayout: 422 | EnforcedStyle: symmetrical 423 | SupportedStyles: 424 | # symmetrical: closing brace is positioned in same way as opening brace 425 | # new_line: closing brace is always on a new line 426 | # same_line: closing brace is always on same line as last element 427 | - symmetrical 428 | - new_line 429 | - same_line 430 | 431 | Layout/MultilineMethodCallBraceLayout: 432 | EnforcedStyle: symmetrical 433 | SupportedStyles: 434 | # symmetrical: closing brace is positioned in same way as opening brace 435 | # new_line: closing brace is always on a new line 436 | # same_line: closing brace is always on the same line as last argument 437 | - symmetrical 438 | - new_line 439 | - same_line 440 | 441 | Layout/MultilineMethodCallIndentation: 442 | EnforcedStyle: aligned 443 | SupportedStyles: 444 | - aligned 445 | - indented 446 | - indented_relative_to_receiver 447 | # By default, the indentation width from Layout/IndentationWidth is used 448 | # But it can be overridden by setting this parameter 449 | IndentationWidth: ~ 450 | 451 | Layout/MultilineMethodDefinitionBraceLayout: 452 | EnforcedStyle: symmetrical 453 | SupportedStyles: 454 | # symmetrical: closing brace is positioned in same way as opening brace 455 | # new_line: closing brace is always on a new line 456 | # same_line: closing brace is always on the same line as last parameter 457 | - symmetrical 458 | - new_line 459 | - same_line 460 | 461 | Layout/MultilineOperationIndentation: 462 | EnforcedStyle: aligned 463 | SupportedStyles: 464 | - aligned 465 | - indented 466 | # By default, the indentation width from `Layout/IndentationWidth` is used 467 | # But it can be overridden by setting this parameter 468 | IndentationWidth: ~ 469 | 470 | Layout/SpaceAroundBlockParameters: 471 | EnforcedStyleInsidePipes: no_space 472 | SupportedStylesInsidePipes: 473 | - space 474 | - no_space 475 | 476 | Layout/SpaceAroundEqualsInParameterDefault: 477 | EnforcedStyle: space 478 | SupportedStyles: 479 | - space 480 | - no_space 481 | 482 | Layout/SpaceAroundOperators: 483 | # When `true`, allows most uses of extra spacing if the intent is to align 484 | # with an operator on the previous or next line, not counting empty lines 485 | # or comment lines. 486 | AllowForAlignment: true 487 | 488 | Layout/SpaceBeforeBlockBraces: 489 | EnforcedStyle: space 490 | SupportedStyles: 491 | - space 492 | - no_space 493 | 494 | Layout/SpaceBeforeFirstArg: 495 | # When `true`, allows most uses of extra spacing if the intent is to align 496 | # things with the previous or next line, not counting empty lines or comment 497 | # lines. 498 | AllowForAlignment: true 499 | 500 | Layout/SpaceInsideBlockBraces: 501 | EnforcedStyle: space 502 | SupportedStyles: 503 | - space 504 | - no_space 505 | EnforcedStyleForEmptyBraces: no_space 506 | SupportedStylesForEmptyBraces: 507 | - space 508 | - no_space 509 | # Space between `{` and `|`. Overrides `EnforcedStyle` if there is a conflict. 510 | SpaceBeforeBlockParameters: true 511 | 512 | Layout/SpaceInsideHashLiteralBraces: 513 | EnforcedStyle: space 514 | SupportedStyles: 515 | - space 516 | - no_space 517 | # 'compact' normally requires a space inside hash braces, with the exception 518 | # that successive left braces or right braces are collapsed together 519 | - compact 520 | EnforcedStyleForEmptyBraces: no_space 521 | SupportedStylesForEmptyBraces: 522 | - space 523 | - no_space 524 | 525 | Layout/SpaceInsideStringInterpolation: 526 | EnforcedStyle: no_space 527 | SupportedStyles: 528 | - space 529 | - no_space 530 | 531 | Layout/TrailingBlankLines: 532 | EnforcedStyle: final_newline 533 | SupportedStyles: 534 | - final_newline 535 | - final_blank_line 536 | 537 | #################### Naming ########################## 538 | 539 | Naming/FileName: 540 | # File names listed in `AllCops:Include` are excluded by default. Add extra 541 | # excludes here. 542 | Exclude: [] 543 | # When `true`, requires that each source file should define a class or module 544 | # with a name which matches the file name (converted to ... case). 545 | # It further expects it to be nested inside modules which match the names 546 | # of subdirectories in its path. 547 | ExpectMatchingDefinition: false 548 | # If non-`nil`, expect all source file names to match the following regex. 549 | # Only the file name itself is matched, not the entire file path. 550 | # Use anchors as necessary if you want to match the entire name rather than 551 | # just a part of it. 552 | Regex: ~ 553 | # With `IgnoreExecutableScripts` set to `true`, this cop does not 554 | # report offending filenames for executable scripts (i.e. source 555 | # files with a shebang in the first line). 556 | IgnoreExecutableScripts: true 557 | AllowedAcronyms: 558 | - CLI 559 | - DSL 560 | - ACL 561 | - API 562 | - ASCII 563 | - CPU 564 | - CSS 565 | - DNS 566 | - EOF 567 | - GUID 568 | - HTML 569 | - HTTP 570 | - HTTPS 571 | - ID 572 | - IP 573 | - JSON 574 | - LHS 575 | - QPS 576 | - RAM 577 | - RHS 578 | - RPC 579 | - SLA 580 | - SMTP 581 | - SQL 582 | - SSH 583 | - TCP 584 | - TLS 585 | - TTL 586 | - UDP 587 | - UI 588 | - UID 589 | - UUID 590 | - URI 591 | - URL 592 | - UTF8 593 | - VM 594 | - XML 595 | - XMPP 596 | - XSRF 597 | - XSS 598 | 599 | Naming/HeredocDelimiterNaming: 600 | Blacklist: 601 | - END 602 | - !ruby/regexp '/EO[A-Z]{1}/' 603 | 604 | Naming/HeredocDelimiterCase: 605 | EnforcedStyle: uppercase 606 | SupportedStyles: 607 | - lowercase 608 | - uppercase 609 | 610 | Naming/MethodName: 611 | EnforcedStyle: snake_case 612 | SupportedStyles: 613 | - snake_case 614 | - camelCase 615 | 616 | Naming/PredicateName: 617 | # Predicate name prefixes. 618 | NamePrefix: 619 | - is_ 620 | - has_ 621 | - have_ 622 | # Predicate name prefixes that should be removed. 623 | NamePrefixBlacklist: 624 | - is_ 625 | - has_ 626 | - have_ 627 | # Predicate names which, despite having a blacklisted prefix, or no `?`, 628 | # should still be accepted 629 | NameWhitelist: 630 | - is_a? 631 | # Exclude Rspec specs because there is a strong convention to write spec 632 | # helpers in the form of `have_something` or `be_something`. 633 | Exclude: 634 | - 'spec/**/*' 635 | 636 | Naming/VariableName: 637 | EnforcedStyle: snake_case 638 | SupportedStyles: 639 | - snake_case 640 | - camelCase 641 | 642 | Naming/VariableNumber: 643 | EnforcedStyle: normalcase 644 | SupportedStyles: 645 | - snake_case 646 | - normalcase 647 | - non_integer 648 | 649 | #################### Style ########################### 650 | 651 | Style/Alias: 652 | EnforcedStyle: prefer_alias 653 | SupportedStyles: 654 | - prefer_alias 655 | - prefer_alias_method 656 | 657 | Style/AndOr: 658 | # Whether `and` and `or` are banned only in conditionals (conditionals) 659 | # or completely (always). 660 | EnforcedStyle: always 661 | SupportedStyles: 662 | - always 663 | - conditionals 664 | 665 | # Checks if usage of `%()` or `%Q()` matches configuration. 666 | Style/BarePercentLiterals: 667 | EnforcedStyle: bare_percent 668 | SupportedStyles: 669 | - percent_q 670 | - bare_percent 671 | 672 | Style/BlockDelimiters: 673 | EnforcedStyle: line_count_based 674 | SupportedStyles: 675 | # The `line_count_based` style enforces braces around single line blocks and 676 | # do..end around multi-line blocks. 677 | - line_count_based 678 | # The `semantic` style enforces braces around functional blocks, where the 679 | # primary purpose of the block is to return a value and do..end for 680 | # procedural blocks, where the primary purpose of the block is its 681 | # side-effects. 682 | # 683 | # This looks at the usage of a block's method to determine its type (e.g. is 684 | # the result of a `map` assigned to a variable or passed to another 685 | # method) but exceptions are permitted in the `ProceduralMethods`, 686 | # `FunctionalMethods` and `IgnoredMethods` sections below. 687 | - semantic 688 | # The `braces_for_chaining` style enforces braces around single line blocks 689 | # and do..end around multi-line blocks, except for multi-line blocks whose 690 | # return value is being chained with another method (in which case braces 691 | # are enforced). 692 | - braces_for_chaining 693 | ProceduralMethods: 694 | # Methods that are known to be procedural in nature but look functional from 695 | # their usage, e.g. 696 | # 697 | # time = Benchmark.realtime do 698 | # foo.bar 699 | # end 700 | # 701 | # Here, the return value of the block is discarded but the return value of 702 | # `Benchmark.realtime` is used. 703 | - benchmark 704 | - bm 705 | - bmbm 706 | - create 707 | - each_with_object 708 | - measure 709 | - new 710 | - realtime 711 | - tap 712 | - with_object 713 | FunctionalMethods: 714 | # Methods that are known to be functional in nature but look procedural from 715 | # their usage, e.g. 716 | # 717 | # let(:foo) { Foo.new } 718 | # 719 | # Here, the return value of `Foo.new` is used to define a `foo` helper but 720 | # doesn't appear to be used from the return value of `let`. 721 | - let 722 | - let! 723 | - subject 724 | - watch 725 | IgnoredMethods: 726 | # Methods that can be either procedural or functional and cannot be 727 | # categorised from their usage alone, e.g. 728 | # 729 | # foo = lambda do |x| 730 | # puts "Hello, #{x}" 731 | # end 732 | # 733 | # foo = lambda do |x| 734 | # x * 100 735 | # end 736 | # 737 | # Here, it is impossible to tell from the return value of `lambda` whether 738 | # the inner block's return value is significant. 739 | - lambda 740 | - proc 741 | - it 742 | 743 | Style/BracesAroundHashParameters: 744 | EnforcedStyle: no_braces 745 | SupportedStyles: 746 | # The `braces` style enforces braces around all method parameters that are 747 | # hashes. 748 | - braces 749 | # The `no_braces` style checks that the last parameter doesn't have braces 750 | # around it. 751 | - no_braces 752 | # The `context_dependent` style checks that the last parameter doesn't have 753 | # braces around it, but requires braces if the second to last parameter is 754 | # also a hash literal. 755 | - context_dependent 756 | 757 | Style/ClassAndModuleChildren: 758 | # Checks the style of children definitions at classes and modules. 759 | # 760 | # Basically there are two different styles: 761 | # 762 | # `nested` - have each child on a separate line 763 | # class Foo 764 | # class Bar 765 | # end 766 | # end 767 | # 768 | # `compact` - combine definitions as much as possible 769 | # class Foo::Bar 770 | # end 771 | # 772 | # The compact style is only forced, for classes or modules with one child. 773 | EnforcedStyle: nested 774 | SupportedStyles: 775 | - nested 776 | - compact 777 | 778 | Style/ClassCheck: 779 | EnforcedStyle: is_a? 780 | SupportedStyles: 781 | - is_a? 782 | - kind_of? 783 | 784 | # Align with the style guide. 785 | Style/CollectionMethods: 786 | # Mapping from undesired method to desired_method 787 | # e.g. to use `detect` over `find`: 788 | # 789 | # CollectionMethods: 790 | # PreferredMethods: 791 | # find: detect 792 | PreferredMethods: 793 | collect: 'map' 794 | collect!: 'map!' 795 | inject: 'reduce' 796 | detect: 'find' 797 | find_all: 'select' 798 | 799 | # Use '`' or '%x' around command literals. 800 | Style/CommandLiteral: 801 | EnforcedStyle: backticks 802 | # backticks: Always use backticks. 803 | # percent_x: Always use `%x`. 804 | # mixed: Use backticks on single-line commands, and `%x` on multi-line commands. 805 | SupportedStyles: 806 | - backticks 807 | - percent_x 808 | - mixed 809 | # If `false`, the cop will always recommend using `%x` if one or more backticks 810 | # are found in the command string. 811 | AllowInnerBackticks: false 812 | 813 | # Checks formatting of special comments 814 | Style/CommentAnnotation: 815 | Keywords: 816 | - TODO 817 | - FIXME 818 | - OPTIMIZE 819 | - HACK 820 | - REVIEW 821 | 822 | Style/ConditionalAssignment: 823 | EnforcedStyle: assign_to_condition 824 | SupportedStyles: 825 | - assign_to_condition 826 | - assign_inside_condition 827 | # When configured to `assign_to_condition`, `SingleLineConditionsOnly` 828 | # will only register an offense when all branches of a condition are 829 | # a single line. 830 | # When configured to `assign_inside_condition`, `SingleLineConditionsOnly` 831 | # will only register an offense for assignment to a condition that has 832 | # at least one multiline branch. 833 | SingleLineConditionsOnly: true 834 | IncludeTernaryExpressions: true 835 | 836 | # Checks that you have put a copyright in a comment before any code. 837 | # 838 | # You can override the default Notice in your .rubocop.yml file. 839 | # 840 | # In order to use autocorrect, you must supply a value for the 841 | # `AutocorrectNotice` key that matches the regexp Notice. A blank 842 | # `AutocorrectNotice` will cause an error during autocorrect. 843 | # 844 | # Autocorrect will add a copyright notice in a comment at the top 845 | # of the file immediately after any shebang or encoding comments. 846 | # 847 | # Example rubocop.yml: 848 | # 849 | # Style/Copyright: 850 | # Enabled: true 851 | # Notice: 'Copyright (\(c\) )?2015 Yahoo! Inc' 852 | # AutocorrectNotice: '# Copyright (c) 2015 Yahoo! Inc.' 853 | # 854 | Style/Copyright: 855 | Notice: '^Copyright (\(c\) )?2[0-9]{3} .+' 856 | AutocorrectNotice: '' 857 | 858 | Style/DocumentationMethod: 859 | RequireForNonPublicMethods: false 860 | 861 | # Warn on empty else statements 862 | # empty - warn only on empty `else` 863 | # nil - warn on `else` with nil in it 864 | # both - warn on empty `else` and `else` with `nil` in it 865 | Style/EmptyElse: 866 | EnforcedStyle: both 867 | SupportedStyles: 868 | - empty 869 | - nil 870 | - both 871 | 872 | Style/EmptyMethod: 873 | EnforcedStyle: compact 874 | SupportedStyles: 875 | - compact 876 | - expanded 877 | 878 | # Checks use of for or each in multiline loops. 879 | Style/For: 880 | EnforcedStyle: each 881 | SupportedStyles: 882 | - for 883 | - each 884 | 885 | # Enforce the method used for string formatting. 886 | Style/FormatString: 887 | EnforcedStyle: format 888 | SupportedStyles: 889 | - format 890 | - sprintf 891 | - percent 892 | 893 | # Enforce using either `%s` or `%{token}` 894 | Style/FormatStringToken: 895 | EnforcedStyle: annotated 896 | SupportedStyles: 897 | # Prefer tokens which contain a sprintf like type annotation like 898 | # `%s`, `%d`, `%f` 899 | - annotated 900 | # Prefer simple looking "template" style tokens like `%{name}`, `%{age}` 901 | - template 902 | 903 | Style/FrozenStringLiteralComment: 904 | EnforcedStyle: when_needed 905 | SupportedStyles: 906 | # `when_needed` will add the frozen string literal comment to files 907 | # only when the `TargetRubyVersion` is set to 2.3+. 908 | - when_needed 909 | # `always` will always add the frozen string literal comment to a file 910 | # regardless of the Ruby version or if `freeze` or `<<` are called on a 911 | # string literal. If you run code against multiple versions of Ruby, it is 912 | # possible that this will create errors in Ruby 2.3.0+. 913 | - always 914 | # `never` will enforce that the frozen string literal comment does not 915 | # exist in a file. 916 | - never 917 | 918 | # Built-in global variables are allowed by default. 919 | Style/GlobalVars: 920 | AllowedVariables: [] 921 | 922 | # `MinBodyLength` defines the number of lines of the a body of an `if` or `unless` 923 | # needs to have to trigger this cop 924 | Style/GuardClause: 925 | MinBodyLength: 1 926 | 927 | Style/HashSyntax: 928 | EnforcedStyle: ruby19 929 | SupportedStyles: 930 | # checks for 1.9 syntax (e.g. {a: 1}) for all symbol keys 931 | - ruby19 932 | # checks for hash rocket syntax for all hashes 933 | - hash_rockets 934 | # forbids mixed key syntaxes (e.g. {a: 1, :b => 2}) 935 | - no_mixed_keys 936 | # enforces both ruby19 and no_mixed_keys styles 937 | - ruby19_no_mixed_keys 938 | # Force hashes that have a symbol value to use hash rockets 939 | UseHashRocketsWithSymbolValues: false 940 | # Do not suggest { a?: 1 } over { :a? => 1 } in ruby19 style 941 | PreferHashRocketsForNonAlnumEndingSymbols: false 942 | 943 | Style/IfUnlessModifier: 944 | MaxLineLength: 120 945 | 946 | Style/InverseMethods: 947 | Enabled: true 948 | # `InverseMethods` are methods that can be inverted by a not (`not` or `!`) 949 | # The relationship of inverse methods only needs to be defined in one direction. 950 | # Keys and values both need to be defined as symbols. 951 | InverseMethods: 952 | :any?: :none? 953 | :even?: :odd? 954 | :==: :!= 955 | :=~: :!~ 956 | :<: :>= 957 | :>: :<= 958 | # `ActiveSupport` defines some common inverse methods. They are listed below, 959 | # and not enabled by default. 960 | #:present?: :blank?, 961 | #:include?: :exclude? 962 | # `InverseBlocks` are methods that are inverted by inverting the return 963 | # of the block that is passed to the method 964 | InverseBlocks: 965 | :select: :reject 966 | :select!: :reject! 967 | 968 | Style/Lambda: 969 | EnforcedStyle: line_count_dependent 970 | SupportedStyles: 971 | - line_count_dependent 972 | - lambda 973 | - literal 974 | 975 | Style/LambdaCall: 976 | EnforcedStyle: call 977 | SupportedStyles: 978 | - call 979 | - braces 980 | 981 | Style/MethodCallWithArgsParentheses: 982 | IgnoreMacros: true 983 | IgnoredMethods: [] 984 | 985 | Style/MethodDefParentheses: 986 | EnforcedStyle: require_parentheses 987 | SupportedStyles: 988 | - require_parentheses 989 | - require_no_parentheses 990 | - require_no_parentheses_except_multiline 991 | 992 | # Checks the grouping of mixins (`include`, `extend`, `prepend`) in `class` and 993 | # `module` bodies. 994 | Style/MixinGrouping: 995 | EnforcedStyle: separated 996 | SupportedStyles: 997 | # separated: each mixed in module goes in a separate statement. 998 | # grouped: mixed in modules are grouped into a single statement. 999 | - separated 1000 | - grouped 1001 | 1002 | Style/ModuleFunction: 1003 | EnforcedStyle: module_function 1004 | SupportedStyles: 1005 | - module_function 1006 | - extend_self 1007 | 1008 | Style/MultilineMemoization: 1009 | EnforcedStyle: keyword 1010 | SupportedStyles: 1011 | - keyword 1012 | - braces 1013 | 1014 | Style/NegatedIf: 1015 | EnforcedStyle: both 1016 | SupportedStyles: 1017 | # both: prefix and postfix negated `if` should both use `unless` 1018 | # prefix: only use `unless` for negated `if` statements positioned before the body of the statement 1019 | # postfix: only use `unless` for negated `if` statements positioned after the body of the statement 1020 | - both 1021 | - prefix 1022 | - postfix 1023 | 1024 | Style/NestedParenthesizedCalls: 1025 | Whitelist: 1026 | - be 1027 | - be_a 1028 | - be_an 1029 | - be_between 1030 | - be_falsey 1031 | - be_kind_of 1032 | - be_instance_of 1033 | - be_truthy 1034 | - be_within 1035 | - eq 1036 | - eql 1037 | - end_with 1038 | - include 1039 | - match 1040 | - raise_error 1041 | - respond_to 1042 | - start_with 1043 | 1044 | Style/Next: 1045 | # With `always` all conditions at the end of an iteration needs to be 1046 | # replaced by next - with `skip_modifier_ifs` the modifier if like this one 1047 | # are ignored: [1, 2].each { |a| return 'yes' if a == 1 } 1048 | EnforcedStyle: skip_modifier_ifs 1049 | # `MinBodyLength` defines the number of lines of the a body of an `if` or `unless` 1050 | # needs to have to trigger this cop 1051 | MinBodyLength: 3 1052 | SupportedStyles: 1053 | - skip_modifier_ifs 1054 | - always 1055 | 1056 | Style/NonNilCheck: 1057 | # With `IncludeSemanticChanges` set to `true`, this cop reports offenses for 1058 | # `!x.nil?` and autocorrects that and `x != nil` to solely `x`, which is 1059 | # **usually** OK, but might change behavior. 1060 | # 1061 | # With `IncludeSemanticChanges` set to `false`, this cop does not report 1062 | # offenses for `!x.nil?` and does no changes that might change behavior. 1063 | IncludeSemanticChanges: false 1064 | 1065 | Style/NumericLiterals: 1066 | MinDigits: 5 1067 | Strict: false 1068 | 1069 | Style/NumericLiteralPrefix: 1070 | EnforcedOctalStyle: zero_with_o 1071 | SupportedOctalStyles: 1072 | - zero_with_o 1073 | - zero_only 1074 | 1075 | Style/NumericPredicate: 1076 | EnforcedStyle: predicate 1077 | SupportedStyles: 1078 | - predicate 1079 | - comparison 1080 | # Exclude RSpec specs because assertions like `expect(1).to be > 0` cause 1081 | # false positives. 1082 | Exclude: 1083 | - 'spec/**/*' 1084 | 1085 | Style/OptionHash: 1086 | # A list of parameter names that will be flagged by this cop. 1087 | SuspiciousParamNames: 1088 | - options 1089 | - opts 1090 | - args 1091 | - params 1092 | - parameters 1093 | 1094 | # Allow safe assignment in conditions. 1095 | Style/ParenthesesAroundCondition: 1096 | AllowSafeAssignment: true 1097 | 1098 | Style/PercentLiteralDelimiters: 1099 | # Specify the default preferred delimiter for all types with the 'default' key 1100 | # Override individual delimiters (even with default specified) by specifying 1101 | # an individual key 1102 | PreferredDelimiters: 1103 | default: () 1104 | '%i': '[]' 1105 | '%I': '[]' 1106 | '%r': '{}' 1107 | '%w': '[]' 1108 | '%W': '[]' 1109 | 1110 | Style/PercentQLiterals: 1111 | EnforcedStyle: lower_case_q 1112 | SupportedStyles: 1113 | - lower_case_q # Use `%q` when possible, `%Q` when necessary 1114 | - upper_case_q # Always use `%Q` 1115 | 1116 | Style/PreferredHashMethods: 1117 | EnforcedStyle: short 1118 | SupportedStyles: 1119 | - short 1120 | - verbose 1121 | 1122 | Style/RaiseArgs: 1123 | EnforcedStyle: exploded 1124 | SupportedStyles: 1125 | - compact # raise Exception.new(msg) 1126 | - exploded # raise Exception, msg 1127 | 1128 | Style/RedundantReturn: 1129 | # When `true` allows code like `return x, y`. 1130 | AllowMultipleReturnValues: false 1131 | 1132 | # Use `/` or `%r` around regular expressions. 1133 | Style/RegexpLiteral: 1134 | EnforcedStyle: slashes 1135 | # slashes: Always use slashes. 1136 | # percent_r: Always use `%r`. 1137 | # mixed: Use slashes on single-line regexes, and `%r` on multi-line regexes. 1138 | SupportedStyles: 1139 | - slashes 1140 | - percent_r 1141 | - mixed 1142 | # If `false`, the cop will always recommend using `%r` if one or more slashes 1143 | # are found in the regexp string. 1144 | AllowInnerSlashes: false 1145 | 1146 | Style/SafeNavigation: 1147 | # Safe navigation may cause a statement to start returning `nil` in addition 1148 | # to whatever it used to return. 1149 | ConvertCodeThatCanStartToReturnNil: false 1150 | 1151 | Style/Semicolon: 1152 | # Allow `;` to separate several expressions on the same line. 1153 | AllowAsExpressionSeparator: false 1154 | 1155 | Style/SignalException: 1156 | EnforcedStyle: only_raise 1157 | SupportedStyles: 1158 | - only_raise 1159 | - only_fail 1160 | - semantic 1161 | 1162 | Style/SingleLineBlockParams: 1163 | Methods: 1164 | - reduce: 1165 | - acc 1166 | - elem 1167 | - inject: 1168 | - acc 1169 | - elem 1170 | 1171 | Style/SingleLineMethods: 1172 | AllowIfMethodIsEmpty: true 1173 | 1174 | Style/SpecialGlobalVars: 1175 | EnforcedStyle: use_english_names 1176 | SupportedStyles: 1177 | - use_perl_names 1178 | - use_english_names 1179 | 1180 | Style/StabbyLambdaParentheses: 1181 | EnforcedStyle: require_parentheses 1182 | SupportedStyles: 1183 | - require_parentheses 1184 | - require_no_parentheses 1185 | 1186 | Style/StringLiterals: 1187 | EnforcedStyle: single_quotes 1188 | SupportedStyles: 1189 | - single_quotes 1190 | - double_quotes 1191 | # If `true`, strings which span multiple lines using `\` for continuation must 1192 | # use the same type of quotes on each line. 1193 | ConsistentQuotesInMultiline: false 1194 | 1195 | Style/StringLiteralsInInterpolation: 1196 | EnforcedStyle: single_quotes 1197 | SupportedStyles: 1198 | - single_quotes 1199 | - double_quotes 1200 | 1201 | Style/StringMethods: 1202 | # Mapping from undesired method to desired_method 1203 | # e.g. to use `to_sym` over `intern`: 1204 | # 1205 | # StringMethods: 1206 | # PreferredMethods: 1207 | # intern: to_sym 1208 | PreferredMethods: 1209 | intern: to_sym 1210 | 1211 | Style/SymbolArray: 1212 | EnforcedStyle: percent 1213 | MinSize: 0 1214 | SupportedStyles: 1215 | - percent 1216 | - brackets 1217 | 1218 | Style/SymbolProc: 1219 | # A list of method names to be ignored by the check. 1220 | # The names should be fairly unique, otherwise you'll end up ignoring lots of code. 1221 | IgnoredMethods: 1222 | - respond_to 1223 | - define_method 1224 | 1225 | Style/TernaryParentheses: 1226 | EnforcedStyle: require_no_parentheses 1227 | SupportedStyles: 1228 | - require_parentheses 1229 | - require_no_parentheses 1230 | - require_parentheses_when_complex 1231 | AllowSafeAssignment: true 1232 | 1233 | Style/TrailingCommaInArguments: 1234 | # If `comma`, the cop requires a comma after the last argument, but only for 1235 | # parenthesized method calls where each argument is on its own line. 1236 | # If `consistent_comma`, the cop requires a comma after the last argument, 1237 | # for all parenthesized method calls with arguments. 1238 | EnforcedStyleForMultiline: no_comma 1239 | SupportedStylesForMultiline: 1240 | - comma 1241 | - consistent_comma 1242 | - no_comma 1243 | 1244 | Style/TrailingCommaInLiteral: 1245 | # If `comma`, the cop requires a comma after the last item in an array or 1246 | # hash, but only when each item is on its own line. 1247 | # If `consistent_comma`, the cop requires a comma after the last item of all 1248 | # non-empty array and hash literals. 1249 | EnforcedStyleForMultiline: no_comma 1250 | SupportedStylesForMultiline: 1251 | - comma 1252 | - consistent_comma 1253 | - no_comma 1254 | 1255 | # `TrivialAccessors` requires exact name matches and doesn't allow 1256 | # predicated methods by default. 1257 | Style/TrivialAccessors: 1258 | # When set to `false` the cop will suggest the use of accessor methods 1259 | # in situations like: 1260 | # 1261 | # def name 1262 | # @other_name 1263 | # end 1264 | # 1265 | # This way you can uncover "hidden" attributes in your code. 1266 | ExactNameMatch: true 1267 | AllowPredicates: true 1268 | # Allows trivial writers that don't end in an equal sign. e.g. 1269 | # 1270 | # def on_exception(action) 1271 | # @on_exception=action 1272 | # end 1273 | # on_exception :restart 1274 | # 1275 | # Commonly used in DSLs 1276 | AllowDSLWriters: false 1277 | IgnoreClassMethods: false 1278 | Whitelist: 1279 | - to_ary 1280 | - to_a 1281 | - to_c 1282 | - to_enum 1283 | - to_h 1284 | - to_hash 1285 | - to_i 1286 | - to_int 1287 | - to_io 1288 | - to_open 1289 | - to_path 1290 | - to_proc 1291 | - to_r 1292 | - to_regexp 1293 | - to_str 1294 | - to_s 1295 | - to_sym 1296 | 1297 | Style/WhileUntilModifier: 1298 | MaxLineLength: 120 1299 | 1300 | # `WordArray` enforces how array literals of word-like strings should be expressed. 1301 | Style/WordArray: 1302 | EnforcedStyle: percent 1303 | SupportedStyles: 1304 | # percent style: %w(word1 word2) 1305 | - percent 1306 | # bracket style: ['word1', 'word2'] 1307 | - brackets 1308 | # The `MinSize` option causes the `WordArray` rule to be ignored for arrays 1309 | # smaller than a certain size. The rule is only applied to arrays 1310 | # whose element count is greater than or equal to `MinSize`. 1311 | MinSize: 0 1312 | # The regular expression `WordRegex` decides what is considered a word. 1313 | WordRegex: !ruby/regexp '/\A[\p{Word}\n\t]+\z/' 1314 | 1315 | Style/YodaCondition: 1316 | EnforcedStyle: all_comparison_operators 1317 | SupportedStyles: 1318 | # check all comparison operators 1319 | - all_comparison_operators 1320 | # check only equality operators: `!=` and `==` 1321 | - equality_operators_only 1322 | 1323 | #################### Metrics ############################### 1324 | 1325 | Metrics/AbcSize: 1326 | # The ABC size is a calculated magnitude, so this number can be an Integer or 1327 | # a Float. 1328 | Max: 15 1329 | 1330 | Metrics/BlockLength: 1331 | CountComments: false # count full line comments? 1332 | Max: 25 1333 | ExcludedMethods: [] 1334 | 1335 | Metrics/BlockNesting: 1336 | CountBlocks: false 1337 | Max: 3 1338 | 1339 | Metrics/ClassLength: 1340 | CountComments: false # count full line comments? 1341 | Max: 100 1342 | 1343 | # Avoid complex methods. 1344 | Metrics/CyclomaticComplexity: 1345 | Max: 6 1346 | 1347 | Metrics/LineLength: 1348 | Max: 120 1349 | # To make it possible to copy or click on URIs in the code, we allow lines 1350 | # containing a URI to be longer than Max. 1351 | AllowHeredoc: true 1352 | AllowURI: true 1353 | URISchemes: 1354 | - http 1355 | - https 1356 | # The IgnoreCopDirectives option causes the LineLength rule to ignore cop 1357 | # directives like '# rubocop: enable ...' when calculating a line's length. 1358 | IgnoreCopDirectives: false 1359 | # The IgnoredPatterns option is a list of !ruby/regexp and/or string 1360 | # elements. Strings will be converted to Regexp objects. A line that matches 1361 | # any regular expression listed in this option will be ignored by LineLength. 1362 | IgnoredPatterns: [] 1363 | 1364 | Metrics/MethodLength: 1365 | CountComments: false # count full line comments? 1366 | Max: 10 1367 | 1368 | Metrics/ModuleLength: 1369 | CountComments: false # count full line comments? 1370 | Max: 100 1371 | 1372 | Metrics/ParameterLists: 1373 | Max: 5 1374 | CountKeywordArgs: true 1375 | 1376 | Metrics/PerceivedComplexity: 1377 | Max: 7 1378 | 1379 | #################### Lint ################################## 1380 | 1381 | # Allow safe assignment in conditions. 1382 | Lint/AssignmentInCondition: 1383 | AllowSafeAssignment: true 1384 | 1385 | # checks whether the end keywords are aligned properly for `do` `end` blocks. 1386 | Lint/BlockAlignment: 1387 | # The value `start_of_block` means that the `end` should be aligned with line 1388 | # where the `do` keyword appears. 1389 | # The value `start_of_line` means it should be aligned with the whole 1390 | # expression's starting line. 1391 | # The value `either` means both are allowed. 1392 | EnforcedStyleAlignWith: either 1393 | SupportedStylesAlignWith: 1394 | - either 1395 | - start_of_block 1396 | - start_of_line 1397 | 1398 | Lint/DefEndAlignment: 1399 | # The value `def` means that `end` should be aligned with the def keyword. 1400 | # The value `start_of_line` means that `end` should be aligned with method 1401 | # calls like `private`, `public`, etc, if present in front of the `def` 1402 | # keyword on the same line. 1403 | EnforcedStyleAlignWith: start_of_line 1404 | SupportedStylesAlignWith: 1405 | - start_of_line 1406 | - def 1407 | AutoCorrect: false 1408 | 1409 | # Align ends correctly. 1410 | Lint/EndAlignment: 1411 | # The value `keyword` means that `end` should be aligned with the matching 1412 | # keyword (`if`, `while`, etc.). 1413 | # The value `variable` means that in assignments, `end` should be aligned 1414 | # with the start of the variable on the left hand side of `=`. In all other 1415 | # situations, `end` should still be aligned with the keyword. 1416 | # The value `start_of_line` means that `end` should be aligned with the start 1417 | # of the line which the matching keyword appears on. 1418 | EnforcedStyleAlignWith: keyword 1419 | SupportedStylesAlignWith: 1420 | - keyword 1421 | - variable 1422 | - start_of_line 1423 | AutoCorrect: false 1424 | 1425 | Lint/InheritException: 1426 | # The default base class in favour of `Exception`. 1427 | EnforcedStyle: runtime_error 1428 | SupportedStyles: 1429 | - runtime_error 1430 | - standard_error 1431 | 1432 | Lint/SafeNavigationChain: 1433 | Whitelist: 1434 | - present? 1435 | - blank? 1436 | - presence 1437 | - try 1438 | 1439 | # Checks for unused block arguments 1440 | Lint/UnusedBlockArgument: 1441 | IgnoreEmptyBlocks: true 1442 | AllowUnusedKeywordArguments: false 1443 | 1444 | # Checks for unused method arguments. 1445 | Lint/UnusedMethodArgument: 1446 | AllowUnusedKeywordArguments: false 1447 | IgnoreEmptyMethods: true 1448 | 1449 | #################### Performance ########################### 1450 | 1451 | Performance/DoubleStartEndWith: 1452 | # Used to check for `starts_with?` and `ends_with?`. 1453 | # These methods are defined by `ActiveSupport`. 1454 | IncludeActiveSupportAliases: false 1455 | 1456 | Performance/RedundantMerge: 1457 | # Max number of key-value pairs to consider an offense 1458 | MaxKeyValuePairs: 2 1459 | 1460 | #################### Rails ################################# 1461 | 1462 | Rails/ActionFilter: 1463 | EnforcedStyle: action 1464 | SupportedStyles: 1465 | - action 1466 | - filter 1467 | Include: 1468 | - app/controllers/**/*.rb 1469 | 1470 | Rails/Date: 1471 | # The value `strict` disallows usage of `Date.today`, `Date.current`, 1472 | # `Date#to_time` etc. 1473 | # The value `flexible` allows usage of `Date.current`, `Date.yesterday`, etc 1474 | # (but not `Date.today`) which are overridden by ActiveSupport to handle current 1475 | # time zone. 1476 | EnforcedStyle: flexible 1477 | SupportedStyles: 1478 | - strict 1479 | - flexible 1480 | 1481 | Rails/Delegate: 1482 | # When set to true, using the target object as a prefix of the 1483 | # method name without using the `delegate` method will be a 1484 | # violation. When set to false, this case is legal. 1485 | EnforceForPrefixed: true 1486 | 1487 | Rails/DynamicFindBy: 1488 | Whitelist: 1489 | - find_by_sql 1490 | 1491 | Rails/EnumUniqueness: 1492 | Include: 1493 | - app/models/**/*.rb 1494 | 1495 | Rails/Exit: 1496 | Include: 1497 | - app/**/*.rb 1498 | - config/**/*.rb 1499 | - lib/**/*.rb 1500 | Exclude: 1501 | - lib/**/*.rake 1502 | 1503 | Rails/FindBy: 1504 | Include: 1505 | - app/models/**/*.rb 1506 | 1507 | Rails/FindEach: 1508 | Include: 1509 | - app/models/**/*.rb 1510 | 1511 | Rails/HasAndBelongsToMany: 1512 | Include: 1513 | - app/models/**/*.rb 1514 | 1515 | Rails/HasManyOrHasOneDependent: 1516 | Include: 1517 | - app/models/**/*.rb 1518 | 1519 | Rails/NotNullColumn: 1520 | Include: 1521 | - db/migrate/*.rb 1522 | 1523 | Rails/Output: 1524 | Include: 1525 | - app/**/*.rb 1526 | - config/**/*.rb 1527 | - db/**/*.rb 1528 | - lib/**/*.rb 1529 | 1530 | Rails/ReadWriteAttribute: 1531 | Include: 1532 | - app/models/**/*.rb 1533 | 1534 | Rails/RequestReferer: 1535 | EnforcedStyle: referer 1536 | SupportedStyles: 1537 | - referer 1538 | - referrer 1539 | 1540 | Rails/ReversibleMigration: 1541 | Include: 1542 | - db/migrate/*.rb 1543 | 1544 | Rails/SafeNavigation: 1545 | # This will convert usages of `try` to use safe navigation as well as `try!`. 1546 | # `try` and `try!` work slighly differently. `try!` and safe navigation will 1547 | # both raise a `NoMethodError` if the receiver of the method call does not 1548 | # implement the intended method. `try` will not raise an exception for this. 1549 | ConvertTry: false 1550 | 1551 | Rails/ScopeArgs: 1552 | Include: 1553 | - app/models/**/*.rb 1554 | 1555 | Rails/TimeZone: 1556 | # The value `strict` means that `Time` should be used with `zone`. 1557 | # The value `flexible` allows usage of `in_time_zone` instead of `zone`. 1558 | EnforcedStyle: flexible 1559 | SupportedStyles: 1560 | - strict 1561 | - flexible 1562 | 1563 | Rails/UniqBeforePluck: 1564 | EnforcedStyle: conservative 1565 | SupportedStyles: 1566 | - conservative 1567 | - aggressive 1568 | AutoCorrect: false 1569 | 1570 | Rails/SkipsModelValidations: 1571 | Blacklist: 1572 | - decrement! 1573 | - decrement_counter 1574 | - increment! 1575 | - increment_counter 1576 | - toggle! 1577 | - touch 1578 | - update_all 1579 | - update_attribute 1580 | - update_column 1581 | - update_columns 1582 | - update_counters 1583 | 1584 | Rails/Validation: 1585 | Include: 1586 | - app/models/**/*.rb 1587 | 1588 | Bundler/OrderedGems: 1589 | TreatCommentsAsGroupSeparators: true 1590 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: trusty 3 | language: ruby 4 | rvm: 5 | - 2.2.7 6 | - 2.3.4 7 | - 2.4.1 8 | cache: bundler 9 | script: 10 | - bundle exec rspec --format documentation 11 | notifications: 12 | email: false 13 | git: 14 | submodules: false 15 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Arthur Purvis 2 | Cameron Dutro 3 | Jay O'Connor 4 | Matthew Leung 5 | Rob Froetscher 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.3.2 2 | - Add debug logging for aws-sdk components 3 | - Add some other debug logging 4 | - Fix for aws api changes: ecs task_definition response includes `compatibilities`, but requests want `requires_compatibilities` 5 | 6 | # 3.3.1 7 | - Use new modularized aws-sdk gems: aws-sdk-ec2 & aws-sdk-ecs 8 | 9 | # 3.3.0 10 | - Add `execute` command to execute arbitrary bash inside a running container 11 | - Add `--all` flag to `execute` to run a command on all containers 12 | - Always turn on TTY interaction when running remote commands. 13 | - Fix bug with command being an array instead of string 14 | 15 | # 3.2.0 16 | - Add ability to execute a bash command on a container 17 | - Output actual bash command being run when log level is debug. 18 | 19 | # 3.1.3 20 | - Better error messaging when trying to bash/ssh/etc to an instance_index that doesn't exist 21 | 22 | # 3.1.2 23 | - Don't instantiate AWS Credentials until necessary 24 | 25 | # 3.1.1 26 | - Handle Fixnum deprecation warning 27 | 28 | # 3.1.0 29 | - Simplified syntax for AWS credentials 30 | 31 | # 3.0.10 32 | - Fix log output when using `scale` command 33 | 34 | # 3.0.9 35 | - Fixing `--tag` handling on `bootstrap` to correctly pass assertion 36 | 37 | # 3.0.8 38 | - Sort the output of `broadside targets` alphabetically 39 | 40 | # 3.0.7 41 | - `--tag` option for `bootstrap` also was named `--optional` (whoops) 42 | 43 | # 3.0.6 44 | - `--tag` option is optional while bootstrapping if you already have a configured `task_definition` 45 | 46 | # 3.0.5 47 | - Make `update_service` work correctly for services configured with load balancers 48 | 49 | # 3.0.4 50 | - `--tag` option required during bootstrapping 51 | 52 | # 3.0.3 53 | - update of GLI specifications, adding on to 3.0.2 54 | 55 | # 3.0.2 56 | - fix absence of tag raising an error in certain cases 57 | 58 | # 3.0.1 59 | - `bootstrap` does not require a `--tag` option 60 | - `run` does not need require a `--instance` 61 | 62 | # 3.0.0 63 | ### Breaking Changes 64 | - `ssh`, `bash`, `logtail`, `status`, and `run` are now top level commands, not subcommands of `deploy` 65 | - No more `RAKE_DB_MIGRATE` constant 66 | - Configuration changes: 67 | - `config.git_repo=` and `config.type=` were removed. 68 | - `config.base` and `config.deploy` are no longer backwards compatible - any options configured at `config.base.something` or `config.deploy.something` must now be configured at `config.something` 69 | - `config.ecs.cluster` and `config.ecs.poll_frequency` are now configured at `config.aws.ecs_default_cluster` and `config.aws.ecs_poll_frequency` 70 | - `config.docker_image` is now `config.default_docker_image` 71 | - `instance` can no longer be configured on a per `Target` basis 72 | 73 | #### Added Features 74 | - Allow configuration of separate `:docker_image` per target 75 | - Put back ability to configure a default `:tag` per target 76 | - Add `broadside targets` command to display all the targets' deployed images and CPU/memory allocations 77 | - `broadside status` has an added `--verbose` switch that displays service and task information 78 | - [#11](https://github.com/lumoslabs/broadside/issues/11): Add option for ssh proxy user and proxy keyfile 79 | - [#2](https://github.com/lumoslabs/broadside/issues/2): Add flag for changing loglevel, and add `--debug` switch that enables GLI debug output 80 | - Failed deploys will rollback the service to the last successfully running scale 81 | - Allow setting an environment variable `BROADSIDE_SYSTEM_CONFIG_FILE` to be used instead of `~/.broadside/config.rb` 82 | - Pre and Post hooks now have access to command-line options and args 83 | 84 | #### General Improvements 85 | - Only load `env_files` for the selected target (rather than preloading from unrelated targets) 86 | - Make `env_files` configuration optional 87 | - `Utils` has been replaced in favor of `LoggingUtils` 88 | - Exceptions will be raised if a target is configured with an invalid hash key 89 | - Tasks run have a more relevant `started_by` tag 90 | - Default log level changed to `INFO` 91 | - [#21](https://github.com/lumoslabs/broadside/issues/21) Print more useful messages when tasks die without exit codes. 92 | - `Command` class to encapsulate the running of various commands 93 | 94 | # 2.0.0 95 | #### Breaking Changes 96 | - [#27](https://github.com/lumoslabs/broadside/issues/27) `rake db:migrate` is no longer the default `predeploy_command` 97 | - Remove ability to configure a default tag for each target 98 | 99 | #### Added Features 100 | - [#38](https://github.com/lumoslabs/broadside/issues/38) ECS cluster can be configured for each target by setting `config.ecs.cluster` 101 | 102 | #### General Improvements 103 | - `base` configuration has been removed - the main `Configuration` object holds all the `base` config. `Broadside.config.base` may be called but will display a deprecation warning. 104 | - `deploy` configuration has been removed - primarily handled through the main `Configuration` object and in `targets=`. `Broadside.config.deploy` may be called but will display a deprecation warning. 105 | - `Target` is a first class object 106 | - `Deploy` is composed of a `Target` plus command line options 107 | 108 | # 1.4.0 109 | - [#42](https://github.com/lumoslabs/broadside/pull/42/files): Update the task definition when running bootstrap 110 | 111 | # 1.3.0 112 | - [#41](https://github.com/lumoslabs/broadside/pull/41/files): Introduce the concept of bootstrap commands, which are designed to be run when setting up a new server or environment. 113 | 114 | # 1.2.1 115 | - [#35](https://github.com/lumoslabs/broadside/pull/35/files): Allows logtail to display more than 10 lines 116 | 117 | # 1.2.0 118 | - [#32](https://github.com/lumoslabs/broadside/pull/32): Deploys will also update service configs defined in a deploy target (see full list in the [AWS Docs](https://docs.aws.amazon.com/sdkforruby/api/Aws/ECS/Client.html#create_service-instance_method)) 119 | - Updates additional container definition configs like cpu, memory. See full list in the [AWS Docs](https://docs.aws.amazon.com/sdkforruby/api/Aws/ECS/Client.html#register_task_definition-instance_method) 120 | - [#24](https://github.com/lumoslabs/broadside/pull/24): Refactored most ECS-specific utility methods into a separate class 121 | 122 | # 1.1.1 123 | - [#25](https://github.com/lumoslabs/broadside/issues/25): Fix issue with undefined local variable 'ecs' 124 | 125 | # 1.1.0 126 | - [#16](https://github.com/lumoslabs/broadside/pull/16): Add bootstrap command; add specs 127 | 128 | # 1.0.3 129 | - [#12](https://github.com/lumoslabs/broadside/issues/12): Fix isssue with not being to use ssh, bash, logtail commands without specifying instance index 130 | 131 | # 1.0.2 132 | - [#7](https://github.com/lumoslabs/broadside/issues/7): Fix issue with getting the wrong container's exit code when running tasks 133 | - Bump aws-sdk version from `2.2.7` to `2.3` 134 | 135 | # 1.0.1 136 | - [#3](https://github.com/lumoslabs/broadside/issues/3): Fix task definition pagination 137 | 138 | # 1.0.0 139 | - Initial release. 140 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in broadside.gemspec 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'pry-byebug' 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Lumos Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Broadside [![Build Status](https://travis-ci.org/lumoslabs/broadside.svg?branch=master)](https://travis-ci.org/lumoslabs/broadside) 2 | 3 | A [GLI](https://github.com/davetron5000/gli) based command-line tool for deploying applications on [AWS EC2 Container Service (ECS)](https://aws.amazon.com/ecs/) 4 | 5 | ### [The wiki](https://github.com/lumoslabs/broadside/wiki) has all kinds of useful information on it so don't just rely on this README. 6 | 7 | ## Overview 8 | Amazon ECS presents a low barrier to entry for production-level docker applications. Combined with ECS's built-in blue-green deployment, Elastic Load Balancers, Autoscale Groups, and CloudWatch, one can theoretically set up a robust cluster that can scale to serve any number of applications in a short amount of time. The ECS GUI, CLI, and overall architecture are not the easiest to work with, however, so Broadside seeks to leverage the [ECS ruby API](http://docs.aws.amazon.com/sdkforruby/api/Aws/ECS.html) to dramatically simplify and improve the configuration and deployment process for developers, offering a simple command line interface and configuration format that should meet most needs. 9 | 10 | Broadside does _not_ attempt to handle operational tasks like infrastructure setup and configuration, which are better suited to tools like [terraform](https://www.terraform.io/). 11 | 12 | ### Capabilities 13 | 14 | - **Trigger** ECS deployments 15 | - **Inject** environment variables into ECS containers from local configuration files 16 | - **Launch a bash shell** on container in the cluster 17 | - **SSH** directly onto a host running a container 18 | - **Execute** an arbitrary shell command on a container (or all containers) 19 | - **Tail logs** of a running container 20 | - **Scale** an existing deployment on the fly 21 | 22 | ### Example Config for Quickstarters 23 | Applications using broadside employ a configuration file that looks something like: 24 | 25 | ```ruby 26 | Broadside.configure do |config| 27 | config.application = 'hello_world' 28 | config.default_docker_image = 'lumoslabs/hello_world' 29 | config.aws.ecs_default_cluster = 'production-cluster' 30 | config.aws.region = 'us-east-1' # 'us-east-1 is the default 31 | config.targets = { 32 | production_web: { 33 | scale: 7, 34 | command: %w(bundle exec unicorn -c config/unicorn.conf.rb), 35 | env_file: '.env.production' 36 | predeploy_commands: [ 37 | %w(bundle exec rake db:migrate), 38 | %w(bundle exec rake data:migrate) 39 | ] 40 | }, 41 | # If you have multiple images or clusters, you can configure them per target 42 | staging_web: { 43 | scale: 1, 44 | command: %w(bundle exec puma), 45 | env_file: '.env.staging', 46 | tag: 'latest', # Set a default tag for this target 47 | cluster: 'staging-cluster', # Overrides config.aws.ecs_default_cluster 48 | docker_image: 'lumoslabs/staging_hello_world' # Overrides config.default_docker_image 49 | }, 50 | json_stream: { 51 | scale: 1, 52 | command: %w(java -cp *:. path.to.MyClass), 53 | # This target has a task_definition and service config which you use to bootstrap a new AWS Service 54 | service_config: { deployment_configuration: { minimum_healthy_percent: 0.5 } }, 55 | task_definition_config: { container_definitions: [ { cpu: 1, memory: 2000, } ] } 56 | } 57 | } 58 | end 59 | ``` 60 | 61 | From here, developers can use broadside's command-line interface to initiate a basic deployment and launch the 62 | configured `command` as an ECS Service: 63 | 64 | ```bash 65 | bundle exec broadside deploy full --target production_web --tag v.1.1.example.tag 66 | ``` 67 | 68 | In the case of an error or timeout during a deploy, broadside will automatically rollback to the latest stable version. You can perform manual rollbacks as well through the command-line. 69 | 70 | ## [For more information on broadside commands, see the complete command-line reference in the wiki](https://github.com/lumoslabs/broadside/wiki/CLI-reference). 71 | 72 | 73 | ## Installation 74 | ### Via Gemfile 75 | First, install broadside by adding it to your application `Gemfile`: 76 | ```ruby 77 | gem 'broadside' 78 | ``` 79 | 80 | Then run 81 | ```bash 82 | bundle install 83 | ``` 84 | 85 | You can now run the executable in your app directory: 86 | ```bash 87 | bundle exec broadside --help 88 | ``` 89 | 90 | You may also generate binstubs using 91 | ```bash 92 | bundle binstubs broadside 93 | ``` 94 | 95 | ### System Wide 96 | Alternatively, you can install broadside using: 97 | ``` 98 | gem install broadside 99 | ``` 100 | 101 | ## Configuration 102 | For full application setup, see the [detailed instructions in the wiki](https://github.com/lumoslabs/broadside/wiki). 103 | 104 | ## Debugging 105 | Use the `--debug` switch to enable stacktraces and debug output. 106 | 107 | ## Contributing 108 | Pull requests, bug reports, and feature suggestions are welcome! 109 | 110 | Before starting on a contribution, we recommend opening an issue or replying to an existing one to give others some initial context on the work needing to be done. 111 | 112 | **Specs must pass on pull requests for them to be considered.** 113 | 114 | ### Running Specs 115 | Broadside has a lot of tests for most of its behaviors - just run 116 | ``` 117 | bundle exec rspec 118 | ``` 119 | in the broadside directory. Don't open pull requests without passing specs. 120 | -------------------------------------------------------------------------------- /bin/broadside: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'gli' 4 | require 'broadside' 5 | 6 | include GLI::App 7 | include Broadside::LoggingUtils 8 | 9 | program_desc 'A command-line tool for deployment and development of docker applications.' 10 | version Broadside::VERSION 11 | 12 | subcommand_option_handling :normal 13 | arguments :strict 14 | synopsis_format :full 15 | 16 | commands_from File.expand_path(File.join(File.dirname(__FILE__), '/../lib/broadside/gli')) 17 | 18 | exit run(ARGV) 19 | -------------------------------------------------------------------------------- /broadside.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'broadside/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'broadside' 8 | spec.version = Broadside::VERSION 9 | spec.authors = ['Matthew Leung', 'Lumos Labs, Inc.'] 10 | spec.email = ['leung.mattp@gmail.com'] 11 | 12 | spec.summary = 'A command-line tool for EC2 Container Service deployment.' 13 | spec.homepage = 'https://github.com/lumoslabs/broadside' 14 | spec.license = 'MIT' 15 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec)/}) } 16 | spec.bindir = 'bin' 17 | spec.executables = ['broadside'] 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_dependency 'activesupport', '>= 3', '< 6' 21 | spec.add_dependency 'activemodel', '>= 3', '< 6' 22 | spec.add_dependency 'aws-sdk-ecs', '~> 1.0' 23 | spec.add_dependency 'aws-sdk-ec2', '~> 1.0' 24 | spec.add_dependency 'dotenv', '>= 0.9.0', '< 3.0' 25 | spec.add_dependency 'gli', '~> 2.13' 26 | spec.add_dependency 'tty', '~> 0.5' 27 | 28 | spec.add_development_dependency 'rspec', '~> 3.4' 29 | spec.add_development_dependency 'bundler', '~> 1.9' 30 | end 31 | -------------------------------------------------------------------------------- /lib/broadside.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | require 'active_support/core_ext' 3 | require 'aws-sdk-ec2' 4 | require 'aws-sdk-ecs' 5 | 6 | require 'broadside/error' 7 | require 'broadside/logging_utils' 8 | require 'broadside/configuration/invalid_configuration' 9 | require 'broadside/configuration/configuration' 10 | require 'broadside/configuration/aws_configuration' 11 | require 'broadside/command' 12 | require 'broadside/target' 13 | require 'broadside/deploy' 14 | require 'broadside/ecs/ecs_deploy' 15 | require 'broadside/ecs/ecs_manager' 16 | require 'broadside/version' 17 | 18 | module Broadside 19 | extend LoggingUtils 20 | 21 | USER_CONFIG_FILE = (ENV['BROADSIDE_SYSTEM_CONFIG_FILE'] || File.join(Dir.home, '.broadside', 'config.rb')).freeze 22 | 23 | def self.configure 24 | yield config 25 | raise ConfigurationError, config.errors.full_messages unless config.valid? 26 | end 27 | 28 | def self.load_config_file(config_file) 29 | raise ArgumentError, "#{config_file} does not exist" unless File.exist?(config_file) 30 | config.config_file = config_file 31 | 32 | begin 33 | if File.exist?(USER_CONFIG_FILE) 34 | debug "Loading user configuration from #{USER_CONFIG_FILE}" 35 | 36 | begin 37 | load(USER_CONFIG_FILE) 38 | rescue ConfigurationError 39 | # Suppress the exception because the system config file can be incomplete and validation failure is expected 40 | end 41 | end 42 | 43 | debug "Loading application configuration from #{config_file}" 44 | load(config_file) 45 | rescue LoadError 46 | error 'Encountered an error loading broadside configuration' 47 | raise 48 | end 49 | end 50 | 51 | def self.config 52 | @config ||= Configuration.new 53 | end 54 | 55 | def self.reset! 56 | @config = nil 57 | EcsManager.instance_variable_set(:@ecs_client, nil) 58 | EcsManager.instance_variable_set(:@ec2_client, nil) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/broadside/command.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'pp' 3 | require 'shellwords' 4 | require 'tty-table' 5 | 6 | module Broadside 7 | module Command 8 | extend LoggingUtils 9 | 10 | BASH = 'bash'.freeze 11 | DEFAULT_TAIL_LINES = 10 12 | 13 | class << self 14 | def targets 15 | table_header = nil 16 | table_rows = [] 17 | 18 | Broadside.config.targets.sort.each do |_, target| 19 | task_definition = EcsManager.get_latest_task_definition(target.family) 20 | service_tasks_running = EcsManager.get_task_arns( 21 | target.cluster, 22 | target.family, 23 | service_name: target.family, 24 | desired_status: 'RUNNING' 25 | ).size 26 | 27 | if task_definition.nil? 28 | warn "Skipping deploy target '#{target.name}' as it does not have a configured task_definition." 29 | next 30 | end 31 | 32 | container_definitions = task_definition[:container_definitions].select { |c| c[:name] == target.family } 33 | warn "Only displaying 1/#{container_definitions.size} containers" if container_definitions.size > 1 34 | container_definition = container_definitions.first 35 | 36 | row_data = target.to_h.merge( 37 | Image: container_definition[:image], 38 | CPU: container_definition[:cpu], 39 | Memory: container_definition[:memory], 40 | Revision: task_definition[:revision], 41 | Tasks: "#{service_tasks_running}/#{target.scale}" 42 | ) 43 | 44 | table_header ||= row_data.keys.map(&:to_s) 45 | table_rows << row_data.values 46 | end 47 | 48 | table = TTY::Table.new(header: table_header, rows: table_rows) 49 | puts table.render(:ascii, padding: [0, 1]) 50 | end 51 | 52 | def status(options) 53 | target = Broadside.config.get_target_by_name!(options[:target]) 54 | cluster = target.cluster 55 | family = target.family 56 | pastel = Pastel.new 57 | debug "Getting status information about #{family}" 58 | 59 | output = [ 60 | pastel.underline('Current task definition information:'), 61 | pastel.blue(PP.pp(EcsManager.get_latest_task_definition(family), '')) 62 | ] 63 | 64 | if options[:verbose] 65 | output << [ 66 | pastel.underline('Current service information:'), 67 | pastel.bright_blue(PP.pp(EcsManager.ecs.describe_services(cluster: cluster, services: [family]), '')) 68 | ] 69 | end 70 | 71 | task_arns = EcsManager.get_task_arns(cluster, family) 72 | if task_arns.empty? 73 | output << ["No running tasks found.\n"] 74 | else 75 | ips = EcsManager.get_running_instance_ips(cluster, family) 76 | 77 | if options[:verbose] 78 | output << [ 79 | pastel.underline('Task information:'), 80 | pastel.bright_cyan(PP.pp(EcsManager.ecs.describe_tasks(cluster: cluster, tasks: task_arns), '')) 81 | ] 82 | end 83 | 84 | output << [ 85 | pastel.underline('Private IPs of instances running tasks:'), 86 | pastel.cyan(ips.map { |ip| "#{ip}: #{Broadside.config.ssh_cmd(ip)}" }.join("\n")) + "\n" 87 | ] 88 | end 89 | 90 | puts output.join("\n") 91 | end 92 | 93 | def logtail(options) 94 | lines = options[:lines] || DEFAULT_TAIL_LINES 95 | target = Broadside.config.get_target_by_name!(options[:target]) 96 | ip = get_running_instance_ip!(target, *options[:instance]) 97 | info "Tailing logs for running container at #{ip}..." 98 | 99 | cmd = "docker logs -f --tail=#{lines} `#{docker_ps_cmd(target.family)}`" 100 | system_exec(Broadside.config.ssh_cmd(ip) + " '#{cmd}'") 101 | end 102 | 103 | def ssh(options) 104 | target = Broadside.config.get_target_by_name!(options[:target]) 105 | ip = get_running_instance_ip!(target, *options[:instance]) 106 | info "Establishing SSH connection to #{ip}..." 107 | 108 | system_exec(Broadside.config.ssh_cmd(ip)) 109 | end 110 | 111 | def bash(options) 112 | target = Broadside.config.get_target_by_name!(options[:target]) 113 | cmd = "docker exec -i -t `#{docker_ps_cmd(target.family)}` #{BASH}" 114 | ip = get_running_instance_ip!(target, *options[:instance]) 115 | info "Executing #{BASH} on running container at #{ip}..." 116 | 117 | system_exec(Broadside.config.ssh_cmd(ip, tty: true) + " '#{cmd}'") 118 | end 119 | 120 | def execute(options) 121 | command = options[:command] 122 | target = Broadside.config.get_target_by_name!(options[:target]) 123 | cmd = "docker exec -i -t `#{docker_ps_cmd(target.family)}` #{command}" 124 | ips = options[:all] ? running_instances(target) : [get_running_instance_ip!(target, *options[:instance])] 125 | 126 | ips.each do |ip| 127 | info "Executing '#{command}' on running container at #{ip}..." 128 | Open3.popen3(Broadside.config.ssh_cmd(ip, tty: true) + " '#{cmd}'") { |_, stdout, _, _| puts stdout.read } 129 | end 130 | end 131 | 132 | private 133 | 134 | def system_exec(cmd) 135 | debug "Executing: #{cmd}" 136 | exec(cmd) 137 | end 138 | 139 | def get_running_instance_ip!(target, instance_index = 0) 140 | instances = running_instances(target) 141 | 142 | begin 143 | instances.fetch(instance_index) 144 | rescue IndexError 145 | raise Error, "There are only #{instances.size} instances; index #{instance_index} does not exist" 146 | end 147 | end 148 | 149 | def running_instances(target) 150 | EcsManager.check_service_and_task_definition_state!(target) 151 | EcsManager.get_running_instance_ips!(target.cluster, target.family) 152 | end 153 | 154 | def docker_ps_cmd(family) 155 | "docker ps -n 1 --quiet --filter name=#{Shellwords.shellescape(family)}" 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/broadside/configuration/aws_configuration.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | class AwsConfiguration 3 | include ActiveModel::Model 4 | include InvalidConfiguration 5 | 6 | validates :region, presence: true, strict: ConfigurationError 7 | validates :ecs_poll_frequency, numericality: { only_integer: true, strict: ConfigurationError } 8 | validates_each(:credentials) do |_, _, val| 9 | raise ConfigurationError, 'credentials is not of type Aws::Credentials' unless val.is_a?(Aws::Credentials) 10 | end 11 | 12 | attr_writer :credentials 13 | attr_accessor( 14 | :ecs_default_cluster, 15 | :ecs_poll_frequency, 16 | :region 17 | ) 18 | 19 | def initialize 20 | @ecs_poll_frequency = 2 21 | @region = 'us-east-1' 22 | end 23 | 24 | def credentials 25 | @credentials ||= Aws::SharedCredentials.new.credentials || Aws::InstanceProfileCredentials.new.credentials 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/broadside/configuration/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Broadside 4 | class Configuration 5 | include ActiveModel::Model 6 | include InvalidConfiguration 7 | 8 | attr_reader( 9 | :aws, 10 | :targets 11 | ) 12 | attr_accessor( 13 | :application, 14 | :config_file, 15 | :default_docker_image, 16 | :logger, 17 | :prehook, 18 | :posthook, 19 | :ssh, 20 | :timeout 21 | ) 22 | 23 | validates :application, :targets, :logger, presence: true 24 | validates_each(:aws) { |_, _, val| raise ConfigurationError, val.errors.full_messages unless val.valid? } 25 | 26 | validates_each(:ssh) do |record, attr, val| 27 | record.errors.add(attr, 'is not a hash') unless val.is_a?(Hash) 28 | 29 | if (proxy = val[:proxy]) 30 | record.errors.add(attr, 'bad proxy config') unless proxy[:host] && proxy[:port] && proxy[:port].is_a?(Integer) 31 | end 32 | end 33 | 34 | def initialize 35 | @aws = AwsConfiguration.new 36 | @logger = ::Logger.new(STDOUT) 37 | @logger.level = ::Logger::INFO 38 | @logger.datetime_format = '%Y-%m-%d_%H:%M:%S' 39 | @ssh = {} 40 | @timeout = 600 41 | end 42 | 43 | # Transform deploy target configs to Target objects 44 | def targets=(targets_hash) 45 | raise ConfigurationError, ':targets must be a hash' unless targets_hash.is_a?(Hash) 46 | 47 | @targets = targets_hash.inject({}) do |h, (target_name, config)| 48 | h.merge(target_name => Target.new(target_name, config)) 49 | end 50 | end 51 | 52 | def get_target_by_name!(name) 53 | @targets.fetch(name) { raise ArgumentError, "Deploy target '#{name}' does not exist!" } 54 | end 55 | 56 | def ssh_cmd(ip, options = {}) 57 | cmd = 'ssh -o StrictHostKeyChecking=no' 58 | cmd << ' -t -t' if options[:tty] 59 | cmd << " -i #{@ssh[:keyfile]}" if @ssh[:keyfile] 60 | if (proxy = @ssh[:proxy]) 61 | cmd << ' -o ProxyCommand="ssh -q' 62 | cmd << " -i #{proxy[:keyfile]}" if proxy[:keyfile] 63 | cmd << ' ' 64 | cmd << "#{proxy[:user]}@" if proxy[:user] 65 | cmd << "#{proxy[:host]} nc #{ip} #{proxy[:port]}\"" 66 | end 67 | cmd << ' ' 68 | cmd << "#{@ssh[:user]}@" if @ssh[:user] 69 | cmd << ip.to_s 70 | cmd 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/broadside/configuration/invalid_configuration.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | module InvalidConfiguration 3 | def method_missing(m, *args, &block) 4 | raise ArgumentError, 'config.' + (is_a?(AwsConfiguration) ? 'aws.' : '') + m.to_s + ' is an invalid config option' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/broadside/deploy.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | class Deploy 3 | include LoggingUtils 4 | 5 | attr_reader :target 6 | 7 | def initialize(options = {}) 8 | @target = Broadside.config.get_target_by_name!(options[:target]) 9 | @tag = options[:tag] || @target.tag 10 | end 11 | 12 | private 13 | 14 | def image_tag 15 | raise ArgumentError, 'Missing tag!' unless @tag 16 | "#{@target.docker_image}:#{@tag}" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/broadside/ecs/ecs_deploy.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | 3 | module Broadside 4 | class EcsDeploy < Deploy 5 | delegate :cluster, to: :target 6 | delegate :family, to: :target 7 | 8 | DEFAULT_CONTAINER_DEFINITION = { 9 | cpu: 1, 10 | essential: true, 11 | memory: 1024 12 | } 13 | 14 | def short 15 | deploy 16 | end 17 | 18 | def full 19 | info "Running predeploy commands for #{family}..." 20 | run_commands(@target.predeploy_commands, started_by: 'predeploy') 21 | info 'Predeploy complete.' 22 | 23 | deploy 24 | end 25 | 26 | def bootstrap 27 | if EcsManager.get_latest_task_definition_arn(family) 28 | info "Task definition for #{family} already exists." 29 | else 30 | raise ConfigurationError, "No :task_definition_config for #{family}" unless @target.task_definition_config 31 | raise ConfigurationError, 'Bootstrapping a task_definition requires a :tag for the image' unless @tag 32 | info "Creating an initial task definition for '#{family}' from the config..." 33 | 34 | EcsManager.ecs.register_task_definition( 35 | @target.task_definition_config.merge( 36 | family: family, 37 | container_definitions: [DEFAULT_CONTAINER_DEFINITION.merge(configured_container_definition)] 38 | ) 39 | ) 40 | end 41 | 42 | run_commands(@target.bootstrap_commands, started_by: 'bootstrap') 43 | 44 | if EcsManager.service_exists?(cluster, family) 45 | info("Service for #{family} already exists.") 46 | else 47 | raise ConfigurationError, "No :service_config for #{family}" unless @target.service_config 48 | info "Service '#{family}' doesn't exist, creating..." 49 | EcsManager.create_service(cluster, family, @target.service_config) 50 | end 51 | end 52 | 53 | def rollback(options = {}) 54 | count = options[:rollback] || 1 55 | info "Rolling back #{count} release(s) for #{family}..." 56 | EcsManager.check_service_and_task_definition_state!(@target) 57 | 58 | begin 59 | EcsManager.deregister_last_n_tasks_definitions(family, count) 60 | update_service(options) 61 | rescue StandardError 62 | error 'Rollback failed to complete!' 63 | raise 64 | end 65 | 66 | info 'Rollback complete.' 67 | end 68 | 69 | def scale(options = {}) 70 | info "Rescaling #{family} with scale=#{options[:scale] || @target.scale}..." 71 | update_service(options) 72 | info 'Rescaling complete.' 73 | end 74 | 75 | def run_commands(commands, options = {}) 76 | return if commands.nil? || commands.empty? 77 | update_task_revision 78 | 79 | begin 80 | commands.each do |command| 81 | command_name = "'#{command.join(' ')}'" 82 | task_arn = EcsManager.run_task(cluster, family, command, options).tasks[0].task_arn 83 | info "Launched #{command_name} task #{task_arn}, waiting for completion..." 84 | 85 | EcsManager.ecs.wait_until(:tasks_stopped, cluster: cluster, tasks: [task_arn]) do |w| 86 | w.max_attempts = nil 87 | w.delay = Broadside.config.aws.ecs_poll_frequency 88 | w.before_attempt do |attempt| 89 | info "Attempt #{attempt}: waiting for #{command_name} to complete..." 90 | end 91 | end 92 | 93 | exit_status = EcsManager.get_task_exit_status(cluster, task_arn, family) 94 | raise EcsError, "#{command_name} failed to start:\n'#{exit_status[:reason]}'" if exit_status[:exit_code].nil? 95 | raise EcsError, "#{command_name} nonzero exit code: #{exit_status[:exit_code]}!" unless exit_status[:exit_code].zero? 96 | 97 | info "#{command_name} task container logs:\n#{get_container_logs(task_arn)}" 98 | info "#{command_name} task #{task_arn} complete" 99 | end 100 | ensure 101 | EcsManager.deregister_last_n_tasks_definitions(family, 1) 102 | end 103 | end 104 | 105 | private 106 | 107 | def deploy 108 | current_scale = EcsManager.current_service_scale(@target) 109 | update_task_revision 110 | 111 | begin 112 | update_service 113 | rescue Interrupt, StandardError => e 114 | msg = e.is_a?(Interrupt) ? 'Caught interrupt signal' : "#{e.class}: #{e.message}" 115 | error "#{msg}, rolling back..." 116 | # In case of failure during deploy, rollback to the previously configured scale 117 | rollback(scale: current_scale) 118 | error 'Deployment did not finish successfully.' 119 | raise e 120 | end 121 | end 122 | 123 | # Creates a new task revision using current directory's env vars, provided tag, and @target.task_definition_config 124 | def update_task_revision 125 | EcsManager.check_task_definition_state!(target) 126 | revision = EcsManager.get_latest_task_definition(family).except( 127 | :requires_attributes, 128 | :revision, 129 | :status, 130 | :task_definition_arn 131 | ) 132 | updatable_container_definitions = revision[:container_definitions].select { |c| c[:name] == family } 133 | raise Error, 'Can only update one container definition!' if updatable_container_definitions.size != 1 134 | 135 | # Deep merge doesn't work well with arrays (e.g. container_definitions), so build the container first. 136 | updatable_container_definitions.first.merge!(configured_container_definition) 137 | revision.deep_merge!((@target.task_definition_config || {}).except(:container_definitions)) 138 | revision[:requires_compatibilities] = revision.delete(:compatibilities) if revision.key? :compatibilities 139 | 140 | debug "Registering updated task definition for #{revision[:family]}" 141 | task_definition = EcsManager.ecs.register_task_definition(revision).task_definition 142 | debug "Successfully created #{task_definition.task_definition_arn}" 143 | end 144 | 145 | def update_service(options = {}) 146 | scale = options[:scale] || @target.scale 147 | raise ArgumentError, ':scale not provided' unless scale 148 | 149 | EcsManager.check_service_and_task_definition_state!(target) 150 | task_definition_arn = EcsManager.get_latest_task_definition_arn(family) 151 | debug "Updating #{family} with scale=#{scale} using task_definition #{task_definition_arn}..." 152 | 153 | update_service_response = EcsManager.ecs.update_service({ 154 | cluster: cluster, 155 | desired_count: scale, 156 | service: family, 157 | task_definition: task_definition_arn 158 | }.deep_merge(@target.service_config_for_update || {})) 159 | 160 | unless update_service_response.successful? 161 | raise EcsError, "Failed to update service:\n#{update_service_response.pretty_inspect}" 162 | end 163 | 164 | EcsManager.ecs.wait_until(:services_stable, cluster: cluster, services: [family]) do |w| 165 | timeout = Broadside.config.timeout 166 | w.delay = Broadside.config.aws.ecs_poll_frequency 167 | w.max_attempts = timeout ? timeout / w.delay : nil 168 | seen_event_id = nil 169 | 170 | w.before_wait do |attempt, response| 171 | info "(#{attempt}/#{w.max_attempts || Float::INFINITY}) Polling ECS for events..." 172 | # Skip first event since it doesn't apply to current request 173 | if response.services[0].events.first && response.services[0].events.first.id != seen_event_id && attempt > 1 174 | seen_event_id = response.services[0].events.first.id 175 | info response.services[0].events.first.message 176 | end 177 | end 178 | end 179 | end 180 | 181 | def get_container_logs(task_arn) 182 | ip = EcsManager.get_running_instance_ips!(cluster, family, task_arn).first 183 | debug "Found IP of container instance: #{ip}" 184 | 185 | find_container_id_cmd = "#{Broadside.config.ssh_cmd(ip)} \"docker ps -aqf 'label=com.amazonaws.ecs.task-arn=#{task_arn}'\"" 186 | debug "Running command to find container id:\n#{find_container_id_cmd}" 187 | container_ids = `#{find_container_id_cmd}`.split 188 | 189 | logs = '' 190 | container_ids.each do |container_id| 191 | get_container_logs_cmd = "#{Broadside.config.ssh_cmd(ip)} \"docker logs #{container_id}\"" 192 | debug "Running command to get logs of container #{container_id}:\n#{get_container_logs_cmd}" 193 | 194 | Open3.popen3(get_container_logs_cmd) do |_, stdout, stderr, _| 195 | logs << "STDOUT (#{container_id}):\n--\n#{stdout.read}\nSTDERR (#{container_id}):\n--\n#{stderr.read}\n" 196 | end 197 | end 198 | 199 | logs 200 | end 201 | 202 | def configured_container_definition 203 | (@target.task_definition_config.try(:[], :container_definitions).try(:first) || {}).merge( 204 | name: family, 205 | command: @target.command, 206 | environment: @target.ecs_env_vars, 207 | image: image_tag 208 | ) 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/broadside/ecs/ecs_manager.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | class EcsManager 3 | DEFAULT_DESIRED_COUNT = 0 4 | 5 | class << self 6 | include LoggingUtils 7 | 8 | def ecs 9 | @ecs_client ||= Aws::ECS::Client.new( 10 | region: Broadside.config.aws.region, 11 | credentials: Broadside.config.aws.credentials, 12 | logger: Broadside.config.logger, 13 | log_formatter: Aws::Log::Formatter.colored 14 | ) 15 | end 16 | 17 | def create_service(cluster, name, service_config = {}) 18 | ecs.create_service( 19 | { 20 | cluster: cluster, 21 | desired_count: DEFAULT_DESIRED_COUNT, 22 | service_name: name, 23 | task_definition: name 24 | }.deep_merge(service_config) 25 | ) 26 | end 27 | 28 | # removes latest n task definitions 29 | def deregister_last_n_tasks_definitions(name, count) 30 | get_task_definition_arns(name).last(count).each do |arn| 31 | ecs.deregister_task_definition(task_definition: arn) 32 | debug "Deregistered #{arn}" 33 | end 34 | end 35 | 36 | def get_latest_task_definition(name) 37 | return nil unless (arn = get_latest_task_definition_arn(name)) 38 | ecs.describe_task_definition(task_definition: arn).task_definition.to_h 39 | end 40 | 41 | def get_latest_task_definition_arn(name) 42 | get_task_definition_arns(name).last 43 | end 44 | 45 | def get_running_instance_ips!(cluster, family, task_arns = nil) 46 | ips = get_running_instance_ips(cluster, family, task_arns) 47 | raise Error, "No running tasks found for '#{family}' on cluster '#{cluster}'!" if ips.empty? 48 | ips 49 | end 50 | 51 | def get_running_instance_ips(cluster, family, task_arns = nil) 52 | task_arns = task_arns ? Array.wrap(task_arns) : get_task_arns(cluster, family) 53 | return [] if task_arns.empty? 54 | 55 | tasks = ecs.describe_tasks(cluster: cluster, tasks: task_arns).tasks 56 | container_instances = ecs.describe_container_instances( 57 | cluster: cluster, 58 | container_instances: tasks.map(&:container_instance_arn) 59 | ).container_instances 60 | 61 | ec2_instance_ids = container_instances.map(&:ec2_instance_id) 62 | reservations = ec2_client.describe_instances(instance_ids: ec2_instance_ids).reservations 63 | 64 | reservations.map(&:instances).flatten.map(&:private_ip_address) 65 | end 66 | 67 | def get_task_arns(cluster, family, filter = {}) 68 | options = { 69 | cluster: cluster, 70 | # Strange AWS restriction requires absence of family if service_name specified 71 | family: filter[:service_name] ? nil : family, 72 | desired_status: filter[:desired_status], 73 | service_name: filter[:service_name], 74 | started_by: filter[:started_by] 75 | }.reject { |_, v| v.nil? } 76 | 77 | all_results(:list_tasks, :task_arns, options) 78 | end 79 | 80 | def get_task_definition_arns(family) 81 | all_results(:list_task_definitions, :task_definition_arns, { family_prefix: family }) 82 | end 83 | 84 | def get_task_exit_status(cluster, task_arn, name) 85 | task = ecs.describe_tasks(cluster: cluster, tasks: [task_arn]).tasks.first 86 | container = task.containers.select { |c| c.name == name }.first 87 | 88 | { 89 | exit_code: container.exit_code, 90 | reason: container.reason 91 | } 92 | end 93 | 94 | def list_task_definition_families 95 | all_results(:list_task_definition_families, :families) 96 | end 97 | 98 | def list_services(cluster) 99 | all_results(:list_services, :service_arns, { cluster: cluster }) 100 | end 101 | 102 | def run_task(cluster, name, command, options = {}) 103 | raise ArgumentError, "command: '#{command}' must be an array" unless command.is_a?(Array) 104 | 105 | response = ecs.run_task( 106 | cluster: cluster, 107 | task_definition: get_latest_task_definition_arn(name), 108 | overrides: { 109 | container_overrides: [ 110 | { 111 | name: name, 112 | command: command 113 | } 114 | ] 115 | }, 116 | count: 1, 117 | started_by: ((options[:started_by] ? "#{options[:started_by]}:" : '') + command.join(' '))[0...36] 118 | ) 119 | 120 | unless response.successful? && response.tasks.try(:[], 0) 121 | raise EcsError, "Failed to run task '#{command.join(' ')}'\n#{response.pretty_inspect}" 122 | end 123 | 124 | response 125 | end 126 | 127 | def service_exists?(cluster, family) 128 | services = ecs.describe_services(cluster: cluster, services: [family]) 129 | services.failures.empty? && services.services.any? 130 | end 131 | 132 | def check_service_and_task_definition_state!(target) 133 | check_task_definition_state!(target) 134 | check_service_state!(target) 135 | end 136 | 137 | def check_task_definition_state!(target) 138 | unless get_latest_task_definition_arn(target.family) 139 | raise Error, "No task definition for '#{target.family}'! Please bootstrap or manually configure one." 140 | end 141 | end 142 | 143 | def check_service_state!(target) 144 | unless service_exists?(target.cluster, target.family) 145 | raise Error, "No service for '#{target.family}'! Please bootstrap or manually configure one." 146 | end 147 | end 148 | 149 | def current_service_scale(target) 150 | check_service_state!(target) 151 | EcsManager.ecs.describe_services(cluster: target.cluster, services: [target.family]).services.first[:desired_count] 152 | end 153 | 154 | private 155 | 156 | def all_results(method, key, args = {}) 157 | page = ecs.public_send(method, args) 158 | results = page.public_send(key) 159 | 160 | while page.next_token 161 | page = ecs.public_send(method, args.merge(next_token: page.next_token)) 162 | results += page.public_send(key) 163 | end 164 | 165 | results 166 | end 167 | 168 | def ec2_client 169 | @ec2_client ||= Aws::EC2::Client.new( 170 | region: Broadside.config.aws.region, 171 | credentials: Broadside.config.aws.credentials, 172 | logger: Broadside.config.logger, 173 | log_formatter: Aws::Log::Formatter.colored 174 | ) 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/broadside/error.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | class ConfigurationError < ArgumentError; end 3 | class EcsError < StandardError; end 4 | class Error < StandardError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/broadside/gli/commands.rb: -------------------------------------------------------------------------------- 1 | def add_tag_flag(cmd) 2 | cmd.desc 'Docker tag for application container' 3 | cmd.arg_name 'TAG' 4 | cmd.flag [:tag] 5 | end 6 | 7 | def add_target_flag(cmd) 8 | cmd.desc 'Deployment target to use, e.g. production_web' 9 | cmd.arg_name 'TARGET' 10 | cmd.flag [:t, :target], type: Symbol, required: true 11 | end 12 | 13 | def add_instance_flag(cmd) 14 | cmd.desc '0-based index into the array of running instances' 15 | cmd.default_value 0 16 | cmd.arg_name 'INSTANCE' 17 | cmd.flag [:n, :instance], type: Integer 18 | end 19 | 20 | def add_command_flags(cmd) 21 | add_instance_flag(cmd) 22 | add_target_flag(cmd) 23 | end 24 | 25 | def add_deploy_flags(cmd) 26 | add_tag_flag(cmd) 27 | add_target_flag(cmd) 28 | end 29 | 30 | desc 'Bootstrap your service and task definition from the configured definition.' 31 | command :bootstrap do |bootstrap| 32 | add_deploy_flags(bootstrap) 33 | 34 | bootstrap.action do |_, options, _| 35 | Broadside::EcsDeploy.new(options).bootstrap 36 | end 37 | end 38 | 39 | desc 'Gives an overview of all of the deploy targets' 40 | command :targets do |targets| 41 | targets.action do |_, _, _| 42 | Broadside::Command.targets 43 | end 44 | end 45 | 46 | desc 'Gets information about what is currently deployed.' 47 | command :status do |status| 48 | status.desc 'Additionally displays service and task information' 49 | status.switch :verbose, negatable: false 50 | 51 | add_target_flag(status) 52 | 53 | status.action do |_, options, _| 54 | Broadside::Command.status(options) 55 | end 56 | end 57 | 58 | desc 'Creates a single instance of the application to run a command.' 59 | command :run do |run| 60 | run.desc 'Broadside::Command to run (wrap argument in quotes)' 61 | run.arg_name 'COMMAND' 62 | run.flag [:command], type: Array 63 | 64 | add_deploy_flags(run) 65 | 66 | run.action do |_, options, _| 67 | EcsDeploy.new(options).run_commands([options[:command]], started_by: 'run') 68 | end 69 | end 70 | 71 | desc 'Tail the logs inside a running container.' 72 | command :logtail do |logtail| 73 | logtail.desc 'Number of lines to tail' 74 | logtail.default_value Broadside::Command::DEFAULT_TAIL_LINES 75 | logtail.arg_name 'TAIL_LINES' 76 | logtail.flag [:l, :lines], type: Integer 77 | 78 | add_command_flags(logtail) 79 | 80 | logtail.action do |_, options, _| 81 | Broadside::Command.logtail(options) 82 | end 83 | end 84 | 85 | desc 'Establish a secure shell on an instance running the container.' 86 | command :ssh do |ssh| 87 | add_command_flags(ssh) 88 | 89 | ssh.action do |_, options, _| 90 | Broadside::Command.ssh(options) 91 | end 92 | end 93 | 94 | desc 'Establish a shell inside a running container.' 95 | command :bash do |bash| 96 | add_command_flags(bash) 97 | 98 | bash.action do |_, options, _| 99 | Broadside::Command.bash(options) 100 | end 101 | end 102 | 103 | desc 'Execute a bash command inside a running container.' 104 | command :execute do |execute| 105 | add_command_flags(execute) 106 | 107 | execute.desc 'bash command to run (wrap argument in quotes)' 108 | execute.arg_name 'BASH_COMMAND' 109 | execute.flag [:c, :command], type: String, required: true 110 | 111 | execute.desc 'run on all containers in series' 112 | execute.arg_name 'ALL_CONTAINERS' 113 | execute.switch :all, negatable: false 114 | 115 | execute.action do |_, options, _| 116 | Broadside::Command.execute(options) 117 | end 118 | end 119 | 120 | desc 'Deploy your application.' 121 | command :deploy do |d| 122 | d.desc 'Deploys WITHOUT running predeploy commands' 123 | d.command :short do |short| 124 | add_deploy_flags(short) 125 | 126 | short.action do |_, options, _| 127 | Broadside::EcsDeploy.new(options).short 128 | end 129 | end 130 | 131 | d.desc 'Deploys WITH running predeploy commands' 132 | d.command :full do |full| 133 | add_deploy_flags(full) 134 | 135 | full.action do |_, options, _| 136 | Broadside::EcsDeploy.new(options).full 137 | end 138 | end 139 | 140 | d.desc 'Scales application to a given count' 141 | d.command :scale do |scale| 142 | scale.desc 'Specify a new scale for application' 143 | scale.arg_name 'NUM' 144 | scale.flag [:s, :scale], type: Integer 145 | 146 | add_target_flag(scale) 147 | 148 | scale.action do |_, options, _| 149 | Broadside::EcsDeploy.new(options).scale(options) 150 | end 151 | end 152 | 153 | d.desc 'Rolls back n releases and deploys' 154 | d.command :rollback do |rollback| 155 | rollback.desc 'Number of releases to rollback' 156 | rollback.arg_name 'COUNT' 157 | rollback.flag [:r, :rollback], type: Integer 158 | 159 | add_target_flag(rollback) 160 | 161 | rollback.action do |_, options, _| 162 | Broadside::EcsDeploy.new(options).rollback(options) 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/broadside/gli/global.rb: -------------------------------------------------------------------------------- 1 | # GLI type coercions 2 | accept Symbol do |val| 3 | val.to_sym 4 | end 5 | accept Array do |val| 6 | val.split(' ') 7 | end 8 | accept Integer do |val| 9 | val.to_i 10 | end 11 | 12 | desc 'Configuration file to use.' 13 | default_value 'config/broadside.conf.rb' 14 | arg_name 'FILE' 15 | flag [:c, :config] 16 | 17 | desc 'Enables debug mode' 18 | switch [:D, :debug], negatable: false 19 | 20 | desc 'Log level output' 21 | arg_name 'LOGLEVEL' 22 | flag [:l, :loglevel], must_match: %w(debug info warn error fatal) 23 | 24 | def call_hook(type, command, options, args) 25 | hook = Broadside.config.public_send(type) 26 | return if hook.nil? 27 | raise "#{type} hook is not a callable proc" unless hook.is_a?(Proc) 28 | 29 | hook_args = { 30 | options: options, 31 | args: args 32 | } 33 | 34 | if command.parent.is_a?(GLI::Command) 35 | hook_args[:command] = command.parent.name 36 | hook_args[:subcommand] = command.name 37 | else 38 | hook_args[:command] = command.name 39 | end 40 | 41 | debug "Calling #{type} with args '#{hook_args}'" 42 | hook.call(hook_args) 43 | end 44 | 45 | pre do |global, command, options, args| 46 | Broadside.load_config_file(global[:config]) 47 | 48 | if global[:debug] 49 | Broadside.config.logger.level = ::Logger::DEBUG 50 | ENV['GLI_DEBUG'] = 'true' 51 | elsif global[:loglevel] 52 | Broadside.config.logger.level = ::Logger.const_get(global[:loglevel].upcase) 53 | end 54 | 55 | call_hook(:prehook, command, options, args) 56 | true 57 | end 58 | 59 | post do |global, command, options, args| 60 | call_hook(:posthook, command, options, args) 61 | true 62 | end 63 | 64 | on_error do |exception| 65 | case exception 66 | when Broadside::ConfigurationError 67 | error exception.message, "Run your last command with --help for more information." 68 | false # false skips default error handling 69 | else 70 | true 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/broadside/logging_utils.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | module LoggingUtils 3 | %w(debug info warn error fatal).each do |log_level| 4 | define_method(log_level) do |*args| 5 | Broadside.config.logger.public_send(log_level.to_sym, args.join(' ')) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/broadside/target.rb: -------------------------------------------------------------------------------- 1 | require 'dotenv' 2 | require 'pathname' 3 | 4 | module Broadside 5 | class Target 6 | include ActiveModel::Model 7 | include LoggingUtils 8 | 9 | attr_reader( 10 | :bootstrap_commands, 11 | :cluster, 12 | :command, 13 | :docker_image, 14 | :name, 15 | :predeploy_commands, 16 | :scale, 17 | :service_config, 18 | :tag, 19 | :task_definition_config 20 | ) 21 | 22 | validates :cluster, :docker_image, :name, presence: true 23 | validates :scale, numericality: { only_integer: true } 24 | 25 | validates_each(:bootstrap_commands, :predeploy_commands, allow_nil: true) do |record, attr, val| 26 | record.errors.add(attr, 'is not array of arrays') unless val.is_a?(Array) && val.all? { |v| v.is_a?(Array) } 27 | end 28 | 29 | validates_each(:service_config, allow_nil: true) do |record, attr, val| 30 | record.errors.add(attr, 'is not a hash') unless val.is_a?(Hash) 31 | end 32 | 33 | validates_each(:task_definition_config, allow_nil: true) do |record, attr, val| 34 | if val.is_a?(Hash) 35 | if val[:container_definitions] && val[:container_definitions].size > 1 36 | record.errors.add(attr, 'specifies > 1 container definition but this is not supported yet') 37 | end 38 | else 39 | record.errors.add(attr, 'is not a hash') 40 | end 41 | end 42 | 43 | validates_each(:command, allow_nil: true) do |record, attr, val| 44 | record.errors.add(attr, 'is not an array of strings') unless val.is_a?(Array) && val.all? { |v| v.is_a?(String) } 45 | end 46 | 47 | CREATE_ONLY_SERVICE_ATTRIBUTES = %i( 48 | client_token 49 | load_balancers 50 | placement_constraints 51 | placement_strategy 52 | role 53 | ).freeze 54 | 55 | def initialize(name, options = {}) 56 | @name = name 57 | 58 | config = options.deep_dup 59 | @bootstrap_commands = config.delete(:bootstrap_commands) 60 | @cluster = config.delete(:cluster) || Broadside.config.aws.ecs_default_cluster 61 | @command = config.delete(:command) 62 | @docker_image = config.delete(:docker_image) || Broadside.config.default_docker_image 63 | @predeploy_commands = config.delete(:predeploy_commands) 64 | @scale = config.delete(:scale) 65 | @service_config = config.delete(:service_config) 66 | @tag = config.delete(:tag) 67 | @task_definition_config = config.delete(:task_definition_config) 68 | 69 | @env_files = Array.wrap(config.delete(:env_files) || config.delete(:env_file)).map do |env_path| 70 | env_file = Pathname.new(env_path) 71 | next env_file if env_file.absolute? 72 | 73 | dir = Broadside.config.config_file ? Pathname.new(Broadside.config.config_file).dirname : Dir.pwd 74 | env_file.expand_path(dir) 75 | end 76 | 77 | raise ConfigurationError, errors.full_messages unless valid? 78 | raise ConfigurationError, "Target #{@name} was configured with invalid options: #{config}" unless config.empty? 79 | end 80 | 81 | def ecs_env_vars 82 | @env_vars ||= @env_files.inject({}) do |env_variables, env_file| 83 | raise ConfigurationError, "Specified env_file: '#{env_file}' does not exist!" unless env_file.exist? 84 | 85 | begin 86 | env_variables.merge(Dotenv.load(env_file)) 87 | rescue Dotenv::FormatError => e 88 | raise e.class, "Error parsing #{env_file}: #{e.message}", e.backtrace 89 | end 90 | end.map { |k, v| { 'name' => k, 'value' => v } } 91 | end 92 | 93 | def family 94 | "#{Broadside.config.application}_#{@name}" 95 | end 96 | 97 | def to_h 98 | { 99 | Target: @name, 100 | Image: "#{@docker_image}:#{@tag || 'no_tag_configured'}", 101 | Cluster: @cluster 102 | } 103 | end 104 | 105 | def service_config_for_update 106 | service_config.try(:except, *CREATE_ONLY_SERVICE_ATTRIBUTES) 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/broadside/version.rb: -------------------------------------------------------------------------------- 1 | module Broadside 2 | VERSION = '3.3.2'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/broadside/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Broadside::Command do 4 | include_context 'deploy configuration' 5 | include_context 'ecs stubs' 6 | 7 | let(:tag) { 'tag_tag' } 8 | let(:context_deploy_config) { {} } 9 | let(:deploy_config) { { target: test_target_name }.merge(context_deploy_config) } 10 | let(:deploy) { Broadside::EcsDeploy.new(deploy_config) } 11 | let(:family) { Broadside.config.get_target_by_name!(deploy_config[:target]).family } 12 | 13 | describe '#bash' do 14 | it 'fails without a running service' do 15 | expect { described_class.bash(deploy_config) }.to raise_error(Broadside::Error, /No task definition/) 16 | end 17 | 18 | context 'with a task definition and service in place' do 19 | include_context 'with a running service' 20 | include_context 'with a task_definition' 21 | 22 | it 'fails without a running task' do 23 | expect { described_class.bash(deploy_config) }.to raise_error /No running tasks found for/ 24 | end 25 | 26 | context 'with a running task' do 27 | let(:task_arn) { 'some_task_arn' } 28 | let(:container_arn) { 'some_container_arn' } 29 | let(:instance_id) { 'i-xxxxxxxx' } 30 | let(:ip) { '123.123.123.123' } 31 | let(:docker_cmd) { "ssh -o StrictHostKeyChecking=no -t -t #{user}@#{ip} 'docker exec -i -t `docker ps -n 1 --quiet --filter name=#{family}`" } 32 | let(:request_log) do 33 | [ 34 | { list_task_definitions: { family_prefix: family } }, 35 | { describe_services: { cluster: cluster, services: [family] } }, 36 | { list_tasks: { cluster: cluster, family: family } }, 37 | { describe_tasks: { cluster: cluster, tasks: [task_arn] } }, 38 | { describe_container_instances: { cluster: cluster, container_instances: [container_arn] } }, 39 | { describe_instances: { instance_ids: [instance_id] } } 40 | ] 41 | end 42 | 43 | before(:each) do 44 | ecs_stub.stub_responses(:list_tasks, task_arns: [task_arn]) 45 | ecs_stub.stub_responses(:describe_tasks, tasks: [{ container_instance_arn: container_arn }]) 46 | ecs_stub.stub_responses(:describe_container_instances, container_instances: [{ ec2_instance_id: instance_id }]) 47 | ec2_stub.stub_responses(:describe_instances, reservations: [{ instances: [{ private_ip_address: ip }] }]) 48 | end 49 | 50 | it 'raises an exception if the requested server index does not exist' do 51 | expect do 52 | described_class.bash(deploy_config.merge(instance: 2)) 53 | end.to raise_error(Broadside::Error, /There are only 1 instances; index 2 does not exist/) 54 | end 55 | 56 | it 'executes bash' do 57 | expect(described_class).to receive(:exec).with("#{docker_cmd} bash'") 58 | expect { described_class.bash(deploy_config) }.to_not raise_error 59 | expect(api_request_log).to eq(request_log) 60 | end 61 | 62 | context '#execute' do 63 | let(:command) { 'ls' } 64 | 65 | it 'executes correct bash command' do 66 | expect(Open3).to receive(:popen3).with("#{docker_cmd} #{command}'") 67 | expect { described_class.execute(deploy_config.merge(command: 'ls')) }.to_not raise_error 68 | expect(api_request_log).to eq(request_log) 69 | end 70 | 71 | it 'executes correct bash with the :all switch' do 72 | expect(Open3).to receive(:popen3).with("#{docker_cmd} #{command}'") 73 | expect { described_class.execute(deploy_config.merge(command: 'ls', all: true)) }.to_not raise_error 74 | expect(api_request_log).to eq(request_log) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/broadside/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Broadside::Configuration do 4 | include_context 'deploy configuration' 5 | 6 | it 'should be able to find a target' do 7 | expect { Broadside.config.get_target_by_name!(test_target_name) }.to_not raise_error 8 | end 9 | 10 | it 'should raise an error when a target is missing' do 11 | expect { Broadside.config.get_target_by_name!('barf') }.to raise_error(ArgumentError) 12 | end 13 | 14 | it 'should raise an error when ecs is misconfigured' do 15 | expect { Broadside.configure { |config| config.aws.region = nil } }.to raise_error(ArgumentError) 16 | expect { Broadside.configure { |config| config.aws.ecs_poll_frequency = 'poll' } }.to raise_error(ArgumentError) 17 | expect { Broadside.configure { |config| config.aws.credentials = 'password' } }.to raise_error(ArgumentError) 18 | end 19 | 20 | it 'should raise a relevant method missing error when misconfigured' do 21 | expect { Broadside.configure { |config| config.aws.bad = 5 } }.to raise_error(ArgumentError, 'config.aws.bad= is an invalid config option') 22 | expect { Broadside.configure { |config| config.bad = 5 } }.to raise_error(ArgumentError, 'config.bad= is an invalid config option') 23 | end 24 | 25 | describe '#ssh_cmd' do 26 | let(:ip) { '123.123.123.123' } 27 | let(:ssh_config) { {} } 28 | 29 | before(:each) do 30 | Broadside.config.ssh = ssh_config 31 | end 32 | 33 | it 'should build the SSH command' do 34 | expect(Broadside.config.ssh_cmd(ip)).to eq("ssh -o StrictHostKeyChecking=no #{ip}") 35 | end 36 | 37 | context 'with configured SSH user and keyfile' do 38 | let(:keyfile) { 'path_to_keyfile' } 39 | let(:ssh_config) { { user: user, keyfile: keyfile } } 40 | 41 | it 'generates an SSH command string with keyfile flag and user set' do 42 | expect(Broadside.config.ssh_cmd(ip)).to eq( 43 | "ssh -o StrictHostKeyChecking=no -i #{keyfile} #{user}@#{ip}" 44 | ) 45 | end 46 | 47 | context 'with tty option' do 48 | it 'generates an SSH command string with -tt flags' do 49 | expect(Broadside.config.ssh_cmd(ip, tty: true)).to eq( 50 | "ssh -o StrictHostKeyChecking=no -t -t -i #{keyfile} #{user}@#{ip}" 51 | ) 52 | end 53 | end 54 | 55 | context 'with configured SSH proxy' do 56 | let(:ssh_proxy_config) { {} } 57 | let(:ssh_config) do 58 | { 59 | user: user, 60 | keyfile: keyfile, 61 | proxy: ssh_proxy_config 62 | } 63 | end 64 | 65 | it 'is invalid if proxy is incorrectly configured' do 66 | expect(Broadside.config.valid?).to be false 67 | end 68 | 69 | context 'with proxy user, host, and port' do 70 | let(:proxy_user) { 'proxy-user' } 71 | let(:proxy_host) { 'proxy-host' } 72 | let(:proxy_port) { 22 } 73 | let(:ssh_proxy_config) do 74 | { 75 | user: proxy_user, 76 | host: proxy_host, 77 | port: proxy_port 78 | } 79 | end 80 | 81 | it 'generates an SSH command string with the configured SSH proxy' do 82 | expect(Broadside.config.ssh_cmd(ip)).to eq( 83 | "ssh -o StrictHostKeyChecking=no -i #{keyfile} -o ProxyCommand=\"ssh -q #{proxy_user}@#{proxy_host} nc #{ip} #{proxy_port}\" #{user}@#{ip}" 84 | ) 85 | end 86 | 87 | context 'with proxy keyfile' do 88 | let(:proxy_keyfile) { 'path_to_proxy_keyfile' } 89 | let(:ssh_proxy_config) do 90 | { 91 | user: proxy_user, 92 | host: proxy_host, 93 | port: proxy_port, 94 | keyfile: proxy_keyfile 95 | } 96 | end 97 | 98 | it 'generates an SSH command string with the configured SSH proxy' do 99 | expect(Broadside.config.ssh_cmd(ip)).to eq( 100 | "ssh -o StrictHostKeyChecking=no -i #{keyfile} -o ProxyCommand=\"ssh -q -i #{proxy_keyfile} #{proxy_user}@#{proxy_host} nc #{ip} #{proxy_port}\" #{user}@#{ip}" 101 | ) 102 | end 103 | end 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/broadside/ecs/ecs_deploy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Broadside::EcsDeploy do 4 | include_context 'deploy configuration' 5 | include_context 'ecs stubs' 6 | 7 | let(:target) { Broadside::Target.new(test_target_name, test_target_config) } 8 | let(:deploy) { described_class.new(target: test_target_name, tag: 'tag_the_bag') } 9 | let(:family) { deploy.family } 10 | let(:desired_count) { 4 } 11 | let(:cpu) { 1 } 12 | let(:memory) { 2000 } 13 | let(:base_service_config) do 14 | { 15 | desired_count: desired_count, 16 | deployment_configuration: { 17 | minimum_healthy_percent: 40, 18 | } 19 | } 20 | end 21 | let(:service_config_with_load_balancers) do 22 | base_service_config.merge( 23 | role: 'ecsServiceRole', 24 | load_balancers: [ 25 | { 26 | container_name: 'test-container', 27 | container_port: 8080, 28 | load_balancer_name: 'test-container-elb' 29 | } 30 | ] 31 | ) 32 | end 33 | let(:service_config) { base_service_config } 34 | 35 | describe '#bootstrap' do 36 | it 'fails without task_definition_config' do 37 | expect { deploy.bootstrap }.to raise_error(Broadside::ConfigurationError, /No :task_definition_config/) 38 | end 39 | 40 | context 'with an existing task definition' do 41 | include_context 'with a task_definition' 42 | 43 | it 'fails without service_config' do 44 | expect { deploy.bootstrap }.to raise_error(/No :service_config/) 45 | end 46 | 47 | context 'with a service_config' do 48 | let(:local_target_config) { { service_config: service_config } } 49 | 50 | it 'sets up the service' do 51 | expect(Broadside::EcsManager).to receive(:create_service).with(cluster, deploy.family, service_config) 52 | expect { deploy.bootstrap }.to_not raise_error 53 | end 54 | end 55 | 56 | shared_examples 'correctly-behaving bootstrap' do 57 | include_context 'with a running service' 58 | 59 | it 'succeeds' do 60 | expect { deploy.bootstrap }.to_not raise_error 61 | end 62 | 63 | context 'and some configured bootstrap commands' do 64 | let(:commands) { [%w(foo bar baz)] } 65 | let(:local_target_config) { { bootstrap_commands: commands } } 66 | 67 | it 'runs bootstrap commands' do 68 | expect(deploy).to receive(:run_commands).with(commands, started_by: 'bootstrap') 69 | deploy.bootstrap 70 | end 71 | end 72 | end 73 | 74 | context 'with an existing service' do 75 | it_behaves_like 'correctly-behaving bootstrap' 76 | end 77 | 78 | context 'with an existing task definition that has create-only parameters' do 79 | let(:service_config) { service_config_with_load_balancers } 80 | 81 | it_behaves_like 'correctly-behaving bootstrap' 82 | end 83 | end 84 | end 85 | 86 | describe '#deploy' do 87 | it 'fails without an existing service' do 88 | expect { deploy.short }.to raise_error(/No service for '#{deploy.family}'!/) 89 | end 90 | 91 | context 'with an existing service' do 92 | include_context 'with a running service' 93 | 94 | it 'fails without an existing task_definition' do 95 | expect { deploy.short }.to raise_error(/No task definition for/) 96 | end 97 | 98 | shared_examples 'correctly-behaving deploy' do 99 | include_context 'with a task_definition' 100 | 101 | it 'short deploy does not fail' do 102 | expect { deploy.short }.to_not raise_error 103 | end 104 | 105 | context 'updating service and task definitions' do 106 | let(:task_definition_config) do 107 | { 108 | container_definitions: [ 109 | { 110 | cpu: cpu, 111 | memory: memory 112 | } 113 | ] 114 | } 115 | end 116 | let(:local_target_config) do 117 | { 118 | task_definition_config: task_definition_config, 119 | service_config: service_config 120 | } 121 | end 122 | 123 | it 'should reconfigure the task definition' do 124 | deploy.short 125 | 126 | register_requests = api_request_log.select { |cmd| cmd.keys.first == :register_task_definition } 127 | expect(register_requests.size).to eq(1) 128 | expect(register_requests.first.values.first[:container_definitions].first[:cpu]).to eq(cpu) 129 | expect(register_requests.first.values.first[:container_definitions].first[:memory]).to eq(memory) 130 | end 131 | 132 | it 'should reconfigure the service definition' do 133 | deploy.short 134 | 135 | service_requests = api_request_log.select { |cmd| cmd.keys.first == :update_service } 136 | expect(service_requests.first.values.first[:desired_count]).to eq(desired_count) 137 | end 138 | 139 | context 'full deploy' do 140 | let(:predeploy_commands) { [%w(x y z), %w(a b c)] } 141 | let(:local_target_config) { { predeploy_commands: predeploy_commands } } 142 | 143 | it 'should run predeploy_commands' do 144 | expect(deploy).to receive(:run_commands).with(predeploy_commands, started_by: 'predeploy') 145 | deploy.full 146 | end 147 | end 148 | end 149 | 150 | context 'rolling back a failed deploy' do 151 | before do 152 | Broadside.config.logger.level = Logger::FATAL 153 | end 154 | 155 | it 'rolls back to the same scale' do 156 | expect(deploy).to receive(:update_service).once.with(no_args).and_raise('fail') 157 | expect(deploy).to receive(:update_service).once.with(scale: deployed_scale) 158 | expect { deploy.short }.to raise_error(/fail/) 159 | end 160 | end 161 | 162 | it 'can rollback' do 163 | deploy.rollback 164 | expect(api_request_methods.include?(:deregister_task_definition)).to be true 165 | expect(api_request_methods.include?(:update_service)).to be true 166 | end 167 | end 168 | 169 | context 'with an existing task definition' do 170 | it_behaves_like 'correctly-behaving deploy' 171 | end 172 | 173 | context 'with an existing task definition that has create-only parameters' do 174 | let(:service_config) { service_config_with_load_balancers } 175 | 176 | it_behaves_like 'correctly-behaving deploy' 177 | end 178 | end 179 | end 180 | 181 | describe '#run_commands' do 182 | let(:commands) { [%w(run some command)] } 183 | 184 | it 'fails without a task definition' do 185 | expect { deploy.run_commands(commands) }.to raise_error(Broadside::Error, /No task definition for/) 186 | end 187 | 188 | context 'with a task_definition' do 189 | include_context 'with a task_definition' 190 | 191 | let(:exit_code) { 0 } 192 | let(:reason) { nil } 193 | let(:task_exit_status) do 194 | { 195 | exit_code: exit_code, 196 | reason: reason 197 | } 198 | end 199 | 200 | before(:each) do 201 | ecs_stub.stub_responses(:run_task, tasks: [task_arn: 'task_arn']) 202 | ecs_stub.stub_responses(:wait_until, true) 203 | allow(Broadside::EcsManager).to receive(:get_task_exit_status).and_return(task_exit_status) 204 | end 205 | 206 | it 'runs' do 207 | expect(ecs_stub).to receive(:wait_until) 208 | expect(deploy).to receive(:get_container_logs) 209 | expect { deploy.run_commands(commands) }.to_not raise_error 210 | end 211 | 212 | context 'tries to start a task that does not produce an exit code' do 213 | let(:exit_code) { nil } 214 | let(:reason) { 'CannotPullContainerError: Tag BLARGH not found in repository lumoslabs/my_project' } 215 | 216 | it 'raises an error displaying the failure reason' do 217 | expect(ecs_stub).to receive(:wait_until) 218 | expect { deploy.run_commands(commands) }.to raise_error(Broadside::EcsError, /#{reason}/) 219 | end 220 | end 221 | 222 | context 'starts a task that produces a non-zero exit code' do 223 | let(:exit_code) { 9000 } 224 | 225 | it 'raises an error and displays the exit code' do 226 | expect(ecs_stub).to receive(:wait_until) 227 | expect { deploy.run_commands(commands) }.to raise_error(Broadside::EcsError, /#{exit_code}/) 228 | end 229 | end 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /spec/broadside/ecs/ecs_manager_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Hitting the stubbed Aws::ECS::Client serves to validate the request format we are sending 4 | 5 | describe Broadside::EcsManager do 6 | include_context 'ecs stubs' 7 | 8 | let(:service_name) { 'service' } 9 | let(:cluster) { 'cluster' } 10 | let(:name) { 'job' } 11 | 12 | describe '#create_service' do 13 | it 'creates an ECS service from the given configs' do 14 | expect { described_class.create_service(cluster, service_name) }.to_not raise_error 15 | end 16 | end 17 | 18 | describe '#list_services' do 19 | it 'returns an array of services belonging to the provided cluster' do 20 | expect { described_class.list_services(cluster) }.to_not raise_error 21 | end 22 | end 23 | 24 | describe '#get_task_arns' do 25 | it 'returns an array of task arns belonging to a provided cluster with the provided name' do 26 | expect { described_class.get_task_arns(cluster, name) }.to_not raise_error 27 | end 28 | end 29 | 30 | describe '#get_task_definition_arns' do 31 | it 'returns an array of task definition arns with the provided name' do 32 | expect { described_class.get_task_definition_arns(name) }.to_not raise_error 33 | expect { described_class.get_latest_task_definition_arn(name) }.to_not raise_error 34 | end 35 | end 36 | 37 | describe '#get_latest_task_definition' do 38 | it 'returns the most recent valid task definition' do 39 | expect(described_class.get_latest_task_definition(name)).to be_nil 40 | end 41 | end 42 | 43 | describe '#all_results' do 44 | let(:task_definition_arns) { ['arn:task-definition/task:1', 'arn:task-definition/other_task:1'] } 45 | let(:stub_task_definition_responses) do 46 | [ 47 | { task_definition_arns: [task_definition_arns[0]], next_token: 'MzQ3N' }, 48 | { task_definition_arns: [task_definition_arns[1]] } 49 | ] 50 | end 51 | 52 | before do 53 | ecs_stub.stub_responses(:list_task_definitions, stub_task_definition_responses) 54 | end 55 | 56 | it 'can pull multipage results' do 57 | expect(described_class.get_task_definition_arns('task')).to eq(task_definition_arns) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/broadside/target_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Broadside::Target do 4 | include_context 'deploy configuration' 5 | 6 | let(:base_possible_options) do 7 | { 8 | bootstrap_commands: [], 9 | cluster: 'some-cluster', 10 | command: %w(some command), 11 | docker_image: 'lumoslabs/hello', 12 | env_file: '.env.test', 13 | predeploy_commands: [], 14 | scale: 9000, 15 | service_config: {}, 16 | tag: 'latest', 17 | task_definition_config: {} 18 | } 19 | end 20 | let(:all_possible_options) { base_possible_options } 21 | let(:all_possible_options_with_create_only_service_options) { base_possible_options } 22 | let(:target) { described_class.new(test_target_name, all_possible_options) } 23 | 24 | describe '#initialize' do 25 | it 'should initialize without erroring using all possible options' do 26 | expect { target }.to_not raise_error 27 | end 28 | end 29 | 30 | shared_examples 'valid_configuration?' do |succeeds, config_hash| 31 | let(:valid_options) { { scale: 100 } } 32 | let(:target) { described_class.new(test_target_name, valid_options.merge(config_hash)) } 33 | 34 | it 'validates target configuration' do 35 | if succeeds 36 | expect { target }.to_not raise_error 37 | else 38 | expect { target }.to raise_error(ArgumentError) 39 | end 40 | end 41 | end 42 | 43 | describe '#validate_targets!' do 44 | it_behaves_like 'valid_configuration?', true, {} 45 | 46 | it_behaves_like 'valid_configuration?', false, scale: 1.1 47 | it_behaves_like 'valid_configuration?', false, scale: nil 48 | 49 | it_behaves_like 'valid_configuration?', true, env_files: nil 50 | it_behaves_like 'valid_configuration?', true, env_files: 'file' 51 | it_behaves_like 'valid_configuration?', true, env_files: %w(file file2) 52 | 53 | it_behaves_like 'valid_configuration?', true, command: nil 54 | it_behaves_like 'valid_configuration?', true, command: %w(do something) 55 | it_behaves_like 'valid_configuration?', false, command: 'do something' 56 | 57 | it_behaves_like 'valid_configuration?', true, predeploy_commands: nil 58 | it_behaves_like 'valid_configuration?', false, predeploy_commands: %w(do something) 59 | it_behaves_like 'valid_configuration?', true, predeploy_commands: [%w(do something)] 60 | it_behaves_like 'valid_configuration?', true, predeploy_commands: [%w(do something), %w(other command)] 61 | 62 | it_behaves_like 'valid_configuration?', false, task_definition_config: { container_definitions: %w(a b) } 63 | end 64 | 65 | describe '#ecs_env_vars' do 66 | let(:valid_options) { { scale: 1, env_files: env_files } } 67 | let(:target) { described_class.new(test_target_name, valid_options) } 68 | let(:dot_env_file) { File.join(FIXTURES_PATH, '.env.rspec') } 69 | 70 | shared_examples 'successfully loaded env_files' do 71 | it 'loads environment variables from a file' do 72 | expect(target.ecs_env_vars).to eq(expected_env_vars) 73 | end 74 | end 75 | 76 | context 'with a single environment file' do 77 | let(:env_files) { dot_env_file } 78 | let(:expected_env_vars) do 79 | [ 80 | { 'name' => 'TEST_KEY1', 'value' => 'TEST_VALUE1' }, 81 | { 'name' => 'TEST_KEY2', 'value' => 'TEST_VALUE2' } 82 | ] 83 | end 84 | 85 | it_behaves_like 'successfully loaded env_files' 86 | end 87 | 88 | context 'with multiple environment files' do 89 | let(:env_files) { [dot_env_file, dot_env_file + '.override'] } 90 | let(:expected_env_vars) do 91 | [ 92 | { 'name' => 'TEST_KEY1', 'value' => 'TEST_VALUE1' }, 93 | { 'name' => 'TEST_KEY2', 'value' => 'TEST_VALUE_OVERRIDE' }, 94 | { 'name' => 'TEST_KEY3', 'value' => 'TEST_VALUE3' } 95 | ] 96 | end 97 | 98 | it_behaves_like 'successfully loaded env_files' 99 | end 100 | end 101 | 102 | describe '#service_config_for_update' do 103 | context 'with no service config' do 104 | it 'does not raise an error' do 105 | expect { target }.to_not raise_error 106 | end 107 | end 108 | 109 | shared_examples 'accessor for update-safe service config parameters' do 110 | it 'does not raise an error' do 111 | expect { target }.to_not raise_error 112 | end 113 | 114 | it 'returns the basic serivce config parameters' do 115 | expect(target.service_config).to eq(base_possible_options[:service_config]) 116 | end 117 | end 118 | 119 | context 'with a normal service config' do 120 | it_behaves_like 'accessor for update-safe service config parameters' 121 | end 122 | 123 | context 'with a service config containing create-only parameters' do 124 | let(:all_possible_options) { all_possible_options_with_create_only_service_options } 125 | 126 | it_behaves_like 'accessor for update-safe service config parameters' 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/broadside_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Broadside do 4 | include_context 'deploy configuration' 5 | 6 | it 'should be able to display the help menu' do 7 | silence_warnings do 8 | exit_value = system('bundle exec broadside --help >/dev/null') 9 | expect(exit_value).to be_truthy 10 | end 11 | end 12 | 13 | describe '#load_config_file' do 14 | it 'calls load for both the system and app config files' do 15 | expect(Broadside).to receive(:load).with(system_config_path).ordered 16 | expect(Broadside).to receive(:load).with(app_config_path).ordered 17 | Broadside.load_config_file(app_config_path) 18 | end 19 | end 20 | 21 | describe '#reset!' do 22 | it 'discards the existing configuration' do 23 | current_config = Broadside.config 24 | expect(current_config).to eq(Broadside.config) 25 | Broadside.reset! 26 | expect(current_config).not_to eq(Broadside.config) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/fixtures/.env.rspec: -------------------------------------------------------------------------------- 1 | TEST_KEY1=TEST_VALUE1 2 | TEST_KEY2=TEST_VALUE2 3 | -------------------------------------------------------------------------------- /spec/fixtures/.env.rspec.override: -------------------------------------------------------------------------------- 1 | TEST_KEY2=TEST_VALUE_OVERRIDE 2 | TEST_KEY3=TEST_VALUE3 3 | -------------------------------------------------------------------------------- /spec/fixtures/broadside_app_example.conf.rb: -------------------------------------------------------------------------------- 1 | Broadside.configure do |c| 2 | c.aws.credentials = Aws::Credentials.new('access', 'secret') 3 | c.aws.ecs_default_cluster = cluster 4 | c.application = test_app 5 | c.default_docker_image = 'rails' 6 | c.logger.level = Logger::ERROR 7 | c.ssh = { user: user } 8 | c.targets = { 9 | test_target_name => test_target_config 10 | } 11 | end 12 | -------------------------------------------------------------------------------- /spec/fixtures/broadside_system_example.conf.rb: -------------------------------------------------------------------------------- 1 | Broadside.configure do |c| 2 | c.ssh = { user: 'system-default-user' } 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'broadside' 2 | require 'pry-byebug' 3 | Dir['./spec/support/**/*.rb'].sort.each { |f| require f } 4 | 5 | FIXTURES_PATH = File.join(File.dirname(__FILE__), 'fixtures') 6 | 7 | RSpec.configure do |config| 8 | config.include AwsStubHelper 9 | 10 | config.before do 11 | Broadside.reset! 12 | end 13 | 14 | config.expect_with :rspec do |expectations| 15 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 16 | end 17 | 18 | config.mock_with :rspec do |mocks| 19 | mocks.verify_partial_doubles = true 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/aws_stub_helper.rb: -------------------------------------------------------------------------------- 1 | module AwsStubHelper 2 | def build_stub_aws_client(klass, api_request_log = []) 3 | client = klass.new( 4 | region: Broadside.config.aws.region, 5 | credentials: Aws::Credentials.new('access', 'secret'), 6 | stub_responses: true 7 | ) 8 | 9 | client.handle do |context| 10 | api_request_log << { context.operation_name => context.params } 11 | @handler.call(context) 12 | end 13 | 14 | client 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/configuration_shared_context.rb: -------------------------------------------------------------------------------- 1 | shared_context 'deploy configuration' do 2 | let(:test_app) { 'TEST_APP' } 3 | let(:cluster) { 'cluster' } 4 | let(:test_target_name) { :test_target } 5 | let(:user) { 'test-user' } 6 | let(:test_target_config) { { scale: 1 }.merge(local_target_config) } 7 | let(:local_target_config) { {} } 8 | let(:arn) { 'arn:aws:ecs:us-east-1:1234' } 9 | let(:system_config_path) { File.join(FIXTURES_PATH, 'broadside_system_example.conf.rb') } 10 | let(:app_config_path) { File.join(FIXTURES_PATH, 'broadside_app_example.conf.rb') } 11 | 12 | before(:each) do 13 | stub_const('Broadside::USER_CONFIG_FILE', system_config_path) 14 | binding.eval(File.read(app_config_path)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/ecs_shared_contexts.rb: -------------------------------------------------------------------------------- 1 | shared_context 'ecs stubs' do 2 | let(:api_request_log) { [] } 3 | let(:api_request_methods) { api_request_log.map(&:keys).flatten } 4 | let(:ecs_stub) { build_stub_aws_client(Aws::ECS::Client, api_request_log) } 5 | let(:ec2_stub) { build_stub_aws_client(Aws::EC2::Client, api_request_log) } 6 | 7 | before(:each) do 8 | Broadside::EcsManager.instance_variable_set(:@ecs_client, ecs_stub) 9 | Broadside::EcsManager.instance_variable_set(:@ec2_client, ec2_stub) 10 | end 11 | end 12 | 13 | shared_context 'with a running service' do 14 | include_context 'ecs stubs' 15 | 16 | let(:deployed_scale) { 2 } 17 | let(:stub_service_response) do 18 | { 19 | services: [ 20 | { 21 | desired_count: deployed_scale, 22 | running_count: deployed_scale, 23 | service_name: test_target_name.to_s, 24 | service_arn: "#{arn}:service/#{test_target_name}", 25 | deployments: [{ desired_count: deployed_scale, running_count: deployed_scale }] 26 | } 27 | ], 28 | failures: [] 29 | } 30 | end 31 | 32 | before(:each) do 33 | ecs_stub.stub_responses(:describe_services, stub_service_response) 34 | end 35 | end 36 | 37 | shared_context 'with a task_definition' do 38 | include_context 'ecs stubs' 39 | 40 | let(:task_definition_arn) { "#{arn}:task-definition/#{test_target_name}:1" } 41 | let(:stub_task_definition_response) { { task_definition_arns: [task_definition_arn] } } 42 | let(:stub_describe_task_definition_response) do 43 | { 44 | task_definition: { 45 | task_definition_arn: task_definition_arn, 46 | container_definitions: [ 47 | { 48 | name: family 49 | } 50 | ], 51 | family: family 52 | } 53 | } 54 | end 55 | 56 | before(:each) do 57 | ecs_stub.stub_responses(:list_task_definitions, stub_task_definition_response) 58 | ecs_stub.stub_responses(:describe_task_definition, stub_describe_task_definition_response) 59 | end 60 | end 61 | --------------------------------------------------------------------------------