├── .gitignore ├── .travis.yml ├── Changes.md ├── Gemfile ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── Rakefile ├── ext ├── build_defaults.yaml ├── debian │ ├── changelog.erb │ ├── compat │ ├── control │ ├── copyright │ ├── mcollective-shell-agent.install │ ├── mcollective-shell-client.install │ ├── mcollective-shell-common.install │ └── rules ├── packaging.rake ├── project_data.yaml └── redhat │ └── mcollective-shell.spec.erb ├── lib └── mcollective │ ├── agent │ ├── shell.ddl │ ├── shell.rb │ └── shell │ │ └── job.rb │ └── application │ ├── shell.rb │ └── shell │ ├── prefix_stream_buf.rb │ └── watcher.rb └── spec ├── spec_helper.rb └── unit └── mcollective ├── agent ├── shell │ └── job_spec.rb └── shell_spec.rb └── application └── shell ├── prefix_stream_buf_spec.rb └── watcher_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | ext/packaging 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | bundler_args: --without development 4 | script: "bundle exec rake test SPEC_OPTS='--format documentation'" 5 | rvm: 6 | - 1.9.3 7 | - 2.0.0 8 | env: 9 | matrix: 10 | - MCOLLECTIVE_GEM_VERSION="~> 2.2.0" 11 | - MCOLLECTIVE_GEM_VERSION="~> 2.4.0" 12 | - MCOLLECTIVE_GEM_VERSION="~> 2.5.0" 13 | - MCOLLECTIVE_GEM_VERSION="~> 2.6.0" 14 | - MCOLLECTIVE_GEM_VERSION="~> 2.7.0" 15 | notifications: 16 | email: false 17 | -------------------------------------------------------------------------------- /Changes.md: -------------------------------------------------------------------------------- 1 | # 0.0.2 2 | 3 | Released 2015-03-12 4 | 5 | * Removed saucy cows from build targets (MCOP-271) 6 | * Add explicit `require 'pathname'` to fix agent in some configuration (PR#4) 7 | 8 | # 0.0.1 9 | 10 | Released 2014-07-17 11 | 12 | * Initial public release 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | #!ruby 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'rake' 6 | gem 'rspec', '~> 2.11.0' 7 | gem 'mocha', '~> 0.10.0' 8 | gem 'mcollective-test' 9 | end 10 | 11 | mcollective_version = ENV['MCOLLECTIVE_GEM_VERSION'] 12 | 13 | if mcollective_version 14 | gem 'mcollective-client', mcollective_version, :require => false 15 | else 16 | gem 'mcollective-client', :require => false 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | mcollective-shell-agent - MCollective agent for shell command supervision. 2 | Copyright 2014 Puppet Labs Inc 3 | 4 | This product includes software developed at 5 | Puppet Labs Inc (http://puppetlabs.com/). 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this software except in compliance with the License. 9 | You may obtain a copy of the License at: 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shell agent 2 | 3 | ## Deprecation Notice 4 | 5 | This repository holds legacy code related to The Marionette Collective project. That project has been deprecated by Puppet Inc and the code donated to the Choria Project. 6 | 7 | Please review the [Choria Project Website](https://choria.io) and specifically the [MCollective Deprecation Notice](https://choria.io/mcollective) for further information and details about the future of the MCollective project. 8 | 9 | ## Overview 10 | 11 | The shell agent allows you to start and manage shell commands via 12 | mcollective. 13 | 14 | It allows the running of long-running processes with a mechanism to check in 15 | on the output from these long-running processes, which is independent of the 16 | mcollective daemon process (the daemon can be restarted without interrupting 17 | the processes) 18 | 19 | To use this agent you need at least: 20 | 21 | * MCollective 2.2.4 22 | * Ruby 1.9 (for Process#spawn) 23 | 24 | Please report any errors or make feature requests in the [MCOP jira project][MCOP] 25 | 26 | Please note: we do not recommend this agent as a way of building out your 27 | automation, for that you're still better off writing your own tailored 28 | [agents][writing-agents] that fit your use case. This agent is targeted 29 | at the ad-hoc needs that people occasionally have. 30 | 31 | [writing-agents]: http://docs.puppetlabs.com/mcollective/simplerpc/agents.html 32 | [MCOP]: http://tickets.puppetlabs.com/browse/MCOP 33 | 34 | ## Installation 35 | 36 | Follow the [basic plugin install guide][install guide], taking all 37 | the code from lib and adding it to your MCollective $libdir 38 | 39 | [install guide]: https://docs.puppet.com/mcollective/deploy/plugins.html 40 | 41 | 42 | ## Configuring the agent 43 | 44 | The agent should work without any additional configuration, though there are 45 | some options you can tune the mcollective server.cfg. 46 | 47 | ### `plugin.shell.state_directory` 48 | 49 | This is where the state used to track processes will live. By default this 50 | will be /var/run/mcollective-shell on Unix systems. 51 | 52 | ``` 53 | plugin.shell.state_directory = /opt/run/mcollective-shell 54 | ``` 55 | 56 | 57 | ## Application usage 58 | 59 | The `mco shell` application has several subcommands to start and manage 60 | processes. 61 | 62 | ### mco shell run 63 | 64 | Runs a command and reports back. Use this for discrete short-living commands. 65 | 66 | For long-running commands look at `start` or `run --tail`. 67 | 68 | ``` 69 | $ mco shell run dir 70 | 71 | * [ ============================================================> ] 2 / 2 72 | 73 | master: 74 | bin dev home lib64 media opt root selinux srv tmp vagrant 75 | boot etc lib lost+found mnt proc sbin src sys usr var 76 | 77 | server2008r2a: 78 | Volume in drive C has no label. 79 | Volume Serial Number is DADF-75F9 80 | 81 | Directory of C:\ 82 | 83 | 09/22/2012 11:45 AM manifests [\\vboxsrv\manifests] 84 | 09/22/2012 11:45 AM modules [\\vboxsrv\modules] 85 | 07/13/2009 08:20 PM PerfLogs 86 | 09/22/2012 11:42 AM Program Files 87 | 03/27/2014 06:52 AM Program Files (x86) 88 | 07/03/2014 07:42 AM src [\\vboxsrv\C:_src] 89 | 03/27/2014 06:39 AM Users 90 | 07/03/2014 07:42 AM vagrant [\\vboxsrv\vagrant] 91 | 03/27/2014 06:41 AM Windows 92 | 0 File(s) 0 bytes 93 | 9 Dir(s) 34,565,091,328 bytes free 94 | 95 | 96 | Finished processing 2 / 2 hosts in 221.28 ms 97 | ``` 98 | 99 | ### mco shell run --tail 100 | 101 | Starts a command, shows you the output from it, kills the command when you 102 | interrupt with control-c, exits normally when the command exits. 103 | 104 | ``` 105 | $ mco shell -I /master/ run --tail vmstat 1 106 | 107 | * [ ============================================================> ] 1 / 1 108 | 109 | master stdout: procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu----- 110 | master stdout: r b swpd free buff cache si so bi bo in cs us sy id wa st 111 | master stdout: 0 1 445812 120584 5808 37348 34 29 52 48 39 47 6 1 93 0 0 112 | master stdout: 1 0 445112 122692 5824 37332 2692 0 2692 84 911 2089 47 9 40 4 0 113 | master stdout: 1 0 444848 122576 5824 37344 288 0 288 0 773 1914 48 5 47 0 0 114 | master stdout: 0 0 444012 121320 5824 37348 1212 0 1212 0 823 1917 47 6 45 1 0 115 | master stdout: 0 0 443984 121204 5824 37372 0 0 0 0 797 1796 52 5 43 0 0 116 | master stdout: 0 0 438800 117244 5824 37360 3896 0 3896 0 910 2123 49 6 45 0 0 117 | master stdout: 1 0 438768 117136 5840 37368 0 0 0 136 811 1926 48 6 45 0 0 118 | ^CAttempting to stopping cleanly, interrupt again to kill 119 | Sending kill to master 6dad5cb9-57f7-46e0-bad7-07ab117369a5 120 | ``` 121 | 122 | 123 | ### mco shell start 124 | 125 | Starts a command in the background and tells you the id that has been assigned 126 | to it. You can then use `mco shell watch`, `mco shell kill`, `mco shell list` 127 | to monitor this process and observe its output 128 | 129 | ``` 130 | $ mco shell -I /master/ start vmstat 1 131 | 132 | * [ ============================================================> ] 1 / 1 133 | 134 | master: 0dd67fac-734f-4824-8b4d-03100d4f9d07 135 | 136 | Finished processing 1 / 1 hosts in 76.37 ms 137 | ``` 138 | 139 | 140 | ### mco shell watch 141 | 142 | Shows you the output of a command you previously started with `mco shell start` 143 | 144 | ``` 145 | $ mco shell watch 0dd67fac-734f-4824-8b4d-03100d4f9d07 146 | 147 | * [ ============================================================> ] 2 / 2 148 | 149 | master stdout: procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu----- 150 | master stdout: r b swpd free buff cache si so bi bo in cs us sy id wa st 151 | master stdout: 2 0 431448 110704 8484 40644 34 29 52 48 40 47 6 1 93 0 0 152 | ``` 153 | 154 | ### mco shell list 155 | 156 | Show a list of running jobs. 157 | 158 | ``` 159 | $ mco shell list -v 160 | 161 | * [ ============================================================> ] 2 / 2 162 | 163 | master: 164 | 0dd67fac-734f-4824-8b4d-03100d4f9d07 165 | 1fd3961a-f48d-4119-b988-146b490a5ca3 166 | d174e20b-9cdb-4c14-9f34-fd29995f30cb 167 | ea809b20-3123-46b4-bf59-10ff7251ca9b 168 | 169 | Finished processing 2 / 2 hosts in 142.34 ms 170 | ``` 171 | 172 | ### mco shell kill 173 | 174 | Kill a running job. 175 | 176 | ``` 177 | $ mco shell kill 0dd67fac-734f-4824-8b4d-03100d4f9d07 178 | 179 | * [ ============================================================> ] 2 / 2 180 | 181 | 182 | Finished processing 2 / 2 hosts in 170.17 ms 183 | ``` 184 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | RAKE_ROOT = File.expand_path(File.dirname(__FILE__)) 2 | specdir = File.join([File.dirname(__FILE__), "spec"]) 3 | 4 | require 'rake' 5 | begin 6 | require 'rspec/core/rake_task' 7 | require 'mcollective' 8 | rescue LoadError 9 | end 10 | 11 | begin 12 | load File.join(RAKE_ROOT, 'ext', 'packaging.rake') 13 | rescue LoadError 14 | end 15 | 16 | def safe_system *args 17 | raise RuntimeError, "Failed: #{args.join(' ')}" unless system *args 18 | end 19 | 20 | def check_build_env 21 | raise "Not all environment variables have been set. Missing #{{"'DESTDIR'" => ENV["DESTDIR"], "'MCLIBDIR'" => ENV["MCLIBDIR"], "'MCBINDIR'" => ENV["MCBINDIR"], "'TARGETDIR'" => ENV["TARGETDIR"]}.reject{|k,v| v != nil}.keys.join(", ")}" unless ENV["DESTDIR"] && ENV["MCLIBDIR"] && ENV["MCBINDIR"] && ENV["TARGETDIR"] 22 | raise "DESTDIR - '#{ENV["DESTDIR"]}' is not a directory" unless File.directory?(ENV["DESTDIR"]) 23 | raise "MCLIBDIR - '#{ENV["MCLIBDIR"]}' is not a directory" unless File.directory?(ENV["MCLIBDIR"]) 24 | raise "MCBINDIR - '#{ENV["MCBINDIR"]}' is not a directory" unless File.directory?(ENV["MCBINDIR"]) 25 | raise "TARGETDIR - '#{ENV["TARGETDIR"]}' is not a directory" unless File.directory?(ENV["TARGETDIR"]) 26 | end 27 | 28 | def build_package(path) 29 | require 'yaml' 30 | options = [] 31 | 32 | if File.directory?(path) 33 | buildops = File.join(path, "buildops.yaml") 34 | buildops = YAML.load_file(buildops) if File.exists?(buildops) 35 | 36 | return unless buildops["build"] 37 | 38 | libdir = ENV["LIBDIR"] || buildops["mclibdir"] 39 | mcname = ENV["MCNAME"] || buildops["mcname"] 40 | sign = ENV["SIGN"] || buildops["sign"] 41 | 42 | options << "--pluginpath=#{libdir}" if libdir 43 | options << "--mcname=#{mcname}" if mcname 44 | options << "--sign" if sign 45 | 46 | options << "--dependency=\"#{buildops["dependencies"].join(" ")}\"" if buildops["dependencies"] 47 | 48 | safe_system("ruby -I #{File.join(ENV["MCLIBDIR"], "lib").shellescape} #{File.join(ENV["MCBINDIR"], "mco").shellescape} plugin package -v #{path.shellescape} #{options.join(" ")}") 49 | move_artifacts 50 | end 51 | end 52 | 53 | def move_artifacts 54 | rpms = FileList["*.rpm"] 55 | debs = FileList["*.deb","*.orig.tar.gz","*.debian.tar.gz","*.diff.gz","*.dsc","*.changes"] 56 | [debs,rpms].each do |pkgs| 57 | unless pkgs.empty? 58 | safe_system("mv #{pkgs} #{ENV["DESTDIR"]}") unless File.expand_path(ENV["DESTDIR"]) == Dir.pwd 59 | end 60 | end 61 | end 62 | 63 | desc "Build packages for specified plugin in target directory" 64 | task :buildplugin do 65 | check_build_env 66 | build_package(ENV["TARGETDIR"]) 67 | end 68 | 69 | desc "Build packages for all plugins in target directory" 70 | task :build do 71 | check_build_env 72 | packages = Dir.glob(File.join(ENV["TARGETDIR"], "*")) 73 | 74 | packages.each do |package| 75 | if File.directory?(File.expand_path(package)) 76 | build_package(File.expand_path(package)) 77 | end 78 | end 79 | end 80 | 81 | desc "Run agent and application tests" 82 | task :test do 83 | require "#{specdir}/spec_helper.rb" 84 | if ENV["TARGETDIR"] 85 | test_pattern = "#{File.expand_path(ENV["TARGETDIR"])}/spec/**/*_spec.rb" 86 | else 87 | test_pattern = 'spec/**/*_spec.rb' 88 | end 89 | sh "rspec #{Dir.glob(test_pattern).sort.join(' ')}" 90 | end 91 | 92 | task :default => :test 93 | -------------------------------------------------------------------------------- /ext/build_defaults.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | packaging_url: 'git://github.com/puppetlabs/packaging.git --branch=master' 3 | packaging_repo: 'packaging' 4 | pbuild_conf: '/etc/pbuilderrc' 5 | default_cow: 'base-squeeze-i386.cow' 6 | cows: 'base-precise-i386.cow base-squeeze-i386.cow base-stable-i386.cow base-testing-i386.cow base-trusty-i386.cow base-wheezy-i386.cow' 7 | packager: 'puppetlabs' 8 | gpg_name: 'info@puppetlabs.com' 9 | gpg_key: '4BD6EC30' 10 | sign_tar: FALSE 11 | # a space separated list of mock configs 12 | final_mocks: 'pl-el-7-x86_64 pl-fedora-20-i386' 13 | yum_host: 'yum.puppetlabs.com' 14 | yum_repo_path: '/opt/repository/yum/' 15 | build_gem: FALSE 16 | build_dmg: FALSE 17 | build_doc: FALSE 18 | build_ips: FALSE 19 | apt_host: 'apt.puppetlabs.com' 20 | apt_repo_url: 'http://apt.puppetlabs.com' 21 | apt_repo_path: '/opt/repository/incoming' 22 | -------------------------------------------------------------------------------- /ext/debian/changelog.erb: -------------------------------------------------------------------------------- 1 | mcollective-shell (<%= @debversion %>) lucid unstable sid squeeze wheezy precise; urgency=low 2 | 3 | * Update to version <%= @debversion %> 4 | 5 | -- Puppet Labs Release <%= Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")%> 6 | -------------------------------------------------------------------------------- /ext/debian/compat: -------------------------------------------------------------------------------- 1 | 7 2 | -------------------------------------------------------------------------------- /ext/debian/control: -------------------------------------------------------------------------------- 1 | Source: mcollective-shell 2 | Homepage: https://github.com/puppetlabs/mcollective-shell-agent 3 | Section: utils 4 | Priority: extra 5 | Maintainer: Puppet Labs 6 | Build-Depends: cdbs, debhelper 7 | Standards-Version: 3.8.0 8 | 9 | Package: mcollective-shell-agent 10 | Architecture: all 11 | Depends: mcollective-shell-common(= ${binary:Version}) 12 | Description: Run commands with the local shell 13 | 14 | Package: mcollective-shell-client 15 | Architecture: all 16 | Depends: mcollective-shell-common(= ${binary:Version}) 17 | Description: Run commands with the local shell 18 | 19 | Package: mcollective-shell-common 20 | Architecture: all 21 | Depends: mcollective-common(>= 2.2.1) 22 | Description: Run commands with the local shell 23 | -------------------------------------------------------------------------------- /ext/debian/copyright: -------------------------------------------------------------------------------- 1 | Upstream Author: 2 | Puppet Labs https://github.com/puppetlabs/mcollective-shell-agent 3 | 4 | License: 5 | ASL 2.0 6 | -------------------------------------------------------------------------------- /ext/debian/mcollective-shell-agent.install: -------------------------------------------------------------------------------- 1 | lib/mcollective/agent/*.rb usr/share/mcollective/plugins/mcollective/agent/ 2 | lib/mcollective/agent/shell/*.rb usr/share/mcollective/plugins/mcollective/agent/shell/ 3 | -------------------------------------------------------------------------------- /ext/debian/mcollective-shell-client.install: -------------------------------------------------------------------------------- 1 | lib/mcollective/application/*.rb usr/share/mcollective/plugins/mcollective/application/ 2 | lib/mcollective/application/shell/*.rb usr/share/mcollective/plugins/mcollective/application/shell/ 3 | -------------------------------------------------------------------------------- /ext/debian/mcollective-shell-common.install: -------------------------------------------------------------------------------- 1 | lib/mcollective/agent/*.ddl usr/share/mcollective/plugins/mcollective/agent/ 2 | -------------------------------------------------------------------------------- /ext/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ 5 | -------------------------------------------------------------------------------- /ext/packaging.rake: -------------------------------------------------------------------------------- 1 | build_defs_file = File.join(RAKE_ROOT, 'ext', 'build_defaults.yaml') 2 | if File.exist?(build_defs_file) 3 | begin 4 | require 'yaml' 5 | @build_defaults ||= YAML.load_file(build_defs_file) 6 | rescue Exception => e 7 | STDERR.puts "Unable to load yaml from #{build_defs_file}:" 8 | raise e 9 | end 10 | @packaging_url = @build_defaults['packaging_url'] 11 | @packaging_repo = @build_defaults['packaging_repo'] 12 | raise "Could not find packaging url in #{build_defs_file}" if @packaging_url.nil? 13 | raise "Could not find packaging repo in #{build_defs_file}" if @packaging_repo.nil? 14 | 15 | namespace :package do 16 | desc "Bootstrap packaging automation, e.g. clone into packaging repo" 17 | task :bootstrap do 18 | if File.exist?(File.join(RAKE_ROOT, "ext", @packaging_repo)) 19 | puts "It looks like you already have ext/#{@packaging_repo}. If you don't like it, blow it away with package:implode." 20 | else 21 | cd File.join(RAKE_ROOT, 'ext') do 22 | %x{git clone #{@packaging_url}} 23 | end 24 | end 25 | end 26 | desc "Remove all cloned packaging automation" 27 | task :implode do 28 | rm_rf File.join(RAKE_ROOT, "ext", @packaging_repo) 29 | end 30 | end 31 | end 32 | 33 | begin 34 | load File.join(RAKE_ROOT, 'ext', 'packaging', 'packaging.rake') 35 | rescue LoadError 36 | end 37 | -------------------------------------------------------------------------------- /ext/project_data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | project: 'mcollective-shell' 3 | author: 'Puppet Labs' 4 | email: 'info@puppetlabs.com' 5 | homepage: 'https://github.com/puppetlabs/mcollective-shell-agent' 6 | summary: 'Run commands with the local shell' 7 | description: 'Run commands with the local shell' 8 | # files and gem_files are space separated lists 9 | files: 10 | - lib 11 | # List of packaging related templates to evaluate before the tarball is packed 12 | templates: 13 | - "ext/redhat/mcollective-shell.spec.erb" 14 | - "ext/debian/changelog.erb" 15 | -------------------------------------------------------------------------------- /ext/redhat/mcollective-shell.spec.erb: -------------------------------------------------------------------------------- 1 | # VERSION is subbed out during rake srpm process 2 | %global realversion <%= @version %> 3 | %global rpmversion <%= @rpmversion %> 4 | 5 | Summary: Run commands with the local shell 6 | Name: mcollective-shell 7 | Version: %{rpmversion} 8 | Release: <%= @rpmrelease -%>%{?dist} 9 | Vendor: %{?_host_vendor} 10 | License: ASL 2.0 11 | URL: https://github.com/puppetlabs/mcollective-shell-agent 12 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 13 | BuildArch: noarch 14 | Group: System Tools 15 | Source0: mcollective-shell-%{realversion}.tar.gz 16 | 17 | %description 18 | Run commands with the local shell 19 | 20 | %prep 21 | %setup -q -n %{name}-%{realversion} 22 | 23 | %build 24 | 25 | %install 26 | rm -rf %{buildroot} 27 | %{__install} -d -m0755 %{buildroot}%{_libexecdir}/mcollective/mcollective 28 | cp -a lib/mcollective/agent lib/mcollective/application %{buildroot}%{_libexecdir}/mcollective/mcollective 29 | 30 | %clean 31 | rm -rf %{buildroot} 32 | 33 | %package agent 34 | Requires: mcollective-shell-common = %{version}-%{release} 35 | Group: System Tools 36 | Summary: Run commands with the local shell 37 | 38 | %package client 39 | Requires: mcollective-shell-common = %{version}-%{release} 40 | Group: System Tools 41 | Summary: Run commands with the local shell 42 | 43 | %package common 44 | Requires: mcollective-common >= 2.2.1 45 | Group: System Tools 46 | Summary: Run commands with the local shell 47 | 48 | %description agent 49 | Run commands with the local shell 50 | 51 | %description client 52 | Run commands with the local shell 53 | 54 | %description common 55 | Run commands with the local shell 56 | 57 | %files agent 58 | %{_libexecdir}/mcollective/mcollective/agent/*.rb 59 | %{_libexecdir}/mcollective/mcollective/agent/shell/*.rb 60 | 61 | %files client 62 | %{_libexecdir}/mcollective/mcollective/application/*.rb 63 | %{_libexecdir}/mcollective/mcollective/application/shell/*.rb 64 | 65 | %files common 66 | %{_libexecdir}/mcollective/mcollective/agent/*.ddl 67 | 68 | %changelog 69 | * <%= Time.now.strftime("%a %b %d %Y") %> Puppet Labs Release - <%= @rpmversion %>-<%= @rpmrelease %> 70 | - Build for <%= @version %> 71 | -------------------------------------------------------------------------------- /lib/mcollective/agent/shell.ddl: -------------------------------------------------------------------------------- 1 | metadata :name => "shell", 2 | :description => "Run commands with the local shell", 3 | :author => "Puppet Labs", 4 | :license => "ASL 2.0", 5 | :version => "0.0.2", 6 | :url => "https://github.com/puppetlabs/mcollective-shell-agent", 7 | :timeout => 180 8 | 9 | action "run", :description => "Run a command" do 10 | display :always 11 | 12 | input :command, 13 | :prompt => "Command", 14 | :description => "Command to run", 15 | :type => :string, 16 | :validation => '.*', 17 | :maxlength => 10 * 1024, 18 | :optional => false 19 | 20 | input :user, 21 | :prompt => "User", 22 | :description => "User to run command as", 23 | :type => :string, 24 | :validation => '.*', 25 | :maxlength => 1024, 26 | :optional => true 27 | 28 | input :timeout, 29 | :prompt => "Timeout", 30 | :description => "Timeout to wait for the command to complete", 31 | :type => :float, 32 | :optional => true 33 | # TODO(richardc): validate positive. May need another validator class 34 | 35 | output :stdout, 36 | :description => "stdout from the command", 37 | :display_as => "stdout" 38 | 39 | output :stderr, 40 | :description => "stderr from the command", 41 | :display_as => "stderr" 42 | 43 | output :success, 44 | :description => "did the process exit successfully", 45 | :display_as => "success" 46 | 47 | output :exitcode, 48 | :description => "exit code of the command", 49 | :display_as => "exitcode" 50 | end 51 | 52 | action "start", :description => "Spawn a command" do 53 | display :always 54 | 55 | input :command, 56 | :prompt => "Command", 57 | :description => "Command to run", 58 | :type => :string, 59 | :validation => '.*', 60 | :maxlength => 10 * 1024, 61 | :optional => false 62 | 63 | input :user, 64 | :prompt => "User", 65 | :description => "User to run command as", 66 | :type => :string, 67 | :validation => '.*', 68 | :maxlength => 1024, 69 | :optional => true 70 | 71 | output :handle, 72 | :description => "identifier to a running command", 73 | :display_as => "handle" 74 | end 75 | 76 | action "status", :description => "Get status of managed command" do 77 | display :always 78 | 79 | input :handle, 80 | :prompt => "Handle", 81 | :description => "Handle of the command", 82 | :type => :string, 83 | :validation => '^[0-9a-z\-]*$', 84 | :maxlength => 36, 85 | :optional => false 86 | 87 | input :stdout_offset, 88 | :prompt => "stdout_offset", 89 | :description => "stdout_offset", 90 | :type => :integer, 91 | :optional => true 92 | 93 | input :stderr_offset, 94 | :prompt => "stderr_offset", 95 | :description => "stderr_offset", 96 | :type => :integer, 97 | :optional => true 98 | 99 | # Running, Exited 100 | output :status, 101 | :description => "status of the command", 102 | :display_as => "status" 103 | 104 | # Stdout to this point - resets internal state 105 | output :stdout, 106 | :description => "stdout of the command", 107 | :display_as => "stdout" 108 | 109 | # Stderr to this point - resets internal state 110 | output :stderr, 111 | :description => "stderr of the command", 112 | :display_as => "stderr" 113 | 114 | # Only meaningful if status == Exited 115 | output :exitcode, 116 | :description => "exitcode of the command", 117 | :display_as => "exitcode" 118 | 119 | end 120 | 121 | action "list", :description => "Get a list of all running commands" do 122 | display :always 123 | 124 | output :jobs, 125 | :description => "state of managed jobs", 126 | :display_as => "jobs" 127 | 128 | end 129 | 130 | action "kill", :description => "Kill a command by handle" do 131 | display :always 132 | 133 | input :handle, 134 | :prompt => "Handle", 135 | :description => "Handle of the command", 136 | :type => :string, 137 | :validation => '^[0-9a-z\-]*$', 138 | :maxlength => 36, 139 | :optional => false 140 | end 141 | -------------------------------------------------------------------------------- /lib/mcollective/agent/shell.rb: -------------------------------------------------------------------------------- 1 | require 'mcollective/agent/shell/job' 2 | 3 | module MCollective 4 | module Agent 5 | class Shell job.handle, 72 | :command => job.command, 73 | :status => job.status, 74 | :signal => job.signal, 75 | } 76 | end 77 | 78 | reply[:jobs] = list 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/mcollective/agent/shell/job.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'pathname' 3 | 4 | # The Job class manages the spawning and state tracking for a process as it's 5 | # running. 6 | # 7 | # The general approach we take is to create a directory per Job 8 | # (Job#state_directory), and populate it as follows: 9 | 10 | # command - the command we've been asked to run 11 | # wrapper - a short program that spawns the command and collects its exit 12 | # status 13 | # error - any problem in spawning the process 14 | # pid - the pid of the spawned process 15 | # stdout - stdout from the process 16 | # stderr - stderr from the process 17 | # exitstatus - the exitstatus of the spawned process 18 | 19 | # The wrapper process is detached from the mcollectived process, so restarting 20 | # the mcollectived will not terminate the process. 21 | 22 | module MCollective 23 | module Agent 24 | class Shell '/', 76 | :in => :close, 77 | :out => :close, 78 | :err => :close, 79 | }) 80 | 81 | if manager == nil 82 | raise "Couldn't spawn manager process" 83 | end 84 | 85 | # busy wait for a pid or error file 86 | while !File.exists?("#{state_directory}/pid") && !File.exists?("#{state_directory}/error") 87 | sleep 0.1 88 | end 89 | 90 | ::Process.detach(manager) 91 | 92 | if File.exists?("#{state_directory}/pid") 93 | @pid = Integer(IO.read("#{state_directory}/pid")) 94 | else 95 | # we must have an error file 96 | raise IO.read("#{state_directory}/error") 97 | end 98 | end 99 | 100 | def stdout(offset = 0) 101 | fh = File.new("#{state_directory}/stdout", 'rb') 102 | fh.seek(offset, IO::SEEK_SET) 103 | out = fh.read 104 | fh.close 105 | return out 106 | end 107 | 108 | def stderr(offset = 0) 109 | fh = File.new("#{state_directory}/stderr", 'rb') 110 | fh.seek(offset, IO::SEEK_SET) 111 | err = fh.read 112 | fh.close 113 | return err 114 | end 115 | 116 | def status 117 | if File.exists?("#{state_directory}/error") 118 | # Process failed to start 119 | return :failed 120 | end 121 | 122 | if !File.exists?("#{state_directory}/pid") 123 | # We haven't started yet 124 | return :starting 125 | end 126 | 127 | if File.exists?("#{state_directory}/exitstatus") 128 | # The manager has written out the exitstatus, so the process is done 129 | return :stopped 130 | end 131 | 132 | return :running 133 | end 134 | 135 | def kill 136 | ::Process.kill('TERM', pid) 137 | end 138 | 139 | def wait_for_process 140 | while status == :running 141 | sleep 0.1 142 | end 143 | get_exitcode 144 | end 145 | 146 | def cleanup_state 147 | FileUtils.remove_entry_secure state_directory 148 | end 149 | 150 | private 151 | 152 | def self.state_path 153 | if Util.windows? 154 | default = "C:/ProgramData/mcollective-shell" 155 | else 156 | default = "/var/run/mcollective-shell" 157 | end 158 | 159 | Config.instance.pluginconf['shell.state_path'] || default 160 | end 161 | 162 | def get_exitcode 163 | statusfile = "#{state_directory}/exitstatus" 164 | if File.exists?(statusfile) 165 | status = Integer(IO.read(statusfile)) 166 | @signal = status & 0xff 167 | @exitcode = status >> 8 168 | end 169 | end 170 | 171 | def find_ruby 172 | if @@ruby 173 | return @@ruby 174 | end 175 | 176 | ruby_config = begin 177 | ::RbConfig::CONFIG 178 | rescue NameError 179 | ::Config::CONFIG 180 | end 181 | 182 | candidates = [ 183 | File.join(ruby_config['bindir'], ruby_config['ruby_install_name']) + ruby_config['EXEEXT'], 184 | 'ruby', 185 | ] 186 | 187 | found = candidates.find { |path| system('"%s" -e 42' % path) } 188 | 189 | if found 190 | @@ruby = found 191 | return @@ruby 192 | else 193 | raise "No ruby found via Config or PATH" 194 | end 195 | end 196 | 197 | def wrapper 198 | return <<-WRAPPER 199 | command = IO.read("#{state_directory}/command").chomp 200 | 201 | options = { 202 | :chdir => '/', 203 | :out => "#{state_directory}/stdout", 204 | :err => "#{state_directory}/stderr", 205 | } 206 | 207 | begin 208 | pid = ::Process.spawn(command, options) 209 | 210 | File.open("#{state_directory}/pid", 'w') do |fh| 211 | fh.puts pid 212 | end 213 | 214 | ::Process.waitpid(pid) 215 | 216 | if $?.nil? 217 | # On win32 $? doesn't seem to get set - probably need to grab a 218 | # handle then call GetExitCode 219 | exitstatus = 0 220 | else 221 | exitstatus = $?.to_i 222 | end 223 | 224 | File.open("#{state_directory}/exitstatus", 'w') do |fh| 225 | fh.puts exitstatus 226 | end 227 | 228 | rescue Exception => e 229 | File.open("#{state_directory}/error", 'w') do |fh| 230 | fh.puts e 231 | end 232 | end 233 | WRAPPER 234 | end 235 | 236 | def state_directory 237 | "#{self.class.state_path}/#{@handle}" 238 | end 239 | end 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /lib/mcollective/application/shell.rb: -------------------------------------------------------------------------------- 1 | require 'mcollective/application/shell/watcher' 2 | 3 | class MCollective::Application::Shell < MCollective::Application 4 | description 'Run shell commands' 5 | 6 | usage <<-END_OF_USAGE 7 | mco shell [OPTIONS] [FILTERS] [ARGS] 8 | 9 | mco shell run [--tail] [COMMAND] 10 | mco shell start [COMMAND] 11 | mco shell watch [HANDLE] 12 | mco shell list 13 | mco shell kill [HANDLE] 14 | END_OF_USAGE 15 | 16 | option :tail, 17 | :arguments => [ '--tail' ], 18 | :description => 'Switch run to tail mode', 19 | :type => :bool 20 | 21 | def post_option_parser(configuration) 22 | if ARGV.size < 1 23 | raise "Please specify an action" 24 | end 25 | 26 | valid_actions = ['run', 'start', 'watch', 'list', 'kill' ] 27 | action = ARGV.shift 28 | 29 | unless valid_actions.include?(action) 30 | raise 'Action has to be one of ' + valid_actions.join(', ') 31 | end 32 | 33 | configuration[:command] = action 34 | end 35 | 36 | def main 37 | send("#{configuration[:command]}_command") 38 | end 39 | 40 | private 41 | 42 | def run_command 43 | command = ARGV.join(' ') 44 | 45 | if configuration[:tail] 46 | tail(command) 47 | else 48 | do_run(command) 49 | end 50 | end 51 | 52 | def start_command 53 | command = ARGV.join(' ') 54 | client = rpcclient('shell') 55 | 56 | responses = client.start(:command => command) 57 | responses.sort_by! { |r| r[:sender] } 58 | 59 | responses.each do |response| 60 | if response[:statuscode] == 0 61 | puts "#{response[:sender]}: #{response[:data][:handle]}" 62 | else 63 | puts "#{response[:sender]}: ERROR: #{response.inspect}" 64 | end 65 | end 66 | printrpcstats :summarize => true, :caption => "Started command: #{command}" 67 | end 68 | 69 | def list_command 70 | client = rpcclient('shell') 71 | 72 | responses = client.list 73 | responses.sort_by! { |r| r[:sender] } 74 | 75 | responses.each do |response| 76 | if response[:statuscode] == 0 77 | next if response[:data][:jobs].empty? 78 | puts "#{response[:sender]}:" 79 | response[:data][:jobs].keys.sort.each do |handle| 80 | puts " #{handle}" 81 | 82 | if client.verbose 83 | puts " command: #{response[:data][:jobs][handle][:command]}" 84 | puts " status: #{response[:data][:jobs][handle][:status]}" 85 | puts "" 86 | end 87 | end 88 | end 89 | end 90 | 91 | printrpcstats :summarize => true, :caption => "Command list" 92 | end 93 | 94 | def watch_command 95 | handles = ARGV 96 | client = rpcclient('shell') 97 | 98 | watchers = [] 99 | client.list.each do |response| 100 | next if response[:statuscode] != 0 101 | response[:data][:jobs].keys.each do |handle| 102 | if handles.include?(handle) 103 | watchers << Watcher.new(response[:sender], handle) 104 | end 105 | end 106 | end 107 | 108 | watch_these(client, watchers) 109 | end 110 | 111 | def kill_command 112 | handle = ARGV.shift 113 | client = rpcclient('shell') 114 | 115 | client.kill(:handle => handle) 116 | 117 | printrpcstats :summarize => true, :caption => "Command list" 118 | end 119 | 120 | def do_run(command) 121 | client = rpcclient('shell') 122 | 123 | responses = client.run(:command => command) 124 | responses.sort_by! { |r| r[:sender] } 125 | 126 | responses.each do |response| 127 | if response[:statuscode] == 0 128 | puts "#{response[:sender]}:" 129 | puts response[:data][:stdout] 130 | if response[:data][:stderr].size > 0 131 | puts " STDERR:" 132 | puts response[:data][:stderr] 133 | end 134 | if response[:data][:exitcode] != 0 135 | puts "exitcode: #{response[:data][:exitcode]}" 136 | end 137 | puts "" 138 | else 139 | puts "#{response[:sender]}: ERROR: #{response.inspect}" 140 | end 141 | end 142 | 143 | printrpcstats :summarize => true, :caption => "Ran command: #{command}" 144 | end 145 | 146 | def tail(command) 147 | client = rpcclient('shell') 148 | 149 | processes = [] 150 | client.start(:command => command).each do |response| 151 | next unless response[:statuscode] == 0 152 | processes << Watcher.new(response[:sender], response[:data][:handle]) 153 | end 154 | 155 | watch_these(client, processes, true) 156 | end 157 | 158 | def watch_these(client, processes, kill_on_interrupt = false) 159 | client.progress = false 160 | 161 | state = :running 162 | if kill_on_interrupt 163 | # trap sigint so we can send a kill to the commands we're watching 164 | trap('SIGINT') do 165 | puts "Attempting to stop cleanly, interrupt again to kill" 166 | state = :stopping 167 | 168 | # if we're double-tapped, just quit (may leave a mess) 169 | trap('SIGINT') do 170 | puts "OK you meant it; bye" 171 | exit 1 172 | end 173 | end 174 | else 175 | # When we get a sigint we should just exit 176 | trap('SIGINT') do 177 | puts "" 178 | exit 1 179 | end 180 | end 181 | 182 | while !processes.empty? 183 | processes.each do |process| 184 | client.filter["identity"].clear 185 | client.identity_filter process.node 186 | 187 | if state == :stopping && kill_on_interrupt 188 | puts "Sending kill to #{process.node} #{process.handle}" 189 | client.kill(:handle => process.handle) 190 | end 191 | 192 | client.status({ 193 | :handle => process.handle, 194 | :stdout_offset => process.stdout_offset, 195 | :stderr_offset => process.stderr_offset, 196 | }).each do |response| 197 | if response[:statuscode] != 0 198 | process.flush 199 | processes.delete(process) 200 | break 201 | end 202 | 203 | process.status(response) 204 | 205 | if response[:data][:status] == :stopped 206 | process.flush 207 | processes.delete(process) 208 | end 209 | end 210 | end 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/mcollective/application/shell/prefix_stream_buf.rb: -------------------------------------------------------------------------------- 1 | # PrefixStreamBuf is a utility class used my 2 | # MCollective::Application::Shell::Watcher. 3 | 4 | # PrefixStreambuf#display takes chunks of input, and on complete lines will 5 | # emit that line with the prefix. Incomplete lines are kept internally for 6 | # the next call to display, unless #flush is called to flush the buffer. 7 | 8 | module MCollective 9 | class Application 10 | class Shell < Application 11 | class PrefixStreamBuf 12 | def initialize(prefix) 13 | @buffer = '' 14 | @prefix = prefix 15 | end 16 | 17 | def display(data) 18 | @buffer += data 19 | chunks = @buffer.lines.to_a 20 | return if chunks.empty? 21 | 22 | if chunks[-1][-1] != "\n" 23 | @buffer = chunks[-1] 24 | chunks.pop 25 | else 26 | @buffer = '' 27 | end 28 | 29 | chunks.each do |chunk| 30 | puts "#{@prefix}#{chunk}" 31 | end 32 | end 33 | 34 | def flush 35 | if @buffer.size > 0 36 | display("\n") 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/mcollective/application/shell/watcher.rb: -------------------------------------------------------------------------------- 1 | require 'mcollective/application/shell/prefix_stream_buf' 2 | 3 | # The Watcher class is a utility class for Application::Shell#watch_these. 4 | # It's effectively a tuple of [node, handle] to identify the command, and 5 | # PrefixStreamBufs and watermarks to track where in the stdout/stderr we have 6 | # seen. 7 | 8 | module MCollective 9 | class Application 10 | class Shell < Application 11 | class Watcher 12 | attr_reader :node, :handle 13 | attr_reader :stdout_offset, :stderr_offset 14 | 15 | def initialize(node, handle) 16 | @node = node 17 | @handle = handle 18 | @stdout = PrefixStreamBuf.new("#{node} stdout: ") 19 | @stderr = PrefixStreamBuf.new("#{node} stderr: ") 20 | @stdout_offset = 0 21 | @stderr_offset = 0 22 | end 23 | 24 | def status(response) 25 | @stdout_offset += response[:data][:stdout].size 26 | @stdout.display(response[:data][:stdout]) 27 | @stderr_offset += response[:data][:stderr].size 28 | @stderr.display(response[:data][:stderr]) 29 | end 30 | 31 | def flush 32 | @stdout.flush 33 | @stderr.flush 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'mocha' 2 | require 'mcollective' 3 | require 'mcollective/test' 4 | 5 | RSpec.configure do |config| 6 | config.mock_with :mocha 7 | config.include(MCollective::Test::Matchers) 8 | 9 | config.before :each do 10 | MCollective::PluginManager.clear 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/unit/mcollective/agent/shell/job_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mcollective/agent/shell/job' 3 | 4 | module MCollective 5 | module Agent 6 | class Shell 7 | describe Job do 8 | before :each do 9 | @tmpdir = Dir.mktmpdir 10 | Shell::Job.stubs(:state_path).returns(@tmpdir) 11 | end 12 | 13 | after :each do 14 | FileUtils.remove_entry_secure @tmpdir 15 | end 16 | 17 | describe 'list' do 18 | it 'should return nothing when no jobs are created' do 19 | Job.list.should == [] 20 | end 21 | 22 | it 'should return two jobs when two are made' do 23 | one = Job.new 24 | two = Job.new 25 | jobs = Job.list 26 | jobs.size.should == 2 27 | jobs[0].class.should == Job 28 | jobs[1].class.should == Job 29 | end 30 | end 31 | 32 | describe '#initialize' do 33 | it 'should use the handle if passed' do 34 | job = Job.new('made-up-handle') 35 | job.handle.should == 'made-up-handle' 36 | end 37 | 38 | it 'should generate a handle if none is specified' do 39 | job = Job.new 40 | job.handle.should =~ /[A-Z0-9-]+/ 41 | end 42 | end 43 | 44 | describe '#start_command' do 45 | it 'should write files, spawn, and check for starting' do 46 | job = Job.new 47 | state_directory = job.send(:state_directory) 48 | job.stubs(:find_ruby).returns('testing-ruby') 49 | File.expects(:open).with("#{state_directory}/command", 'w') 50 | File.expects(:open).with("#{state_directory}/wrapper", 'w') 51 | Process.expects(:spawn).with('testing-ruby', "#{state_directory}/wrapper", { 52 | :chdir => '/', 53 | :in => :close, 54 | :out => :close, 55 | :err => :close, 56 | }).returns(53) 57 | File.expects(:exists?).with("#{state_directory}/pid").returns(true).twice 58 | IO.expects(:read).with("#{state_directory}/pid").returns("54\n") 59 | job.start_command('echo foo') 60 | job.pid.should == 54 61 | 62 | # explicitly unstub so the after block can fire 63 | File.unstub(:open) 64 | end 65 | end 66 | 67 | describe '#stdout' do 68 | it 'should default to an offset of zero' do 69 | job = Job.new 70 | state_directory = job.send(:state_directory) 71 | 72 | filehandle = mock('filehandle') 73 | File.expects(:new).with("#{state_directory}/stdout", 'rb').returns(filehandle) 74 | filehandle.expects(:seek).with(0, IO::SEEK_SET) 75 | filehandle.expects(:read).returns("some data") 76 | filehandle.expects(:close) 77 | job.stdout.should == "some data" 78 | end 79 | 80 | it 'should read from an offset' do 81 | job = Job.new 82 | state_directory = job.send(:state_directory) 83 | 84 | filehandle = mock('filehandle') 85 | File.expects(:new).with("#{state_directory}/stdout", 'rb').returns(filehandle) 86 | filehandle.expects(:seek).with(5, IO::SEEK_SET) 87 | filehandle.expects(:read).returns("some data at an offset") 88 | filehandle.expects(:close) 89 | job.stdout(5).should == "some data at an offset" 90 | end 91 | end 92 | 93 | describe '#stderr' do 94 | it 'should default to an offset of zero' do 95 | job = Job.new 96 | state_directory = job.send(:state_directory) 97 | 98 | filehandle = mock('filehandle') 99 | File.expects(:new).with("#{state_directory}/stderr", 'rb').returns(filehandle) 100 | filehandle.expects(:seek).with(0, IO::SEEK_SET) 101 | filehandle.expects(:read).returns("some data") 102 | filehandle.expects(:close) 103 | job.stderr.should == "some data" 104 | end 105 | 106 | it 'should read from an offset' do 107 | job = Job.new 108 | state_directory = job.send(:state_directory) 109 | 110 | filehandle = mock('filehandle') 111 | File.expects(:new).with("#{state_directory}/stderr", 'rb').returns(filehandle) 112 | filehandle.expects(:seek).with(5, IO::SEEK_SET) 113 | filehandle.expects(:read).returns("some data at an offset") 114 | filehandle.expects(:close) 115 | job.stderr(5).should == "some data at an offset" 116 | end 117 | end 118 | 119 | describe '#status' do 120 | let(:job) do 121 | test_job = Job.new 122 | test_job.stubs(:state_directory).returns('test') 123 | test_job 124 | end 125 | 126 | it 'should be :failed if there is an error file' do 127 | File.expects(:exists?).with('test/error').returns(true) 128 | job.status.should == :failed 129 | end 130 | 131 | it 'should be :starting if there is no pid' do 132 | File.expects(:exists?).with('test/error').returns(false) 133 | File.expects(:exists?).with('test/pid').returns(false) 134 | job.status.should == :starting 135 | end 136 | 137 | it 'should be :stopped is there is an exitstatus' do 138 | File.expects(:exists?).with('test/error').returns(false) 139 | File.expects(:exists?).with('test/pid').returns(true) 140 | File.expects(:exists?).with('test/exitstatus').returns(true) 141 | job.status.should == :stopped 142 | end 143 | 144 | it 'should be :running if there is no exitstatus' do 145 | File.expects(:exists?).with('test/error').returns(false) 146 | File.expects(:exists?).with('test/pid').returns(true) 147 | File.expects(:exists?).with('test/exitstatus').returns(false) 148 | job.status.should == :running 149 | end 150 | end 151 | 152 | describe '#kill' do 153 | it 'should send TERM to the pid' do 154 | job = Job.new 155 | job.stubs(:pid).returns(58) 156 | Process.expects(:kill).with('TERM', 58) 157 | job.kill 158 | end 159 | end 160 | 161 | describe '#wait_for_process' do 162 | it 'should not loop if process is not :running' do 163 | job = Job.new 164 | job.expects(:status).returns(:stopped) 165 | job.expects(:sleep).never 166 | job.wait_for_process 167 | end 168 | 169 | it 'should sleep and loop if status is :running' do 170 | job = Job.new 171 | job.stubs(:status).returns(:running, :stopped) 172 | job.expects(:sleep).with(0.1).once 173 | job.wait_for_process 174 | end 175 | end 176 | 177 | describe '#cleanup_state' do 178 | it 'should blow away the state directory' do 179 | job = Job.new 180 | job.stubs(:state_directory).returns('test') 181 | FileUtils.expects(:remove_entry_secure).with('test') 182 | job.cleanup_state 183 | 184 | # explicitly unstub so the after block can fire 185 | FileUtils.unstub(:remove_entry_secure) 186 | end 187 | end 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /spec/unit/mcollective/agent/shell_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MCollective 4 | module Agent 5 | describe Shell do 6 | let(:agent_file) { File.join('lib', 'mcollective', 'agent', 'shell.rb')} 7 | let(:agent) { MCollective::Test::LocalAgentTest.new('shell', :agent_file => agent_file).plugin } 8 | 9 | describe '#run' do 10 | it 'should delegate to #run_command' do 11 | agent.expects(:run_command).with({:command => 'echo foo'}).returns({ 12 | :exitcode => 0, 13 | :stdout => "foo\n", 14 | :stderr => '', 15 | }) 16 | result = agent.call(:run, :command => 'echo foo') 17 | result.should be_successful 18 | end 19 | end 20 | 21 | describe '#run_command' do 22 | let(:reply) { {} } 23 | 24 | before :each do 25 | agent.stubs(:reply).returns(reply) 26 | @tmpdir = Dir.mktmpdir 27 | Shell::Job.stubs(:state_path).returns(@tmpdir) 28 | end 29 | 30 | after :each do 31 | FileUtils.remove_entry_secure @tmpdir 32 | end 33 | 34 | it 'should run cleanly' do 35 | agent.send(:run_command, :command => 'echo foo') 36 | reply[:exitcode].should == 0 37 | reply[:stdout].should == "foo\n" 38 | end 39 | 40 | it 'should cope with large amounts of output' do 41 | agent.send(:run_command, :command => %{ruby -e '8000.times { puts "flirble wirble" }'}) 42 | reply[:success].should == true 43 | reply[:exitcode].should == 0 44 | reply[:stdout].should == "flirble wirble\n" * 8000 45 | end 46 | 47 | it 'should cope with large amounts of output on both channels' do 48 | agent.send(:run_command, :command => %{ruby -e '8000.times { STDOUT.puts "flirble wirble"; STDERR.puts "flooble booble" }'}) 49 | reply[:success].should == true 50 | reply[:exitcode].should == 0 51 | reply[:stdout].should == "flirble wirble\n" * 8000 52 | reply[:stderr].should == "flooble booble\n" * 8000 53 | end 54 | 55 | it 'raise on a non-existent command' do 56 | expect { 57 | agent.send(:run_command, :command => 'i_really_should_not_exist') 58 | }.to raise_error(/No such file or directory - i_really_should_not_exist/) 59 | end 60 | 61 | context 'timeout' do 62 | it 'should not timeout commands that exit quickly enough' do 63 | agent.send(:run_command, { 64 | :command => %{ruby -e 'puts "started"; sleep 1; puts "finished"'}, 65 | :timeout => 2.0, 66 | }) 67 | reply[:success].should == true 68 | reply[:exitcode].should == 0 69 | reply[:stdout].should == "started\nfinished\n" 70 | reply[:stderr].should == '' 71 | end 72 | 73 | it 'should timeout long running commands' do 74 | start = Time.now() 75 | agent.send(:run_command, { 76 | :command => %{ruby -e 'STDOUT.sync = true; puts "started"; sleep 5; puts "finished"'}, 77 | :timeout => 1.0, 78 | }) 79 | elapsed = Time.now() - start 80 | elapsed.should <= 2 81 | reply[:success].should == false 82 | reply[:exitcode].should == nil 83 | reply[:stdout].should == "started\n" 84 | reply[:stderr].should == '' 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/unit/mcollective/application/shell/prefix_stream_buf_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mcollective/application/shell/prefix_stream_buf' 3 | 4 | module MCollective 5 | class Application 6 | class Shell < Application 7 | describe PrefixStreamBuf do 8 | let(:buf) { PrefixStreamBuf.new('test: ') } 9 | 10 | describe '#initialize' do 11 | it 'should record a prefix and null the buffer' do 12 | buf.instance_variable_get(:@buffer).should == '' 13 | buf.instance_variable_get(:@prefix).should == 'test: ' 14 | end 15 | end 16 | 17 | describe '#display' do 18 | it 'should print an entire line' do 19 | buf.expects(:puts).with("test: line\n") 20 | buf.display("line\n") 21 | end 22 | 23 | it 'should not print a partial line' do 24 | buf.expects(:puts).never 25 | buf.display('partial') 26 | end 27 | 28 | it 'should print two complete lines and not a partial' do 29 | buf.expects(:puts).with("test: one\n") 30 | buf.expects(:puts).with("test: two\n") 31 | buf.display("one\ntwo\nthree") 32 | end 33 | 34 | it 'should buffer until it has a complete line' do 35 | buf.expects(:puts).with("test: one two\n") 36 | buf.display("one ") 37 | buf.display("two\n") 38 | end 39 | end 40 | 41 | describe '#flush' do 42 | it 'should print partial lines when flushed' do 43 | buf.expects(:puts).with("test: partial\n") 44 | buf.display("partial") 45 | buf.flush 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/unit/mcollective/application/shell/watcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mcollective/application/shell/watcher' 3 | 4 | module MCollective 5 | class Application 6 | class Shell < Application 7 | describe Watcher do 8 | let(:watcher) { Watcher.new('test-node', 'test-handle') } 9 | 10 | describe '#initalize' do 11 | it 'should record the node, handle, and zero the offsets' do 12 | watcher.node.should == 'test-node' 13 | watcher.handle.should == 'test-handle' 14 | watcher.stdout_offset.should == 0 15 | watcher.stderr_offset.should == 0 16 | end 17 | end 18 | 19 | describe '#status' do 20 | it 'should increment the offsets' do 21 | watcher.status({ :data => { :stdout => "four", :stderr => "five " } }) 22 | watcher.stdout_offset.should == 4 23 | watcher.stderr_offset.should == 5 24 | end 25 | end 26 | 27 | describe '#flush' do 28 | it 'should flush the managed PrefixStreamBufs' do 29 | watcher.instance_variable_get(:@stdout).expects(:flush) 30 | watcher.instance_variable_get(:@stderr).expects(:flush) 31 | watcher.flush 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | --------------------------------------------------------------------------------