├── .gitignore ├── .pylintrc ├── HACKING.md ├── LICENSE ├── README.md ├── evil-minions ├── evilminions ├── __init__.py ├── hydra.py ├── hydrahead.py ├── proxy.py ├── utils.py └── vampire.py ├── override.conf └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | lsync.conf 2 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Profiled execution. 4 | profile=no 5 | 6 | # Add files or directories to the blacklist. They should be base names, not 7 | # paths. 8 | ignore=CVS 9 | 10 | # Pickle collected data for later comparisons. 11 | persistent=yes 12 | 13 | # Use multiple processes to speed up Pylint. 14 | # Don't bump this values on PyLint 1.4.0 - Know bug that ignores the passed --rcfile 15 | jobs=1 16 | 17 | # Allow loading of arbitrary C extensions. Extensions are imported into the 18 | # active Python interpreter and may run arbitrary code. 19 | unsafe-load-any-extension=no 20 | 21 | # A comma-separated list of package or module names from where C extensions may 22 | # be loaded. Extensions are loading into the active Python interpreter and may 23 | # run arbitrary code 24 | extension-pkg-whitelist= 25 | 26 | # Fileperms Lint Plugin Settings 27 | fileperms-default=0644 28 | 29 | # Py3 Modernize PyLint Plugin Settings 30 | modernize-nofix = libmodernize.fixes.fix_dict_six 31 | 32 | # Minimum Python Version To Enforce 33 | minimum-python-version = 2.6 34 | 35 | [MESSAGES CONTROL] 36 | # Only show warnings with the listed confidence levels. Leave empty to show 37 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 38 | confidence= 39 | 40 | # Enable the message, report, category or checker with the given id(s). You can 41 | # either give multiple identifier separated by comma (,) or put this option 42 | # multiple time. See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | disable=W0142, 55 | C0330, 56 | I0011, 57 | I0012, 58 | W1202, 59 | E8402, 60 | E8731 61 | # E8121, 62 | # E8122, 63 | # E8123, 64 | # E8124, 65 | # E8125, 66 | # E8126, 67 | # E8127, 68 | # E8128 69 | 70 | # Disabled Checks 71 | # 72 | # W0142 (star-args) 73 | # W1202 (logging-format-interpolation) Use % formatting in logging functions but pass the % parameters as arguments 74 | # E812* All PEP8 E12* 75 | # E8402 module level import not at top of file 76 | # E8501 PEP8 line too long 77 | # E8731 do not assign a lambda expression, use a def 78 | # C0330 (bad-continuation) 79 | # I0011 (locally-disabling) 80 | # I0012 (locally-enabling) 81 | # W1202 (logging-format-interpolation) 82 | 83 | 84 | [REPORTS] 85 | # Set the output format. Available formats are text, parseable, colorized, msvs 86 | # (visual studio) and html. You can also give a reporter class, eg 87 | # mypackage.mymodule.MyReporterClass. 88 | output-format=text 89 | 90 | # Put messages in a separate file for each module / package specified on the 91 | # command line instead of printing them on stdout. Reports (if any) will be 92 | # written in a file name "pylint_global.[txt|html]". 93 | files-output=no 94 | 95 | # Tells whether to display a full report or only the messages 96 | reports=yes 97 | 98 | # Python expression which should return a note less than 10 (10 is the highest 99 | # note). You have access to the variables errors warning, statement which 100 | # respectively contain the number of errors / warnings messages and the total 101 | # number of statements analyzed. This is used by the global evaluation report 102 | # (RP0004). 103 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 104 | 105 | # Add a comment according to your evaluation note. This is used by the global 106 | # evaluation report (RP0004). 107 | comment=no 108 | 109 | # Template used to display messages. This is a python new-style format string 110 | # used to format the message information. See doc for all details 111 | #msg-template= 112 | 113 | 114 | [VARIABLES] 115 | # Tells whether we should check for unused import in __init__ files. 116 | init-import=no 117 | 118 | # A regular expression matching the name of dummy variables (i.e. expectedly 119 | # not used). 120 | dummy-variables-rgx=_|dummy 121 | 122 | # List of additional names supposed to be defined in builtins. Remember that 123 | # you should avoid to define new builtins when possible. 124 | additional-builtins=__opts__, 125 | __utils__, 126 | __salt__, 127 | __pillar__, 128 | __grains__, 129 | __context__, 130 | __ret__, 131 | __env__, 132 | __low__, 133 | __states__, 134 | __lowstate__, 135 | __running__, 136 | __active_provider_name__, 137 | __master_opts__, 138 | __jid_event__, 139 | __instance_id__, 140 | __salt_system_encoding__, 141 | __proxy__ 142 | 143 | # List of strings which can identify a callback function by name. A callback 144 | # name must start or end with one of those strings. 145 | callbacks=cb_,_cb 146 | 147 | 148 | [FORMAT] 149 | # Maximum number of characters on a single line. 150 | max-line-length=120 151 | 152 | # Regexp for a line that is allowed to be longer than the limit. 153 | ignore-long-lines=^\s*(# )??$ 154 | 155 | # Allow the body of an if to be on the same line as the test if there is no 156 | # else. 157 | single-line-if-stmt=no 158 | 159 | # List of optional constructs for which whitespace checking is disabled 160 | no-space-check=trailing-comma,dict-separator 161 | 162 | # Maximum number of lines in a module 163 | max-module-lines=3000 164 | 165 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 166 | # tab). 167 | indent-string=' ' 168 | 169 | # Number of spaces of indent required inside a hanging or continued line. 170 | indent-after-paren=4 171 | 172 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 173 | expected-line-ending-format=LF 174 | 175 | [LOGGING] 176 | # Logging modules to check that the string format arguments are in logging 177 | # function parameter format 178 | #logging-modules=logging 179 | # 180 | # Disabled because it not only complains about %s arguments but it also 181 | # complains about {0} arguments 182 | logging-modules= 183 | 184 | 185 | [TYPECHECK] 186 | # Tells whether missing members accessed in mixin class should be ignored. A 187 | # mixin class is detected if its name ends with "mixin" (case insensitive). 188 | ignore-mixin-members=yes 189 | 190 | # List of module names for which member attributes should not be checked 191 | # (useful for modules/projects where namespaces are manipulated during runtime 192 | # and thus existing member attributes cannot be deduced by static analysis 193 | ignored-modules= 194 | 195 | # List of classes names for which member attributes should not be checked 196 | # (useful for classes with attributes dynamically set). 197 | ignored-classes=SQLObject 198 | 199 | # When zope mode is activated, add a predefined set of Zope acquired attributes 200 | # to generated-members. 201 | zope=no 202 | 203 | # List of members which are set dynamically and missed by pylint inference 204 | # system, and so shouldn't trigger E0201 when accessed. Python regular 205 | # expressions are accepted. 206 | generated-members=REQUEST,acl_users,aq_parent 207 | 208 | 209 | [MISCELLANEOUS] 210 | # List of note tags to take in consideration, separated by a comma. 211 | notes=FIXME,FIX,XXX,TODO 212 | 213 | 214 | [SIMILARITIES] 215 | # Minimum lines number of a similarity. 216 | min-similarity-lines=4 217 | 218 | # Ignore comments when computing similarities. 219 | ignore-comments=yes 220 | 221 | # Ignore docstrings when computing similarities. 222 | ignore-docstrings=yes 223 | 224 | # Ignore imports when computing similarities. 225 | ignore-imports=no 226 | 227 | 228 | [BASIC] 229 | # Required attributes for module, separated by a comma 230 | required-attributes= 231 | 232 | # List of builtins function names that should not be used, separated by a comma 233 | bad-functions=map,filter,apply,input 234 | 235 | # Good variable names which should always be accepted, separated by a comma 236 | good-names=i,j,k,ex,Run,_,log 237 | 238 | # Bad variable names which should always be refused, separated by a comma 239 | bad-names=foo,bar,baz,toto,tutu,tata 240 | 241 | # Colon-delimited sets of names that determine each other's naming style when 242 | # the name regexes allow several styles. 243 | name-group= 244 | 245 | # Include a hint for the correct naming format with invalid-name 246 | include-naming-hint=no 247 | 248 | # Regular expression matching correct function names 249 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 250 | 251 | # Naming hint for function names 252 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 253 | 254 | # Regular expression matching correct variable names 255 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 256 | 257 | # Naming hint for variable names 258 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 259 | 260 | # Regular expression matching correct constant names 261 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 262 | 263 | # Naming hint for constant names 264 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 265 | 266 | # Regular expression matching correct attribute names 267 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 268 | 269 | # Naming hint for attribute names 270 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 271 | 272 | # Regular expression matching correct argument names 273 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 274 | 275 | # Naming hint for argument names 276 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 277 | 278 | # Regular expression matching correct class attribute names 279 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 280 | 281 | # Naming hint for class attribute names 282 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 283 | 284 | # Regular expression matching correct inline iteration names 285 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 286 | 287 | # Naming hint for inline iteration names 288 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 289 | 290 | # Regular expression matching correct class names 291 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 292 | 293 | # Naming hint for class names 294 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 295 | 296 | # Regular expression matching correct module names 297 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 298 | 299 | # Naming hint for module names 300 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 301 | 302 | # Regular expression matching correct method names 303 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 304 | 305 | # Naming hint for method names 306 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 307 | 308 | # Regular expression which should only match function or class names that do 309 | # not require a docstring. 310 | no-docstring-rgx=__.*__ 311 | 312 | # Minimum line length for functions/classes that require docstrings, shorter 313 | # ones are exempt. 314 | docstring-min-length=-1 315 | 316 | 317 | [SPELLING] 318 | # Spelling dictionary name. Available dictionaries: none. To make it working 319 | # install python-enchant package. 320 | spelling-dict= 321 | 322 | # List of comma separated words that should not be checked. 323 | spelling-ignore-words= 324 | 325 | # A path to a file that contains private dictionary; one word per line. 326 | spelling-private-dict-file= 327 | 328 | # Tells whether to store unknown words to indicated private dictionary in 329 | # --spelling-private-dict-file option instead of raising a message. 330 | spelling-store-unknown-words=no 331 | 332 | 333 | [CLASSES] 334 | # List of interface methods to ignore, separated by a comma. This is used for 335 | # instance to not check methods defines in Zope's Interface base class. 336 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 337 | 338 | # List of method names used to declare (i.e. assign) instance attributes. 339 | defining-attr-methods=__init__,__new__,setUp 340 | 341 | # List of valid names for the first argument in a class method. 342 | valid-classmethod-first-arg=cls 343 | 344 | # List of valid names for the first argument in a metaclass class method. 345 | valid-metaclass-classmethod-first-arg=mcs 346 | 347 | # List of member names, which should be excluded from the protected access 348 | # warning. 349 | exclude-protected=_asdict,_fields,_replace,_source,_make 350 | 351 | 352 | [IMPORTS] 353 | # Deprecated modules which should not be used, separated by a comma 354 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 355 | 356 | # Create a graph of every (i.e. internal and external) dependencies in the 357 | # given file (report RP0402 must not be disabled) 358 | import-graph= 359 | 360 | # Create a graph of external dependencies in the given file (report RP0402 must 361 | # not be disabled) 362 | ext-import-graph= 363 | 364 | # Create a graph of internal dependencies in the given file (report RP0402 must 365 | # not be disabled) 366 | int-import-graph= 367 | 368 | 369 | [DESIGN] 370 | # Maximum number of arguments for function / method 371 | max-args=35 372 | 373 | # Argument names that match this expression will be ignored. Default to name 374 | # with leading underscore 375 | ignored-argument-names=_.* 376 | 377 | # Maximum number of locals for function / method body 378 | max-locals=40 379 | 380 | # Maximum number of return / yield for function / method body 381 | max-returns=6 382 | 383 | # Maximum number of branch for function / method body 384 | # We create a lot of branches in salt, 4x the default value 385 | max-branches=48 386 | 387 | # Maximum number of statements in function / method body 388 | max-statements=100 389 | 390 | # Maximum number of parents for a class (see R0901). 391 | max-parents=7 392 | 393 | # Maximum number of attributes for a class (see R0902). 394 | max-attributes=7 395 | 396 | # Minimum number of public methods for a class (see R0903). 397 | min-public-methods=2 398 | 399 | # Maximum number of public methods for a class (see R0904). 400 | max-public-methods=20 401 | 402 | 403 | [EXCEPTIONS] 404 | 405 | # Exceptions that will emit a warning when being caught. Defaults to 406 | # "Exception" 407 | overgeneral-exceptions=Exception 408 | -------------------------------------------------------------------------------- /HACKING.md: -------------------------------------------------------------------------------- 1 | # Development instructions 2 | 3 | ## Update the package in openSUSE Build Service 4 | 5 | ```bash 6 | osc rm evil-minions-*.tar.xz # remove old tarball 7 | osc service disabledrun # update sources 8 | osc build SLE_15_SP1 # local build dry run 9 | osc vc -m "description of changes" # update changelog 10 | osc add evil-minions-*.tar.xz # track new tarball 11 | osc commit # hey ho let's go! 12 | ``` 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 SUSE LLC 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following disclaimer 12 | in the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of SUSE LLC nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## evil-minions 2 | 3 | ![Evil Minions from the movie Despicable Me 2](https://vignette3.wikia.nocookie.net/despicableme/images/5/52/Screenshot_2016-02-10-01-09-16.jpg/revision/latest?cb=20161028002525) 4 | 5 | `evil-minions` is a load generator for [Salt](https://github.com/saltstack/salt). It is used at SUSE for Salt, [Uyuni](https://www.uyuni-project.org/) and [SUSE Manager](https://www.suse.com/products/suse-manager/) scalability testing. 6 | 7 | ### Intro 8 | 9 | `evil-minions` is a program that monkey-patches `salt-minion` in order to spawn a number of additional simulated "evil" minions alongside the original one. 10 | 11 | Evil minions will mimic the original by responding to commands from the Salt master by copying and minimally adapting responses from the original one. Responses are expected to be identical apart from a few details like the minion ID (it needs to be different in order for the Master to treat them as separate). 12 | 13 | Evil minions are lightweight - hundreds can run on a modern x86 core. 14 | 15 | ### Installation 16 | 17 | [sumaform](https://github.com/moio/sumaform) users: follow [sumaform-specific instructions](https://github.com/moio/sumaform/blob/master/README_ADVANCED.md#evil-minions-load-generator) 18 | 19 | SUSE distros: install via RPM package 20 | ``` 21 | # replace openSUSE_Leap_15.0 below with a different distribution if needed 22 | zypper addrepo https://download.opensuse.org/repositories/systemsmanagement:/sumaform:/tools/openSUSE_Leap_15.0/systemsmanagement:sumaform:tools.repo 23 | zypper install evil-minions 24 | ``` 25 | 26 | Other distros: install via pip 27 | ``` 28 | git checkout https://github.com/moio/evil-minions.git 29 | cd evil-minions 30 | pip install -r requirements.txt 31 | 32 | mkdir -p /etc/systemd/system/salt-minion.service.d 33 | cp override.conf /etc/systemd/system/salt-minion.service.d 34 | ``` 35 | 36 | ### Usage 37 | 38 | Starting salt-minion will automatically spawn any configured evil minions (10 by default). `systemd` is used in order to work correctly in case the minion service is restarted. 39 | 40 | Every time the original minion receives and responds to a command, the command itself and the responses are "learned" by evil minions which will subsequently be able to respond to the same command. In practice, issuing the same command to all minions will work, eg. 41 | 42 | `salt '*' test.ping` 43 | 44 | evil minions will wait if presented with a command they have not learnt yet from the original minion. 45 | 46 | Several parameters can be changed via commandline options in `/etc/systemd/system/salt-minion.service.d/override.conf`. 47 | 48 | #### `--count` 49 | 50 | The number of evil minions can can be changed via the `--count` commandline switch. 51 | 52 | Simulating minions is not very resource intensive: 53 | - each evil-minon consumes ~2 MB of main memory, so thousands can be fit on a modern server 54 | - ~1000 evil-minions can be simulated at full speed (or near full speed) on one modern x86_64 core (circa 2018) 55 | - this means that a hypervisor vCPU, typically mapped to one HyperThread, will be able to support hundreds of evil minions 56 | 57 | `evil-minions` combines [multiprocessing](https://docs.python.org/3.4/library/multiprocessing.html) and [Tornado](https://www.tornadoweb.org/en/stable/) to fully utilize available CPUs. 58 | 59 | #### `--slowdown-factor` 60 | 61 | By default, evil minions will respond as fast as possible, which might not be appropriate depending on the objectives of your simulation. To reproduce delays observed by the original minion from which the dump was taken, use the `--slowdown-factor` switch: 62 | - `0.0`, the default value, makes evil minions respond as fast as possible 63 | - `1.0` makes `evil-minion` introduce delays to match the response times of the original minion 64 | - `2.0` makes `evil-minion` react twice as slow as the original minion 65 | - `0.5` makes `evil-minion` react twice as fast as the original minion 66 | 67 | #### `--random-slowdown-factor` 68 | 69 | By setting the `random-slowdown-factor` value, the evil minions will respond using the user defined `slowdown-factor` value plus a random extra delay expressed as a percentage of the original time, for example: 70 | - with a `slowdown-factor` of `1.0` and a `random-slowdown-factor` of `0.2`, the evil-minions will reply with a slowdown-factor in the range of `1.0` and `1.2`. This value it's calculated per-evil-minion and is constant throughout the whole execution. 71 | 72 | The default value of `--random-slowdown-factor` parameter is 0. 73 | For more information, see `--slowdown-factor` section above. 74 | 75 | #### Other parameters 76 | 77 | Please, use `evil-minions --help` to get the detailed list. 78 | 79 | ### Known limitations 80 | - only the ZeroMQ transport is supported 81 | - only `*` and exact minion id targeting are supported at the moment 82 | - some Salt features are not faithfully reproduced: `mine` events, `beacon` events, and `state.sls`'s `concurrent` option 83 | - some Uyuni features are not currently supported: Action Chains 84 | -------------------------------------------------------------------------------- /evil-minions: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ''' 3 | Script to start a salt-minion that will act as many minions (from salt-master's 4 | point of view). 5 | Every response sent by the minion will be replicated and sent out separately 6 | to the master with different minion ids, as if multiple minions were connected 7 | and responding in the same way. 8 | ''' 9 | 10 | import argparse 11 | import distutils.spawn 12 | import logging 13 | from multiprocessing import Process, cpu_count, Semaphore 14 | import sys 15 | 16 | import salt.log 17 | 18 | from evilminions.proxy import start_proxy 19 | from evilminions.vampire import Vampire 20 | from evilminions.hydra import Hydra 21 | 22 | def main(): 23 | # parse commandline switches 24 | parser = argparse.ArgumentParser(description='Starts a salt-minion and many evil minions that mimic it.') 25 | parser.add_argument('--count', dest='count', type=int, default=10, 26 | help='number of evil minions (default: 10)') 27 | parser.add_argument('--id-prefix', dest='prefix', default='evil', 28 | help='minion id prefix for evil minions. (default: evil)') 29 | parser.add_argument('--id-offset', dest='offset', type=int, default=0, 30 | help='minion id counter offset for evil minions. (default: 0)') 31 | parser.add_argument('--ramp-up-delay', dest='ramp_up_delay', type=int, default=0, 32 | help='time between evil minion starts in seconds (default: 0)') 33 | parser.add_argument('--slowdown-factor', dest='slowdown_factor', type=float, default=0.0, 34 | help='slow down evil minions (default is 0.0 "as fast as possible", ' + 35 | '1.0 is "as fast as the original")') 36 | parser.add_argument('--random-slowdown-factor', dest='random_slowdown_factor', type=float, default=0.0, 37 | help='a random extra delay expressed as a percentage of the original time (default: 0.0)') 38 | parser.add_argument('--processes', dest='processes', type=int, default=cpu_count(), 39 | help='number of concurrent processes (default is the CPU count: %d)' % cpu_count()) 40 | parser.add_argument('--keysize', dest='keysize', type=int, default=2048, 41 | help='size of Salt keys generated for each evil minion (default: 2048)') 42 | args, remaining_args = parser.parse_known_args() 43 | 44 | # set up logging 45 | salt.log.setup_console_logger(log_level='debug') 46 | log = logging.getLogger(__name__) 47 | log.debug("Starting evil-minions, setting up infrastructure...") 48 | 49 | # set up Hydras, one per process. Hydras are so called because they have 50 | # many HydraHeads, each HydraHead running one evil-minion 51 | semaphore = Semaphore(0) 52 | chunks = minion_chunks(args.count, args.processes) 53 | hydras = [Process(target=Hydra(i).start, kwargs={ 54 | 'hydra_count': len(chunks), 55 | 'chunk': chunk, 56 | 'prefix': args.prefix, 57 | 'offset': args.offset, 58 | 'ramp_up_delay': args.ramp_up_delay, 59 | 'slowdown_factor': args.slowdown_factor, 60 | 'random_slowdown_factor': args.random_slowdown_factor, 61 | 'keysize': args.keysize, 62 | 'semaphore': semaphore 63 | }) for i, chunk in enumerate(chunks)] 64 | 65 | for hydra in hydras: 66 | hydra.start() 67 | 68 | for hydra in hydras: 69 | semaphore.acquire() 70 | 71 | # set up Vampire, to intercept messages from the original minion and send them to Proxy 72 | vampire = Vampire() 73 | vampire.attach() 74 | 75 | # set up Proxy, to route messages from Vampire to Hydras 76 | proxy = Process(target=start_proxy, kwargs={'semaphore': semaphore}) 77 | proxy.start() 78 | semaphore.acquire() 79 | 80 | # restore salt-minion's orignal command line args and launch it 81 | sys.argv = [sys.argv[0]] + remaining_args 82 | salt_minion_executable = distutils.spawn.find_executable('salt-minion') 83 | exec(compile(open(salt_minion_executable).read(), salt_minion_executable, 'exec')) 84 | 85 | def minion_chunks(count, processes): 86 | '''Returns chunks of minion indexes, per process (eg. 4 minions and 3 processes: [[0, 1], [2], [3]])''' 87 | minions_per_process = count // processes 88 | rest = count % processes 89 | lengths = [minions_per_process + 1] * rest + [minions_per_process] * (processes - rest) 90 | starts = [sum(lengths[:i]) for i in range(processes)] 91 | 92 | indexes = list(range(count)) 93 | 94 | return [indexes[starts[i]:starts[i] + lengths[i]] for i in range(processes)] 95 | 96 | if __name__ == "__main__": 97 | main() 98 | -------------------------------------------------------------------------------- /evilminions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uyuni-project/evil-minions/4270de1ae72d260753f732b759c702a25cc58be4/evilminions/__init__.py -------------------------------------------------------------------------------- /evilminions/hydra.py: -------------------------------------------------------------------------------- 1 | '''Replicates the behavior of a minion many times''' 2 | 3 | import logging 4 | 5 | import tornado.gen 6 | import zmq 7 | 8 | import salt.config 9 | import salt.loader 10 | import salt.payload 11 | 12 | import random 13 | 14 | from evilminions.hydrahead import HydraHead 15 | from evilminions.utils import fun_call_id 16 | 17 | # HACK: turn the trace function into a no-op 18 | # this almost doubles evil-minion's performance 19 | salt.log.mixins.LoggingTraceMixIn.trace = lambda self, msg, *args, **kwargs: None 20 | 21 | class Hydra(object): 22 | '''Spawns HydraHeads, listens for messages coming from the Vampire.''' 23 | def __init__(self, hydra_number): 24 | self.hydra_number = hydra_number 25 | self.current_reactions = {} 26 | self.reactions = {} 27 | self.last_time = None 28 | self.serial = salt.payload.Serial({}) 29 | self.log = None 30 | 31 | def start(self, hydra_count, chunk, prefix, offset, 32 | ramp_up_delay, slowdown_factor, random_slowdown_factor, keysize, semaphore): 33 | '''Per-process entry point (one per Hydra)''' 34 | 35 | # set up logging 36 | self.log = logging.getLogger(__name__) 37 | self.log.debug("Starting Hydra on: %s", chunk) 38 | 39 | # set up the IO loop 40 | zmq.eventloop.ioloop.install() 41 | io_loop = zmq.eventloop.ioloop.ZMQIOLoop.current() 42 | 43 | # set up ZeroMQ connection to the Proxy 44 | context = zmq.Context() 45 | socket = context.socket(zmq.SUB) 46 | socket.connect('ipc:///tmp/evil-minions-pub.ipc') 47 | socket.setsockopt_string(zmq.SUBSCRIBE, "") 48 | stream = zmq.eventloop.zmqstream.ZMQStream(socket, io_loop) 49 | stream.on_recv(self.update_reactions) 50 | 51 | # Load original settings and grains 52 | opts = salt.config.minion_config('/etc/salt/minion') 53 | grains = salt.loader.grains(opts) 54 | 55 | # set up heads! 56 | first_head_number = chunk[0] if chunk else 0 57 | delays = [ramp_up_delay * ((head_number - first_head_number) * hydra_count + self.hydra_number) 58 | for head_number in chunk] 59 | offset_head_numbers = [head_number + offset for head_number in chunk] 60 | 61 | slowdown_factors = self._resolve_slowdown_factors(slowdown_factor, random_slowdown_factor, len(chunk)) 62 | heads = [HydraHead('{}-{}'.format(prefix, offset_head_numbers[i]), io_loop, keysize, opts, grains, delays[i], 63 | slowdown_factors[i], self.reactions) for i in range(len(chunk))] 64 | 65 | # start heads! 66 | for head in heads: 67 | io_loop.spawn_callback(head.start) 68 | 69 | semaphore.release() 70 | 71 | io_loop.start() 72 | 73 | def _resolve_slowdown_factors(self, slowdown_factor, random_slowdown_factor, heads_count): 74 | random.seed(self.hydra_number) 75 | return [(slowdown_factor + random.randint(0, random_slowdown_factor * 100) / 100.0) for i in range(heads_count)] 76 | 77 | @tornado.gen.coroutine 78 | def update_reactions(self, packed_events): 79 | '''Called whenever a message from Vampire is received. 80 | Updates the internal self.reactions hash to contain reactions that will be mimicked''' 81 | for packed_event in packed_events: 82 | event = self.serial.loads(packed_event) 83 | 84 | load = event['load'] 85 | socket = event['header']['socket'] 86 | current_time = event['header']['time'] 87 | self.last_time = self.last_time or current_time 88 | 89 | # if this is the very first PUB message we receive, store all reactions so far 90 | # as minion start reactions (fun_call_id(None, None)) 91 | if socket == 'PUB' and self.reactions == {}: 92 | initial_reactions = [reaction for pid, reactions in self.current_reactions.items() 93 | for reaction in reactions] 94 | self.reactions[fun_call_id(None, None)] = [initial_reactions] 95 | self.last_time = current_time 96 | 97 | self.log.debug("Hydra #{} learned initial reaction list ({} reactions)".format(self.hydra_number, 98 | len(initial_reactions))) 99 | for reaction in initial_reactions: 100 | cmd = reaction['load']['cmd'] 101 | pid = "pid={}".format(reaction['header']['pid']) 102 | self.log.debug(" - {}({})".format(cmd, pid)) 103 | 104 | if socket == 'REQ': 105 | if load['cmd'] == '_auth': 106 | continue 107 | pid = event['header']['pid'] 108 | 109 | self.current_reactions[pid] = self.current_reactions.get(pid, []) + [event] 110 | 111 | if load['cmd'] == '_return': 112 | call_id = fun_call_id(load['fun'], load['fun_args']) 113 | 114 | if call_id not in self.reactions: 115 | self.reactions[call_id] = [] 116 | 117 | self.reactions[call_id] = self.reactions.get(call_id, []) + [self.current_reactions[pid]] 118 | self.log.debug("Hydra #{} learned reaction list #{} ({} reactions) for call: {}".format( 119 | self.hydra_number, 120 | len(self.reactions[call_id]), 121 | len(self.current_reactions[pid]), 122 | call_id)) 123 | for reaction in self.current_reactions[pid]: 124 | load = reaction['load'] 125 | cmd = load['cmd'] 126 | path = "path={} ".format(load['path']) if 'path' in load else '' 127 | pid = "pid={}".format(reaction['header']['pid']) 128 | self.log.debug(" - {}({}{})".format(cmd, path, pid)) 129 | 130 | self.current_reactions[pid] = [] 131 | 132 | event['header']['duration'] = current_time - self.last_time 133 | self.last_time = current_time 134 | -------------------------------------------------------------------------------- /evilminions/hydrahead.py: -------------------------------------------------------------------------------- 1 | '''Implements one evil minion''' 2 | 3 | from distutils.dir_util import mkpath 4 | import hashlib 5 | import logging 6 | import os 7 | import socket 8 | from uuid import UUID, uuid5 9 | 10 | import tornado.gen 11 | import zmq 12 | 13 | import salt.transport.client 14 | 15 | from evilminions.utils import replace_recursively, fun_call_id 16 | 17 | class HydraHead(object): 18 | '''Replicates the behavior of a minion''' 19 | def __init__(self, minion_id, io_loop, keysize, opts, grains, ramp_up_delay, slowdown_factor, reactions): 20 | self.minion_id = minion_id 21 | self.io_loop = io_loop 22 | self.ramp_up_delay = ramp_up_delay 23 | self.slowdown_factor = slowdown_factor 24 | self.reactions = reactions 25 | self.current_time = 0 26 | 27 | self.current_jobs = [] 28 | 29 | # Compute replacement dict 30 | self.replacements = { 31 | grains['id']: minion_id, 32 | grains['machine_id']: hashlib.md5(minion_id.encode('utf-8')).hexdigest(), 33 | grains['uuid']: str(uuid5(UUID('d77ed710-0deb-47d9-b053-f2fa2ef78106'), minion_id)) 34 | } 35 | 36 | # Override ID settings 37 | self.opts = opts.copy() 38 | self.opts['id'] = minion_id 39 | 40 | # Override calculated settings 41 | self.opts['master_uri'] = 'tcp://%s:4506' % self.opts['master'] 42 | self.opts['master_ip'] = socket.gethostbyname(self.opts['master']) 43 | 44 | # Override directory settings 45 | pki_dir = '/tmp/%s' % minion_id 46 | mkpath(pki_dir) 47 | 48 | cache_dir = os.path.join(pki_dir, 'cache', 'minion') 49 | if not os.path.exists(cache_dir): 50 | os.makedirs(cache_dir) 51 | 52 | sock_dir = os.path.join(pki_dir, 'sock', 'minion') 53 | if not os.path.exists(sock_dir): 54 | os.makedirs(sock_dir) 55 | 56 | self.opts['pki_dir'] = pki_dir 57 | self.opts['sock_dir'] = sock_dir 58 | self.opts['cache_dir'] = cache_dir 59 | 60 | # Override performance settings 61 | self.opts['keysize'] = keysize 62 | self.opts['acceptance_wait_time'] = 10 63 | self.opts['acceptance_wait_time_max'] = 0 64 | self.opts['auth_tries'] = 600 65 | self.opts['zmq_filtering'] = False 66 | self.opts['tcp_keepalive'] = True 67 | self.opts['tcp_keepalive_idle'] = 300 68 | self.opts['tcp_keepalive_cnt'] = -1 69 | self.opts['tcp_keepalive_intvl'] = -1 70 | self.opts['recon_max'] = 10000 71 | self.opts['recon_default'] = 1000 72 | self.opts['recon_randomize'] = True 73 | self.opts['ipv6'] = False 74 | self.opts['zmq_monitor'] = False 75 | self.opts['open_mode'] = False 76 | self.opts['verify_master_pubkey_sign'] = False 77 | self.opts['always_verify_signature'] = False 78 | 79 | @tornado.gen.coroutine 80 | def start(self): 81 | '''Opens ZeroMQ sockets, starts listening to PUB events and kicks off initial REQs''' 82 | self.log = logging.getLogger(__name__) 83 | yield tornado.gen.sleep(self.ramp_up_delay) 84 | self.log.info("HydraHead %s started", self.opts['id']) 85 | 86 | factory_kwargs = {'timeout': 60, 'safe': True, 'io_loop': self.io_loop} 87 | pub_channel = salt.transport.client.AsyncPubChannel.factory(self.opts, **factory_kwargs) 88 | self.tok = pub_channel.auth.gen_token(b'salt') 89 | yield pub_channel.connect() 90 | self.req_channel = salt.transport.client.AsyncReqChannel.factory(self.opts, **factory_kwargs) 91 | 92 | pub_channel.on_recv(self.mimic) 93 | yield self.mimic({'load': {'fun': None, 'arg': None, 'tgt': [self.minion_id], 94 | 'tgt_type': 'list', 'load': None, 'jid': None}}) 95 | 96 | @tornado.gen.coroutine 97 | def mimic(self, load): 98 | '''Finds appropriate reactions to a PUB message and dispatches them''' 99 | load = load['load'] 100 | fun = load['fun'] 101 | tgt = load['tgt'] 102 | tgt_type = load['tgt_type'] 103 | 104 | if tgt != self.minion_id and ( 105 | (tgt_type == 'glob' and tgt != '*') or 106 | (tgt_type == 'list' and self.minion_id not in tgt)): 107 | # ignore call that targets a different minion 108 | return 109 | 110 | # react in ad-hoc ways to some special calls 111 | if fun == 'test.ping': 112 | yield self.react_to_ping(load) 113 | elif fun == 'saltutil.find_job': 114 | yield self.react_to_find_job(load) 115 | elif fun == 'saltutil.running': 116 | yield self.react_to_running(load) 117 | else: 118 | # try to find a suitable reaction and use it 119 | call_id = fun_call_id(load['fun'], load['arg'] or []) 120 | 121 | reactions = self.get_reactions(call_id) 122 | while not reactions: 123 | self.log.debug("No known reaction for call: {}, sleeping 1 second and retrying".format(call_id)) 124 | yield tornado.gen.sleep(1) 125 | reactions = self.get_reactions(call_id) 126 | 127 | self.current_time = reactions[0]['header']['time'] 128 | yield self.react(load, reactions) 129 | 130 | def get_reactions(self, call_id): 131 | '''Returns reactions for the specified call_id''' 132 | reaction_sets = self.reactions.get(call_id) 133 | if not reaction_sets: 134 | return None 135 | 136 | # if multiple reactions were produced in different points in time, attempt to respect 137 | # historical order (pick the one which has the lowest timestamp after the last processed) 138 | future_reaction_sets = [s for s in reaction_sets if s[0]['header']['time'] >= self.current_time] 139 | if future_reaction_sets: 140 | return future_reaction_sets[0] 141 | 142 | # if there are reactions but none of them were recorded later than the last processed one, meaning 143 | # we are seeing an out-of-order request compared to the original ordering, let's be content and return 144 | # the last known one. Not optimal but hey, Hydras have no crystal balls 145 | return reaction_sets[-1] 146 | 147 | @tornado.gen.coroutine 148 | def react(self, load, original_reactions): 149 | '''Dispatches reactions in response to typical functions''' 150 | self.current_jobs.append(load) 151 | reactions = replace_recursively(self.replacements, original_reactions) 152 | 153 | self.current_jobs.append(load) 154 | for reaction in reactions: 155 | request = reaction['load'] 156 | if 'tok' in request: 157 | request['tok'] = self.tok 158 | if request['cmd'] == '_return' and request.get('fun') == load.get('fun'): 159 | request['jid'] = load['jid'] 160 | if 'metadata' in load: 161 | request['metadata']['suma-action-id'] = load['metadata'].get('suma-action-id') 162 | header = reaction['header'] 163 | duration = header['duration'] 164 | yield tornado.gen.sleep(duration * self.slowdown_factor) 165 | method = header['method'] 166 | kwargs = header['kwargs'] 167 | yield getattr(self.req_channel, method)(request, **kwargs) 168 | self.current_jobs.remove(load) 169 | 170 | @tornado.gen.coroutine 171 | def react_to_ping(self, load): 172 | '''Dispatches a reaction to a ping call''' 173 | request = { 174 | 'cmd': '_return', 175 | 'fun': load['fun'], 176 | 'fun_args': load['arg'], 177 | 'id': self.minion_id, 178 | 'jid': load['jid'], 179 | 'retcode': 0, 180 | 'return': True, 181 | 'success': True, 182 | } 183 | yield self.req_channel.send(request, timeout=60) 184 | 185 | @tornado.gen.coroutine 186 | def react_to_find_job(self, load): 187 | '''Dispatches a reaction to a find_job call''' 188 | jobs = [j for j in self.current_jobs if j['jid'] == load['arg'][0]] 189 | ret = dict(list(jobs[0].items()) + list({'pid': 1234}.items())) if jobs else {} 190 | 191 | request = { 192 | 'cmd': '_return', 193 | 'fun': load['fun'], 194 | 'fun_args': load['arg'], 195 | 'id': self.minion_id, 196 | 'jid': load['jid'], 197 | 'retcode': 0, 198 | 'return': ret, 199 | 'success': True, 200 | } 201 | yield self.req_channel.send(request, timeout=60) 202 | 203 | @tornado.gen.coroutine 204 | def react_to_running(self, load): 205 | '''Dispatches a reaction to a running call''' 206 | request = { 207 | 'cmd': '_return', 208 | 'fun': load['fun'], 209 | 'fun_args': load['arg'], 210 | 'id': self.minion_id, 211 | 'jid': load['jid'], 212 | 'retcode': 0, 213 | 'return': self.current_jobs, 214 | 'success': True, 215 | } 216 | yield self.req_channel.send(request, timeout=60) 217 | -------------------------------------------------------------------------------- /evilminions/proxy.py: -------------------------------------------------------------------------------- 1 | '''Relays ZeroMQ traffic between processes''' 2 | 3 | import logging 4 | import zmq 5 | 6 | def start_proxy(semaphore): 7 | '''Relays traffic from Vampire to Hydras.''' 8 | # set up logging 9 | log = logging.getLogger(__name__) 10 | 11 | # HACK: set up a PULL/PUB proxy 12 | # https://stackoverflow.com/questions/43129714/zeromq-xpub-xsub-serious-flaw 13 | log.debug("Starting proxy...") 14 | context = zmq.Context() 15 | xsub = context.socket(zmq.PULL) 16 | xsub.bind('ipc:///tmp/evil-minions-pull.ipc') 17 | xpub = context.socket(zmq.PUB) 18 | xpub.bind('ipc:///tmp/evil-minions-pub.ipc') 19 | log.debug("Proxy ready") 20 | semaphore.release() 21 | zmq.proxy(xpub, xsub) 22 | -------------------------------------------------------------------------------- /evilminions/utils.py: -------------------------------------------------------------------------------- 1 | '''Various utility functions''' 2 | 3 | def replace_recursively(replacements, dump): 4 | '''Replaces occurences of replacements.keys with corresponding values in a list/dict structure, recursively''' 5 | if isinstance(dump, list): 6 | return [replace_recursively(replacements, e) for e in dump] 7 | 8 | if isinstance(dump, dict): 9 | return {k: replace_recursively(replacements, v) for k, v in dump.items()} 10 | 11 | if isinstance(dump, str): 12 | try: 13 | result = dump 14 | for original, new in replacements.items(): 15 | result = result.replace(original, new) 16 | return result 17 | except UnicodeDecodeError: 18 | return dump 19 | 20 | if dump in replacements: 21 | return replacements[dump] 22 | 23 | return dump 24 | 25 | def fun_call_id(fun, args): 26 | '''Returns a hashable object that represents the call of a function, with actual parameters''' 27 | clean_args = [_zap_uyuni_specifics(_zap_kwarg(arg)) for arg in args or []] 28 | return (fun, _immutable(clean_args)) 29 | 30 | def _zap_kwarg(arg): 31 | '''Takes a list/dict stucture and returns a copy with '__kwarg__' keys removed''' 32 | if isinstance(arg, dict): 33 | return {k: v for k, v in arg.items() if k != '__kwarg__'} 34 | return arg 35 | 36 | def _zap_uyuni_specifics(data): 37 | '''Takes a list/dict stucture and returns a copy with Uyuni specific varying keys recursively removed''' 38 | if isinstance(data, dict): 39 | uyuni_repo = data.get('alias', '').startswith("susemanager:") 40 | if uyuni_repo: 41 | return {k: v for k, v in data.items() if k != 'token'} 42 | else: 43 | return {k: _zap_uyuni_specifics(v) for k, v in data.items()} 44 | if isinstance(data, list): 45 | return [_zap_uyuni_specifics(e) for e in data] 46 | return data 47 | 48 | def _immutable(data): 49 | '''Returns an immutable version of a list/dict stucture''' 50 | if isinstance(data, dict): 51 | return tuple((k, _immutable(v)) for k, v in sorted(data.items())) 52 | if isinstance(data, list): 53 | return tuple(_immutable(e) for e in data) 54 | return data 55 | -------------------------------------------------------------------------------- /evilminions/vampire.py: -------------------------------------------------------------------------------- 1 | '''Intercepts ZeroMQ traffic''' 2 | 3 | import logging 4 | import os 5 | import time 6 | 7 | import tornado.gen 8 | from tornado.ioloop import IOLoop 9 | import zmq 10 | 11 | from salt.payload import Serial 12 | from salt.transport.zeromq import AsyncZeroMQReqChannel 13 | from salt.transport.zeromq import AsyncZeroMQPubChannel 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | class Vampire(object): 18 | '''Intercepts traffic to and from the minion via monkey patching and sends it into the Proxy.''' 19 | 20 | def __init__(self): 21 | self.serial = Serial({}) 22 | pass 23 | 24 | def attach(self): 25 | '''Monkey-patches ZeroMQ core I/O class to capture flowing messages.''' 26 | AsyncZeroMQReqChannel.dump = self.dump 27 | AsyncZeroMQReqChannel._original_send = AsyncZeroMQReqChannel.send 28 | AsyncZeroMQReqChannel.send = _dumping_send 29 | AsyncZeroMQReqChannel._original_crypted_transfer_decode_dictentry = AsyncZeroMQReqChannel.crypted_transfer_decode_dictentry 30 | AsyncZeroMQReqChannel.crypted_transfer_decode_dictentry = _dumping_crypted_transfer_decode_dictentry 31 | 32 | AsyncZeroMQPubChannel.dump = self.dump 33 | AsyncZeroMQPubChannel._original_on_recv = AsyncZeroMQPubChannel.on_recv 34 | AsyncZeroMQPubChannel.on_recv = _dumping_on_recv 35 | 36 | def dump(self, load, socket, method, **kwargs): 37 | '''Dumps a ZeroMQ message to the Proxy''' 38 | 39 | header = { 40 | 'socket' : socket, 41 | 'time' : time.time(), 42 | 'pid' : os.getpid(), 43 | 'method': method, 44 | 'kwargs': kwargs, 45 | } 46 | event = { 47 | 'header' : header, 48 | 'load' : load, 49 | } 50 | 51 | try: 52 | context = zmq.Context() 53 | zsocket = context.socket(zmq.PUSH) 54 | zsocket.connect('ipc:///tmp/evil-minions-pull.ipc') 55 | io_loop = IOLoop.current() 56 | stream = zmq.eventloop.zmqstream.ZMQStream(zsocket, io_loop) 57 | stream.send(self.serial.dumps(event)) 58 | stream.flush() 59 | stream.close() 60 | except Exception as exc: 61 | log.error("Event: {}".format(event)) 62 | log.error("Unable to dump event: {}".format(exc)) 63 | 64 | @tornado.gen.coroutine 65 | def _dumping_send(self, load, **kwargs): 66 | '''Dumps a REQ ZeroMQ and sends it''' 67 | self.dump(load, 'REQ', 'send', **kwargs) 68 | ret = yield self._original_send(load, **kwargs) 69 | raise tornado.gen.Return(ret) 70 | 71 | @tornado.gen.coroutine 72 | def _dumping_crypted_transfer_decode_dictentry(self, load, **kwargs): 73 | '''Dumps a REQ crypted ZeroMQ message and sends it''' 74 | self.dump(load, 'REQ', 'crypted_transfer_decode_dictentry', **kwargs) 75 | ret = yield self._original_crypted_transfer_decode_dictentry(load, **kwargs) 76 | raise tornado.gen.Return(ret) 77 | 78 | def _dumping_on_recv(self, callback): 79 | '''Dumps a PUB ZeroMQ message then handles it''' 80 | def _logging_callback(load): 81 | self.dump(load, 'PUB', 'on_recv') 82 | callback(load) 83 | return self._original_on_recv(_logging_callback) 84 | -------------------------------------------------------------------------------- /override.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | 3 | # change the salt-minion executable with evil-minions 4 | # 5 | # for a full list of commandline switches use `evil-minions --help` 6 | # 7 | ExecStart= 8 | ExecStart=/usr/bin/evil-minions --count=10 --ramp-up-delay=0 --slowdown-factor=0.0 9 | 10 | # allow for more generous limits as evil-minion is descriptor-intensive 11 | LimitNOFILE=infinity 12 | TasksMax=infinity 13 | 14 | # kill subprocesses when stopping this service 15 | KillMode=control-group 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | salt 2 | msgpack 3 | pyzmq 4 | requests 5 | tornado 6 | --------------------------------------------------------------------------------