chef-rundeck2 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # Encoding: UTF-8
2
+ # rubocop: disable LineLength
3
+ #
4
+ # Gem Name:: chef-rundeck
5
+ # Module:: Auth
6
+ #
7
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
8
+ #
9
+ # All rights reserved - Do Not Redistribute
10
+ #
11
+
12
+ require 'chef-rundeck/config'
13
+ require 'chef-rundeck/util'
14
+ require 'digest'
15
+
16
+ module ChefRunDeck
17
+ # => Authorization Module
18
+ module Auth
19
+ extend self
20
+
21
+ #############################
22
+ # => Authorization <= #
23
+ #############################
24
+
25
+ # => This holds the Authorization State
26
+ attr_accessor :auth
27
+
28
+ def auth
29
+ # => Define Authorization
30
+ @auth ||= reset!
31
+ end
32
+
33
+ def reset!
34
+ # => Reset Authorization
35
+ @auth = { 'roles' => [] }
36
+ end
37
+
38
+ def parse(user = nil)
39
+ # => Try to Find the User and their Authorization
40
+ auth = Util.parse_json_config(Config.auth_file, false)
41
+ return reset! unless auth && auth[user]
42
+ @auth = auth[user]
43
+ end
44
+
45
+ def admin?
46
+ # => Check if a User is an Administrator
47
+ auth['roles'].any? { |x| x.casecmp('admin') == 0 }
48
+ end
49
+
50
+ def creator?(node)
51
+ # => Grab the Node-State Object
52
+ existing = State.find_state(node)
53
+ return false unless existing
54
+ # => Check if Auth User was the Node-State Creator
55
+ existing[:creator].to_s.casecmp(Config.query_params['auth_user'].to_s) == 0
56
+ end
57
+
58
+ # => Validate the User's Authentication Key ## TODO: Use this, passthrough from a RunDeck Option Field
59
+ def key?
60
+ # => We store a SHA512 Hex Digest of the Key
61
+ return false unless Config.query_params['auth_key']
62
+ Digest::SHA512.hexdigest(Config.query_params['auth_key']) == auth['auth_key']
63
+ end
64
+
65
+ # => TODO: Project-Based Validation
66
+ def project_admin?(project = nil)
67
+ return false unless project.is_a?(Array)
68
+ # => parse_auth.include?(user) && parse_auth[user]['roles'].any? { |r| ['admin', project].include? r.to_s.downcase }
69
+ auth['roles'].any? { |r| ['admin', project].include? r.to_s.downcase }
70
+ end
71
+
72
+ # => Role-Based Administration
73
+ def role_admin?(run_list = nil)
74
+ return false unless run_list.is_a?(Array)
75
+ run_list.empty? || auth['roles'].any? { |role| run_list.any? { |r| r =~ /role\[#{role}\]/i } }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,193 @@
1
+ # Encoding: UTF-8
2
+ # rubocop: disable LineLength, MethodLength
3
+ #
4
+ # Gem Name:: chef-rundeck
5
+ # Module:: Chef
6
+ #
7
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
8
+ #
9
+ # All rights reserved - Do Not Redistribute
10
+ #
11
+
12
+ require 'chef-api'
13
+ require 'chef-rundeck/config'
14
+
15
+ module ChefRunDeck
16
+ # => This is the Chef module. It interacts with the Chef server
17
+ module Chef # rubocop: disable ModuleLength
18
+ extend self
19
+ # => Include Modules
20
+ include ChefAPI::Resource
21
+
22
+ #########################
23
+ # => ChefAPI <= #
24
+ #########################
25
+
26
+ def api_client
27
+ # => Configure a Chef API Client
28
+ ChefAPI.endpoint = Config.chef_api_endpoint
29
+ ChefAPI.client = Config.chef_api_client
30
+ ChefAPI.key = Config.chef_api_client_key
31
+ end
32
+
33
+ def admin_api_client
34
+ # => Configure an Administrative Chef API Client
35
+ ChefAPI.endpoint = Config.chef_api_endpoint
36
+ ChefAPI.client = Config.chef_api_admin
37
+ ChefAPI.key = Config.chef_api_admin_key
38
+ end
39
+
40
+ def reset!
41
+ # => Reset the Chef API Configuration
42
+ ChefAPI.reset!
43
+ # => Clear Transient Configuration
44
+ Config.clear(:rundeck)
45
+ end
46
+
47
+ # => Get Node
48
+ def get_node(node, casecomp = false)
49
+ node = Node.list.find { |n| n =~ /^#{node}$/i } if casecomp
50
+ return false unless Node.exists?(node)
51
+ Node.fetch(node)
52
+ end
53
+
54
+ # => Return Array List of Nodes
55
+ def list
56
+ Node.list
57
+ end
58
+
59
+ # => Return a Node's Run List
60
+ def run_list(node)
61
+ return [] unless Node.exists?(node)
62
+ Node.fetch(node).run_list
63
+ end
64
+
65
+ # => Delete a Node Object
66
+ def delete(node)
67
+ # => Make sure the Node Exists
68
+ return 'Node not found on Chef Server' unless Node.exists?(node)
69
+
70
+ # => Initialize the Admin API Client Settings
71
+ admin_api_client
72
+
73
+ # => Delete the Client & Node Object
74
+ Client.delete(node)
75
+ Node.delete(node)
76
+ 'Client/Node Deleted from Chef Server'
77
+ end
78
+
79
+ #############################
80
+ # => Resource Provider <= #
81
+ #############################
82
+
83
+ #
84
+ # => Try to Parse Project-Specific Settings
85
+ #
86
+ def project_settings(project)
87
+ settings = Util.parse_json_config(Config.projects_file, false)
88
+ return {} unless settings && settings[project]
89
+ settings[project]
90
+ end
91
+
92
+ #
93
+ # => Construct Query-Specific Configuration
94
+ #
95
+ def transient_settings # rubocop: disable AbcSize
96
+ # => Initialize any Project-Specific Settings
97
+ project = project_settings(Config.query_params['project'])
98
+
99
+ # => Build the Configuration
100
+ cfg = {}
101
+ cfg[:username] = Config.query_params['username'] || project['username'] || Config.rd_node_username
102
+ cfg[:pattern] = Config.query_params['pattern'] || project['pattern'] || '*:*'
103
+ cfg[:extras] = Util.serialize_csv(Config.query_params['extras']) || project['extras']
104
+
105
+ # => Make the Settings Available via the Config Object
106
+ Config.add(rundeck: cfg)
107
+ end
108
+
109
+ #
110
+ # => Base Search Filter Definition
111
+ #
112
+ def default_search_filter
113
+ {
114
+ name: ['name'],
115
+ kernel_machine: ['kernel', 'machine'],
116
+ kernel_os: ['kernel', 'os'],
117
+ fqdn: ['fqdn'],
118
+ run_list: ['run_list'],
119
+ roles: ['roles'],
120
+ recipes: ['recipes'],
121
+ chef_environment: ['chef_environment'],
122
+ platform: ['platform'],
123
+ platform_version: ['platform_version'],
124
+ tags: ['tags'],
125
+ hostname: ['hostname']
126
+ }
127
+ end
128
+
129
+ #
130
+ # => Parse Additional Filter Elements
131
+ #
132
+ # => Default Elements can be removed by passing them in here as null or empty
133
+ #
134
+ def search_filter_additions
135
+ attribs = {}
136
+ Array(Config.rundeck[:extras]).each do |attrib|
137
+ attribs[attrib.to_sym] = [attrib]
138
+ end
139
+ # => Return the Custom Filter Additions Hash
140
+ attribs
141
+ end
142
+
143
+ #
144
+ # => Construct the Search Filter
145
+ #
146
+ def search_filter
147
+ # => Merge the Default Filter with Additions
148
+ default_search_filter.merge(search_filter_additions).reject { |_k, v| v.nil? || String(v).empty? }
149
+ end
150
+
151
+ #
152
+ # => Define Extra Attributes for Resource Return
153
+ #
154
+ def custom_attributes(node)
155
+ attribs = {}
156
+ Array(Config.rundeck[:extras]).each do |attrib|
157
+ attribs[attrib.to_sym] = node[attrib].inspect
158
+ end
159
+ # => Return the Custom Attributes Hash
160
+ attribs
161
+ end
162
+
163
+ def search(pattern = '*:*') # rubocop: disable AbcSize
164
+ # => Initialize the Configuration
165
+ transient_settings
166
+
167
+ # => Pull in the Pattern
168
+ pattern = Config.rundeck[:pattern]
169
+
170
+ # => Execute the Chef Search
171
+ result = PartialSearch.query(:node, search_filter, pattern, start: 0)
172
+
173
+ # => Custom-Tailor the Resulting Objects
174
+ result.rows.collect do |node|
175
+ {
176
+ nodename: node['name'],
177
+ hostname: node['fqdn'] || node['hostname'],
178
+ osArch: node['kernel_machine'],
179
+ osFamily: node['platform'],
180
+ osName: node['platform'],
181
+ osVersion: node['platform_version'],
182
+ description: node['name'],
183
+ roles: node['roles'].join(','),
184
+ recipes: node['recipes'].join(','),
185
+ tags: [node['roles'], node['recipes'], node['chef_environment'], node['tags']].flatten.join(','),
186
+ environment: node['chef_environment'],
187
+ editUrl: ::File.join(Config.chef_api_endpoint, 'nodes', node['name']),
188
+ username: Config.rundeck[:username]
189
+ }.merge(custom_attributes(node)).reject { |_k, v| v.nil? || String(v).empty? }
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,132 @@
1
+ # Encoding: UTF-8
2
+ # rubocop: disable LineLength, MethodLength, AbcSize
3
+ #
4
+ # Gem Name:: chef-rundeck
5
+ # ChefRunDeck:: CLI
6
+ #
7
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
8
+ #
9
+ # All rights reserved - Do Not Redistribute
10
+ #
11
+
12
+ require 'mixlib/cli'
13
+ require 'chef-rundeck/config'
14
+ require 'chef-rundeck/util'
15
+
16
+ module ChefRunDeck
17
+ #
18
+ # => Chef-RunDeck Launcher
19
+ #
20
+ module CLI
21
+ extend self
22
+ #
23
+ # => Options Parser
24
+ #
25
+ class Options
26
+ # => Mix-In the CLI Option Parser
27
+ include Mixlib::CLI
28
+
29
+ option :cache_timeout,
30
+ short: '-t CACHE_TIMEOUT',
31
+ long: '--timeout CACHE_TIMEOUT',
32
+ description: 'Sets the cache timeout in seconds for API query response data.'
33
+
34
+ option :config_file,
35
+ short: '-c CONFIG',
36
+ long: '--config CONFIG',
37
+ description: 'The configuration file to use, as opposed to command-line parameters (optional)'
38
+
39
+ option :auth_file,
40
+ short: '-a CONFIG',
41
+ long: '--auth-json CONFIG',
42
+ description: "The JSON file containing authorization information (Default: #{Config.auth_file})"
43
+
44
+ option :state_file,
45
+ short: '-s STATE',
46
+ long: '--state-json STATE',
47
+ description: "The JSON file containing node state & auditing information (Default: #{Config.state_file})"
48
+
49
+ option :chef_api_endpoint,
50
+ short: '-ce ENDPOINT',
51
+ long: '--chef-api-endpoint ENDPOINT',
52
+ description: 'The Chef API Endpoint URL (e.g. https://api.chef.io/)'
53
+
54
+ option :chef_api_client,
55
+ short: '-ccn CLIENT_NAME',
56
+ long: '--chef-api-client-name CLIENT_NAME',
57
+ description: 'The name of the Non-Privileged API Client'
58
+
59
+ option :chef_api_client_key,
60
+ short: '-cck CLIENT_KEY',
61
+ long: '--chef-api-client-key CLIENT_KEY',
62
+ description: 'The path to the Non-Privileged API Client Keyfile'
63
+
64
+ option :chef_api_admin,
65
+ short: '-can ADMIN_NAME',
66
+ long: '--chef-api-admin-name ADMIN_NAME',
67
+ description: 'The name of the Administratively-Privileged API Client'
68
+
69
+ option :chef_api_admin_key,
70
+ short: '-cak ADMIN_KEY',
71
+ long: '--chef-api-admin-key ADMIN_KEY',
72
+ description: 'The path to the Administratively-Privileged API Client Keyfile'
73
+
74
+ option :rd_node_username,
75
+ short: '-u USERNAME',
76
+ long: '--rundeck-node-user USERNAME',
77
+ description: 'The name of the User Account to place into the RunDeck Resource Provider'
78
+
79
+ option :bind,
80
+ short: '-b HOST',
81
+ long: '--bind HOST',
82
+ description: "Listen on Interface or IP (Default: #{Config.bind})"
83
+
84
+ option :port,
85
+ short: '-p PORT',
86
+ long: '--port PORT',
87
+ description: "The port to run on. (Default: #{Config.port})"
88
+
89
+ option :environment,
90
+ short: '-e ENV',
91
+ long: '--env ENV',
92
+ description: 'Sets the environment for chef-rundeck to execute under. Use "development" for more logging.',
93
+ default: 'production'
94
+ end
95
+
96
+ # => Launch the Application
97
+ def run(argv = ARGV)
98
+ # => Parse CLI Configuration
99
+ cli = Options.new
100
+ cli.parse_options(argv)
101
+
102
+ # => Parse JSON Config File (If Specified & Exists)
103
+ json_config = Util.parse_json_config(cli.config[:config_file])
104
+
105
+ # => Grab the Default Values
106
+ default = ChefRunDeck::Config.options
107
+
108
+ # => Merge Configuration (JSON File Wins)
109
+ config = [default, json_config, cli.config].compact.reduce(:merge)
110
+
111
+ # => Apply Configuration
112
+ ChefRunDeck::Config.setup do |cfg|
113
+ cfg.config_file = config[:config_file]
114
+ cfg.cache_timeout = config[:cache_timeout].to_i
115
+ cfg.bind = config[:bind]
116
+ cfg.port = config[:port]
117
+ cfg.auth_file = config[:auth_file]
118
+ cfg.state_file = config[:state_file]
119
+ cfg.environment = config[:environment].to_sym
120
+ cfg.chef_api_endpoint = config[:chef_api_endpoint]
121
+ cfg.chef_api_client = config[:chef_api_client]
122
+ cfg.chef_api_client_key = config[:chef_api_client_key]
123
+ cfg.chef_api_admin = config[:chef_api_admin]
124
+ cfg.chef_api_admin_key = config[:chef_api_admin_key]
125
+ cfg.rd_node_username = config[:rd_node_username]
126
+ end
127
+
128
+ # => Launch the API
129
+ ChefRunDeck::API.run!
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,100 @@
1
+ # Encoding: UTF-8
2
+ #
3
+ # Gem Name:: chef-rundeck
4
+ # ChefRunDeck:: Config
5
+ #
6
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
7
+ #
8
+ # All rights reserved - Do Not Redistribute
9
+ #
10
+
11
+ require 'chef-rundeck/helpers/configuration'
12
+ require 'pathname'
13
+
14
+ module ChefRunDeck
15
+ # => This is the Configuration module.
16
+ module Config
17
+ extend self
18
+ extend Configuration
19
+
20
+ # => Gem Root Directory
21
+ define_setting :root, Pathname.new(File.expand_path('../../../', __FILE__))
22
+
23
+ # => My Name
24
+ define_setting :author, 'Brian Dwyer - Intelligent Digital Services'
25
+
26
+ # => Application Environment
27
+ define_setting :environment, :production
28
+
29
+ # => Sinatra Configuration
30
+ define_setting :port, '9125'
31
+ define_setting :bind, 'localhost'
32
+ define_setting :cache_timeout, 30
33
+
34
+ # => Config File
35
+ define_setting :config_file, File.join(root, 'config', 'config.json')
36
+
37
+ # => Authentication File
38
+ define_setting :auth_file, File.join(root, 'config', 'auth.json')
39
+
40
+ # => State File
41
+ define_setting :state_file, File.join(root, 'config', 'state.json')
42
+
43
+ # => Project Configuration File
44
+ define_setting :projects_file, File.join(root, 'config', 'projects.json')
45
+
46
+ #
47
+ # => Chef API Configuration
48
+ #
49
+ # => Chef Endpoint
50
+ define_setting :chef_api_endpoint, 'https://api.chef.io'
51
+
52
+ # => Unprivileged Client
53
+ define_setting :chef_api_client # => Username
54
+ define_setting :chef_api_client_key # => Path to Key
55
+
56
+ # => Administratively-Privileged Client
57
+ define_setting :chef_api_admin # => Username
58
+ define_setting :chef_api_admin_key # => Path to Key
59
+
60
+ #
61
+ # => RunDeck Node Resource Configuration
62
+ #
63
+ # => Default Username (nil)
64
+ define_setting :rd_node_username, nil
65
+
66
+ #
67
+ # => Facilitate Dynamic Addition of Configuration Values
68
+ #
69
+ # => @return [class_variable]
70
+ #
71
+ def add(config = {})
72
+ config.each do |key, value|
73
+ define_setting key.to_sym, value
74
+ end
75
+ end
76
+
77
+ #
78
+ # => Facilitate Dynamic Removal of Configuration Values
79
+ #
80
+ # => @return nil
81
+ #
82
+ def clear(config)
83
+ Array(config).each do |setting|
84
+ delete_setting setting
85
+ end
86
+ end
87
+
88
+ #
89
+ # => List the Configurable Keys as a Hash
90
+ #
91
+ # @return [Hash]
92
+ #
93
+ def options
94
+ map = ChefRunDeck::Config.class_variables.map do |key|
95
+ [key.to_s.tr('@', '').to_sym, class_variable_get(:"#{key}")]
96
+ end
97
+ Hash[map]
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,57 @@
1
+ # Encoding: UTF-8
2
+ #
3
+ # Gem Name:: chef-rundeck
4
+ # Helper:: Configuration
5
+ #
6
+ # Author: Eli Fatsi - https://www.viget.com/articles/easy-gem-configuration-variables-with-defaults
7
+ # Contributor: Brian Dwyer - Intelligent Digital Services
8
+ #
9
+
10
+ # => Configuration Helper Module
11
+ module Configuration
12
+ #
13
+ # => Provides a method to configure an Application
14
+ # => Example:
15
+ # ChefRunDeck::Config.setup do |cfg|
16
+ # cfg.config_file = 'abc.json'
17
+ # cfg.app_name = 'GemBase'
18
+ # end
19
+ #
20
+ def setup
21
+ yield self
22
+ end
23
+
24
+ def define_setting(name, default = nil)
25
+ class_variable_set("@@#{name}", default)
26
+
27
+ define_class_method "#{name}=" do |value|
28
+ class_variable_set("@@#{name}", value)
29
+ end
30
+
31
+ define_class_method name do
32
+ class_variable_get("@@#{name}")
33
+ end
34
+ end
35
+
36
+ def delete_setting(name)
37
+ remove_class_variable("@@#{name}")
38
+
39
+ delete_class_method(name)
40
+ rescue NameError # => Handle Non-Existent Settings
41
+ return
42
+ end
43
+
44
+ private
45
+
46
+ def define_class_method(name, &block)
47
+ (class << self; self; end).instance_eval do
48
+ define_method name, &block
49
+ end
50
+ end
51
+
52
+ def delete_class_method(name)
53
+ (class << self; self; end).instance_eval do
54
+ undef_method name
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,86 @@
1
+ # Encoding: UTF-8
2
+ # rubocop: disable LineLength
3
+ #
4
+ # Gem Name:: chef-rundeck
5
+ # ChefRunDeck:: CLI
6
+ #
7
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
8
+ #
9
+ # All rights reserved - Do Not Redistribute
10
+ #
11
+
12
+ require 'chef-rundeck/config'
13
+ require 'chef-rundeck/util'
14
+
15
+ module ChefRunDeck
16
+ # => This is the State controller. It manages State information
17
+ module State
18
+ extend self
19
+
20
+ ##############################
21
+ # => State Operations <= #
22
+ ##############################
23
+
24
+ attr_accessor :state
25
+
26
+ def state
27
+ @state ||= Util.parse_json_config(Config.state_file) || []
28
+ end
29
+
30
+ def find_state(node)
31
+ state.detect { |h| h[:name].casecmp(node) == 0 }
32
+ end
33
+
34
+ def update_state(hash) # rubocop: disable AbcSize
35
+ # => Check if Node Already Exists
36
+ # => existing = state.detect { |h| h[:name].casecmp(hash[:name]) == 0 }
37
+ existing = find_state(hash[:name])
38
+ if existing # => Update the Existing Node
39
+ state.delete(existing)
40
+ audit_string = [DateTime.now, hash[:creator]].join(' - ')
41
+ existing[:last_modified] = existing[:last_modified].is_a?(Array) ? existing[:last_modified].take(5).unshift(audit_string) : [audit_string]
42
+ hash = existing
43
+ end
44
+
45
+ # => Update the State
46
+ state.push(hash)
47
+
48
+ # => Write Out the Updated State
49
+ write_state
50
+ end
51
+
52
+ # => Add Node to the State
53
+ def add_state(node, user, params)
54
+ # => Create a Node-State Object
55
+ (n = {}) && (n[:name] = node)
56
+ n[:created] = DateTime.now
57
+ n[:creator] = user
58
+ n[:type] = params['type'] if params['type']
59
+ # => Build the Updated State
60
+ update_state(n)
61
+ # => Return the Added Node
62
+ find_state(node)
63
+ end
64
+
65
+ # => Remove Node from the State
66
+ def delete_state(node)
67
+ # => Find the Node
68
+ existing = find_state(node)
69
+ return 'Node not present in state' unless existing
70
+ # => Delete the Node from State
71
+ state.delete(existing)
72
+ # => Write Out the Updated State
73
+ write_state
74
+ # => Return the Deleted Node
75
+ existing
76
+ end
77
+
78
+ def write_state
79
+ # => Sort & Unique State
80
+ state.sort_by! { |h| h[:name].downcase }.uniq!
81
+
82
+ # => Write Out the Updated State
83
+ Util.write_json_config(Config.state_file, state)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,64 @@
1
+ # Encoding: UTF-8
2
+ # rubocop: disable LineLength
3
+ #
4
+ # Gem Name:: chef-rundeck
5
+ # ChefRunDeck:: Util
6
+ #
7
+ # Copyright (C) 2016 Brian Dwyer - Intelligent Digital Services
8
+ #
9
+ # All rights reserved - Do Not Redistribute
10
+ #
11
+
12
+ require 'json'
13
+
14
+ module ChefRunDeck
15
+ # => Utility Methods
16
+ module Util
17
+ extend self
18
+
19
+ ########################
20
+ # => File I/O <= #
21
+ ########################
22
+
23
+ # => Define JSON Parser
24
+ def parse_json_config(file = nil, symbolize = true)
25
+ return unless file && ::File.exist?(file.to_s)
26
+ begin
27
+ ::JSON.parse(::File.read(file.to_s), symbolize_names: symbolize)
28
+ rescue JSON::ParserError
29
+ return
30
+ end
31
+ end
32
+
33
+ # => Define JSON Writer
34
+ def write_json_config(file, object)
35
+ return unless file && object
36
+ begin
37
+ File.open(file, 'w') { |f| f.write(JSON.pretty_generate(object)) }
38
+ end
39
+ end
40
+
41
+ #############################
42
+ # => Serialization <= #
43
+ #############################
44
+
45
+ def serialize(response)
46
+ # => Serialize Object into JSON Array
47
+ JSON.pretty_generate(response.map(&:name).sort_by(&:downcase))
48
+ end
49
+
50
+ def serialize_csv(csv)
51
+ # => Serialize a CSV String into an Array
52
+ return unless csv && csv.is_a?(String)
53
+ csv.split(',')
54
+ end
55
+
56
+ def serialize_revisions(branches, tags)
57
+ # => Serialize Branches/Tags into JSON Array
58
+ # => Branches = String, Tags = Key/Value
59
+ branches = branches.map(&:name).sort_by(&:downcase)
60
+ tags = tags.map(&:name).sort_by(&:downcase).reverse.map { |tag| { name: "Tag: #{tag}", value: tag } }
61
+ JSON.pretty_generate(branches + tags)
62
+ end
63
+ end
64
+ end