├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── pups ├── lib ├── pups.rb └── pups │ ├── cli.rb │ ├── command.rb │ ├── config.rb │ ├── docker.rb │ ├── exec_command.rb │ ├── file_command.rb │ ├── merge_command.rb │ ├── replace_command.rb │ ├── runit.rb │ └── version.rb ├── pups.gemspec └── test ├── cli_test.rb ├── config_test.rb ├── docker_test.rb ├── exec_command_test.rb ├── file_command_test.rb ├── merge_command_test.rb ├── replace_command_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | jobs: 10 | lint: 11 | name: "pups lint" 12 | runs-on: ${{ matrix.os }} 13 | timeout-minutes: 5 14 | 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | os: [ubuntu-latest] 19 | ruby: ["3.2"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - run: bundle exec rubocop 28 | test: 29 | name: "pups tests" 30 | runs-on: ${{ matrix.os }} 31 | timeout-minutes: 5 32 | 33 | strategy: 34 | fail-fast: true 35 | matrix: 36 | os: [ubuntu-latest] 37 | ruby: ["3.2"] 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ${{ matrix.ruby }} 44 | bundler-cache: true 45 | - name: Run minitest 46 | run: | 47 | rake test 48 | 49 | publish: 50 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 51 | needs: [test] 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - name: Release Gem 58 | uses: discourse/publish-rubygems-action@v2 59 | env: 60 | RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 61 | GIT_EMAIL: team@discourse.org 62 | GIT_NAME: discoursebot 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.swp 19 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: default.yml 3 | 4 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 1.2.0 - 10-22-2023 2 | 3 | - Add --tags and --skip-tags options 4 | 5 | 1.0.3 - 09-04-2021 6 | 7 | - Started changelog - release to rubygems 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in pups.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :minitest do 4 | # with Minitest::Unit 5 | watch(%r{^test/(.*)/?(.*)_test\.rb$}) 6 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[2]}_test.rb" } 7 | watch(%r{^test/test_helper\.rb$}) { 'test' } 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Sam Saffron 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pups 2 | 3 | Simple YAML--based bootstrapper 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'pups' 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install pups 18 | 19 | ## Usage 20 | 21 | pups is a small library that allows you to automate the process of creating Unix images. 22 | 23 | ``` 24 | Usage: pups [options] [FILE|--stdin] 25 | --stdin Read input from stdin. 26 | --quiet Don't print any logs. 27 | --ignore Ignore specific configuration elements, multiple elements can be provided (comma-delimited). 28 | Useful if you want to skip over config in a pups execution. 29 | e.g. `--ignore env,params`. 30 | --tags Only run tagged commands. 31 | --skip-tags Run all but listed tagged commands. 32 | --gen-docker-run-args Output arguments from the pups configuration for input into a docker run command. All other pups config is ignored. 33 | -h, --help 34 | ``` 35 | 36 | pups requires input either via a stdin stream or a filename. The entire input is parsed prior to any templating or command execution. 37 | 38 | Example: 39 | 40 | ``` 41 | # somefile.yaml 42 | params: 43 | hello: hello world 44 | 45 | run: 46 | - exec: /bin/bash -c 'echo $hello >> hello' 47 | ``` 48 | 49 | Running: `pups somefile.yaml` will execute the shell script resulting in a file called "hello" with the contents "hello world". 50 | 51 | ### Features 52 | 53 | #### Filtering run commands by tags 54 | 55 | The `--tags` and `--skip-tags` argument allows pups to target a subset of commands listed in the somefile.yaml. To use this, you may tag your commands in the runblock. `--tags` will only run commands when commands have a matching tag. `--skip-tags` will skip when commands have a matching tag. 56 | 57 | Note, hooks from tagged commands will be present or absent depending on if the tag is filtered out or not as well. A command filtered out by targeting tag will also filter out the command's `before_` and `after_` hooks. 58 | 59 | Example: 60 | 61 | ``` 62 | # somefile.yaml 63 | 64 | run: 65 | - exec: 66 | cmd: /bin/bash -c 'echo hello >> hello' 67 | tag: sometag 68 | - exec: 69 | cmd: /bin/bash -c 'echo hi >> hello' 70 | tag: anothertag 71 | - exec: 72 | cmd: /bin/bash -c 'echo goodbye >> hello' 73 | tag: thirdtag 74 | ``` 75 | Running: `pups --tags="sometag,anothertag" somefile.yaml` will not run the echo goodbye statement. 76 | 77 | Running: `pups --skip-tags="sometag,anothertag" somefile.yaml` will ONLY run the echo goodbye statement. 78 | 79 | #### Docker run argument generation 80 | 81 | The `--gen-docker-run-args` argument is used to make pups output arguments be in the format of `docker run `. Specifically, pups 82 | will take any `env`, `volume`, `labels`, `links`, and `expose` configuration, and coerce that into the format expected by `docker run`. This can be useful 83 | when pups is being used to configure an image (e.g. by executing a series of commands) that is then going to be run as a container. That way, the runtime and image 84 | configuration can be specified within the same yaml files. 85 | 86 | 87 | #### Environment Variables 88 | 89 | By default, pups automatically imports your environment variables and includes them as params. 90 | 91 | ``` 92 | # In bash 93 | export SECRET_KEY="secret value" 94 | 95 | # In somefile.yaml 96 | run: 97 | - exec: echo "$SECRET_KEY" 98 | ``` 99 | 100 | Running the above code with pups will produce `secret value`. 101 | 102 | #### Execution 103 | 104 | Run multiple commands in one path: 105 | 106 | ``` 107 | run: 108 | - exec: 109 | cd: some/path 110 | cmd: 111 | - echo 1 112 | - echo 2 113 | ``` 114 | 115 | Run commands in the background (for services etc) 116 | 117 | ``` 118 | run: 119 | - exec: 120 | cmd: /usr/bin/sshd 121 | background: true 122 | ``` 123 | 124 | Suppress exceptions on certain commands 125 | 126 | ``` 127 | run: 128 | - exec: 129 | cmd: /test 130 | raise_on_fail: false 131 | ``` 132 | 133 | #### Replacements: 134 | 135 | ``` 136 | run: 137 | - replace: 138 | filename: "/etc/redis/redis.conf" 139 | from: /^pidfile.*$/ 140 | to: "" 141 | ``` 142 | 143 | Will substitute the regex with blank, removing the pidfile line 144 | 145 | ``` 146 | run: 147 | - replace: 148 | filename: "/etc/nginx/conf.d/discourse.conf" 149 | from: /upstream[^\}]+\}/m 150 | to: "upstream discourse { 151 | server 127.0.0.1:3000; 152 | }" 153 | ``` 154 | 155 | Additional params: 156 | 157 | Global replace (as opposed to first match) 158 | ``` 159 | global: true 160 | ``` 161 | 162 | #### Hooks 163 | 164 | Execute commands before and after a specific command by defining a hook. 165 | 166 | ``` 167 | run 168 | - exec: 169 | hook: hello 170 | cmd: echo 'Hello' 171 | 172 | hooks: 173 | before_hello: 174 | - exec: 175 | cmd: echo 'Starting...' 176 | 177 | after_hello: 178 | - exec: 179 | cmd: echo 'World' 180 | ``` 181 | 182 | #### Merge yaml files 183 | 184 | ``` 185 | home: /var/www/my_app 186 | params: 187 | database_yml: 188 | production: 189 | username: discourse 190 | password: foo 191 | 192 | run: 193 | - merge: $home/config/database.yml $database_yml 194 | 195 | ``` 196 | 197 | Will merge the yaml file with the inline contents. 198 | 199 | #### A common environment 200 | 201 | Environment variables can be specified under the `env` key, which will be included in the environment for the template. 202 | 203 | ``` 204 | env: 205 | MY_ENV: "a couple of words" 206 | run: 207 | - exec: echo $MY_ENV > tmpfile 208 | ``` 209 | 210 | `tmpfile` will contain `a couple of words`. 211 | 212 | You can also specify variables to be templated within the environment, such as: 213 | 214 | ``` 215 | env: 216 | greeting: "hello, {{location}}!" 217 | env_template: 218 | location: world 219 | ``` 220 | 221 | In this example, the `greeting` environment variable will be set to `hello, world!` during initialisation as the `{{location}}` variable will be templated as `world`. 222 | Pups will also look in the environment itself at runtime for template variables, prefixed with `env_template_`. 223 | Note that strings should be quoted to prevent YAML from parsing the `{ }` characters. 224 | 225 | All commands executed will inherit the environment once parsing and variable interpolation has been completed. 226 | 227 | ## Contributing 228 | 229 | 1. Fork it 230 | 2. Create your feature branch (`git checkout -b my-new-feature`) 231 | 3. Commit your changes (`git commit -am 'Add some feature'`) 232 | 4. Push to the branch (`git push origin my-new-feature`) 233 | 5. Create new Pull Request 234 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'test' 8 | t.libs << 'lib' 9 | t.test_files = FileList['test/*_test.rb'] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/pups: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/../lib" 5 | 6 | require 'pups' 7 | require 'pups/cli' 8 | 9 | Pups::Cli.run(ARGV) 10 | -------------------------------------------------------------------------------- /lib/pups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | require "yaml" 5 | 6 | require "pups/version" 7 | require "pups/config" 8 | require "pups/command" 9 | require "pups/exec_command" 10 | require "pups/merge_command" 11 | require "pups/replace_command" 12 | require "pups/file_command" 13 | require "pups/docker" 14 | require "pups/runit" 15 | 16 | module Pups 17 | class ExecError < RuntimeError 18 | attr_accessor :exit_code 19 | end 20 | 21 | def self.log 22 | # at the moment docker likes this 23 | @logger ||= Logger.new($stderr) 24 | end 25 | 26 | def self.log=(logger) 27 | @logger = logger 28 | end 29 | 30 | def self.silence 31 | @logger.close if @logger 32 | 33 | @logger = Logger.new(File.open(File::NULL, "w")) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/pups/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "optparse" 4 | 5 | module Pups 6 | class Cli 7 | def self.opts 8 | OptionParser.new do |opts| 9 | opts.banner = "Usage: pups [FILE|--stdin]" 10 | opts.on("--stdin", "Read input from stdin.") 11 | opts.on("--quiet", "Don't print any logs.") 12 | opts.on( 13 | "--ignore ", 14 | Array, 15 | "Ignore these template configuration elements, multiple elements can be provided (comma-delimited)." 16 | ) 17 | opts.on( 18 | "--gen-docker-run-args", 19 | "Output arguments from the pups configuration for input into a docker run command. All other pups config is ignored." 20 | ) 21 | opts.on("--tags ", Array, "Only run tagged commands.") 22 | opts.on( 23 | "--skip-tags ", 24 | Array, 25 | "Run all but listed tagged commands." 26 | ) 27 | opts.on("-h", "--help") do 28 | puts opts 29 | exit 30 | end 31 | end 32 | end 33 | 34 | def self.parse_args(args) 35 | options = {} 36 | opts.parse!(args, into: options) 37 | options 38 | end 39 | 40 | def self.run(args) 41 | options = parse_args(args) 42 | input_file = options[:stdin] ? "stdin" : args.last 43 | unless input_file 44 | puts opts.parse!(%w[--help]) 45 | exit 46 | end 47 | 48 | Pups.silence if options[:quiet] 49 | 50 | Pups.log.info("Reading from #{input_file}") 51 | 52 | if options[:stdin] 53 | conf = $stdin.readlines.join 54 | split = conf.split("_FILE_SEPERATOR_") 55 | 56 | conf = nil 57 | split.each do |data| 58 | current = YAML.safe_load(data.strip) 59 | conf = 60 | if conf 61 | Pups::MergeCommand.deep_merge(conf, current, :merge_arrays) 62 | else 63 | current 64 | end 65 | end 66 | 67 | config = 68 | Pups::Config.new( 69 | conf, 70 | options[:ignore], 71 | tags: options[:tags], 72 | skip_tags: options[:"skip-tags"] 73 | ) 74 | else 75 | config = 76 | Pups::Config.load_file( 77 | input_file, 78 | options[:ignore], 79 | tags: options[:tags], 80 | skip_tags: options[:"skip-tags"] 81 | ) 82 | end 83 | 84 | if options[:"gen-docker-run-args"] 85 | print config.generate_docker_run_arguments 86 | return 87 | end 88 | 89 | config.run 90 | ensure 91 | Pups::ExecCommand.terminate_async 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/pups/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class Command 5 | def self.run(command, params) 6 | case command 7 | when String 8 | from_str(command, params).run 9 | when Hash 10 | from_hash(command, params).run 11 | end 12 | end 13 | 14 | def self.interpolate_params(cmd, params) 15 | Pups::Config.interpolate_params(cmd, params) 16 | end 17 | 18 | def interpolate_params(cmd) 19 | Pups::Command.interpolate_params(cmd, @params) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/pups/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class Config 5 | attr_reader :config, :params 6 | 7 | def initialize( 8 | config, 9 | ignored = nil, 10 | tags: tags = nil, 11 | skip_tags: skip_tags = nil 12 | ) 13 | @config = config 14 | 15 | # remove any ignored config elements prior to any more processing 16 | ignored&.each { |e| @config.delete(e) } 17 | 18 | filter_tags(include_tags: tags, exclude_tags: skip_tags) 19 | 20 | # set some defaults to prevent checks in various functions 21 | %w[env_template env labels params].each do |key| 22 | @config[key] = {} unless @config.has_key?(key) 23 | end 24 | 25 | # Order here is important. 26 | Pups::Config.combine_template_and_process_env(@config, ENV) 27 | Pups::Config.prepare_env_template_vars(@config["env_template"], ENV) 28 | 29 | # Templating is supported in env and label variables. 30 | Pups::Config.transform_config_with_templated_vars( 31 | @config["env_template"], 32 | ENV 33 | ) 34 | Pups::Config.transform_config_with_templated_vars( 35 | @config["env_template"], 36 | @config["env"] 37 | ) 38 | Pups::Config.transform_config_with_templated_vars( 39 | @config["env_template"], 40 | @config["labels"] 41 | ) 42 | 43 | @params = @config["params"] 44 | ENV.each { |k, v| @params["$ENV_#{k}"] = v } 45 | inject_hooks 46 | end 47 | 48 | def self.load_file( 49 | config_file, 50 | ignored = nil, 51 | tags: tags = nil, 52 | skip_tags: skip_tags = nil 53 | ) 54 | Config.new( 55 | YAML.load_file(config_file), 56 | ignored, 57 | tags: tags, 58 | skip_tags: skip_tags 59 | ) 60 | rescue Exception 61 | warn "Failed to parse #{config_file}" 62 | warn "This is probably a formatting error in #{config_file}" 63 | warn "Cannot continue. Edit #{config_file} and try again." 64 | raise 65 | end 66 | 67 | def self.load_config( 68 | config, 69 | ignored = nil, 70 | tags: tags = nil, 71 | skip_tags: skip_tags = nil 72 | ) 73 | Config.new( 74 | YAML.safe_load(config), 75 | ignored, 76 | tags: tags, 77 | skip_tags: skip_tags 78 | ) 79 | end 80 | 81 | def self.prepare_env_template_vars(env_template, env) 82 | # Merge env_template variables from env and templates. 83 | env.each do |k, v| 84 | if k.include?("env_template_") 85 | key = k.gsub("env_template_", "") 86 | env_template[key] = v.to_s 87 | end 88 | end 89 | end 90 | 91 | def self.transform_config_with_templated_vars(env_template, to_transform) 92 | # Transform any templated variables prior to copying to params. 93 | # This has no effect if no env_template was provided. 94 | env_template.each do |k, v| 95 | to_transform.each do |key, val| 96 | if val.to_s.include?("{{#{k}}}") 97 | to_transform[key] = val.gsub("{{#{k}}}", v.to_s) 98 | end 99 | end 100 | end 101 | end 102 | 103 | def self.combine_template_and_process_env(config, env) 104 | # Merge all template env variables and process env variables, so that env 105 | # variables can be provided both by configuration and runtime variables. 106 | config["env"].each { |k, v| env[k] = v.to_s } 107 | end 108 | 109 | # Filter run commands by tag: by default, keep all commands that contain tags. 110 | # If skip_tags argument is true, keep all commands that DO NOT contain tags. 111 | def filter_tags( 112 | include_tags: include_tags = nil, 113 | exclude_tags: exclude_tags = nil 114 | ) 115 | if include_tags 116 | @config["run"] = @config["run"].select do |row| 117 | keep = false 118 | command = row.first 119 | if command[1].is_a?(Hash) 120 | tag = command[1]["tag"] 121 | keep = include_tags.include?(tag) 122 | end 123 | keep 124 | end 125 | end 126 | 127 | if exclude_tags 128 | @config["run"] = @config["run"].select do |row| 129 | keep = true 130 | command = row.first 131 | if command[1].is_a?(Hash) 132 | tag = command[1]["tag"] 133 | keep = !exclude_tags.include?(tag) 134 | end 135 | keep 136 | end 137 | end 138 | end 139 | 140 | def inject_hooks 141 | return unless hooks = @config["hooks"] 142 | 143 | run = @config["run"] 144 | 145 | positions = {} 146 | run.each do |row| 147 | next unless row.is_a?(Hash) 148 | 149 | command = row.first 150 | if command[1].is_a?(Hash) 151 | hook = command[1]["hook"] 152 | positions[hook] = row if hook 153 | end 154 | end 155 | 156 | hooks.each do |full, list| 157 | offset = nil 158 | name = nil 159 | 160 | if full =~ /^after_/ 161 | name = full[6..-1] 162 | offset = 1 163 | end 164 | 165 | if full =~ /^before_/ 166 | name = full[7..-1] 167 | offset = 0 168 | end 169 | 170 | index = run.index(positions[name]) 171 | 172 | if index && index >= 0 173 | run.insert(index + offset, *list) 174 | else 175 | Pups.log.info "Skipped missing #{full} hook" 176 | end 177 | end 178 | end 179 | 180 | def generate_docker_run_arguments 181 | output = [] 182 | output << Pups::Docker.generate_env_arguments(config["env"]) 183 | output << Pups::Docker.generate_link_arguments(config["links"]) 184 | output << Pups::Docker.generate_expose_arguments(config["expose"]) 185 | output << Pups::Docker.generate_volume_arguments(config["volumes"]) 186 | output << Pups::Docker.generate_label_arguments(config["labels"]) 187 | output.sort!.join(" ").strip 188 | end 189 | 190 | def run 191 | run_commands 192 | rescue StandardError => e 193 | exit_code = 1 194 | exit_code = e.exit_code if e.is_a?(Pups::ExecError) 195 | unless exit_code == 77 196 | puts 197 | puts 198 | puts "FAILED" 199 | puts "-" * 20 200 | puts "#{e.class}: #{e}" 201 | puts "Location of failure: #{e.backtrace[0]}" 202 | if @last_command 203 | puts "#{@last_command[:command]} failed with the params #{@last_command[:params].inspect}" 204 | end 205 | end 206 | exit exit_code 207 | end 208 | 209 | def run_commands 210 | @config["run"]&.each do |item| 211 | item.each do |k, v| 212 | type = 213 | case k 214 | when "exec" 215 | Pups::ExecCommand 216 | when "merge" 217 | Pups::MergeCommand 218 | when "replace" 219 | Pups::ReplaceCommand 220 | when "file" 221 | Pups::FileCommand 222 | else 223 | raise SyntaxError, "Invalid run command #{k}" 224 | end 225 | 226 | @last_command = { command: k, params: v } 227 | type.run(v, @params) 228 | end 229 | end 230 | end 231 | 232 | def interpolate_params(cmd) 233 | self.class.interpolate_params(cmd, @params) 234 | end 235 | 236 | def self.interpolate_params(cmd, params) 237 | return unless cmd 238 | 239 | processed = cmd.dup 240 | params.each { |k, v| processed.gsub!("$#{k}", v.to_s) } 241 | processed 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /lib/pups/docker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "shellwords" 3 | 4 | class Pups::Docker 5 | class << self 6 | def generate_env_arguments(config) 7 | output = [] 8 | config&.each do |k, v| 9 | if !v.to_s.empty? 10 | output << "--env #{k}=#{escape_user_string_literal(v)}" 11 | end 12 | end 13 | normalize_output(output) 14 | end 15 | 16 | def generate_link_arguments(config) 17 | output = [] 18 | config&.each do |c| 19 | output << "--link #{c["link"]["name"]}:#{c["link"]["alias"]}" 20 | end 21 | normalize_output(output) 22 | end 23 | 24 | def generate_expose_arguments(config) 25 | output = [] 26 | config&.each do |c| 27 | if c.to_s.include?(":") 28 | output << "--publish #{c}" 29 | else 30 | output << "--expose #{c}" 31 | end 32 | end 33 | normalize_output(output) 34 | end 35 | 36 | def generate_volume_arguments(config) 37 | output = [] 38 | config&.each do |c| 39 | output << "--volume #{c["volume"]["host"]}:#{c["volume"]["guest"]}" 40 | end 41 | normalize_output(output) 42 | end 43 | 44 | def generate_label_arguments(config) 45 | output = [] 46 | config&.each do |k, v| 47 | output << "--label #{k}=#{escape_user_string_literal(v)}" 48 | end 49 | normalize_output(output) 50 | end 51 | 52 | private 53 | 54 | def escape_user_string_literal(str) 55 | # We need to escape the following strings as they are more likely to contain 56 | # special characters than any of the other config variables on a Linux system: 57 | # - the value side of an environment variable 58 | # - the value side of a label. 59 | Shellwords.escape(str) 60 | end 61 | 62 | def normalize_output(output) 63 | output.empty? ? "" : output.join(" ") 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/pups/exec_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "timeout" 4 | require "English" 5 | 6 | module Pups 7 | class ExecCommand < Pups::Command 8 | attr_reader :commands, :cd 9 | attr_accessor :background, :raise_on_fail, :stdin, :stop_signal 10 | 11 | def self.terminate_async(opts = {}) 12 | return unless defined?(@@asyncs) 13 | 14 | Pups.log.info("Terminating async processes") 15 | 16 | @@asyncs.each do |async| 17 | Pups.log.info( 18 | "Sending #{async[:stop_signal]} to #{async[:command]} pid: #{async[:pid]}" 19 | ) 20 | begin 21 | Process.kill(async[:stop_signal], async[:pid]) 22 | rescue StandardError 23 | nil 24 | end 25 | end 26 | 27 | @@asyncs 28 | .map do |async| 29 | Thread.new do 30 | Timeout.timeout(opts[:wait] || 10) do 31 | Process.wait(async[:pid]) 32 | rescue StandardError 33 | nil 34 | end 35 | rescue Timeout::Error 36 | Pups.log.info( 37 | "#{async[:command]} pid:#{async[:pid]} did not terminate cleanly, forcing termination!" 38 | ) 39 | begin 40 | Process.kill("KILL", async[:pid]) 41 | Process.wait(async[:pid]) 42 | rescue Errno::ESRCH 43 | rescue Errno::ECHILD 44 | end 45 | end 46 | end 47 | .each(&:join) 48 | end 49 | 50 | def self.from_hash(hash, params) 51 | cmd = new(params, hash["cd"]) 52 | 53 | case c = hash["cmd"] 54 | when String 55 | cmd.add(c) 56 | when Array 57 | c.each { |i| cmd.add(i) } 58 | end 59 | 60 | cmd.background = hash["background"] 61 | cmd.stop_signal = hash["stop_signal"] || "TERM" 62 | cmd.raise_on_fail = hash["raise_on_fail"] if hash.key? "raise_on_fail" 63 | cmd.stdin = interpolate_params(hash["stdin"], params) 64 | 65 | cmd 66 | end 67 | 68 | def self.from_str(str, params) 69 | cmd = new(params) 70 | cmd.add(str) 71 | cmd 72 | end 73 | 74 | def initialize(params, cd = nil) 75 | @commands = [] 76 | @params = params 77 | @cd = interpolate_params(cd) 78 | @raise_on_fail = true 79 | end 80 | 81 | def add(cmd) 82 | @commands << process_params(cmd) 83 | end 84 | 85 | def run 86 | commands.each do |command| 87 | Pups.log.info("> #{command}") 88 | pid = spawn(command) 89 | Pups.log.info(@result.readlines.join("\n")) if @result 90 | pid 91 | end 92 | rescue StandardError 93 | raise if @raise_on_fail 94 | end 95 | 96 | def spawn(command) 97 | if background 98 | pid = Process.spawn(command) 99 | (@@asyncs ||= []) << { 100 | pid: pid, 101 | command: command, 102 | stop_signal: (stop_signal || "TERM") 103 | } 104 | Thread.new do 105 | begin 106 | Process.wait(pid) 107 | rescue Errno::ECHILD 108 | # already exited so skip 109 | end 110 | @@asyncs.delete_if { |async| async[:pid] == pid } 111 | end 112 | return pid 113 | end 114 | 115 | IO.popen(command, "w+") do |f| 116 | if stdin 117 | # need a way to get stdout without blocking 118 | Pups.log.info(stdin) 119 | f.write stdin 120 | f.close 121 | else 122 | Pups.log.info(f.readlines.join) 123 | end 124 | end 125 | 126 | unless $CHILD_STATUS == 0 127 | err = 128 | Pups::ExecError.new( 129 | "#{command} failed with return #{$CHILD_STATUS.inspect}" 130 | ) 131 | err.exit_code = $CHILD_STATUS.exitstatus 132 | raise err 133 | end 134 | 135 | nil 136 | end 137 | 138 | def process_params(cmd) 139 | processed = interpolate_params(cmd) 140 | @cd ? "cd #{cd} && #{processed}" : processed 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/pups/file_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class FileCommand < Pups::Command 5 | attr_accessor :path, :contents, :params, :type, :chmod, :chown 6 | 7 | def self.from_hash(hash, params) 8 | command = new 9 | command.path = hash["path"] 10 | command.contents = hash["contents"] 11 | command.chmod = hash["chmod"] 12 | command.chown = hash["chown"] 13 | command.params = params 14 | 15 | command 16 | end 17 | 18 | def initialize 19 | @params = {} 20 | @type = :bash 21 | end 22 | 23 | attr_writer :params 24 | 25 | def run 26 | path = interpolate_params(@path) 27 | 28 | `mkdir -p #{File.dirname(path)}` 29 | File.open(path, "w") { |f| f.write(interpolate_params(contents)) } 30 | `chmod #{@chmod} #{path}` if @chmod 31 | `chown #{@chown} #{path}` if @chown 32 | Pups.log.info("File > #{path} chmod: #{@chmod} chown: #{@chown}") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/pups/merge_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class MergeCommand < Pups::Command 5 | attr_reader :filename, :merge_hash 6 | 7 | def self.from_str(command, params) 8 | new(command, params) 9 | end 10 | 11 | def self.parse_command(command) 12 | split = command.split(" ") 13 | unless split[-1][0] == "$" 14 | raise ArgumentError, "Invalid merge command #{command}" 15 | end 16 | 17 | [split[0..-2].join(" "), split[-1][1..-1]] 18 | end 19 | 20 | def initialize(command, params) 21 | @params = params 22 | 23 | filename, target_param = Pups::MergeCommand.parse_command(command) 24 | @filename = interpolate_params(filename) 25 | @merge_hash = params[target_param] 26 | end 27 | 28 | def run 29 | merged = self.class.deep_merge(YAML.load_file(@filename), @merge_hash) 30 | File.open(@filename, "w") { |f| f.write(merged.to_yaml) } 31 | Pups.log.info("Merge: #{@filename} with: \n#{@merge_hash.inspect}") 32 | end 33 | 34 | def self.deep_merge(first, second, *args) 35 | args ||= [] 36 | merge_arrays = args.include? :merge_arrays 37 | 38 | merger = 39 | proc do |_key, v1, v2| 40 | if v1.is_a?(Hash) && v2.is_a?(Hash) 41 | v1.merge(v2, &merger) 42 | elsif v1.is_a?(Array) && v2.is_a?(Array) 43 | merge_arrays ? v1 + v2 : v2 44 | elsif v2.is_a?(NilClass) 45 | v1 46 | else 47 | v2 48 | end 49 | end 50 | first.merge(second, &merger) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/pups/replace_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class ReplaceCommand < Pups::Command 5 | attr_accessor :text, :from, :to, :filename, :direction, :global 6 | 7 | def self.from_hash(hash, params) 8 | replacer = new(params) 9 | replacer.from = guess_replace_type(hash["from"]) 10 | replacer.to = guess_replace_type(hash["to"]) 11 | replacer.text = File.read(hash["filename"]) 12 | replacer.filename = hash["filename"] 13 | replacer.direction = hash["direction"].to_sym if hash["direction"] 14 | replacer.global = hash["global"].to_s == "true" 15 | replacer 16 | end 17 | 18 | def self.guess_replace_type(item) 19 | # evaling to get all the regex flags easily 20 | item[0] == "/" ? eval(item) : item # rubocop:disable Security/Eval 21 | end 22 | 23 | def initialize(params) 24 | @params = params 25 | end 26 | 27 | def replaced_text 28 | new_to = to 29 | new_to = interpolate_params(to) if to.is_a?(String) 30 | if global 31 | text.gsub(from, new_to) 32 | elsif direction == :reverse 33 | index = text.rindex(from) 34 | text[0..index - 1] << text[index..-1].sub(from, new_to) 35 | else 36 | text.sub(from, new_to) 37 | end 38 | end 39 | 40 | def run 41 | Pups.log.info("Replacing #{from} with #{to} in #{filename}") 42 | File.open(filename, "w") { |f| f.write replaced_text } 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/pups/runit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | class Runit 5 | attr_accessor :env, :exec, :cd, :name 6 | 7 | def initialize(name) 8 | @name = name 9 | end 10 | 11 | def setup 12 | `mkdir -p /etc/service/#{name}` 13 | run = "/etc/service/#{name}/run" 14 | File.open(run, "w") { |f| f.write(run_script) } 15 | `chmod +x #{run}` 16 | end 17 | 18 | def run_script 19 | "#!/bin/bash 20 | exec 2>&1 21 | #{env_script} 22 | #{cd_script} 23 | #{exec} 24 | " 25 | end 26 | 27 | def cd_script 28 | "cd #{@cd}" if @cd 29 | end 30 | 31 | def env_script 32 | @env&.map { |k, v| "export #{k}=#{v}" }&.join("\n") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/pups/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pups 4 | VERSION = "1.2.1" 5 | end 6 | -------------------------------------------------------------------------------- /pups.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'pups/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'pups' 9 | spec.version = Pups::VERSION 10 | spec.authors = ['Sam Saffron'] 11 | spec.email = ['sam.saffron@gmail.com'] 12 | spec.description = 'Simple docker image creator' 13 | spec.summary = 'Toolkit for orchestrating a composed docker image' 14 | spec.homepage = '' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_development_dependency 'bundler' 23 | spec.add_development_dependency 'guard' 24 | spec.add_development_dependency 'guard-minitest' 25 | spec.add_development_dependency 'minitest' 26 | spec.add_development_dependency 'rake' 27 | spec.add_development_dependency 'rubocop' 28 | spec.add_development_dependency 'rubocop-discourse' 29 | spec.add_development_dependency 'rubocop-minitest' 30 | spec.add_development_dependency 'rubocop-rake' 31 | end 32 | -------------------------------------------------------------------------------- /test/cli_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | require "stringio" 6 | 7 | module Pups 8 | class CliTest < ::Minitest::Test 9 | def test_cli_option_parsing_stdin 10 | options = Cli.parse_args(["--stdin"]) 11 | assert_equal(true, options[:stdin]) 12 | end 13 | 14 | def test_cli_option_parsing_none 15 | options = Cli.parse_args([]) 16 | assert_nil(options[:stdin]) 17 | end 18 | 19 | def test_cli_read_config_from_file 20 | # for testing output 21 | f = Tempfile.new("test_output") 22 | f.close 23 | 24 | # for testing input 25 | cf = Tempfile.new("test_config") 26 | cf.puts <<~YAML 27 | params: 28 | run: #{f.path} 29 | run: 30 | - exec: echo hello world >> #{f.path} 31 | YAML 32 | cf.close 33 | 34 | Cli.run([cf.path]) 35 | assert_equal("hello world", File.read(f.path).strip) 36 | end 37 | 38 | def test_cli_ignore_config_element 39 | # for testing output 40 | f = Tempfile.new("test_output") 41 | f.close 42 | 43 | # for testing input 44 | cf = Tempfile.new("test_config") 45 | cf.puts <<~YAML 46 | env: 47 | MY_IGNORED_VAR: a_word 48 | params: 49 | a_param_var: another_word 50 | run: 51 | - exec: echo repeating $MY_IGNORED_VAR and also $a_param_var >> #{f.path} 52 | YAML 53 | cf.close 54 | 55 | Cli.run(["--ignore", "env,params", cf.path]) 56 | assert_equal("repeating and also", File.read(f.path).strip) 57 | end 58 | 59 | def test_cli_gen_docker_run_args_ignores_other_config 60 | # When generating the docker run arguments it should ignore other template configuration 61 | # like 'run' directives. 62 | 63 | # for testing output 64 | f = Tempfile.new("test_output") 65 | f.close 66 | 67 | # for testing input 68 | cf = Tempfile.new("test_config") 69 | cf.puts <<~YAML 70 | env: 71 | foo: 1 72 | bar: 5 73 | baz: 'hello_{{spam}}' 74 | env_template: 75 | spam: 'eggs' 76 | config: my_app 77 | params: 78 | run: #{f.path} 79 | run: 80 | - exec: echo hello world >> #{f.path} 81 | expose: 82 | - "2222:22" 83 | - "127.0.0.1:20080:80" 84 | - 5555 85 | volumes: 86 | - volume: 87 | host: /var/discourse/shared 88 | guest: /shared 89 | - volume: 90 | host: /bar 91 | guest: /baz 92 | links: 93 | - link: 94 | name: postgres 95 | alias: postgres 96 | - link: 97 | name: foo 98 | alias: bar 99 | labels: 100 | monitor: "true" 101 | app_name: "{{config}}_discourse" 102 | YAML 103 | cf.close 104 | 105 | expected = [] 106 | expected << "--env foo=1 --env bar=5 --env baz=hello_eggs" 107 | expected << "--publish 2222:22 --publish 127.0.0.1:20080:80 --expose 5555" 108 | expected << "--volume /var/discourse/shared:/shared --volume /bar:/baz" 109 | expected << "--link postgres:postgres --link foo:bar" 110 | expected << "--label monitor=true --label app_name=my_app_discourse" 111 | expected.sort! 112 | 113 | assert_equal("", File.read(f.path).strip) 114 | assert_output(expected.join(" ")) do 115 | Cli.run(["--gen-docker-run-args", cf.path]) 116 | end 117 | end 118 | 119 | def test_cli_tags 120 | # for testing output 121 | f = Tempfile.new("test_output") 122 | f.close 123 | 124 | # for testing input 125 | cf = Tempfile.new("test_config") 126 | cf.puts <<~YAML 127 | run: 128 | - exec: 129 | tag: '1' 130 | cmd: echo 1 >> #{f.path} 131 | - exec: 132 | tag: '2' 133 | cmd: echo 2 >> #{f.path} 134 | - exec: 135 | tag: '3' 136 | cmd: echo 3 >> #{f.path} 137 | YAML 138 | cf.close 139 | 140 | Cli.run(["--tags", "1,3", cf.path]) 141 | assert_equal("1\n3", File.read(f.path).strip) 142 | end 143 | def test_cli_skip_tags 144 | # for testing output 145 | f = Tempfile.new("test_output") 146 | f.close 147 | 148 | # for testing input 149 | cf = Tempfile.new("test_config") 150 | cf.puts <<~YAML 151 | run: 152 | - exec: 153 | tag: '1' 154 | cmd: echo 1 >> #{f.path} 155 | - exec: 156 | tag: '2' 157 | cmd: echo 2 >> #{f.path} 158 | - exec: 159 | tag: '3' 160 | cmd: echo 3 >> #{f.path} 161 | YAML 162 | cf.close 163 | 164 | Cli.run(["--skip-tags", "1,3", cf.path]) 165 | assert_equal("2", File.read(f.path).strip) 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/config_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | 6 | module Pups 7 | class ConfigTest < ::Minitest::Test 8 | def test_config_from_env 9 | ENV["HELLO"] = "world" 10 | config = Config.new({}) 11 | assert_equal("world", config.params["$ENV_HELLO"]) 12 | end 13 | 14 | def test_env_param 15 | ENV["FOO"] = "BAR" 16 | config = <<~YAML 17 | env: 18 | BAR: baz 19 | hello: WORLD 20 | one: 1 21 | YAML 22 | 23 | config = Config.new(YAML.safe_load(config)) 24 | %w[BAR hello one].each { |e| ENV.delete(e) } 25 | assert_equal("BAR", config.params["$ENV_FOO"]) 26 | assert_equal("baz", config.params["$ENV_BAR"]) 27 | assert_equal("WORLD", config.params["$ENV_hello"]) 28 | assert_equal("1", config.params["$ENV_one"]) 29 | end 30 | 31 | def test_env_with_template 32 | ENV["FOO"] = "BAR" 33 | config = <<~YAML 34 | env: 35 | greeting: "{{hello}}, {{planet}}!" 36 | one: 1 37 | other: "where are we on {{planet}}?" 38 | env_template: 39 | planet: pluto 40 | hello: hola 41 | YAML 42 | config_hash = YAML.safe_load(config) 43 | 44 | config = Config.new(config_hash) 45 | %w[greeting one other].each { |e| ENV.delete(e) } 46 | assert_equal("hola, pluto!", config.params["$ENV_greeting"]) 47 | assert_equal("1", config.params["$ENV_one"]) 48 | assert_equal("BAR", config.params["$ENV_FOO"]) 49 | assert_equal("where are we on pluto?", config.params["$ENV_other"]) 50 | end 51 | 52 | def test_label_with_template 53 | ENV["FOO"] = "BAR" 54 | config = <<~YAML 55 | env: 56 | greeting: "{{hello}}, {{planet}}!" 57 | one: 1 58 | other: "where are we on {{planet}}?" 59 | env_template: 60 | planet: pluto 61 | hello: hola 62 | config: various 63 | labels: 64 | app_name: "{{config}}_discourse" 65 | YAML 66 | config_hash = YAML.load(config) 67 | 68 | config = Config.new(config_hash) 69 | %w[greeting one other].each { |e| ENV.delete(e) } 70 | assert_equal("various_discourse", config.config["labels"]["app_name"]) 71 | end 72 | 73 | def test_env_with_ENV_templated_variable 74 | ENV["env_template_config"] = "my_application" 75 | config = <<~YAML 76 | env: 77 | greeting: "{{hello}}, {{planet}}!" 78 | one: 1 79 | other: "building {{config}}" 80 | env_template: 81 | planet: pluto 82 | hello: hola 83 | YAML 84 | config_hash = YAML.safe_load(config) 85 | 86 | config = Config.new(config_hash) 87 | %w[greeting one other].each { |e| ENV.delete(e) } 88 | assert_equal("hola, pluto!", config.params["$ENV_greeting"]) 89 | assert_equal("1", config.params["$ENV_one"]) 90 | assert_equal("building my_application", config.params["$ENV_other"]) 91 | ENV["env_template_config"] = nil 92 | end 93 | 94 | def test_integration 95 | f = Tempfile.new("test") 96 | f.close 97 | 98 | config = <<~YAML 99 | env: 100 | PLANET: world 101 | params: 102 | run: #{f.path} 103 | greeting: hello 104 | run: 105 | - exec: echo $greeting $PLANET >> #{f.path} 106 | YAML 107 | 108 | Config.new(YAML.safe_load(config)).run 109 | ENV.delete("PLANET") 110 | assert_equal("hello world", File.read(f.path).strip) 111 | ensure 112 | f.unlink 113 | end 114 | 115 | def test_hooks 116 | yaml = <<~YAML 117 | run: 118 | - exec: 1 119 | - exec: 120 | hook: middle 121 | cmd: 2 122 | - exec: 3 123 | hooks: 124 | after_middle: 125 | - exec: 2.1 126 | before_middle: 127 | - exec: 1.9 128 | YAML 129 | 130 | config = Config.load_config(yaml).config 131 | assert_equal({ "exec" => 1.9 }, config["run"][1]) 132 | assert_equal({ "exec" => 2.1 }, config["run"][3]) 133 | end 134 | 135 | def test_ignored_elements 136 | f = Tempfile.new("test") 137 | f.close 138 | 139 | yaml = <<~YAML 140 | env: 141 | PLANET: world 142 | params: 143 | greeting: hello 144 | run: 145 | - exec: 1 146 | - exec: 147 | hook: middle 148 | cmd: 2 149 | - exec: 3 150 | - exec: echo $greeting $PLANET >> #{f.path} 151 | hooks: 152 | after_middle: 153 | - exec: 2.1 154 | before_middle: 155 | - exec: 1.9 156 | YAML 157 | 158 | conf = Config.load_config(yaml, %w[hooks params]) 159 | config = conf.config 160 | assert_equal({ "exec" => 1 }, config["run"][0]) 161 | assert_equal( 162 | { "exec" => { "hook" => "middle", "cmd" => 2 } }, 163 | config["run"][1] 164 | ) 165 | assert_equal({ "exec" => 3 }, config["run"][2]) 166 | assert_equal( 167 | { "exec" => "echo $greeting $PLANET >> #{f.path}" }, 168 | config["run"][3] 169 | ) 170 | 171 | # $greet from params will be an empty var as it was ignored 172 | conf.run 173 | ENV.delete("PLANET") 174 | assert_equal("world", File.read(f.path).strip) 175 | end 176 | 177 | def test_generate_docker_run_arguments 178 | yaml = <<~YAML 179 | env: 180 | foo: 1 181 | bar: 2 182 | baz: 'hello_{{spam}}' 183 | env_template: 184 | spam: 'eggs' 185 | config: my_app 186 | expose: 187 | - "2222:22" 188 | - "127.0.0.1:20080:80" 189 | - 5555 190 | volumes: 191 | - volume: 192 | host: /var/discourse/shared 193 | guest: /shared 194 | - volume: 195 | host: /bar 196 | guest: /baz 197 | links: 198 | - link: 199 | name: postgres 200 | alias: postgres 201 | - link: 202 | name: foo 203 | alias: bar 204 | labels: 205 | monitor: "true" 206 | app_name: "{{config}}_discourse" 207 | YAML 208 | 209 | config = Config.load_config(yaml) 210 | args = config.generate_docker_run_arguments 211 | 212 | expected = [] 213 | expected << "--env foo=1 --env bar=2 --env baz=hello_eggs" 214 | expected << "--publish 2222:22 --publish 127.0.0.1:20080:80 --expose 5555" 215 | expected << "--volume /var/discourse/shared:/shared --volume /bar:/baz" 216 | expected << "--link postgres:postgres --link foo:bar" 217 | expected << "--label monitor=true --label app_name=my_app_discourse" 218 | expected.sort! 219 | 220 | assert_equal(expected.join(" "), args) 221 | end 222 | 223 | def test_tag_filtering 224 | f = Tempfile.new("test") 225 | f.close 226 | 227 | yaml = <<~YAML 228 | run: 229 | - exec: 1 230 | - exec: 231 | hook: middle 232 | cmd: 2 233 | tag: one_tag 234 | - exec: 235 | cmd: 3 236 | tag: two_tag 237 | hooks: 238 | after_middle: 239 | - exec: 2.1 240 | before_middle: 241 | - exec: 1.9 242 | YAML 243 | 244 | # No tagging loads everything 245 | conf = Config.load_config(yaml) 246 | config = conf.config 247 | assert_equal({ "exec" => 1 }, config["run"][0]) 248 | assert_equal({ "exec" => 1.9 }, config["run"][1]) 249 | assert_equal( 250 | { "exec" => { "hook" => "middle", "cmd" => 2, "tag" => "one_tag" } }, 251 | config["run"][2] 252 | ) 253 | assert_equal({ "exec" => 2.1 }, config["run"][3]) 254 | assert_equal( 255 | { "exec" => { "cmd" => 3, "tag" => "two_tag" } }, 256 | config["run"][4] 257 | ) 258 | 259 | # hooks get applied if hook command is not filtered 260 | conf = Config.load_config(yaml, tags: ["one_tag"]) 261 | config = conf.config 262 | assert_equal({ "exec" => 1.9 }, config["run"][0]) 263 | assert_equal( 264 | { "exec" => { "hook" => "middle", "cmd" => 2, "tag" => "one_tag" } }, 265 | config["run"][1] 266 | ) 267 | assert_equal({ "exec" => 2.1 }, config["run"][2]) 268 | 269 | # hooks get filtered out if the main hook command is filtered 270 | conf = Config.load_config(yaml, tags: ["two_tag"]) 271 | config = conf.config 272 | assert_equal( 273 | { "exec" => { "cmd" => 3, "tag" => "two_tag" } }, 274 | config["run"][0] 275 | ) 276 | 277 | # skip tags filter out commands with tags 278 | conf = Config.load_config(yaml, skip_tags: ["one_tag"]) 279 | config = conf.config 280 | assert_equal({ "exec" => 1 }, config["run"][0]) 281 | assert_equal( 282 | { "exec" => { "cmd" => 3, "tag" => "two_tag" } }, 283 | config["run"][1] 284 | ) 285 | end 286 | end 287 | end 288 | -------------------------------------------------------------------------------- /test/docker_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | require "tempfile" 4 | require "shellwords" 5 | 6 | module Pups 7 | class DockerTest < ::Minitest::Test 8 | def test_gen_env_arguments 9 | yaml = <<~YAML 10 | env: 11 | foo: 1 12 | bar: 2 13 | baz: 'hello_{{spam}}' 14 | env_template: 15 | spam: 'eggs' 16 | YAML 17 | 18 | config = Config.load_config(yaml) 19 | Config.transform_config_with_templated_vars( 20 | config.config["env_template"], 21 | config.config["env"] 22 | ) 23 | args = Docker.generate_env_arguments(config.config["env"]) 24 | assert_equal("--env foo=1 --env bar=2 --env baz=hello_eggs", args) 25 | end 26 | 27 | def test_gen_env_arguments_empty 28 | yaml = <<~YAML 29 | env: 30 | foo: 1 31 | bar: 2 32 | baz: '' 33 | YAML 34 | 35 | config = Config.load_config(yaml) 36 | Config.transform_config_with_templated_vars( 37 | config.config["env_template"], 38 | config.config["env"] 39 | ) 40 | args = Docker.generate_env_arguments(config.config["env"]) 41 | assert_equal("--env foo=1 --env bar=2", args) 42 | end 43 | 44 | def test_gen_env_arguments_escaped 45 | yaml = <<~YAML 46 | env: 47 | password: "{{spam}}*`echo`@e$t| = >>$()&list;#" 48 | env_template: 49 | spam: 'eggs' 50 | YAML 51 | 52 | config = Config.load_config(yaml) 53 | Config.transform_config_with_templated_vars( 54 | config.config["env_template"], 55 | config.config["env"] 56 | ) 57 | args = Docker.generate_env_arguments(config.config["env"]) 58 | assert_equal( 59 | "--env password=#{Shellwords.escape("eggs*`echo`@e$t| = >>$()&list;#")}", 60 | args 61 | ) 62 | end 63 | 64 | def test_gen_env_arguments_quoted_with_a_space 65 | yaml = <<~YAML 66 | env: 67 | a_variable: here is a sentence 68 | YAML 69 | 70 | config = Config.load_config(yaml) 71 | Config.transform_config_with_templated_vars( 72 | config.config["env_template"], 73 | config.config["env"] 74 | ) 75 | args = Docker.generate_env_arguments(config.config["env"]) 76 | assert_equal('--env a_variable=here\ is\ a\ sentence', args) 77 | end 78 | 79 | def test_gen_env_arguments_newline 80 | pw = <<~PW 81 | this password is 82 | a weird one 83 | PW 84 | 85 | yaml = <<~YAML 86 | env: 87 | password: "#{pw}" 88 | env_template: 89 | spam: 'eggs' 90 | YAML 91 | 92 | config = Config.load_config(yaml) 93 | Config.transform_config_with_templated_vars( 94 | config.config["env_template"], 95 | config.config["env"] 96 | ) 97 | args = Docker.generate_env_arguments(config.config["env"]) 98 | assert_equal('--env password=this\ password\ is\ a\ weird\ one\ ', args) 99 | end 100 | 101 | def test_gen_expose_arguments 102 | yaml = <<~YAML 103 | expose: 104 | - "2222:22" 105 | - "127.0.0.1:20080:80" 106 | - 5555 107 | YAML 108 | 109 | config = Config.load_config(yaml) 110 | args = Docker.generate_expose_arguments(config.config["expose"]) 111 | assert_equal( 112 | "--publish 2222:22 --publish 127.0.0.1:20080:80 --expose 5555", 113 | args 114 | ) 115 | end 116 | 117 | def test_gen_volume_arguments 118 | yaml = <<~YAML 119 | volumes: 120 | - volume: 121 | host: /var/discourse/shared 122 | guest: /shared 123 | - volume: 124 | host: /bar 125 | guest: /baz 126 | YAML 127 | 128 | config = Config.load_config(yaml) 129 | args = Docker.generate_volume_arguments(config.config["volumes"]) 130 | assert_equal( 131 | "--volume /var/discourse/shared:/shared --volume /bar:/baz", 132 | args 133 | ) 134 | end 135 | 136 | def test_gen_link_arguments 137 | yaml = <<~YAML 138 | links: 139 | - link: 140 | name: postgres 141 | alias: postgres 142 | - link: 143 | name: foo 144 | alias: bar 145 | YAML 146 | 147 | config = Config.load_config(yaml) 148 | args = Docker.generate_link_arguments(config.config["links"]) 149 | assert_equal("--link postgres:postgres --link foo:bar", args) 150 | end 151 | 152 | def test_gen_label_arguments 153 | yaml = <<~YAML 154 | env_template: 155 | config: my_app 156 | labels: 157 | monitor: "true" 158 | app_name: "{{config}}_discourse" 159 | YAML 160 | 161 | config = Config.load_config(yaml) 162 | Config.transform_config_with_templated_vars( 163 | config.config["env_template"], 164 | config.config["labels"] 165 | ) 166 | args = Docker.generate_label_arguments(config.config["labels"]) 167 | assert_equal( 168 | "--label monitor=true --label app_name=my_app_discourse", 169 | args 170 | ) 171 | end 172 | 173 | def test_gen_label_arguments_escaped 174 | yaml = <<~YAML 175 | labels: 176 | app_name: "{{config}}'s_di$course" 177 | env_template: 178 | config: my_app 179 | YAML 180 | 181 | config = Config.load_config(yaml) 182 | Config.transform_config_with_templated_vars( 183 | config.config["env_template"], 184 | config.config["labels"] 185 | ) 186 | args = Docker.generate_label_arguments(config.config["labels"]) 187 | assert_equal( 188 | "--label app_name=#{Shellwords.escape("my_app's_di$course")}", 189 | args 190 | ) 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /test/exec_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | 6 | module Pups 7 | class ExecCommandTest < ::Minitest::Test 8 | def from_str(str, params = {}) 9 | ExecCommand.from_str(str, params).commands 10 | end 11 | 12 | def from_hash(hash, params = {}) 13 | ExecCommand.from_hash(hash, params).commands 14 | end 15 | 16 | def test_simple_str_command 17 | assert_equal(["do_something"], from_str("do_something")) 18 | end 19 | 20 | def test_simple_str_command_with_param 21 | assert_equal( 22 | ["hello world"], 23 | from_str("hello $bob", { "bob" => "world" }) 24 | ) 25 | end 26 | 27 | def test_nested_command 28 | assert_equal(["first"], from_hash("cmd" => "first")) 29 | end 30 | 31 | def test_multi_commands 32 | assert_equal(%w[first second], from_hash("cmd" => %w[first second])) 33 | end 34 | 35 | def test_multi_commands_with_home 36 | assert_equal( 37 | ["cd /home/sam && first", "cd /home/sam && second"], 38 | from_hash("cmd" => %w[first second], "cd" => "/home/sam") 39 | ) 40 | end 41 | 42 | def test_exec_works 43 | ExecCommand.from_str("ls", {}).run 44 | end 45 | 46 | def test_fails_for_bad_command 47 | assert_raises(Errno::ENOENT) { ExecCommand.from_str("boom", {}).run } 48 | end 49 | 50 | def test_backgroud_task_do_not_fail 51 | cmd = ExecCommand.new({}) 52 | cmd.background = true 53 | cmd.add("sleep 10 && exit 1") 54 | cmd.run 55 | end 56 | 57 | def test_raise_on_fail 58 | cmd = ExecCommand.new({}) 59 | cmd.add("chgrp -a") 60 | cmd.raise_on_fail = false 61 | cmd.run 62 | end 63 | 64 | def test_stdin 65 | `touch test_file` 66 | cmd = ExecCommand.new({}) 67 | cmd.add("read test ; echo $test > test_file") 68 | cmd.stdin = "hello" 69 | cmd.run 70 | 71 | assert_equal("hello\n", File.read("test_file")) 72 | ensure 73 | File.delete("test_file") 74 | end 75 | 76 | def test_fails_for_non_zero_exit 77 | assert_raises(Pups::ExecError) do 78 | ExecCommand.from_str("chgrp -a", {}).run 79 | end 80 | end 81 | 82 | def test_can_terminate_async 83 | cmd = ExecCommand.new({}) 84 | cmd.background = true 85 | pid = cmd.spawn("sleep 10 && exit 1") 86 | ExecCommand.terminate_async 87 | assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) } 88 | end 89 | 90 | def test_can_terminate_rogues 91 | cmd = ExecCommand.new({}) 92 | cmd.background = true 93 | pid = cmd.spawn('trap "echo TERM && sleep 100" TERM ; sleep 100') 94 | # we need to give bash enough time to trap 95 | sleep 0.01 96 | 97 | ExecCommand.terminate_async(wait: 0.1) 98 | 99 | assert_raises(Errno::ECHILD) { Process.waitpid(pid, Process::WNOHANG) } 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/file_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | 6 | module Pups 7 | class FileCommandTest < ::Minitest::Test 8 | def test_simple_file_creation 9 | tmp = Tempfile.new("test") 10 | tmp.write("x") 11 | tmp.close 12 | 13 | cmd = FileCommand.new 14 | cmd.path = tmp.path 15 | cmd.contents = "hello $world" 16 | cmd.params = { "world" => "world" } 17 | cmd.run 18 | 19 | assert_equal("hello world", File.read(tmp.path)) 20 | ensure 21 | tmp.close 22 | tmp.unlink 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/merge_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | 6 | module Pups 7 | class MergeCommandTest < ::Minitest::Test 8 | def test_deep_merge_arrays 9 | a = { a: { a: ["hi", 1] } } 10 | b = { a: { a: ["hi", 2] } } 11 | c = { a: {} } 12 | 13 | d = Pups::MergeCommand.deep_merge(a, b, :merge_arrays) 14 | d = Pups::MergeCommand.deep_merge(d, c, :merge_arrays) 15 | 16 | assert_equal(["hi", 1, "hi", 2], d[:a][:a]) 17 | end 18 | 19 | def test_merges 20 | source = <<~YAML 21 | user: 22 | name: "bob" 23 | password: "xyz" 24 | YAML 25 | 26 | f = Tempfile.new("test") 27 | f.write source 28 | f.close 29 | 30 | merge = <<~YAML 31 | user: 32 | name: "bob2" 33 | YAML 34 | 35 | MergeCommand.from_str( 36 | "#{f.path} $yaml", 37 | { "yaml" => YAML.safe_load(merge) } 38 | ).run 39 | 40 | changed = YAML.load_file(f.path) 41 | 42 | assert_equal( 43 | { "user" => { "name" => "bob2", "password" => "xyz" } }, 44 | changed 45 | ) 46 | 47 | def test_deep_merge_nil 48 | a = { param: { venison: "yes please" } } 49 | b = { param: nil } 50 | 51 | r1 = Pups::MergeCommand.deep_merge(a, b) 52 | r2 = Pups::MergeCommand.deep_merge(b, a) 53 | 54 | assert_equal({ venison: "yes please" }, r1[:param]) 55 | assert_equal({ venison: "yes please" }, r2[:param]) 56 | end 57 | ensure 58 | f.unlink 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/replace_command_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "tempfile" 5 | 6 | module Pups 7 | class ReplaceCommandTest < ::Minitest::Test 8 | def test_simple 9 | command = ReplaceCommand.new({}) 10 | command.text = "hello world" 11 | command.from = /he[^o]+o/ 12 | command.to = "world" 13 | 14 | assert_equal("world world", command.replaced_text) 15 | end 16 | 17 | def test_reverse 18 | source = <<~SCR 19 | 1 one thousand 1 20 | 1 one thousand 1 21 | 1 one thousand 1 22 | SCR 23 | 24 | f = Tempfile.new("test") 25 | f.write source 26 | f.close 27 | 28 | hash = { 29 | "filename" => f.path, 30 | "from" => "/one t.*d/", 31 | "to" => "hello world", 32 | "direction" => "reverse" 33 | } 34 | 35 | command = ReplaceCommand.from_hash(hash, {}) 36 | 37 | assert_equal( 38 | "1 one thousand 1\n1 one thousand 1\n1 hello world 1\n", 39 | command.replaced_text 40 | ) 41 | ensure 42 | f.unlink 43 | end 44 | 45 | def test_global 46 | source = <<~SCR 47 | one 48 | one 49 | one 50 | SCR 51 | 52 | f = Tempfile.new("test") 53 | f.write source 54 | f.close 55 | 56 | hash = { 57 | "filename" => f.path, 58 | "from" => "/one/", 59 | "to" => "two", 60 | "global" => "true" 61 | } 62 | 63 | command = ReplaceCommand.from_hash(hash, {}) 64 | 65 | assert_equal("two\ntwo\ntwo\n", command.replaced_text) 66 | ensure 67 | f.unlink 68 | end 69 | 70 | def test_replace_with_env 71 | source = "123" 72 | 73 | f = Tempfile.new("test") 74 | f.write source 75 | f.close 76 | 77 | hash = { "filename" => f.path, "from" => "123", "to" => "hello $hellos" } 78 | 79 | command = ReplaceCommand.from_hash(hash, { "hello" => "world" }) 80 | assert_equal("hello worlds", command.replaced_text) 81 | ensure 82 | f.unlink 83 | end 84 | 85 | def test_parse 86 | source = <<~SCR 87 | this { 88 | is a test 89 | } 90 | SCR 91 | 92 | f = Tempfile.new("test") 93 | f.write source 94 | f.close 95 | 96 | hash = { 97 | "filename" => f.path, 98 | "from" => "/this[^\}]+\}/m", 99 | "to" => "hello world" 100 | } 101 | 102 | command = ReplaceCommand.from_hash(hash, {}) 103 | 104 | assert_equal("hello world", command.replaced_text.strip) 105 | ensure 106 | f.unlink 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pups" 4 | require "pups/cli" 5 | require "minitest/autorun" 6 | require "minitest/pride" 7 | --------------------------------------------------------------------------------