From 47ad0d6842999ee2fa8dd2fe2b1966fc525400c2 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Fri, 27 Dec 2013 01:06:55 -0500 Subject: [PATCH] Initial upgrade of 2.0.0 and new DSL methods --- .travis.yml | 3 +- CHANGELOG.md | 16 + README.md | 70 ++-- Rakefile | 15 + bin/bake | 3 +- features/actions/bump.feature | 22 ++ features/actions/changelog.feature | 45 +++ features/actions/dev.feature | 18 + features/actions/upload.feature | 48 +++ features/changelog.feature | 22 -- features/cli.feature | 11 - features/devodd.feature | 19 -- features/git.feature | 34 -- features/plugins/git.feature | 24 ++ features/rake.feature | 3 +- features/step_definitions/cli_steps.rb | 28 +- ...unity_site_steps.rb => community_steps.rb} | 14 +- features/step_definitions/config_steps.rb | 24 ++ features/step_definitions/cookbook_steps.rb | 34 +- features/step_definitions/cucumber_steps.rb | 12 + features/step_definitions/git_steps.rb | 17 +- features/support/env.rb | 40 +-- features/support/stove/git.rb | 48 +++ features/upload.feature | 40 --- lib/stove.rb | 121 +++++-- lib/stove/actions/base.rb | 21 ++ lib/stove/actions/bump.rb | 25 ++ lib/stove/actions/changelog.rb | 71 ++++ lib/stove/actions/dev.rb | 22 ++ lib/stove/actions/finish.rb | 8 + lib/stove/actions/start.rb | 7 + lib/stove/actions/upload.rb | 27 ++ lib/stove/cli.rb | 186 ++++++----- lib/stove/community.rb | 124 +++++++ lib/stove/community_site.rb | 85 ----- lib/stove/config.rb | 75 ++++- lib/stove/cookbook.rb | 314 +++++------------- lib/stove/cookbook/metadata.rb | 27 +- lib/stove/error.rb | 120 +------ lib/stove/filter.rb | 59 ++++ lib/stove/formatter.rb | 7 - lib/stove/formatter/base.rb | 32 -- lib/stove/formatter/human.rb | 9 - lib/stove/formatter/silent.rb | 10 - lib/stove/git.rb | 82 ----- lib/stove/github.rb | 43 --- lib/stove/jira.rb | 104 ++++-- lib/stove/logger.rb | 56 ---- lib/stove/middlewares/chef_authentication.rb | 60 ++++ lib/stove/middlewares/exceptions.rb | 17 + lib/stove/mixins/filterable.rb | 11 + lib/stove/mixins/insideable.rb | 13 + lib/stove/mixins/instanceable.rb | 23 ++ lib/stove/mixins/loggable.rb | 32 ++ lib/stove/mixins/optionable.rb | 41 +++ lib/stove/mixins/validatable.rb | 7 + lib/stove/packager.rb | 45 +-- lib/stove/plugins/base.rb | 35 ++ lib/stove/plugins/git.rb | 71 ++++ lib/stove/plugins/github.rb | 108 ++++++ lib/stove/plugins/jira.rb | 72 ++++ lib/stove/rake_task.rb | 93 +++--- lib/stove/runner.rb | 84 +++++ lib/stove/uploader.rb | 64 ---- lib/stove/util.rb | 56 ++++ lib/stove/validator.rb | 67 ++++ lib/stove/version.rb | 2 +- locales/en.yml | 231 +++++++++++++ spec/support/community_site.rb | 33 -- spec/support/git.rb | 52 --- stove.gemspec | 22 +- 71 files changed, 2209 insertions(+), 1275 deletions(-) create mode 100644 features/actions/bump.feature create mode 100644 features/actions/changelog.feature create mode 100644 features/actions/dev.feature create mode 100644 features/actions/upload.feature delete mode 100644 features/changelog.feature delete mode 100644 features/cli.feature delete mode 100644 features/devodd.feature delete mode 100644 features/git.feature create mode 100644 features/plugins/git.feature rename features/step_definitions/{community_site_steps.rb => community_steps.rb} (51%) create mode 100644 features/step_definitions/config_steps.rb create mode 100644 features/step_definitions/cucumber_steps.rb create mode 100644 features/support/stove/git.rb delete mode 100644 features/upload.feature create mode 100644 lib/stove/actions/base.rb create mode 100644 lib/stove/actions/bump.rb create mode 100644 lib/stove/actions/changelog.rb create mode 100644 lib/stove/actions/dev.rb create mode 100644 lib/stove/actions/finish.rb create mode 100644 lib/stove/actions/start.rb create mode 100644 lib/stove/actions/upload.rb create mode 100644 lib/stove/community.rb delete mode 100644 lib/stove/community_site.rb create mode 100644 lib/stove/filter.rb delete mode 100644 lib/stove/formatter.rb delete mode 100644 lib/stove/formatter/base.rb delete mode 100644 lib/stove/formatter/human.rb delete mode 100644 lib/stove/formatter/silent.rb delete mode 100644 lib/stove/git.rb delete mode 100644 lib/stove/github.rb delete mode 100644 lib/stove/logger.rb create mode 100644 lib/stove/middlewares/chef_authentication.rb create mode 100644 lib/stove/middlewares/exceptions.rb create mode 100644 lib/stove/mixins/filterable.rb create mode 100644 lib/stove/mixins/insideable.rb create mode 100644 lib/stove/mixins/instanceable.rb create mode 100644 lib/stove/mixins/loggable.rb create mode 100644 lib/stove/mixins/optionable.rb create mode 100644 lib/stove/mixins/validatable.rb create mode 100644 lib/stove/plugins/base.rb create mode 100644 lib/stove/plugins/git.rb create mode 100644 lib/stove/plugins/github.rb create mode 100644 lib/stove/plugins/jira.rb create mode 100644 lib/stove/runner.rb delete mode 100644 lib/stove/uploader.rb create mode 100644 lib/stove/util.rb create mode 100644 lib/stove/validator.rb create mode 100644 locales/en.yml delete mode 100644 spec/support/community_site.rb delete mode 100644 spec/support/git.rb diff --git a/.travis.yml b/.travis.yml index fe68c18..972016d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ rvm: - 1.9.3 - 2.0.0 + - 2.1.0 before_script: - git config --global user.email "ci@traivs-ci.org" - git config --global user.name "Travis CI" -script: - - bundle exec cucumber --color --format progress --strict diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b0e36..a987250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ Stove CHANGELOG =============== This is the Changelog for the Stove gem. +v2.0.0 (unreleased) +------------------- +- Completely refactor the runner for speed optimizations +- Introduce a new configuration file format +- Add i18n support +- Add Filters and validations that execute before any commands are run +- Autoload plugins and actions +- Improve help output by grouping options +- Trap Signal interrupts cleanly +- Remove formatters +- Switch to log4r +- Introduce significantly more logging and log levels +- Remove HTTParty & Jiralicious in favor or Faraday +- Improve rake task to automatically perfom a minor bump when no version if given +- Persist data across the cookbook object +- Use singleton classes to save memory and loadtime v1.1.0 ------ diff --git a/README.md b/README.md index 1050d26..0b9131a 100644 --- a/README.md +++ b/README.md @@ -33,36 +33,38 @@ Alternatively, you may install it as a gem: $ gem install stove -Create a special JIRA credentials file at '~/.stove' that has the following JSON: +The use of some plugins (such as GitHub and JIRA) require a Stove configuration file. The Stove config is a JSON file stored at `~/.stove` on your local hard drive. The schema looks like this: ```javascript { - "jira_username": "JIRA_USERNAME", - "jira_password": "JIRA_PASSWORD", - "opscode_username": "OPSCODE_USERNAME", - "opscode_pem_file": "OPSCODE_PEM_FILE", - "github_access_token": "PERSONAL_API_TOKEN" + "field": { + "option": "value" + } } ``` -- `jira_username` - The username used to login to Opscode's JIRA -- `jira_password` - The password used to login to Opscode's JIRA -- `opscode_username` - The username used to login to Opscode's Community Site -- `opscode_password` - The password used to login to Opscode's Community Site -- `github_access_token` - Your personal access token for the GitHub API - -For example: +For example, my local Stove configuration looks like this: ```javascript { - "jira_username": "sethvargo", - "jira_password": "bAc0ñ", - "opscode_username": "sethvargo", - "opscode_pem_file": "~/.chef/sethvargo.pem", - "github_access_token": "abcdefg1234567" + "community": { + "username": "sethvargo", + "key": "~/.chef/sethvargo.pem" + }, + "github": { + "access_token": "..." + }, + "jira": { + "username": "sethvargo", + "password": "..." + } } ``` +If you are using Stove 1.0, you need to update your configuration file syntax. + +**It is recommended that the permissions on this file be 0600 to prevent unauthorized reading!** + Usage ----- @@ -70,19 +72,29 @@ The gem is packaged as a binary. It should be run from _inside the cookbook to r (~/cookbooks/bacon) $ bake 1.2.3 +You can always use the `--help` flag to get information: + ```text Usage: bake x.y.z - -l, --log-level [LEVEL] Ruby log level - -c, --category [CATEGORY] The category for the cookbook (optional for existing cookbooks) - -p, --path [PATH] The path to the cookbook to release (default: PWD) - --[no-]git Automatically tag and push to git (default: true) - --[no-]github Automatically release to GitHub (default: true) - -r, --remote The name of the git remote to push to - -b, --branch The name of the git branch to push to - --[no-]devodd Automatically bump the metadata for devodd releases - --[no-]jira Automatically populate the CHANGELOG from JIRA tickets and close them (default: false) - --[no-]upload Upload the cookbook to the Opscode Community Site (default: true) - --[no-]changelog Automatically generate a CHANGELOG (default: true) + +Actions: + --no-bump Perform a version bump the local version automatically + --no-changelog Generate and prompt for a CHANGELOG + --no-dev Bump a minor version release for development purposes + --no-upload Upload the cookbook to the community site + +Plugins: + --no-git Tag and push to a git remote + --no-github Publish the release to GitHub + --no-jira Resolve JIRA issues + +Global Options: + --locale [LANGUAGE] Change the language to output messages + --log-level [LEVEL] Set the log verbosity + --category [CATEGORY] Set category for the cookbook + --path [PATH] Change the path to a cookbook + --remote [REMOTE] The name of the git remote to push to + --branch [BRANCH] The name of the git branch to push to -h, --help Show this message -v, --version Show version ``` diff --git a/Rakefile b/Rakefile index c702cfc..4a3743e 100644 --- a/Rakefile +++ b/Rakefile @@ -1 +1,16 @@ require 'bundler/gem_tasks' + +require 'cucumber/rake/task' +Cucumber::Rake::Task.new(:acceptance) do |t| + t.cucumber_opts = [].tap do |a| + a.push('--color') + a.push('--format progress') + a.push('--strict') + a.push('--tags ~@wip') + end.join(' ') +end + +desc 'Run all tests' +task :test => [:acceptance] + +task :default => [:test] diff --git a/bin/bake b/bin/bake index 204196d..40300cb 100755 --- a/bin/bake +++ b/bin/bake @@ -1,5 +1,4 @@ #!/usr/bin/env ruby -$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))) -require 'stove/cli' +require 'stove' Stove::Cli.new(ARGV.dup).execute! diff --git a/features/actions/bump.feature b/features/actions/bump.feature new file mode 100644 index 0000000..a98878b --- /dev/null +++ b/features/actions/bump.feature @@ -0,0 +1,22 @@ +Feature: Bump + Background: + * the Stove config is empty + * the CLI options are all off + * I have a cookbook named "bacon" at version "1.0.0" + + Scenario: When the version has not changed + * I run `bake 1.0.0 --bump` + * it should fail with "version you are trying to bump already exists" + + Scenario: When the version is not greater than the current + * I run `bake 0.1.0 --bump` + * it should fail with "bump to is less than the existing version" + + Scenario: In isolation + * I successfully run `bake 2.0.0 --bump` + * the file "metadata.rb" should contain "2.0.0" + + Scenario: With the git plugin + * I have a cookbook named "bacon" with git support + * I successfully run `bake 1.0.0 --bump --git` + * the git remote should have the commit "Version bump to 1.0.0" diff --git a/features/actions/changelog.feature b/features/actions/changelog.feature new file mode 100644 index 0000000..76878e4 --- /dev/null +++ b/features/actions/changelog.feature @@ -0,0 +1,45 @@ +Feature: Changelog + Background: + * the Stove config is empty + * the CLI options are all off + * I have a cookbook named "bacon" + + Scenario: When the Changelog does not exist + * I remove the file "CHANGELOG.md" + * I run `bake --changelog` + * it should fail with "There is no `CHANGELOG.md' found" + + Scenario: When the Changelog is not a proper format + * I write to "CHANGELOG.md" with: + """ + This can't possibly be a valid Changelog + """ + * I run `bake --changelog` + * it should fail with "does not appear to be a valid format" + + Scenario: When the $EDITOR is not set + * the environment variable "EDITOR" is unset + * I run `bake --changelog` + * it should fail with "The `$EDITOR' environment variable is not set" + + Scenario: In isolation + * the environment variable "EDITOR" is "cat" + * I successfully run `bake --changelog` + * the file "CHANGELOG.md" should contain "v0.0.0" + + Scenario: With bump + * the environment variable "EDITOR" is "cat" + * I successfully run `bake 1.0.0 --changelog --bump` + * the file "CHANGELOG.md" should contain "v1.0.0" + + Scenario: With the git plugin + * I have a cookbook named "bacon" with git support + * the environment variable "EDITOR" is "cat" + * I successfully run `bake --changelog --git` + * the git remote should have the commit "Publish 0.0.0 Changelog" + + @wip + Scenario: With the GitHub plugin + + @wip + Scenario: With the JIRA plugin diff --git a/features/actions/dev.feature b/features/actions/dev.feature new file mode 100644 index 0000000..9d3b4e2 --- /dev/null +++ b/features/actions/dev.feature @@ -0,0 +1,18 @@ +Feature: Dev + Background: + * the Stove config is empty + * the CLI options are all off + * I have a cookbook named "bacon" at version "1.0.0" + + Scenario: In isolation + * I successfully run `bake --dev` + * the file "metadata.rb" should contain "1.0.1" + + Scenario: With bump + * I successfully run `bake 2.0.0 --bump --dev` + * the file "metadata.rb" should contain "2.0.1" + + Scenario: With the git plugin + * I have a cookbook named "bacon" with git support + * I successfully run `bake --dev --git` + * the git remote should have the commit "Version bump to 0.0.1 (for development)" diff --git a/features/actions/upload.feature b/features/actions/upload.feature new file mode 100644 index 0000000..6a51230 --- /dev/null +++ b/features/actions/upload.feature @@ -0,0 +1,48 @@ +Feature: Upload + Background: + * the Stove config is empty + * the CLI options are all off + * I have a cookbook named "bacon" + * I am using the community server + + Scenario: When the configuration does not exist + * I run `bake --upload` + * it should fail with "configuration for the Chef community site does not exist" + + Scenario: When the username does not exist + * the Stove config at "community._" is "" + * I run `bake --upload` + * it should fail with "does not contain a username" + + Scenario: When the key does not exist + * the Stove config at "community.username" is "bobo" + * I run `bake --upload` + * it should fail with "does not contain a key" + + Scenario: When the category does not exist + * the Stove config at "community.username" is "bobo" + * the Stove config at "community.key" is "../../features/support/stove.pem" + * I run `bake --upload` + * it should fail with "You did not specify a category" + + Scenario: In isolation + * the Stove config at "community.username" is "bobo" + * the Stove config at "community.key" is "../../features/support/stove.pem" + * the community server has the cookbook: + | bacon | 1.2.3 | Application | + * I successfully run `bake --upload` + + Scenario: With the git plugin + * I have a cookbook named "bacon" with git support + * the Stove config at "community.username" is "bobo" + * the Stove config at "community.key" is "../../features/support/stove.pem" + * the community server has the cookbook: + | bacon | 1.2.3 | Application | + * I successfully run `bake --git --upload` + * the git remote should have the tag "v0.0.0" + + @wip + Scenario: With the GitHub plugin + + @wip + Scenario: With the JIRA plugin diff --git a/features/changelog.feature b/features/changelog.feature deleted file mode 100644 index 0b0eabe..0000000 --- a/features/changelog.feature +++ /dev/null @@ -1,22 +0,0 @@ -Feature: Changelog - Background: - * the CLI options are all off - * I have a cookbook named "bacon" - - Scenario: --no-changelog - * I successfully run `bake 1.0.0 --no-changelog` - - Scenario: --changelog - * the environment variable EDITOR is "cat" - * I successfully run `bake 1.0.0 --changelog` - * the file "CHANGELOG.md" should contain: - """ - v1.0.0 - ------ - _Enter CHANGELOG for bacon (1.0.0) here_ - """ - - Scenario: bad $EDITOR - * the environment variable EDITOR is "not-a-real-shell-command" - * I run `bake 1.0.0 --changelog` - * the exit status will be "Error" diff --git a/features/cli.feature b/features/cli.feature deleted file mode 100644 index 39c9c94..0000000 --- a/features/cli.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: Cli - Background: - * I have a cookbook named "bacon" - - Scenario: no version - * I run `bake` - * the exit status will be "InvalidVersionError" - - Scenario: invalid version - * I run `bake 1.2` - * the exit status will be "InvalidVersionError" diff --git a/features/devodd.feature b/features/devodd.feature deleted file mode 100644 index 2ad12ad..0000000 --- a/features/devodd.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Devodd - Background: - * the CLI options are all off - * I have a cookbook named "bacon" - - Scenario: --no-devodd - * I successfully run `bake 1.0.0 --no-devodd` - - Scenario: --devodd - * I successfully run `bake 1.0.0 --devodd` - * the file "metadata.rb" should contain: - """ - version '1.0.1' - """ - - Scenario: --git --devodd - * I have a cookbook named "bacon" with git support - * I successfully run `bake 1.0.0 --devodd --git` - * the git remote will have the commit "Version bump to v1.0.1" diff --git a/features/git.feature b/features/git.feature deleted file mode 100644 index 9a1efb2..0000000 --- a/features/git.feature +++ /dev/null @@ -1,34 +0,0 @@ -Feature: Git - Background: - * the CLI options are all off - - Scenario: --no-git - * I have a cookbook named "bacon" with git support - * I successfully run `bake 1.0.0 --no-git` - * the git remote will not have the commit "Version bump to v1.0.0" - * the git remote will not have the tag "v1.0.0" - - Scenario: --git - * I have a cookbook named "bacon" with git support - * I successfully run `bake 1.0.0 --git` - * the git remote will have the commit "Version bump to v1.0.0" - * the git remote will have the tag "v1.0.0" - - Scenario: A dirty git repo - * I have a cookbook named "bacon" with git support - * I append to "CHANGELOG.md" with "# A change" - * I run `bake 1.0.0 --git` - * the git remote will not have the commit "Version bump to v1.0.0" - * the git remote will not have the tag "v1.0.0" - * the exit status will be "GitError::DirtyRepo" - - Scenario: Not a git repo - * I have a cookbook named "bacon" - * I run `bake 1.0.0 --git` - * the exit status will be "GitError::NotARepo" - - Scenario: Remote repository out of sync - * I have a cookbook named "bacon" with git support - * the remote repository has additional commits - * I run `bake 1.0.0 --git` - * the exit status will be "GitError::OutOfSync" diff --git a/features/plugins/git.feature b/features/plugins/git.feature new file mode 100644 index 0000000..832071f --- /dev/null +++ b/features/plugins/git.feature @@ -0,0 +1,24 @@ +Feature: git Plugin + Background: + * the Stove config is empty + * the CLI options are all off + * I have a cookbook named "bacon" + + Scenario: When the directory is not a git repository + * I run `bake --git` + * it should fail with "does not appear to be a valid git repository" + + Scenario: When the directory is dirty + * I have a cookbook named "bacon" with git support + * I write to "new" with: + """ + This is new content + """ + * I run `bake --git` + * it should fail with "has untracked files" + + Scenario: When the local is out of date with the remote + * I have a cookbook named "bacon" with git support + * the remote repository has additional commits + * I run `bake --git` + * it should fail with "out of sync with the remote repository" diff --git a/features/rake.feature b/features/rake.feature index 0133628..120d4dc 100644 --- a/features/rake.feature +++ b/features/rake.feature @@ -1,4 +1,3 @@ -@spawn Feature: Rake Task Background: * I have a cookbook named "bacon" @@ -12,5 +11,5 @@ Feature: Rake Task * I successfully run `rake -T` * the output should contain: """ - rake publish # Publish this cookbook + rake publish[version] # Publish this cookbook """ diff --git a/features/step_definitions/cli_steps.rb b/features/step_definitions/cli_steps.rb index 286199e..6aed564 100644 --- a/features/step_definitions/cli_steps.rb +++ b/features/step_definitions/cli_steps.rb @@ -1,29 +1,3 @@ -Given /^the environment variable (.+) is "(.+)"$/ do |variable, value| - set_env(variable, value) -end - -Then /^the exit status will be "(.+)"$/ do |error| - # Ruby 1.9.3 sucks - klass = error.split('::').inject(Stove) { |c, n| c.const_get(n) } - assert_exit_status(klass.exit_code) -end - When /^the CLI options are all off$/ do - class Stove::Cli - private - def options - @options ||= { - path: Dir.pwd, - git: false, - github: false, - devodd: false, - remote: 'origin', - branch: 'master', - jira: false, - upload: false, - changelog: false, - log_level: :fatal, - } - end - end + set_env('CLI_DEFAULT', 'false') end diff --git a/features/step_definitions/community_site_steps.rb b/features/step_definitions/community_steps.rb similarity index 51% rename from features/step_definitions/community_site_steps.rb rename to features/step_definitions/community_steps.rb index ce52cb5..27090de 100644 --- a/features/step_definitions/community_site_steps.rb +++ b/features/step_definitions/community_steps.rb @@ -1,19 +1,23 @@ -Given /^the Community Site has the cookbooks?:$/ do |table| +Given /^I am using the community server$/ do + set_env('COMMUNITY_URL', CommunityZero::RSpec.url) +end + +Given /^the community server has the cookbooks?:$/ do |table| table.raw.each do |name, version, category| version ||= '0.0.0' category ||= 'Other' - CommunityZero::Cookbook.create({ + CommunityZero::RSpec.store.add(CommunityZero::Cookbook.new( name: name, version: version, category: category, - }) + )) end end -Then /^the Community Site will( not)? have the cookbooks?:$/ do |negate, table| +Then /^the community server will( not)? have the cookbooks?:$/ do |negate, table| table.raw.each do |name, version, category| - cookbook = CommunityZero::Store.find(name, version) + cookbook = CommunityZero::RSpec.store.find(name, version) if negate expect(cookbook).to be_nil diff --git a/features/step_definitions/config_steps.rb b/features/step_definitions/config_steps.rb new file mode 100644 index 0000000..cc68d25 --- /dev/null +++ b/features/step_definitions/config_steps.rb @@ -0,0 +1,24 @@ +Given /^the Stove config is empty$/ do + path = File.join(tmp_path, '_config.rb') + + write_file(path, '{}') + set_env('STOVE_CONFIG', path) +end + +Given /^the Stove config at "(.+)" is "(.*)"$/ do |path, value| + parts = path.split('.').map(&:to_sym) + parents = parts[0...-1] + + path = ENV['STOVE_CONFIG'] + config = JSON.parse(File.read(path), symbolize_names: true) + + # Vivify the hash + parent = parents.inject(config) do |config, parent| + config[parent] ||= {} + config[parent] + end + + parent[parts.last] = value + + File.open(path, 'w') { |f| f.write(JSON.generate(config)) } +end diff --git a/features/step_definitions/cookbook_steps.rb b/features/step_definitions/cookbook_steps.rb index 022da91..8c399e9 100644 --- a/features/step_definitions/cookbook_steps.rb +++ b/features/step_definitions/cookbook_steps.rb @@ -1,23 +1,45 @@ -Given /^I have a cookbook named "(.+)"(?: (?:at|with) version "(.+)")?$/ do |name, version| +Given /^I have a cookbook named "(\w+)" at version "([\d\.]+)"$/ do |name, version| create_cookbook(name, version) end -Given /^I have a cookbook named "(.+)"(?: (?:at|with) version "(.+)")? with git support$/ do |name, version| - create_cookbook(name, version, git: true) +Given /^I have a cookbook named "(\w+)"$/ do |name| + create_cookbook(name, '0.0.0') +end + +Given /^I have a cookbook named "(\w+)" with git support$/ do |name| + create_cookbook(name, '0.0.0', git: true) end +# # Create a new cookbook with the given name and version. # # @param [String] name # @param [String] version (default: 0.0.0.0) # @param [Hash] options +# def create_cookbook(name, version, options = {}) create_dir(name) cd(name) - write_file('CHANGELOG.md', "#{name} Cookbook CHANGELOG\n=====\n\nv0.0.0\n-----") - write_file('README.md', 'This is a README') - write_file('metadata.rb', "name '#{name}'\nversion '#{version || '0.0.0'}'") + + write_file('CHANGELOG.md', <<-EOH.gsub(/^ {4}/, '')) + Changelog + ========= + + v#{version} (#{Time.now.to_date}) + ---------------------------- + - This is an entry + - This is another entry + EOH + + write_file('README.md', <<-EOH.gsub(/^ {4}/, '')) + This is the README for #{name} + EOH + + write_file('metadata.rb', <<-EOH.gsub(/^ {4}/, '')) + name '#{name}' + version '#{version}' + EOH if options[:git] git_init(current_dir) diff --git a/features/step_definitions/cucumber_steps.rb b/features/step_definitions/cucumber_steps.rb new file mode 100644 index 0000000..4c7130c --- /dev/null +++ b/features/step_definitions/cucumber_steps.rb @@ -0,0 +1,12 @@ +# These are steps that should really exist in cucumber, but they don't... +When /^the environment variable "(.+)" is "(.+)"/ do |variable, value| + set_env(variable, value) +end + +When /^the environment variable "(.+)" is unset$/ do |variable| + set_env(variable, nil) +end + +Then /^it should (pass|fail) with "(.+)"$/ do |pass_fail, partial| + self.__send__("assert_#{pass_fail}ing_with", partial) +end diff --git a/features/step_definitions/git_steps.rb b/features/step_definitions/git_steps.rb index be0c350..375af01 100644 --- a/features/step_definitions/git_steps.rb +++ b/features/step_definitions/git_steps.rb @@ -1,12 +1,15 @@ Given /^the remote repository has additional commits/ do - Dir.chdir(fake_git_remote) do - shellout 'touch myfile.txt' - git 'add myfile.txt' - git 'commit -m "Add a new file"' - end + cmd = [ + 'cd "' + fake_git_remote + '"', + 'touch myfile.txt', + 'git add --force myfile.txt', + 'git commit --message "Add new file"', + ].join(' && ') + + %x|#{cmd}| end -Then /^the git remote will( not)? have the commit "(.+)"$/ do |negate, message| +Then /^the git remote should( not)? have the commit "(.+)"$/ do |negate, message| commits = git_commits(fake_git_remote) if negate @@ -16,7 +19,7 @@ end end -Then /^the git remote will( not)? have the tag "(.+)"$/ do |negate, tag| +Then /^the git remote should( not)? have the tag "(.+)"$/ do |negate, tag| tags = git_tags(fake_git_remote) if negate diff --git a/features/support/env.rb b/features/support/env.rb index b874670..e04d230 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -1,40 +1,24 @@ +require 'bundler/setup' + require 'aruba/api' require 'aruba/cucumber' -require 'aruba/in_process' +require 'cucumber/rspec/doubles' require 'rspec/expectations' + +require 'community_zero/rspec' +CommunityZero::RSpec.start +Before { CommunityZero::RSpec.reset! } + require 'stove' -require_relative '../../spec/support/community_site' -require_relative '../../spec/support/git' +require File.expand_path('../stove/git', __FILE__) World(Aruba::Api) -World(Stove::RSpec::Git) - -Stove.set_formatter(:silent) -Stove::Config.instance_variable_set(:@instance, { - 'jira_username' => 'default', - 'jira_password' => 'default', - 'github_usernmae' => 'default', - 'github_password' => 'default', - 'opscode_username' => 'stove', - 'opscode_pem_file' => File.expand_path(File.join(__FILE__, '..', 'stove.pem')), -}) -Stove::RSpec::CommunitySite.start(port: 3390) -Stove::CommunitySite.base_uri(Stove::RSpec::CommunitySite.server_url) -Stove::CommunitySite.http_uri(Stove::RSpec::CommunitySite.server_url) +World(Stove::Git) Before do - @dirs = [Dir.mktmpdir] - Stove::RSpec::CommunitySite.reset! -end - -Before('~@spawn') do - Aruba::InProcess.main_class = Stove::Cli - Aruba.process = Aruba::InProcess -end - -Before('@spawn') do - Aruba.process = Aruba::SpawnProcess + FileUtils.rm_rf(tmp_path) + @aruba_timeout_seconds = 15 end # The path to Aruba's "stuff" diff --git a/features/support/stove/git.rb b/features/support/stove/git.rb new file mode 100644 index 0000000..f815c17 --- /dev/null +++ b/features/support/stove/git.rb @@ -0,0 +1,48 @@ +require 'fileutils' + +module Stove + module Git + def git_init(path = Dir.pwd) + cmd = [ + 'cd "' + path + '"', + 'git init .', + 'git add --all', + 'git commit --message "Initial commit"', + 'git remote add origin file://' + fake_git_remote, + 'git push --quiet --force origin master', + ].join(' && ') + + %x|#{cmd}| + end + + def fake_git_remote + path = File.expand_path(File.join(tmp_path, 'remote.git')) + return path if File.exists?(path) + + FileUtils.mkdir_p(path) + cmd = [ + 'cd "' + path + '"', + 'git init .', + 'git config receive.denyCurrentBranch ignore', + 'git config receive.denyNonFastforwards true', + 'git config core.sharedrepository 1', + ].join(' && ') + + %x|#{cmd}| + + path + end + + def git_shas(path) + %x|cd "#{path}" && git log --oneline|.split("\n").map { |line| line.split(/\s+/, 2).first.strip } rescue [] + end + + def git_commits(path) + %x|cd "#{path}" && git log --oneline|.split("\n").map { |line| line.split(/\s+/, 2).last.strip } rescue [] + end + + def git_tags(path) + %x|cd "#{path}" && git tag --list|.split("\n").map(&:strip) rescue [] + end + end +end diff --git a/features/upload.feature b/features/upload.feature deleted file mode 100644 index 2ff61d0..0000000 --- a/features/upload.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: Upload - Background: - * I have a cookbook named "bacon" - * the CLI options are all off - - Scenario: --no-upload - * I successfully run `bake 1.0.0 --no-upload` - * the Community Site will not have the cookbook: - | bacon | 1.0.0 | - - Scenario: --upload (no category, no existing) - * I run `bake 1.0.0 --upload` - * the Community Site will not have the cookbook: - | bacon | 1.0.0 | - * the exit status will be "CookbookCategoryNotFound" - - Scenario: --upload (no category, existing) - * the Community Site has the cookbook: - | bacon | 0.0.0 | Application | - * I successfully run `bake 1.0.0 --upload` - * the Community Site will have the cookbook: - | bacon | 1.0.0 | Application | - - Scenario: --upload (category, no existing) - * I successfully run `bake 1.0.0 --upload --category Application` - * the Community Site will have the cookbook: - | bacon | 1.0.0 | Application | - - Scenario: --upload (category, existing) - * the Community Site has the cookbook: - | bacon | 0.0.0 | Application | - * I successfully run `bake 1.0.0 --upload --category Application` - * the Community Site will have the cookbook: - | bacon | 1.0.0 | Application | - - Scenario: --upload (existing version) - * the Community Site has the cookbook: - | bacon | 1.0.0 | Application | - * I run `bake 1.0.0 --upload` - * the exit status will be "UploadError" diff --git a/lib/stove.rb b/lib/stove.rb index 058247e..19bb0f7 100644 --- a/lib/stove.rb +++ b/lib/stove.rb @@ -1,27 +1,110 @@ +require 'pathname' + +require 'log4r' +Log4r.define_levels(*Log4r::Log4rConfig::LogLevels) + module Stove - require_relative 'stove/config' - require_relative 'stove/git' - require_relative 'stove/logger' - - require_relative 'stove/cli' - require_relative 'stove/community_site' - require_relative 'stove/cookbook' - require_relative 'stove/error' - require_relative 'stove/formatter' - require_relative 'stove/github' - require_relative 'stove/jira' - require_relative 'stove/mash' - require_relative 'stove/packager' - require_relative 'stove/uploader' - require_relative 'stove/version' + autoload :Config, 'stove/config' + autoload :Community, 'stove/community' + autoload :Cookbook, 'stove/cookbook' + autoload :Cli, 'stove/cli' + autoload :Error, 'stove/error' + autoload :Filter, 'stove/filter' + autoload :JIRA, 'stove/jira' + autoload :Mash, 'stove/mash' + autoload :Packager, 'stove/packager' + autoload :Runner, 'stove/runner' + autoload :Util, 'stove/util' + autoload :Validator, 'stove/validator' + autoload :VERSION, 'stove/version' + + module Action + autoload :Base, 'stove/actions/base' + autoload :Bump, 'stove/actions/bump' + autoload :Changelog, 'stove/actions/changelog' + autoload :Dev, 'stove/actions/dev' + autoload :Finish, 'stove/actions/finish' + autoload :Start, 'stove/actions/start' + autoload :Upload, 'stove/actions/upload' + end + + module Middleware + autoload :ChefAuthentication, 'stove/middlewares/chef_authentication' + autoload :Exceptions, 'stove/middlewares/exceptions' + end + + module Mixin + autoload :Filterable, 'stove/mixins/filterable' + autoload :Insideable, 'stove/mixins/insideable' + autoload :Instanceable, 'stove/mixins/instanceable' + autoload :Loggable, 'stove/mixins/loggable' + autoload :Optionable, 'stove/mixins/optionable' + autoload :Validatable, 'stove/mixins/validatable' + end + + module Plugin + autoload :Base, 'stove/plugins/base' + autoload :Git, 'stove/plugins/git' + autoload :GitHub, 'stove/plugins/github' + autoload :JIRA, 'stove/plugins/jira' + end + + # + # A constant to represent an unset value. +nil+ is too generic and doesn't + # allow users to specify a value as +nil+. Using this constant, we can + # safely create +set_or_return+-style methods. + # + # @return [Object] + # + UNSET_VALUE = Object.new + + # + # The User-Agent to use for HTTP requests + # + # @return [String] + # + USER_AGENT = "Stove #{VERSION}" class << self - def formatter - @formatter ||= Stove::Formatter::Human.new + # + # The source root of the ChefAPI gem. This is useful when requiring files + # that are relative to the root of the project. + # + # @return [Pathname] + # + def root + @root ||= Pathname.new(File.expand_path('../../', __FILE__)) end - def set_formatter(name) - @formatter = Stove::Formatter::Base.formatters[name.to_sym].new + # + # The current log level for the entire application. + # + # @return [Integer] + # + def log_level + Log4r::Logger.global.level + end + + # + # Set the global log level. + # + # @example Set the log level to warn + # Stove.log_level = :warn + # + # @param [String, Symbol] id + # the log level to set + # + def log_level=(id) + level = Log4r.const_get(id.to_s.upcase) + raise NameError unless level.is_a?(Integer) + + Log4r::Logger.global.level = level + rescue NameError + $stderr.puts "ERROR `#{id}' is not a valid Log Level!" end end end + +require 'i18n' +I18n.enforce_available_locales = true +I18n.load_path << Dir[Stove.root.join('locales', '*.yml').to_s] diff --git a/lib/stove/actions/base.rb b/lib/stove/actions/base.rb new file mode 100644 index 0000000..408b4af --- /dev/null +++ b/lib/stove/actions/base.rb @@ -0,0 +1,21 @@ +module Stove + class Action::Base + extend Mixin::Loggable + extend Mixin::Optionable + extend Mixin::Validatable + + option :id + option :description + + attr_reader :cookbook + attr_reader :options + + def initialize(cookbook, options = {}) + @cookbook, @options = cookbook, options + end + + def run + raise Error::AbstractMethod.new(method: 'Action::Base#run') + end + end +end diff --git a/lib/stove/actions/bump.rb b/lib/stove/actions/bump.rb new file mode 100644 index 0000000..d2ca4a2 --- /dev/null +++ b/lib/stove/actions/bump.rb @@ -0,0 +1,25 @@ +module Stove + class Action::Bump < Action::Base + id 'bump' + description 'Perform a version bump the local version automatically' + + validate(:changed) do + cookbook.version != options[:version] + end + + validate(:incremented) do + version = Gem::Version.new(options[:version]) + Gem::Requirement.new("> #{cookbook.version}").satisfied_by?(version) + end + + def run + log.info('Performing version bump') + log.debug("Version is currently #{cookbook.version}") + log.debug("Bumped version is #{options[:version]}") + + cookbook.bump(options[:version]) + + log.debug("Version is now #{cookbook.version}") + end + end +end diff --git a/lib/stove/actions/changelog.rb b/lib/stove/actions/changelog.rb new file mode 100644 index 0000000..829f008 --- /dev/null +++ b/lib/stove/actions/changelog.rb @@ -0,0 +1,71 @@ +module Stove + class Action::Changelog < Action::Base + id 'changelog' + description 'Generate and prompt for a CHANGELOG' + + validate(:exists) do + File.exists?('CHANGELOG.md') + end + + validate(:format) do + lines = File.read('CHANGELOG.md') + lines.match(/^[\w\s]+\n=+(.*\n)+v[0-9\.]+(\ \(.+\))?\n\-+/) + end + + validate(:editor) do + !ENV['EDITOR'].nil? + end + + def run + log.info('Generating new Changelog') + log.debug("Generated changeset:\n#{default_changeset}") + + # Open a file prompt for changes + prompt_for_changeset + + log.debug("New changeset:\n#{cookbook.changeset}") + + # Write the new changelog to disk + path = File.join(cookbook.path, 'CHANGELOG.md') + contents = File.readlines(path) + index = contents.find_index { |line| line =~ /^(--)+/ } + + log.debug("Writing changelog at `#{path}', index #{index}") + + contents.insert(index - 2, "\n" + cookbook.changeset + "\n\n") + + File.open(path, 'w') { |file| file.write(contents.join('')) } + end + + def prompt_for_changeset + tempfile = Tempfile.new(["#{cookbook.name}-changeset-#{Time.now}", '.md']) + tempfile.write(default_changeset) + tempfile.rewind + + # Shell out to the default editor + system %Q|$EDITOR "#{tempfile.path}"| + + # Save the resulting changes back to the cookbook object + cookbook.changeset = File.read(tempfile.path).strip + + # Cleanup + tempfile.close + tempfile.unlink + end + + def default_changeset + return @default_changeset if @default_changeset + + header = "v#{cookbook.version} (#{Time.now.to_date})" + + contents = [] + contents << header + contents << '-'*header.length + contents << cookbook.changeset || 'Enter CHANGELOG entries here' + contents << '' + + @default_changeset = contents.join("\n") + @default_changeset + end + end +end diff --git a/lib/stove/actions/dev.rb b/lib/stove/actions/dev.rb new file mode 100644 index 0000000..9c956ed --- /dev/null +++ b/lib/stove/actions/dev.rb @@ -0,0 +1,22 @@ +module Stove + class Action::Dev < Action::Base + id 'dev' + description 'Bump a minor version release for development purposes' + + def run + log.info('Bumping for development release') + log.debug("Version is currently #{cookbook.version}") + log.debug("Bumped version is #{dev_version}") + + cookbook.bump(dev_version) + + log.debug("Version is now #{cookbook.version}") + end + + def dev_version + split = cookbook.version.split('.').map(&:to_i) + split[2] += 1 + split.join('.') + end + end +end diff --git a/lib/stove/actions/finish.rb b/lib/stove/actions/finish.rb new file mode 100644 index 0000000..f2afa90 --- /dev/null +++ b/lib/stove/actions/finish.rb @@ -0,0 +1,8 @@ +module Stove + class Action::Finish < Action::Base + def run + log.debug('Running cleanup hooks...') + log.info('Done!') + end + end +end diff --git a/lib/stove/actions/start.rb b/lib/stove/actions/start.rb new file mode 100644 index 0000000..5a08b04 --- /dev/null +++ b/lib/stove/actions/start.rb @@ -0,0 +1,7 @@ +module Stove + class Action::Start < Action::Base + def run + log.info("Running Stove #{Stove::VERSION} on `#{cookbook.name}'") + end + end +end diff --git a/lib/stove/actions/upload.rb b/lib/stove/actions/upload.rb new file mode 100644 index 0000000..13814c3 --- /dev/null +++ b/lib/stove/actions/upload.rb @@ -0,0 +1,27 @@ +module Stove + class Action::Upload < Action::Base + id 'upload' + description 'Upload the cookbook to the community site' + + validate(:configuration) do + Config.has_key?(:community) + end + + validate(:username) do + Config[:community].has_key?(:username) + end + + validate(:key) do + Config[:community].has_key?(:key) + end + + validate(:category) do + !cookbook.category.nil? + end + + def run + log.info('Uploading to the Chef community site') + Community.upload(cookbook) + end + end +end diff --git a/lib/stove/cli.rb b/lib/stove/cli.rb index 1f21ecf..a99882b 100644 --- a/lib/stove/cli.rb +++ b/lib/stove/cli.rb @@ -3,122 +3,150 @@ module Stove class Cli + include Mixin::Loggable + def initialize(argv, stdin=STDIN, stdout=STDOUT, stderr=STDERR, kernel=Kernel) @argv, @stdin, @stdout, @stderr, @kernel = argv, stdin, stdout, stderr, kernel - $stdout, @stderr = @stdout, @stderr end def execute! + $stdout, $stderr = @stdout, @stderr + + # Parse the options hash option_parser.parse!(@argv) - options[:new_version] = @argv.first - raise Stove::InvalidVersionError unless valid_version?(options[:new_version]) + # Set the log level + Stove.log_level = options[:log_level] - Stove::Logger.set_level(options.delete(:log_level)) + # Parse out the version from ARGV + options[:version] = @argv.shift - Stove::Cookbook.new(options).release! - @kernel.exit(0) - rescue => e - @stderr.puts "#{e.class}: #{e.message}" + # Useful debugging output for when people type the wrong fucking command + # and then open an issue like it's somehow my fault + log.info("Options: #{options.inspect}") + log.info("ARGV: #{@argv.inspect}") - if Stove::Logger.sev_threshold == ::Logger::DEBUG - @stderr.puts " #{e.backtrace.join("\n ")}" + # Unless the user specified --no-bump, version is a required argument, so + # blow up if we don't get it or if it's not a nice version string + if options[:bump] + raise OptionParser::MissingArgument.new(:version) unless options[:version] end + # Make a new cookbook object - this will raise an exception if there is + # no cookbook at the given path + cookbook = Cookbook.new(options[:path]) + + # Now execute the actual runners (validations and errors might occur) + Runner.run(cookbook, options) + + # If we got this far, everything was successful :) + @kernel.exit(0) + rescue => e + log.error('Stove experienced an error!') + log.error(e.class.name) + log.error(e.message) + log.error(e.backtrace.join("\n")) + @kernel.exit(e.respond_to?(:exit_code) ? e.exit_code : 500) ensure $stdout, $stderr = STDOUT, STDERR end private - # The option parser for handling command line flags. - # - # @return [OptionParser] - def option_parser - @option_parser ||= OptionParser.new do |opts| - opts.banner = "Usage: bake x.y.z" - - opts.on('-l', '--log-level [LEVEL]', [:fatal, :error, :warn, :info, :debug], 'Ruby log level') do |v| - options[:log_level] = v - end - opts.on('-c', '--category [CATEGORY]', String, 'The category for the cookbook (optional for existing cookbooks)') do |v| - options[:category] = v + # + # The option parser for handling command line flags. + # + # @return [OptionParser] + # + def option_parser + @option_parser ||= OptionParser.new do |opts| + opts.banner = 'Usage: bake x.y.z' + + opts.separator '' + opts.separator 'Actions:' + + actions = Action.constants.map(&Action.method(:const_get)) + actions.select(&:id).each do |action| + opts.on("--[no-]#{action.id}", action.description) do |v| + options[action.id.to_sym] = v end + end - opts.on('-p', '--path [PATH]', String, 'The path to the cookbook to release (default: PWD)') do |v| - options[:path] = v - end + opts.separator '' + opts.separator 'Plugins:' - opts.on('--[no-]git', 'Automatically tag and push to git (default: true)') do |v| - options[:git] = v + plugins = Plugin.constants.map(&Plugin.method(:const_get)) + plugins.select(&:id).each do |plugin| + opts.on("--[no-]#{plugin.id}", plugin.description) do |v| + options[plugin.id.to_sym] = v end + end - opts.on('--[no-]github', 'Automatically release to GitHub (default: true)') do |v| - options[:git] = v if v - options[:github] = v - end + opts.separator '' + opts.separator 'Global Options:' - opts.on('-r', '--remote [REMOTE]', String, 'The name of the git remote to push to') do |v| - options[:remote] = v - end + opts.on('--locale [LANGUAGE]', 'Change the language to output messages') do |locale| + I18n.locale = locale + end - opts.on('-b', '--branch [BRANCH]', String, 'The name of the git branch to push to') do |v| - options[:branch] = v - end + opts.on('--log-level [LEVEL]', 'Set the log verbosity') do |v| + options[:log_level] = v + end - opts.on('--[no-]devodd', 'Automatically bump the metadata for devodd releases') do |v| - options[:devodd] = v - end + opts.on('--category [CATEGORY]', 'Set category for the cookbook') do |v| + options[:category] = v + end - opts.on('--[no-]jira', 'Automatically populate the CHANGELOG from JIRA tickets and close them (default: false)') do |v| - options[:jira] = v - end + opts.on('--path [PATH]', 'Change the path to a cookbook') do |v| + options[:path] = v + end - opts.on('--[no-]upload', 'Upload the cookbook to the Opscode Community Site (default: true)') do |v| - options[:upload] = v - end + opts.on('--remote [REMOTE]', 'The name of the git remote to push to') do |v| + options[:remote] = v + end - opts.on('--[no-]changelog', 'Automatically generate a CHANGELOG (default: true)') do |v| - options[:changelog] = v - end + opts.on('--branch [BRANCH]', 'The name of the git branch to push to') do |v| + options[:branch] = v + end - opts.on_tail('-h', '--help', 'Show this message') do - puts opts - exit - end + opts.on_tail('-h', '--help', 'Show this message') do + puts opts + exit + end - opts.on_tail('-v', '--version', 'Show version') do - puts Stove::VERSION - exit(0) - end + opts.on_tail('-v', '--version', 'Show version') do + puts Stove::VERSION + exit(0) end end + end - # The options to pass to the cookbook. Includes default values - # that are manipulated by the option parser. - # - # @return [Hash] - def options - @options ||= { - path: Dir.pwd, - git: true, - github: true, - devodd: false, - remote: 'origin', - branch: 'master', - jira: false, - upload: true, - changelog: true, - log_level: :warn, - } + # The options to pass to the cookbook. Includes default values + # that are manipulated by the option parser. + # + # @return [Hash] + def options + @options ||= Hash.new(default_value).tap do |h| + h[:path] = Dir.pwd + h[:log_level] = :warn + + # Default actions/plugins + h[:jira] = false + h[:start] = true + h[:finish] = true + + h[:remote] = 'origin' + h[:branch] = 'master' end + end - # Determine if the given string is a valid version string. - # - # @return [Boolean] - def valid_version?(version) - version.to_s =~ /^\d+\.\d+\.\d+$/ + def default_value + @default_value ||= if ENV['CLI_DEFAULT'] + !!(ENV['CLI_DEFAULT'] =~ /^(true|t|yes|y|1)$/i) + else + true end + end end end diff --git a/lib/stove/community.rb b/lib/stove/community.rb new file mode 100644 index 0000000..283cb6e --- /dev/null +++ b/lib/stove/community.rb @@ -0,0 +1,124 @@ +require 'faraday' +require 'faraday_middleware' + +module Stove + class Community + include Mixin::Instanceable + include Mixin::Loggable + include Mixin::Optionable + + option :base_url, + ENV['COMMUNITY_URL'] || 'https://cookbooks.opscode.com/api/v1' + + # + # Get and cache a community cookbook's JSON response from the given name + # and version. + # + # @example Find a cookbook by name + # Community.cookbook('apache2') #=> {...} + # + # @example Find a cookbook by name and version + # Community.cookbook('apache2', '1.0.0') #=> {...} + # + # @example Find a non-existent cookbook + # Community.cookbook('not-real') #=> Community::BadResponse + # + # @raise [Community::BadResponse] + # if the given cookbook (or cookbook version) does not exist on the community site + # + # @param [String] name + # the name of the cookbook on the community site + # @param [String] version (optional) + # the version of the cookbook to find + # + # @return [Hash] + # the hash of the cookbook + # + def cookbook(name, version = nil) + if version.nil? + connection.get("cookbooks/#{name}").body + else + connection.get("cookbooks/#{name}/versions/#{Util.version_for_url(version)}").body + end + end + + # + # Upload a cookbook to the community site. + # + # @param [Cookbook] cookbook + # the cookbook to upload + # + def upload(cookbook) + connection.post('cookbooks', { + tarball: Faraday::UploadIO.new(cookbook.tarball, 'application/x-tar'), + cookbook: { category: cookbook.category }.to_json, + }) + end + + private + + # + # The Faraday connection object with lots of pretty middleware. + # + def connection + @connection ||= Faraday.new(base_url) do |builder| + # Enable multi-part requests (for uploading) + builder.request :multipart + builder.request :url_encoded + + # Encode request bodies as JSON + builder.request :json + + # Add Mixlib authentication headers + builder.use Stove::Middleware::ChefAuthentication, client, key + + # Handle any common errors + builder.use Stove::Middleware::Exceptions + + # Decode responses as JSON if the Content-Type is json + builder.response :json + builder.response :json_fix + + # Allow up to 3 redirects + builder.response :follow_redirects, limit: 3 + + # Log all requests and responses (useful for development) + builder.response :logger, log + + # Raise errors on 40x and 50x responses + builder.response :raise_error + + # Use the default adapter (Net::HTTP) + builder.adapter :net_http + + # Set the User-Agent header for logging purposes + builder.headers[:user_agent] = Stove::USER_AGENT + + # Set some options, such as timeouts + builder.options[:timeout] = 30 + builder.options[:open_timeout] = 30 + end + end + + # + # The name of the client to use (by default, this is the username). + # + # @return [String] + # + def client + Config[:community][:username] + end + + # + # The path to the key on disk for authentication with the community site. + # If a relative path is given, it is expanded relative to the configuration + # file on disk. + # + # @return [String] + # the path to the key on disk + # + def key + File.expand_path(Config[:community][:key], Config.__path__) + end + end +end diff --git a/lib/stove/community_site.rb b/lib/stove/community_site.rb deleted file mode 100644 index e4bb05c..0000000 --- a/lib/stove/community_site.rb +++ /dev/null @@ -1,85 +0,0 @@ -require 'httparty' - -module Stove - class CommunitySite - include HTTParty - base_uri 'https://cookbooks.opscode.com/api/v1' - headers 'Content-Type' => 'application/json', 'Accept' => 'application/json' - - class << self - # The URI for the web-based version of the site. (default: - # https://community.opscode.com). - # - # If a parameter is given, the {http_uri} is set to that value. - # - # @return [String] - def http_uri(arg = nil) - if arg.nil? - @http_uri ||= 'https://community.opscode.com' - else - @http_uri = arg - @http_uri - end - end - - # Get and cache a community cookbook's JSON response from the given name - # and version. - # - # @example Find a cookbook by name - # CommunitySite.cookbook('apache2') #=> {...} - # - # @example Find a cookbook by name and version - # CommunitySite.cookbook('apache2', '1.0.0') #=> {...} - # - # @example Find a non-existent cookbook - # CommunitySite.cookbook('not-real') #=> CommunitySite::BadResponse - # - # @raise [CommunitySite::BadResponse] - # if the given cookbook (or cookbook version) does not exist on the community site - # - # @param [String] name - # the name of the cookbook on the community site - # @param [String] version (optional) - # the version of the cookbook to find - def cookbook(name, version = nil) - if version.nil? - get("/cookbooks/#{name}") - else - get("/cookbooks/#{name}/versions/#{format_version(version)}") - end - end - - private - # Convert a version string (x.y.z) to a community-site friendly format - # (x_y_z). - # - # @example Convert a version to a version string - # format_version('1.2.3') #=> 1_2_3 - # - # @param [#to_s] version - # the version string to convert - # - # @return [String] - def format_version(version) - version.gsub('.', '_') - end - - # @override [HTTParty.get] - def get(path, options = {}, &block) - cache[path] ||= begin - Stove::Logger.debug "Getting #{path}" - response = super(path) - raise Stove::BadResponse.new(response) unless response.ok? - response.parsed_response - end - end - - # A small, unpersisted cache for storing responses - # - # @return [Hash] - def cache - @cache ||= {} - end - end - end -end diff --git a/lib/stove/config.rb b/lib/stove/config.rb index 21499b8..2b4a047 100644 --- a/lib/stove/config.rb +++ b/lib/stove/config.rb @@ -1,18 +1,67 @@ +require 'json' + module Stove - class Config < ::Hash - class << self - def [](thing) - instance[thing] - end - - def instance - @instance ||= load! - end - - private - def load! - JSON.parse(File.read(File.expand_path('~/.stove'))) rescue {} + class Config + include Mixin::Instanceable + include Mixin::Loggable + + # + # Create a new configuration object. If a configuration file does not + # exist, this method will output a warning to the UI and use an empty + # hash as the data structure. + # + def initialize + log.debug("Reading from config at `#{__path__}'") + + contents = File.read(__path__) + data = JSON.parse(contents, symbolize_names: true) + + log.debug("Config:\n#{JSON.pretty_generate(sanitize(data))}") + + @data = data + rescue Errno::ENOENT + log.warn(<<-EOH.gsub(/^ {8}/, '')) + No Stove configuration file found at `#{__path__}'. Stove will assume an + empty configuration, which may cause problems with some plugins. It is + recommended that you create a Stove configuration file as documented: + + https://github.com/sethvargo/stove#installation + EOH + + @data = {} + end + + # + # This is a special key that tells me where stove lives. If you actually + # have a key in your config called +__path__+, then it sucks to be you. + # + # @return [String] + # + def __path__ + @path ||= File.expand_path(ENV['STOVE_CONFIG'] || '~/.stove') + end + + # + # Deletegate all method calls to the underlyng hash. + # + def method_missing(m, *args, &block) + @data.send(m, *args, &block) + end + + private + + def sanitize(data) + Hash[*data.map do |key, value| + if value.is_a?(Hash) + [key, sanitize(value)] + else + if key =~ /acecss|token|password/ + [key, '[FILTERED]'] + else + [key, value] + end end + end.flatten(1)] end end end diff --git a/lib/stove/cookbook.rb b/lib/stove/cookbook.rb index b65f001..1e7e662 100644 --- a/lib/stove/cookbook.rb +++ b/lib/stove/cookbook.rb @@ -1,165 +1,128 @@ require 'fileutils' -require 'retryable' require 'tempfile' require 'time' module Stove class Cookbook - require_relative 'cookbook/metadata' + include Mixin::Loggable - include Stove::Git + require_relative 'cookbook/metadata' - # The path to this cookbook. # - # @return [String] + # The path to this cookbook on disk. + # + # @return [Pathname] + # attr_reader :path + # # The name of the cookbook (must correspond to the name of the # cookbook on the community site). # # @return [String] + # attr_reader :name + # # The version of this cookbook (originally). # # @return [String] + # attr_reader :version - # The new version of the cookbook. # - # @return [String] - attr_reader :new_version - # The metadata for this cookbook. # # @return [Stove::Cookbook::Metadata] + # attr_reader :metadata - # The list of options passed to the cookbook. # - # @return [Hash] - attr_reader :options + # The changeset for this cookbook. This is written by the changelog + # generator and read by various plugins. + # + # @return [String, nil] + # the changeset for this cookbook + # + attr_accessor :changeset + # # Create a new wrapper around the cookbook object. # - # @param [Hash] options - # the list of options - def initialize(options = {}) - @path = options[:path] || Dir.pwd - @new_version = options[:new_version] - @options = options - + # @param [String] path + # the relative or absolute path to the cookbook on disk + # + def initialize(path) + @path = Pathname.new(path).expand_path load_metadata! end + # # The category for this cookbook on the community site. # # @return [String] + # def category - @category ||= options[:category] || Stove::CommunitySite.cookbook(name)['category'] - rescue - raise Stove::CookbookCategoryNotFound + @category ||= Community.cookbook(name)['category'] + rescue Faraday::Error::ResourceNotFound + log.warn("Cookbook `#{name}' not found on the Chef community site") + nil end + # # The URL for the cookbook on the Community Site. # # @return [String] + # def url - "#{Stove::CommunitySite.http_uri}/cookbooks/#{name}" + URI.join(Community.base_url, 'cookbooks', name) end - # Deterine if this cookbook version is released on the community site - def released? - @_released ||= begin - Stove::CommunitySite.cookbook(name, version) - true - rescue Stove::BadResponse - false - end + # + # The tag version. This is just the current version prefixed with the + # letter "v". + # + # @example Tag version for 1.0.0 + # cookbook.tag_version #=> "v1.0.0" + # + # @return [String] + # + def tag_version + "v#{version}" end - # The unreleased JIRA tickets for this cookbook. # - # @return [Hashie::Dash, Array] - def unreleased_tickets - @unreleased_tickets ||= Stove::JIRA.unreleased_tickets_for(name) + # Deterine if this cookbook version is released on the community site + # + # @warn + # This is a fairly expensive operation and the result cannot be + # reliably cached! + # + # @return [Boolean] + # true if this cookbook at the current version exists on the community + # site, false otherwise + # + def released? + Community.cookbook(name, version) + true + rescue Faraday::Error::ResourceNotFound + false end # def release! - if options[:git] - Stove::Logger.info "Running validations" - validate_git_repo! - validate_git_clean! - validate_remote_updated! - end - - Stove::Logger.info "Bumping version" - version_bump - if options[:changelog] - Stove::Logger.info "Updating changelog" + log.info('Updating changelog') update_changelog end - - if options[:git] - Dir.chdir(path) do - Stove::Logger.info "Committing git changes in '#{path}'" - - git "add metadata.rb" - git "add CHANGELOG.md" - git "commit -m \"Version bump to #{tag_version}\"" - git "push #{options[:remote]} #{options[:branch]}" - - if options[:github] - Stove::Logger.info "Pushing release to GitHub" - Stove::GitHub.new(self).publish_release! - else - Stove::Logger.info "Tagging a release" - git "tag #{tag_version}" - git "push #{options[:remote]} #{tag_version}" - end - end - end - - if options[:upload] - Stove::Logger.info "Uploading cookbook" - retryable(tries: 3) do - upload - end - end - - if options[:jira] - Stove::Logger.info "Resolving JIRA issues" - resolve_jira_issues - end - - if options[:devodd] - Stove::Logger.info "Bumping devodd release" - split = version.split('.').map(&:to_i) - split[2] += 1 - devodd = split.join('.') - - version_bump(devodd) - - if options[:git] - Dir.chdir(path) do - git "add metadata.rb" - git "commit -m \"Version bump to #{tag_version}\"" - git "push #{options[:remote]} #{options[:branch]}" - end - end - end - end - - def tag_version - "v#{version}" end + # # So there's this really really crazy bug that the tmp directory could # be deleted mid-request... # # @return [File] + # def tarball return @tarball if @tarball && File.exists?(@tarball) @@ -170,59 +133,25 @@ def tarball end # - def upload - Stove::Uploader.new(self).upload! - end - - # The URL for this repository on GitHub. This method automatically - # translates SSH and git:// URLs to https:// URLs. + # Bump the version in the metdata.rb to the specified + # parameter. # - # @return [String] - def repository_url - @repository_url ||= git("config --get remote.#{options[:remote]}.url") - .strip - .gsub(/\.git$/, '') - .gsub(':', '/') - .gsub('@', '://') - .gsub('git://', 'https://') - end - - # The set of changes for this diff/patch in markdown format. + # @param [String] new_version + # the version to bump to # # @return [String] - def changeset - return @changeset if @changeset - - contents = [] - contents << "v#{version}" - contents << '-'*(version.length+1) - - if options[:jira] - by_type = unreleased_tickets.inject({}) do |hash, ticket| - issue_type = ticket.fields.current['issuetype']['name'] - hash[issue_type] ||= [] - hash[issue_type] << { - number: ticket.jira_key, - details: ticket.fields.current['summary'], - } + # the new version string + # + def bump(new_version) + return true if new_version.to_s == version.to_s - hash - end + metadata_path = path.join('metadata.rb') + contents = File.read(metadata_path) - by_type.each do |issue_type, tickets| - contents << "### #{issue_type}" - tickets.sort { |a,b| b[:number].to_i <=> a[:number].to_i }.each do |ticket| - contents << "- **[#{ticket[:number]}](#{Stove::JIRA::JIRA_URL}/browse/#{ticket[:number]})** - #{ticket[:details]}" - end - contents << "" - end - else - contents << "_Enter CHANGELOG for #{name} (#{version}) here_" - contents << "" - end + contents.sub!(/^version(\s+)('|")#{version}('|")/, "version\\1\\2#{new_version}\\3") - @changeset = contents.join("\n") - @changeset + File.open(metadata_path, 'w') { |f| f.write(contents) } + reload_metadata! end private @@ -234,7 +163,7 @@ def changeset # @return [String] # the path to the metadata file def load_metadata! - metadata_path = File.expand_path(File.join(path, 'metadata.rb')) + metadata_path = path.join('metadata.rb') @metadata = Stove::Cookbook::Metadata.from_file(metadata_path) @name = @metadata.name @@ -243,96 +172,5 @@ def load_metadata! metadata_path end alias_method :reload_metadata!, :load_metadata! - - # Update the CHANGELOG with the new contents, but inserting - # the newest version's CHANGELOG at the top of the file (after - # the header) - def update_changelog - changelog = File.join(path, 'CHANGELOG.md') - contents = File.readlines(changelog) - index = contents.find_index { |line| line =~ /(--)+/ } - - if index.nil? - raise Stove::InvalidChangelogError, "Your CHANGELOG does not exist" \ - " or is not in a valid format!" - end - - tmpfile = Tempfile.new(['changes', '.md']) - tmpfile.write(changeset) - tmpfile.rewind - response = shellout("$EDITOR #{tmpfile.path}") - - unless response.success? - Stove::Logger.debug response.stdout - Stove::Logger.debug response.stderr - raise Stove::Error, response.stderr - end - - @changeset = File.read(tmpfile.path).strip - - contents.insert(index - 2, "\n" + @changeset + "\n\n") - File.open(changelog, 'w') { |f| f.write(contents.join('')) } - rescue SystemExit, Interrupt - raise Stove::UserCanceledError - ensure - if defined?(tmpfile) - tmpfile.close - tmpfile.unlink - end - end - - # Bump the version in the metdata.rb to the specified - # parameter. - # - # @return [String] - # the new version string - def version_bump(bump_version = new_version) - return true if bump_version.to_s == version.to_s - - metadata_path = File.join(path, 'metadata.rb') - contents = File.read(metadata_path) - - contents.sub!(/^version(\s+)('|")#{version.to_s}('|")/, "version\\1\\2#{bump_version.to_s}\\3") - - File.open(metadata_path, 'w') { |f| f.write(contents) } - reload_metadata! - end - - # Resolve all the JIRA issues that have been merged. - def resolve_jira_issues - unreleased_tickets.collect do |ticket| - Thread.new { Stove::JIRA.comment_and_close(ticket, self) } - end.map(&:join) - end - - # Validate that the current working directory is git repo. - # - # @raise [Stove::GitError::NotARepo] - # if this is not currently a git repo - def validate_git_repo! - Dir.chdir(path) do - raise Stove::GitError::NotARepo unless git_repo? - end - end - - # Validate that the current. - # - # @raise [Stove::GitError::DirtyRepo] - # if the current working directory is not clean - def validate_git_clean! - Dir.chdir(path) do - raise Stove::GitError::DirtyRepo unless git_repo_clean? - end - end - - # Validate that the remote git repository is up to date. - # - # @raise [Stove::GitError::OutOfSync] - # if the current git repo is not up to date with the remote - def validate_remote_updated! - Dir.chdir(path) do - raise Stove::GitError::OutOfSync unless git_remote_uptodate?(options) - end - end end end diff --git a/lib/stove/cookbook/metadata.rb b/lib/stove/cookbook/metadata.rb index 96435a8..a1a7680 100644 --- a/lib/stove/cookbook/metadata.rb +++ b/lib/stove/cookbook/metadata.rb @@ -134,7 +134,7 @@ def from_file(path) self.instance_eval(IO.read(path), path, 1) self else - raise Stove::MetadataNotFound.new(path) + raise Error::MetadataNotFound.new(path: path) end end @@ -144,9 +144,13 @@ def ==(other) end end - def version(arg = nil) - @version = Solve::Version.new(arg) if arg - @version.to_s + def version(arg = UNSET_VALUE) + if arg == UNSET_VALUE + @version.to_s + else + @version = Solve::Version.new(arg) + @version.to_s + end end def to_hash @@ -176,15 +180,16 @@ def to_json(*args) end private - def set_or_return(symbol, arg) - iv_symbol = "@#{symbol.to_s}".to_sym - if arg.nil? && self.instance_variable_defined?(iv_symbol) - self.instance_variable_get(iv_symbol) - else - self.instance_variable_set(iv_symbol, arg) - end + def set_or_return(symbol, arg) + iv_symbol = "@#{symbol.to_s}".to_sym + + if arg.nil? && self.instance_variable_defined?(iv_symbol) + self.instance_variable_get(iv_symbol) + else + self.instance_variable_set(iv_symbol, arg) end + end end end end diff --git a/lib/stove/error.rb b/lib/stove/error.rb index f4fbe8b..b4101e7 100644 --- a/lib/stove/error.rb +++ b/lib/stove/error.rb @@ -1,118 +1,24 @@ module Stove - class Error < StandardError - class << self - def set_exit_code(code) - define_method(:exit_code) { code } - define_singleton_method(:exit_code) { code } - end - end - - set_exit_code 100 - end - - class InvalidVersionError < Error - set_exit_code 101 - - def message - 'You must specify a valid version!' - end - end - - class MetadataNotFound < Error - set_exit_code 102 + module Error + class StoveError < StandardError + def initialize(options = {}) + return super(options[:_message]) if options[:_message] - def initialize(filepath) - @filepath = File.expand_path(filepath) rescue filepath - end + class_name = self.class.to_s.split('::').last + error_key = Util.underscore(class_name) - def message - "No metadata.rb found at: '#{@filepath}'" - end - end - - class CookbookCategoryNotFound < Error - set_exit_code 110 - - def message - 'The cookbook\'s category could not be inferred from the community site. ' << - 'If this is a new cookbook, you must specify the category with the ' << - '--category flag.' - end - end - - class UserCanceledError < Error - set_exit_code 120 - - def message - 'Action canceled by user!' - end - end - - class GitError < Error - set_exit_code 130 - - def message - 'Git Error: ' + super - end - - class NotARepo < GitError - set_exit_code 131 - - def message - 'Not a git repo!' + super I18n.t("stove.errors.#{error_key}", options) end end - class DirtyRepo < GitError - set_exit_code 132 - - def message - 'You have untracked files!' + class ValidationFailed < StoveError + def initialize(klass, id, options = {}) + super _message: I18n.t("stove.validations.#{klass}.#{id}", options) end end - class OutOfSync < GitError - set_exit_code 133 - - def message - 'Your remote repository is out of sync!' - end - end - end - - class UploadError < Error - set_exit_code 140 - - def initialize(response) - @response = response - end - - def message - "The following errors occured when uploading:\n" << - (@response.parsed_response['error_messages'] || []).map do |error| - " - #{error}" - end.join("\n") - end - end - - class BadResponse < Error - set_exit_code 150 - - def initialize(response) - @response = response - end - - def message - "The following errors occured when making the request:\n" << - @response.parsed_response - end - end - - class AbstractFunction < Error - set_exit_code 160 - end - - class InvalidChangelogFormat < Error - set_exit_code 170 + class GitFailed < StoveError; end + class MetadataNotFound < StoveError; end + class ServerUnavailable < StoveError; end end end diff --git a/lib/stove/filter.rb b/lib/stove/filter.rb new file mode 100644 index 0000000..e06c2d6 --- /dev/null +++ b/lib/stove/filter.rb @@ -0,0 +1,59 @@ +module Stove + class Filter + include Mixin::Insideable + include Mixin::Loggable + + # + # The class that created this filter. + # + # @return [~Plugin::Base] + # + attr_reader :klass + + # + # The message given by the filter. + # + # @return [String] + # + attr_reader :message + + # + # The block captured by the filter. + # + # @return [Proc] + # + attr_reader :block + + # + # Create a new filter object. + # + # @param [~Plugin::Base] klass + # the class that created this filter + # @param [String] message + # the message given by the filter + # @param [Proc] block + # the block captured by this filter + # + def initialize(klass, message, &block) + @klass = klass + @message = message + @block = block + end + + # + # Execute this filter in the context of the creating class, inside the + # given cookbook's path. + # + # @param [Cookbook] + # the cookbook to run this filter against + # + def run(cookbook, options = {}) + log.info(message) + instance = klass.new(cookbook, options) + + inside(cookbook) do + instance.instance_eval(&block) + end + end + end +end diff --git a/lib/stove/formatter.rb b/lib/stove/formatter.rb deleted file mode 100644 index eafb33e..0000000 --- a/lib/stove/formatter.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Stove - module Formatter - require_relative 'formatter/base' - require_relative 'formatter/human' - require_relative 'formatter/silent' - end -end diff --git a/lib/stove/formatter/base.rb b/lib/stove/formatter/base.rb deleted file mode 100644 index 99a6cee..0000000 --- a/lib/stove/formatter/base.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Stove - module Formatter - class Base - class << self - def inherited(base) - key = base.to_s.split('::').last.gsub(/(.)([A-Z])/,'\1_\2').downcase.to_sym - formatters[key] = base - end - - def formatter_method(*methods) - methods.each do |name| - formatter_methods << name - - define_method(name) do |*args| - raise Stove::AbstractFunction - end - end - end - - def formatters - @formatters ||= {} - end - - def formatter_methods - @formatter_methods ||= [] - end - end - - formatter_method :upload - end - end -end diff --git a/lib/stove/formatter/human.rb b/lib/stove/formatter/human.rb deleted file mode 100644 index 1b38165..0000000 --- a/lib/stove/formatter/human.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Stove - module Formatter - class Human < Base - def upload(cookbook) - puts "Uploaded #{cookbook.name} (#{cookbook.version}) to '#{cookbook.url}'" - end - end - end -end diff --git a/lib/stove/formatter/silent.rb b/lib/stove/formatter/silent.rb deleted file mode 100644 index efd31c0..0000000 --- a/lib/stove/formatter/silent.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Stove - module Formatter - # Silence all output - class Silent < Base - Stove::Formatter::Base.formatter_methods.each do |name| - define_method(name) do |*args|; end - end - end - end -end diff --git a/lib/stove/git.rb b/lib/stove/git.rb deleted file mode 100644 index 89c5f81..0000000 --- a/lib/stove/git.rb +++ /dev/null @@ -1,82 +0,0 @@ -require 'tempfile' - -module Stove - module Git - # Run a git command. - # - # @param [String] command - # the command to run - # - # @return [String] - # the stdout from the command - def git(command) - Stove::Logger.debug "shellout 'git #{command}'" - response = shellout("git #{command}") - - Stove::Logger.debug response.stdout - - unless response.success? - Stove::Logger.debug response.stderr - raise Stove::GitError, response.stderr - end - - response.stdout.strip - end - - # Return true if the current working directory is a valid - # git repot, false otherwise. - # - # @return [Boolean] - def git_repo? - git('rev-parse --show-toplevel') - true - rescue - false - end - - # Return true if the current working directory is clean, - # false otherwise - # - # @return [Boolean] - def git_repo_clean? - !!git('status -s').strip.empty? - rescue - false - end - - def git_remote_uptodate?(options = {}) - git('fetch') - local = git("rev-parse #{options[:branch]}").strip - remote = git("rev-parse #{options[:remote]}/#{options[:branch]}").strip - - local == remote - end - - def shellout(command) - out, err = Tempfile.new('shellout.stdout'), Tempfile.new('shellout.stderr') - - begin - pid = Process.spawn(command, out: out.to_i, err: err.to_i) - pid, status = Process.waitpid2(pid) - - # Check if we're getting back a process status because win32-process 6.x was a fucking MURDERER. - # https://github.com/djberg96/win32-process/blob/master/lib/win32/process.rb#L494-L519 - exitstatus = status.is_a?(Process::Status) ? status.exitstatus : status - rescue Errno::ENOENT => e - err.write('') - err.write('Command not found: ' + command) - end - - out.close - err.close - - OpenStruct.new({ - exitstatus: exitstatus, - stdout: File.read(out).strip, - stderr: File.read(err).strip, - success?: exitstatus == 0, - error?: exitstatus == 0, - }) - end - end -end diff --git a/lib/stove/github.rb b/lib/stove/github.rb deleted file mode 100644 index 4dd3528..0000000 --- a/lib/stove/github.rb +++ /dev/null @@ -1,43 +0,0 @@ -require 'octokit' - -module Stove - class GitHub - attr_reader :cookbook - - def initialize(cookbook) - @cookbook = cookbook - - Octokit.configure do |config| - config.access_token = Stove::Config['github_access_token'] - end - end - - def publish_release! - release = Octokit.create_release(repository, cookbook.tag_version, - name: cookbook.tag_version, - body: changeset, - ) - asset = Octokit.upload_asset("repos/#{repository}/releases/#{release.id}", cookbook.tarball, - content_type: 'application/x-gzip', - name: filename, - ) - Octokit.update_release_asset("repos/#{repository}/releases/assets/#{asset.id}", - name: filename, - label: 'Download Cookbook', - ) - end - - private - def repository - @repository ||= Octokit::Repository.from_url(cookbook.repository_url) - end - - def changeset - cookbook.changeset.split("\n")[2..-1].join("\n").strip - end - - def filename - @filename ||= "#{cookbook.name}-#{cookbook.version}.tar.gz" - end - end -end diff --git a/lib/stove/jira.rb b/lib/stove/jira.rb index 39b4c71..c4af8d1 100644 --- a/lib/stove/jira.rb +++ b/lib/stove/jira.rb @@ -1,43 +1,87 @@ -require 'jiralicious' -require 'json' +require 'faraday' +require 'faraday_middleware' module Stove class JIRA - JIRA_URL = 'https://tickets.opscode.com' + include Mixin::Instanceable + include Mixin::Loggable + include Mixin::Optionable - Jiralicious.configure do |config| - config.username = Stove::Config['jira_username'] - config.password = Stove::Config['jira_password'] - config.uri = JIRA_URL + option :base_url, + ENV['JIRA_URL'] || 'https://tickets.opscode.com/rest/api/2' + + def issue(key, options = {}) + connection.get("issue/#{key}", options).body + end + + def search(query = {}) + jql = query.map { |k,v| %Q|#{k} = "#{v}"| }.join(' AND ') + connection.get('search', jql: jql).body end - class << self - def unreleased_tickets_for(component) - jql = [ - 'project = COOK', - 'resolution = Fixed', - 'status = "Fix Committed"', - 'component = ' + component.inspect - ].join(' AND ') - Stove::Logger.debug "JQL: #{jql.inspect}" - - Jiralicious.search(jql).issues + def close_and_comment(key, comment) + transitions = issue(key, expand: 'transitions')['transitions'] + close = transitions.first { |transition| transition['name'] == 'Close' } + + if close.nil? + log.warn("Issue #{key} does not have a `Close' transition") + return end - # Comment and close a particular issue. - # - # @param [Jiralicious::Issue] ticket - # the JIRA ticket - # @param [Stove::Cookbook] cookbook - # the cookbook to release - def comment_and_close(ticket, cookbook) - comment = "Released in [#{cookbook.version}|#{cookbook.url}]" + connection.post("issue/#{key}/transitions", { + transition: { id: close['id'] }, + update: { + comment: [ + { add: { body: comment.to_s } } + ] + }, + fields: { + resolution: { + name: 'Fixed' + }, + assignee: { + name: nil + } + } + }) + end + + private + + def connection + @connection ||= Faraday.new(base_url) do |builder| + # Encode request bodies as JSON + builder.request :json + + # Add basic authentication information + builder.request :basic_auth, Stove::Config[:jira][:username], + Stove::Config[:jira][:password] + + # Handle any common errors + builder.use Stove::Middleware::Exceptions + + # Decode responses as JSON if the Content-Type is json + builder.response :json + builder.response :json_fix + + # Allow up to 3 redirects + builder.response :follow_redirects, limit: 3 + + # Log all requests and responses (useful for development) + builder.response :logger, log + + # Raise errors on 40x and 50x responses + builder.response :raise_error + + # Use the default adapter (Net::HTTP) + builder.adapter :net_http - transition = Jiralicious::Issue::Transitions.find(ticket.jira_key).find do |key, value| - !value.is_a?(String) && value.name == 'Close' - end.last + # Set the User-Agent header for logging purposes + builder.headers[:user_agent] = Stove::USER_AGENT - Jiralicious::Issue::Transitions.go(ticket.jira_key, transition.id, { comment: comment }) + # Set some options, such as timeouts + builder.options[:timeout] = 30 + builder.options[:open_timeout] = 30 end end end diff --git a/lib/stove/logger.rb b/lib/stove/logger.rb deleted file mode 100644 index 61a851c..0000000 --- a/lib/stove/logger.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'logger' - -module Stove - module Logger - class << self - def set_level(level) - logger.level = level_to_constant(level) - logger - end - - def set_output(output) - old_level = @logger.sev_threshold - - @logger = ::Logger.new(output) - @logger.level = old_level - @logger - end - - [:fatal, :error, :warn, :info, :debug, :sev_threshold].each do |name| - define_method(name) do |*args| - logger.send(name, *args) - end - end - - private - def logger - @logger ||= begin - logger = ::Logger.new($stdout) - logger.level = ::Logger::WARN - logger - end - end - - # Convert a string to it's logger constant. - # - # @return [Object] - def level_to_constant(level) - return level if level.kind_of?(Fixnum) - case level.to_s.strip.downcase.to_sym - when :fatal - ::Logger::FATAL - when :error - ::Logger::ERROR - when :warn - ::Logger::WARN - when :info - ::Logger::INFO - when :debug - ::Logger::DEBUG - else - ::Logger::INFO - end - end - end - end -end diff --git a/lib/stove/middlewares/chef_authentication.rb b/lib/stove/middlewares/chef_authentication.rb new file mode 100644 index 0000000..d4ad8e0 --- /dev/null +++ b/lib/stove/middlewares/chef_authentication.rb @@ -0,0 +1,60 @@ +require 'pp' + +module Stove + class Middleware::ChefAuthentication < Faraday::Middleware + dependency do + require 'mixlib/authentication/signedheaderauth' + require 'openssl' + require 'uri' + end + + # + # @param [Faraday::Application] app + # @param [String] client + # the name of the client to use for Chef + # @param [OpenSSL::PKey::RSA] key + # the RSA private key to sign with + # + def initialize(app, client, key) + super(app) + + @client = client + @key = OpenSSL::PKey::RSA.new(File.read(key)) + end + + def call(env) + env[:request_headers].merge!(signing_object(env)) + @app.call(env) + end + + private + + def signing_object(env) + params = { + :http_method => env[:method], + :timestamp => Time.now.utc.iso8601, + :user_id => @client, + :path => env[:url].path, + :body => env[:body] || '', + } + + # Royal fucking hack + # 1. (n.) This code sample + # 2. (v.) Having to decompose a Faraday response because Mixlib + # Authentication couldn't get a date to the prom + if env[:body] && env[:body].is_a?(Faraday::CompositeReadIO) + file = env[:body] + .instance_variable_get(:@parts) + .first { |part| part.is_a?(Faraday::Parts::FilePart) } + .instance_variable_get(:@io) + .instance_variable_get(:@ios)[1] + .instance_variable_get(:@local_path) + + params[:file] = File.new(file) + end + + object = Mixlib::Authentication::SignedHeaderAuth.signing_object(params) + object.sign(@key) + end + end +end diff --git a/lib/stove/middlewares/exceptions.rb b/lib/stove/middlewares/exceptions.rb new file mode 100644 index 0000000..c610cf1 --- /dev/null +++ b/lib/stove/middlewares/exceptions.rb @@ -0,0 +1,17 @@ +module Stove + class Middleware::Exceptions < Faraday::Middleware + include Mixin::Loggable + + def call(env) + begin + @app.call(env) + rescue Faraday::Error::ConnectionFailed + url = env[:url].to_s.gsub(env[:url].path, '') + raise Error::ServerUnavailable.new(url: url) + rescue Faraday::Error::ClientError => e + log.debug(env.inspect) + raise + end + end + end +end diff --git a/lib/stove/mixins/filterable.rb b/lib/stove/mixins/filterable.rb new file mode 100644 index 0000000..0506c26 --- /dev/null +++ b/lib/stove/mixins/filterable.rb @@ -0,0 +1,11 @@ +module Stove + module Mixin::Filterable + def before(action, message, &block) + Runner.filters[action][:before] << Filter.new(self, message, &block) + end + + def after(action, message, &block) + Runner.filters[action][:after] << Filter.new(self, message, &block) + end + end +end diff --git a/lib/stove/mixins/insideable.rb b/lib/stove/mixins/insideable.rb new file mode 100644 index 0000000..4432369 --- /dev/null +++ b/lib/stove/mixins/insideable.rb @@ -0,0 +1,13 @@ +module Stove + module Mixin::Insideable + # + # Execute the command inside the cookbook. + # + # @param [Cookbook] + # the cookbook to execute inside of + # + def inside(cookbook, &block) + Dir.chdir(cookbook.path, &block) + end + end +end diff --git a/lib/stove/mixins/instanceable.rb b/lib/stove/mixins/instanceable.rb new file mode 100644 index 0000000..c810922 --- /dev/null +++ b/lib/stove/mixins/instanceable.rb @@ -0,0 +1,23 @@ +require 'singleton' + +module Stove + module Mixin::Instanceable + def self.included(base) + base.send(:include, Singleton) + base.send(:undef_method, :inspect, :to_s) + base.send(:extend, ClassMethods) + end + + def self.extended(base) + base.send(:include, Singleton) + base.send(:undef_method, :inspect, :to_s) + base.send(:extend, ClassMethods) + end + + module ClassMethods + def method_missing(m, *args, &block) + instance.send(m, *args, &block) + end + end + end +end diff --git a/lib/stove/mixins/loggable.rb b/lib/stove/mixins/loggable.rb new file mode 100644 index 0000000..66d375d --- /dev/null +++ b/lib/stove/mixins/loggable.rb @@ -0,0 +1,32 @@ +require 'log4r' + +module Stove + module Mixin::Loggable + def self.extended(base) + base.send(:include, InstanceMethods) + base.send(:extend, ClassMethods) + end + + def self.included(base) + base.send(:include, InstanceMethods) + base.send(:extend, ClassMethods) + end + + module ClassMethods + def log + return @log if @log + + @log = Log4r::Logger.new(self.name) + @log.outputters = Log4r::Outputter.stdout + @log.level = 1 + @log + end + end + + module InstanceMethods + def log + self.class.log + end + end + end +end diff --git a/lib/stove/mixins/optionable.rb b/lib/stove/mixins/optionable.rb new file mode 100644 index 0000000..2e2f77c --- /dev/null +++ b/lib/stove/mixins/optionable.rb @@ -0,0 +1,41 @@ +module Stove + module Mixin::Optionable + def self.included(base) + base.send(:extend, ClassMethods) + end + + def self.extended(base) + base.send(:extend, ClassMethods) + end + + module ClassMethods + # + # This is a magical method. It does three things: + # + # 1. Defines a class method getter and setter for the given option + # 2. Defines an instance method that delegates to the class method + # 3. (Optionally) sets the initial value + # + # @param [String, Symbol] name + # the name of the option + # @param [Object] initial + # the initial value to set (optional) + # + def option(name, initial = UNSET_VALUE) + define_singleton_method(name) do |value = UNSET_VALUE| + if value == UNSET_VALUE + instance_variable_get("@#{name}") + else + instance_variable_set("@#{name}", value) + end + end + + define_method(name) { self.class.send(name) } + + unless initial == UNSET_VALUE + send(name, initial) + end + end + end + end +end diff --git a/lib/stove/mixins/validatable.rb b/lib/stove/mixins/validatable.rb new file mode 100644 index 0000000..a1f4f7e --- /dev/null +++ b/lib/stove/mixins/validatable.rb @@ -0,0 +1,7 @@ +module Stove + module Mixin::Validatable + def validate(id, &block) + Runner.validations << Validator.new(self, id, &block) + end + end +end diff --git a/lib/stove/packager.rb b/lib/stove/packager.rb index bc52f58..6d92646 100644 --- a/lib/stove/packager.rb +++ b/lib/stove/packager.rb @@ -49,34 +49,35 @@ def package_path end private - def pack! - destination = Tempfile.new(cookbook.name).path - # Sandbox - sandbox = Dir.mktmpdir - FileUtils.mkdir_p(sandbox) + def pack! + destination = Tempfile.new(cookbook.name).path - # Containing folder - container = File.join(sandbox, cookbook.name) - FileUtils.mkdir_p(container) + # Sandbox + sandbox = Dir.mktmpdir + FileUtils.mkdir_p(sandbox) - # Copy filles - FileUtils.cp_r(cookbook_files, container) + # Containing folder + container = File.join(sandbox, cookbook.name) + FileUtils.mkdir_p(container) - # Generate metadata - File.open(File.join(container, 'metadata.json'), 'w') do |f| - f.write(cookbook.metadata.to_json) - end + # Copy filles + FileUtils.cp_r(cookbook_files, container) - Dir.chdir(sandbox) do |dir| - # This is super fucking annoying. The community site should really - # be better at reading tarballs - relative_path = container.gsub(sandbox + '/', '') + '/' - tgz = Zlib::GzipWriter.new(File.open(destination, 'wb')) - Archive::Tar::Minitar.pack(relative_path, tgz) - end + # Generate metadata + File.open(File.join(container, 'metadata.json'), 'w') do |f| + f.write(cookbook.metadata.to_json) + end - return destination + Dir.chdir(sandbox) do |dir| + # This is super fucking annoying. The community site should really + # be better at reading tarballs + relative_path = container.gsub(sandbox + '/', '') + '/' + tgz = Zlib::GzipWriter.new(File.open(destination, 'wb')) + Archive::Tar::Minitar.pack(relative_path, tgz) end + + return destination + end end end diff --git a/lib/stove/plugins/base.rb b/lib/stove/plugins/base.rb new file mode 100644 index 0000000..cb77f17 --- /dev/null +++ b/lib/stove/plugins/base.rb @@ -0,0 +1,35 @@ +module Stove + class Plugin::Base + extend Mixin::Filterable + extend Mixin::Loggable + extend Mixin::Optionable + extend Mixin::Validatable + + option :id + option :description + + class << self + def onload(&block) + if block + @onload = block + else + @onload + end + end + end + + attr_reader :cookbook + attr_reader :options + + def initialize(cookbook, options = {}) + @cookbook, @options = cookbook, options + instance_eval(&onload) + end + + private + + def onload + self.class.onload || Proc.new {} + end + end +end diff --git a/lib/stove/plugins/git.rb b/lib/stove/plugins/git.rb new file mode 100644 index 0000000..a1b4a68 --- /dev/null +++ b/lib/stove/plugins/git.rb @@ -0,0 +1,71 @@ +module Stove + class Plugin::Git < Plugin::Base + id 'git' + description 'Tag and push to a git remote' + + validate(:repository) do + File.directory?(File.join(Dir.pwd, '.git')) + end + + validate(:clean) do + git_null('status -s').strip.empty? + end + + validate(:up_to_date) do + git_null('fetch') + local = git_null("rev-parse #{options[:branch]}").strip + remote = git_null("rev-parse #{options[:remote]}/#{options[:branch]}").strip + + log.debug("Local SHA: #{local}") + log.debug("Remote SHA: #{remote}") + + local == remote + end + + after(:bump, 'Performing version bump') do + git %|add metadata.rb| + git %|commit -m "Version bump to #{cookbook.version}"| + end + + after(:changelog, 'Committing CHANGELOG') do + git %|add CHANGELOG.md| + git %|commit -m "Publish #{cookbook.version} Changelog"| + end + + before(:upload, 'Tagging new release') do + git %|tag #{cookbook.tag_version}| + git %|push #{options[:remote]} #{cookbook.tag_version}| + end + + after(:dev, 'Bumping devodd release') do + git %|add metadata.rb| + git %|commit -m "Version bump to #{cookbook.version} (for development)"| + end + + before(:finish, 'Pushing to git remote(s)') do + git %|push #{options[:remote]} #{options[:branch]}| + end + + def git(command, errors = true) + log.debug("Running `git #{command}', errors: #{errors}") + response = %x|cd "#{cookbook.path}" && git #{command}| + + if errors && !$?.success? + raise Error::GitFailed.new(command: command) + end + + response + end + + def git_null(command) + null = case RbConfig::CONFIG['host_os'] + when /mswin|mingw|cygwin/ + 'NUL' + else + '/dev/null' + end + + git("#{command} 2>#{null}", false) + end + end +end diff --git a/lib/stove/plugins/github.rb b/lib/stove/plugins/github.rb new file mode 100644 index 0000000..ba12289 --- /dev/null +++ b/lib/stove/plugins/github.rb @@ -0,0 +1,108 @@ +module Stove + class Plugin::GitHub < Plugin::Base + id 'github' + description 'Publish the release to GitHub' + + onload do + require 'faraday' + require 'faraday_middleware' + require 'octokit' + end + + validate(:git) do + options[:git] + end + + validate(:configuration) do + Config.has_key?(:github) + end + + validate(:access_token) do + Config[:github].has_key?(:access_token) + end + + after(:upload, 'Publishing the release to GitHub') do + release = client.create_release(repository, cookbook.tag_version, + name: cookbook.tag_version, + body: cookbook.changeset, + ) + asset = client.upload_asset("repos/#{repository}/releases/#{release.id}", cookbook.tarball, + content_type: 'application/x-gzip', + name: filename, + ) + client.update_release_asset("repos/#{repository}/releases/assets/#{asset.id}", + name: filename, + label: 'Download Cookbook', + ) + end + + def client + return @client if @client + + config = {}.tap do |h| + h[:middleware] = middleware + h[:access_token] = Config[:github][:access_token] + h[:api_endpoint] = Config[:github][:api_endpoint] if Config[:github][:api_endpoint] + end + + @client = Octokit::Client.new(config) + @client + end + + def changeset + @changeset ||= cookbook.changeset.split("\n")[2..-1].join("\n").strip + end + + def repository + @repository ||= Octokit::Repository.from_url(repo_url) + end + + def filename + @filename ||= "#{cookbook.name}-#{cookbook.version}.tar.gz" + end + + def middleware + Faraday::Builder.new do |builder| + # Handle any common errors + builder.use Stove::Middleware::Exceptions + builder.use Octokit::Response::RaiseError + + # Log all requests and responses (useful for development) + builder.response :logger, log + + # Raise errors on 40x and 50x responses + builder.response :raise_error + + # Use the default adapter (Net::HTTP) + builder.adapter :net_http + end + end + + # + # The URL for this repository on GitHub. This method automatically + # translates SSH and git:// URLs to https:// URLs. + # + # @return [String] + # + def repo_url + return @repo_url if @repo_url + + path = File.join('.git', 'config') + log.debug("Calculating repo_url from `#{path}'") + + config = File.read(path) + log.debug("Config contents:\n#{config}") + + config =~ /\[remote "#{options[:remote]}"\]\n\s+url = (.+)$/ + log.debug("Match: #{$1.inspect}") + + @repo_url = $1.to_s + .strip + .gsub(/\.git$/, '') + .gsub(':', '/') + .gsub('@', '://') + .gsub('git://', 'https://') + @repo_url + end + end +end diff --git a/lib/stove/plugins/jira.rb b/lib/stove/plugins/jira.rb new file mode 100644 index 0000000..2eddeb2 --- /dev/null +++ b/lib/stove/plugins/jira.rb @@ -0,0 +1,72 @@ +module Stove + class Plugin::JIRA < Plugin::Base + id 'jira' + description 'Resolve JIRA issues' + + validate(:configuration) do + Config.has_key?(:jira) + end + + validate(:username) do + Config[:jira].has_key?(:username) + end + + validate(:password) do + Config[:jira].has_key?(:password) + end + + before(:changelog, 'Generate JIRA changeset') do + by_type = unreleased_issues.inject({}) do |hash, issue| + type = issue['fields']['issuetype']['name'] + hash[type] ||= [] + hash[type] << { + key: issue['key'], + summary: issue['fields']['summary'], + } + + hash + end + + # Calculate the JIRA path based off of the JIRA base_url + jira_base = URI.parse(JIRA.base_url) + jira_base.path = '' + jira_base = jira_base.to_s + log.debug("JIRA base is `#{jira_base}'") + + contents = [] + + by_type.each do |type, issues| + contents << "### #{type}" + issues.sort { |a, b| b[:key].to_i <=> a[:key].to_i }.each do |issue| + url = "#{jira_base}/browse/#{issue[:key]}" + contents << "- **[#{issue[:key]}](#{url})** - #{issue[:summary]}" + end + contents << '' + end + + cookbook.changeset = contents.join("\n") + end + + after(:upload, 'Resolving JIRA issues') do + unreleased_issues.collect do |issue| + Thread.new do + JIRA.close_and_comment(issue['key'], "Released in #{cookbook.version}") + end + end.map(&:join) + end + + # + # The list of unreleased tickets on JIRA. + # + # @return [Array] + # + def unreleased_issues + @unreleased_issues ||= JIRA.search( + project: 'COOK', + resolution: 'Fixed', + status: 'Fix Committed', + component: cookbook.name, + )['issues'] + end + end +end diff --git a/lib/stove/rake_task.rb b/lib/stove/rake_task.rb index 90eea5c..f11c911 100644 --- a/lib/stove/rake_task.rb +++ b/lib/stove/rake_task.rb @@ -1,63 +1,82 @@ require 'rake' require 'rake/tasklib' +require 'stove' +# +# @todo Most of these options are duplicated from the CLI, can we unify? +# module Stove - # - # Run Stove tasks from your +Rakefile+. - # - # @example - # desc "Run stove tasks" - # Stove::RakeTask.new(:release) do |stove| - # stove.git = true - # stove.devodd = true - # end - # - class RakeTask < ::Rake::TaskLib + class RakeTask < Rake::TaskLib + include Mixin::Loggable + class << self # # Define a CLI option. # # @param [Symbol] option # - def cli_option(option) + def option(option) define_method("#{option}=".to_sym) do |value| - options[option] = value - end - - define_method(option.to_sym) do - options[option] + log.debug("Setting #{option} = #{value.inspect}") + options[option.to_sym] = value end end end - # @return [Symbol] - attr_accessor :name + # Actions + Action.constants.map(&Action.method(:const_get)).select(&:id).each do |action| + option action.id + end - # @return [Hash] - attr_reader :options + # Plugins + Plugin.constants.map(&Plugin.method(:const_get)).select(&:id).each do |plugin| + option plugin.id + end - def initialize(task_name = nil) - @options = {} - @name = (task_name || :publish).to_sym + option :category + option :path + option :remote + option :branch + def initialize(name = nil) yield self if block_given? desc 'Publish this cookbook' unless ::Rake.application.last_comment - task name do |t, args| - require 'stove' - Stove::Cookbook.new(options).release! + task(name || :publish, :version) do |t, args| + log.info("Options: #{options.inspect}") + + cookbook = Cookbook.new(options[:path]) + options[:version] = args[:version] || minor_bump(cookbook.version) + Runner.run(cookbook, options) end end - cli_option :branch - cli_option :category - cli_option :changelog - cli_option :devodd - cli_option :git - cli_option :jira - cli_option :log_level - cli_option :path - cli_option :remote - cli_option :upload + def locale=(locale) + log.debug("Setting locale = #{locale.inspect}") + I18n.locale = locale + end + + def log_level=(level) + log.debug("Setting log_level = #{level.inspect}") + Stove.log_level = level + end + + private + + def minor_bump(version) + split = version.split('.').map(&:to_i) + split[2] += 1 + split.join('.') + end + + def options + @options ||= Hash.new(true).tap do |h| + h[:path] = Dir.pwd + h[:jira] = false + + h[:remote] = 'origin' + h[:branch] = 'master' + end + end end end diff --git a/lib/stove/runner.rb b/lib/stove/runner.rb new file mode 100644 index 0000000..461324c --- /dev/null +++ b/lib/stove/runner.rb @@ -0,0 +1,84 @@ +module Stove + class Runner + include Mixin::Instanceable + include Mixin::Loggable + include Mixin::Optionable + + class << self + def action(id) + actions << id + filters[id] = { before: [], after: [] } + end + end + + attr_reader :cookbook + attr_reader :options + attr_reader :validations + + option :actions, [] + option :filters, {} + + action :start + action :bump + action :changelog + action :upload + action :dev + action :finish + + def initialize + @validations = [] + end + + def run(cookbook, options = {}) + @cookbook, @options = cookbook, options + + run_validations + run_actions + end + + private + + def skip?(thing) + !options[thing.to_sym] + end + + def run_actions + actions.each do |action| + if skip?(action) + log.debug("Skipping action `#{action}' and filters") + else + run_filters(:before, action) + + klass = Action.const_get(Util.camelize(action)) + klass.new(cookbook, options).run + + run_filters(:after, action) + end + end + end + + def run_filters(placement, action) + filters[action][placement].each do |filter| + plugin = filter.klass.id + + if skip?(plugin) + log.debug("Skipping filter `#{filter.message}'") + else + filter.run(cookbook, options) + end + end + end + + def run_validations + validations.each do |validation| + parent = validation.klass.id + + if skip?(parent) + log.debug("Skipping validation `#{validation.id}' for `#{parent}'") + else + validation.run(cookbook, options) + end + end + end + end +end diff --git a/lib/stove/uploader.rb b/lib/stove/uploader.rb deleted file mode 100644 index 3d9099b..0000000 --- a/lib/stove/uploader.rb +++ /dev/null @@ -1,64 +0,0 @@ -require 'httparty' -require 'httmultiparty' -require 'mixlib/authentication/signedheaderauth' -require 'openssl' - -module Stove - class Uploader - include HTTMultiParty - - # The cookbook associated with this uploader - # - # @return [Stove::Cookbook] - attr_reader :cookbook - - # Create a new uploader instance for the given cookbook. - # - # @param [Stove::Cookbook] cookbook - # the cookbook for this uploader - def initialize(cookbook) - @cookbook = cookbook - end - - def upload! - response = self.class.post(upload_url, { - :headers => headers, - :query => { - :tarball => File.new(cookbook.tarball), - :cookbook => { category: cookbook.category }.to_json, - }, - }) - - if response.success? - Stove.formatter.upload(cookbook) - else - raise Stove::UploadError.new(response) - end - end - - private - def headers - { - 'Accept' => 'application/json', - }.merge(Mixlib::Authentication::SignedHeaderAuth.signing_object({ - :http_method => 'post', - :timestamp => Time.now.utc.iso8601, - :user_id => username, - :path => URI.parse(upload_url).path, - :file => File.new(cookbook.tarball), - }).sign(pem_file)) - end - - def pem_file - OpenSSL::PKey::RSA.new(File.read(File.expand_path(Stove::Config['opscode_pem_file']))) - end - - def username - Stove::Config['opscode_username'] - end - - def upload_url - "#{Stove::CommunitySite.base_uri}/cookbooks" - end - end -end diff --git a/lib/stove/util.rb b/lib/stove/util.rb new file mode 100644 index 0000000..320b750 --- /dev/null +++ b/lib/stove/util.rb @@ -0,0 +1,56 @@ +module Stove + module Util + extend self + + # Convert a version string (x.y.z) to a community-site friendly format + # (x_y_z). + # + # @example Convert a version to a version string + # format_version('1.2.3') #=> 1_2_3 + # + # @param [#to_s] version + # the version string to convert + # + # @return [String] + def version_for_url(version) + version + .to_s + .gsub('.', '_') + end + + # + # Covert the given CaMelCaSeD string to under_score. Graciously borrowed + # from http://stackoverflow.com/questions/1509915. + # + # @param [String] string + # the string to use for transformation + # + # @return [String] + # + def underscore(string) + string + .to_s + .gsub(/::/, '/') + .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2') + .gsub(/([a-z\d])([A-Z])/,'\1_\2') + .tr('-', '_') + .downcase + end + + # + # Convert an underscored string to it's camelcase equivalent constant. + # + # @param [String] + # the string to convert + # + # @return [String] + # + def camelize(string) + string + .to_s + .split('_') + .map { |e| e.capitalize } + .join + end + end +end diff --git a/lib/stove/validator.rb b/lib/stove/validator.rb new file mode 100644 index 0000000..89a269a --- /dev/null +++ b/lib/stove/validator.rb @@ -0,0 +1,67 @@ +module Stove + class Validator + include Mixin::Insideable + include Mixin::Loggable + + # + # The class that created this validator. + # + # @return [~Class] + # + attr_reader :klass + + # + # The identifier or field this validator runs against. + # + # @return [Symbol] + # + attr_reader :id + + # + # The block to execute to see if the validation passes. + # + # @return [Proc] + # + attr_reader :block + + # + # Create a new validator object. + # + # @param [~Class] klass + # the class that created this validator + # @param [Symbol] id + # the identifier or field this validator runs against + # @param [Proc] block + # the block to execute to see if the validation passes + # + def initialize(klass, id, &block) + @klass = klass + @id = id + @block = block + end + + # + # Execute this validation in the context of the creating class, inside the + # given cookbook's path. + # + # @param [Cookbook] + # the cookbook to run this validation against + # + def run(cookbook, options = {}) + log.info("Running validations for #{klass.id}.#{id}") + + inside(cookbook) do + instance = klass.new(cookbook, options) + unless result = instance.instance_eval(&block) + log.debug("Validation failed, result: #{result.inspect}") + raise Error::ValidationFailed.new(klass.id, id, + path: Dir.pwd, + result: result, + ) + end + end + + log.debug("Validation #{id} passed!") + end + end +end diff --git a/lib/stove/version.rb b/lib/stove/version.rb index e654ca5..23543ec 100644 --- a/lib/stove/version.rb +++ b/lib/stove/version.rb @@ -1,3 +1,3 @@ module Stove - VERSION = '1.1.2' + VERSION = '2.0.0.beta.1' end diff --git a/locales/en.yml b/locales/en.yml new file mode 100644 index 0000000..b8e8f12 --- /dev/null +++ b/locales/en.yml @@ -0,0 +1,231 @@ +en: + stove: + errors: + abstract_method: > + `%{method}` is an abstract method. You must override this method in + your subclass with the proper implementation and logic. For more + information, please see the inline documentation for %{method}. If you + are not a developer, this is most likely a bug. Please file a bug report + at https://github.com/sethvargo/stove/issues/new and include the + command(s) you ran to arrive at this error. + + git_failed: > + An error occurred while running: + + git %{command} + + There is likely an informative message from git that explains what + happened right above this message. + + metadata_not_found: > + The file at `%{path}' does not exist or does not contain valid metadata. + Please make sure you have specified the correct path and that the + metdata file exists. + + server_unavailable: > + The server at `%{url}` is unavailable or is not currently accepting + client connections. Please ensure the server is accessible via ping (or + telnet) on your local network. If this error persists, please contact + your network administrator. + + + validations: + # Bump validations + # ---------------- + bump: + changed: > + The version you are trying to bump already exists! You must specify a + new version. + + incremented: > + The cookbook version you are attempting to bump to is less than the + existing version. You cannot (re-)release a previous version of the + same cookbook. Please specify a higher version. + + + # Changelog validations + # --------------------- + changelog: + editor: > + The `$EDITOR' environment variable is not set. In order to use the + Changelog plugin, you must set a default editor for Stove to open when + generating the CHANGLEOG. You can set the editor like this: + + export EDITOR=vi + + exists: > + There is no `CHANGELOG.md' found at `%{path}. In order to use the + Changelog plugin, you must have a changelog in markdown format at the + root of your cookbook. You can also skip the Changelog plugin by + specifying the `--no-changelog' option: + + bake x.y.z --no-changelog + + format: > + The changelog at `%{path}' does not appear to be a valid format. The + changelog must be in the following format: + + [Cookbook Name] + =============== + + v[version] ([release date]) + --------------------------- + - [Release point] + + For example: + + Apache 2 + ======== + + v1.0.0 (2013-04-05) + ------------------- + - Initial release + + + # Git validations + # --------------- + git: + clean: > + The cookbook at `%{path}' has untracked files! In order to use the git + plugin, you must have a clean working directory. Please commit or + stash your changes before running Stove again. + + repository: > + The cookbook at `%{path}' does not appear to be a valid git + repository. In order to use the git plugin, your cookbook must be + initialized as a git repository. To create a git repository, run: + + git init %{path} + + up_to_date: > + The cookbook at `%{path}' is out of sync with the remote repository. + Please update your local cache with the remote repository before + continuing: + + git pull + + And then push your local changes to the remote repository: + + git push + + + # GitHub validations + # ------------------ + github: + access_token: > + The Stove configuration for GitHub does not contain an access token. + In order to use the GitHub plugin, you must set an access token in the + GitHub section of the `~/.stove' file: + + { + "github": { + "access_token": "YOUR_ACCESS_TOKEN" + } + } + + You can generate an access token for GitHub by following the + instructions at: https://help.github.com/articles/creating-an-access-token-for-command-line-use. + + configuration: > + The Stove configuration for GitHub does not exist! In order to use the + GitHub plugin, you must set your GitHub information in the `~/.stove' + file: + + { + "github": { + "access_token": "YOUR_ACCESS_TOKEN" + } + } + + git: > + In order to use the GitHub plugin, you must also activate the `git' + plugin. + + + # JIRA validations + # ---------------- + jira: + configuration: > + The Stove configuration for JIRA does not exist! In order to use the + JIRA plugin, you must set your JIRA information in the `~/.stove' + file: + + { + "jira": { + "username": "YOUR_USERNAME", + "password": "YOUR_PASSWORD" + } + } + + password: > + The Stove configuration for JIRA does not contain a password. In order + to use JIRA features, you must set a password in the jira section of + the `~/.stove' file: + + { + "jira": { + "password": "YOUR_PASSWORD" + } + } + + username: > + The Stove configuration for JIRA does not contain a username. In order + to use JIRA features, you must set a username in the jira section of + the `~/.stove' file: + + { + "jira": { + "username": "YOUR_USERNAME" + } + } + + + # Upload validations + # --------------------- + upload: + configuration: > + The Stove configuration for the Chef community site does not exist! In + order to perform operations against the Chef community site, you must + set your username and path to your client key in the community section + of the `~/.stove' file: + + { + "community": { + "username": "YOUR_USERNAME", + "key": "YOUR_KEY_PATH" + } + } + + key: > + The Stove configuration for the Chef community site does not contain a + key. In order to use the Chef community site features, you must set + the path to your key the community section of the `~/.stove' file: + + { + "community": { + "key": "YOUR_KEY_PATH" + } + } + + username: > + The Stove configuration for the Chef community site does not contain a + username. In order to use the Chef community site features, you must + set a username the community section of the `~/.stove' file: + + { + "community": { + "username": "YOUR_USERNAME" + } + } + + category: > + You did not specify a category! The Chef community site requires all + cookbooks belong to a category. For existing cookboks, Stove can query + the Chef community site API and automatically complete the category + for you. However, for new cookbooks, you must specify the `--category' + flag at runtime: + + bake x.y.z --category Utilities + + For a complete listing of categories, please see the Chef community + site. diff --git a/spec/support/community_site.rb b/spec/support/community_site.rb deleted file mode 100644 index 25e2c70..0000000 --- a/spec/support/community_site.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'community_zero/server' - -module Stove - module RSpec - module CommunitySite - class << self - def start(options = {}) - return @server if @server - - @server = CommunityZero::Server.new(options) - @server.start_background - @server - end - - def stop - @server.stop if running? - end - - def running? - !!(@server && @server.running?) - end - - def reset! - @server && @server.reset! - end - - def server_url - @server && @server.url - end - end - end - end -end diff --git a/spec/support/git.rb b/spec/support/git.rb deleted file mode 100644 index a6548f7..0000000 --- a/spec/support/git.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'fileutils' - -module Stove - module RSpec - module Git - include Stove::Git - - def git_init(path = Dir.pwd) - Dir.chdir(path) do - git 'init .' - git 'add --all' - git 'commit --message "Initial commit"' - git 'remote add origin file://' + fake_git_remote - git 'push origin master' - end - end - - def fake_git_remote - path = File.expand_path(File.join(tmp_path, 'remote.git')) - return path if File.exists?(path) - - FileUtils.mkdir_p(path) - Dir.chdir(path) do - git 'init .' - git 'config receive.denyCurrentBranch ignore' - git 'config receive.denyNonFastforwards true' - git 'config core.sharedrepository 1' - end - - path - end - - def git_shas(path) - Dir.chdir(path) do - git('log --oneline').split("\n").map { |line| line.split(/\s+/, 2).first.strip } rescue [] - end - end - - def git_commits(path) - Dir.chdir(path) do - git('log --oneline').split("\n").map { |line| line.split(/\s+/, 2).last.strip } rescue [] - end - end - - def git_tags(path) - Dir.chdir(path) do - git('tag --list').split("\n").map(&:strip) rescue [] - end - end - end - end -end diff --git a/stove.gemspec b/stove.gemspec index 8591877..1723bae 100644 --- a/stove.gemspec +++ b/stove.gemspec @@ -8,8 +8,8 @@ Gem::Specification.new do |spec| spec.version = Stove::VERSION spec.authors = ['Seth Vargo'] spec.email = ['sethvargo@gmail.com'] - spec.description = %q{A simple gem for packaging, releasing, and sanity-checking a community cookbook} - spec.summary = %q{A simple gem for packaging, releasing, and sanity-checking an Opscode community cookbook. This gem automatically packages the appropiate files, syncs with JIRA issues (if applicable), and automatically generates CHANGELOGs.} + spec.description = %q|A simple gem for packaging, releasing, and sanity-checking a community cookbook| + spec.summary = %q|A simple gem for packaging, releasing, and sanity-checking an Opscode community cookbook. This gem automatically packages the appropiate files, syncs with JIRA issues (if applicable), and automatically generates CHANGELOGs.| spec.homepage = 'https://github.com/sethvargo/stove' spec.license = 'Apache 2.0' @@ -18,19 +18,19 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'httparty', '~> 0.11' - spec.add_dependency 'httmultiparty', '~> 0.3' - spec.add_dependency 'jiralicious', '~> 0.3' + # Runtime dependencies + spec.add_dependency 'faraday', '~> 0.8' + spec.add_dependency 'faraday_middleware', '~> 0.9' + spec.add_dependency 'i18n', '~> 0.6' + spec.add_dependency 'log4r', '~> 1.1' spec.add_dependency 'minitar', '~> 0.5' spec.add_dependency 'mixlib-authentication', '~> 1.3' - spec.add_dependency 'octokit', '~> 2.2' - spec.add_dependency 'retryable', '~> 1.3' + spec.add_dependency 'octokit', '~> 2.7' spec.add_dependency 'solve', '~> 0.8' - spec.add_development_dependency 'bundler', '~> 1.3' - spec.add_development_dependency 'rake' - spec.add_development_dependency 'aruba', '~> 0.5' - spec.add_development_dependency 'community-zero', '~> 1.1' + spec.add_development_dependency 'bundler', '~> 1.3' + spec.add_development_dependency 'community-zero', '~> 2.0' + spec.add_development_dependency 'rake' spec.add_development_dependency 'rspec', '~> 2.14' end