forcer 0.4.0
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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.idea/.name +1 -0
- data/.idea/.rakeTasks +7 -0
- data/.idea/encodings.xml +4 -0
- data/.idea/forcer.iml +40 -0
- data/.idea/misc.xml +14 -0
- data/.idea/modules.xml +8 -0
- data/.idea/scopes/scope_settings.xml +5 -0
- data/.idea/vcs.xml +6 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/Gemfile +12 -0
- data/README.md +91 -0
- data/Rakefile +1 -0
- data/bin/forcer +15 -0
- data/forcer.gemspec +33 -0
- data/lib/forcer/version.rb +3 -0
- data/lib/forcer_main.rb +45 -0
- data/lib/metadata_services/deploy_request.xml +20 -0
- data/lib/metadata_services/enterprise.wsdl +36389 -0
- data/lib/metadata_services/exclude_components.yml +3 -0
- data/lib/metadata_services/exclude_xml_nodes.yml +8 -0
- data/lib/metadata_services/list_metadata_request.xml +16 -0
- data/lib/metadata_services/metadata.wsdl +7758 -0
- data/lib/metadata_services/metadata_service.rb +171 -0
- data/lib/metadata_services/sfdc_directory_service.rb +190 -0
- data/lib/utilities/action_options_service.rb +31 -0
- data/lib/utilities/status_print_service.rb +63 -0
- metadata +103 -0
@@ -0,0 +1,171 @@
|
|
1
|
+
require "savon"
|
2
|
+
require_relative "../metadata_services/sfdc_directory_service"
|
3
|
+
require_relative "../utilities/status_print_service"
|
4
|
+
|
5
|
+
=begin
|
6
|
+
client.operations => [
|
7
|
+
:cancel_deploy,
|
8
|
+
:check_deploy_status,
|
9
|
+
:check_retrieve_status,
|
10
|
+
:create_metadata,
|
11
|
+
:delete_metadata,
|
12
|
+
:deploy,
|
13
|
+
:deploy_recent_validation,
|
14
|
+
:describe_metadata,
|
15
|
+
:describe_value_type,
|
16
|
+
:list_metadata,
|
17
|
+
:read_metadata,
|
18
|
+
:rename_metadata,
|
19
|
+
:retrieve,
|
20
|
+
:update_metadata,
|
21
|
+
:upsert_metadata
|
22
|
+
]
|
23
|
+
=end
|
24
|
+
module Metadata
|
25
|
+
class MetadataService
|
26
|
+
|
27
|
+
API_VERSION = 33.0 # todo move to constants file
|
28
|
+
attr_accessor :metadata_client, :current_session_id, :zip_name
|
29
|
+
|
30
|
+
def initialize(args = {})
|
31
|
+
@args = args
|
32
|
+
@metadata_client = get_client
|
33
|
+
end
|
34
|
+
|
35
|
+
# lists metadata types like Classes, Pages
|
36
|
+
def list
|
37
|
+
default_list = ["CustomObject", "ApexClass", "ApexTrigger", "CustomLabels", "CustomTab", "EmailTemplate",
|
38
|
+
"Profile", "Queue", "StaticResource", "ApexComponent", "ApexPage"]
|
39
|
+
|
40
|
+
# assume components listed in terminal without commas as option to program
|
41
|
+
if @args[:types] != nil
|
42
|
+
types = @args[:types]
|
43
|
+
elsif
|
44
|
+
types = default_list
|
45
|
+
end
|
46
|
+
|
47
|
+
queries = ""
|
48
|
+
types.each do |type|
|
49
|
+
queries += "<met:type>#{type.to_s}</met:type><met:folder>#{type.to_s}</met:folder>"
|
50
|
+
end
|
51
|
+
|
52
|
+
list_metadata_template = File.read(File.dirname(__FILE__) + "/list_metadata_request.xml")
|
53
|
+
xml_param = list_metadata_template % [@current_session_id, queries, API_VERSION]
|
54
|
+
response = @metadata_client.call(:list_metadata, :xml => xml_param)
|
55
|
+
|
56
|
+
return response
|
57
|
+
end
|
58
|
+
|
59
|
+
def deploy
|
60
|
+
begin
|
61
|
+
dir_zip_service = SfdcDirectoryService.new(@args)
|
62
|
+
@zip_name = dir_zip_service.write
|
63
|
+
blob_zip = Base64.encode64(File.open(@zip_name, "rb").read)
|
64
|
+
|
65
|
+
# todo read options from console arguments
|
66
|
+
options = {
|
67
|
+
singlePackage: true,
|
68
|
+
rollbackOnError: true,
|
69
|
+
checkOnly: false,
|
70
|
+
allowMissingFiles: false,
|
71
|
+
runAllTests: false,
|
72
|
+
ignoreWarnings: false
|
73
|
+
}
|
74
|
+
|
75
|
+
# prepare xml for deployment
|
76
|
+
deploy_options_snippet = ""
|
77
|
+
options.each do |k, v|
|
78
|
+
# todo take care of array options if any
|
79
|
+
value = @args[k].nil? ? v.to_s : @args[k].to_s
|
80
|
+
key = k.to_s
|
81
|
+
deploy_options_snippet += "<met:#{key}>#{value}</met:#{key}>"
|
82
|
+
end
|
83
|
+
|
84
|
+
debug_options_snippet = "" #by default no debug options
|
85
|
+
|
86
|
+
deploy_request_xml = File.read(File.dirname(__FILE__) + "/deploy_request.xml");
|
87
|
+
xml_param = deploy_request_xml % [debug_options_snippet, @current_session_id, blob_zip, deploy_options_snippet]
|
88
|
+
response = @metadata_client.call(:deploy, :xml => xml_param)
|
89
|
+
# todo catch exceptions
|
90
|
+
|
91
|
+
if response.body[:deploy_response][:result][:state] == "Queued"
|
92
|
+
p "DEPLOYMENT STARTED. YOU CAN ALSO CHECK DEPLOYMENT STATUS IN SALESFORCE ORG."
|
93
|
+
|
94
|
+
Forcer::StatusPrintService.new().run_status_check(
|
95
|
+
{id: response.body[:deploy_response][:result][:id], session_id: @current_session_id},
|
96
|
+
lambda { |header, body| @metadata_client.call(:check_deploy_status, soap_header: header) { message(body) }}
|
97
|
+
) unless @args[:unit_test_running]
|
98
|
+
|
99
|
+
else
|
100
|
+
p "DEPLOYMENT FAILED. CHECK DEPLOYMENT STATUS LOG IN SALESFORCE ORG."
|
101
|
+
end
|
102
|
+
ensure
|
103
|
+
p "deleting zip file with project metadata"
|
104
|
+
FileUtils.rm_f @zip_name
|
105
|
+
end
|
106
|
+
|
107
|
+
return response
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
# login to salesforce and obtain session information
|
112
|
+
def login
|
113
|
+
"login request to #{@args[:host]}"
|
114
|
+
endpoint_url = @args[:host]
|
115
|
+
options = {
|
116
|
+
endpoint: "#{endpoint_url}/services/Soap/c/#{API_VERSION}",
|
117
|
+
wsdl: File.expand_path("../enterprise.wsdl", __FILE__),
|
118
|
+
:headers => {
|
119
|
+
"Authentication" => "secret"
|
120
|
+
}
|
121
|
+
}
|
122
|
+
enterprise_client = Savon.client(options)
|
123
|
+
|
124
|
+
message = {
|
125
|
+
username: @args[:username],
|
126
|
+
password: "#{@args[:password]}#{@args[:security_token]}"
|
127
|
+
}
|
128
|
+
|
129
|
+
# === login
|
130
|
+
response = enterprise_client.call(:login, message: message)
|
131
|
+
# todo catch exceptions
|
132
|
+
@current_session_id = response.body[:login_response][:result][:session_id]
|
133
|
+
@metadata_server_url = response.body[:login_response][:result][:metadata_server_url]
|
134
|
+
end
|
135
|
+
|
136
|
+
# using session information create metadata client
|
137
|
+
def get_client
|
138
|
+
login
|
139
|
+
p "creating metadata client from wsdl"
|
140
|
+
options = {
|
141
|
+
wsdl: File.expand_path("../metadata.wsdl", __FILE__),
|
142
|
+
endpoint: @metadata_server_url,
|
143
|
+
soap_header: {
|
144
|
+
"tns:SessionHeader" => {
|
145
|
+
"tns:sessionId" => @current_session_id
|
146
|
+
}
|
147
|
+
},
|
148
|
+
read_timeout: 60 * 10,
|
149
|
+
open_timeout: 60 * 10
|
150
|
+
}
|
151
|
+
return Savon.client(options)
|
152
|
+
end
|
153
|
+
|
154
|
+
end # class MetadataService
|
155
|
+
end # module Metadata
|
156
|
+
|
157
|
+
# test area
|
158
|
+
|
159
|
+
# args = {
|
160
|
+
# host: "https://test.salesforce.com",
|
161
|
+
# username: "gaziz@eventbrite.com.comitydev",
|
162
|
+
# password: "?kMMTR[d}X7`Fd}>@T.",
|
163
|
+
# security_token: "fpX1t6k2We39Qtq42NKbnLWSQ"
|
164
|
+
# }
|
165
|
+
# metadata_service = Metadata::MetadataService.new(
|
166
|
+
# File.expand_path("../../../tmp/TestProject", __FILE__),
|
167
|
+
# args
|
168
|
+
# )
|
169
|
+
#
|
170
|
+
# p metadata_service.list.body
|
171
|
+
# p metadata_service.deploy.body[:deploy_response][:result][:state]
|
@@ -0,0 +1,190 @@
|
|
1
|
+
require "zip"
|
2
|
+
require "securerandom"
|
3
|
+
require "yaml"
|
4
|
+
require "nokogiri"
|
5
|
+
require "Find"
|
6
|
+
|
7
|
+
module Metadata
|
8
|
+
|
9
|
+
class SfdcDirectoryService
|
10
|
+
|
11
|
+
public
|
12
|
+
|
13
|
+
def initialize(args = {})
|
14
|
+
@args = args
|
15
|
+
@output_file_name = tempfile_name("zip")
|
16
|
+
@files_to_exclude = Set.new()
|
17
|
+
@snippets_to_exclude = {}
|
18
|
+
find_source_dir
|
19
|
+
prepare_files_to_exclude
|
20
|
+
prepare_xml_nodes_to_exclude
|
21
|
+
end
|
22
|
+
|
23
|
+
# copy files from original directory to be xml_filtered later
|
24
|
+
# Create zip file with contents of force.com project
|
25
|
+
# Return absolute path to the file
|
26
|
+
def write
|
27
|
+
begin
|
28
|
+
@zip_io = Zip::File.open(@output_file_name, Zip::File::CREATE)
|
29
|
+
raise "package.xml NOT FOUND" unless verify_package_xml
|
30
|
+
|
31
|
+
p "making temporary copy of project folder"
|
32
|
+
tmpdir = Dir.mktmpdir
|
33
|
+
FileUtils.cp_r(@input_dir_name, tmpdir)
|
34
|
+
@input_dir_name = tmpdir.to_s + "/src"
|
35
|
+
|
36
|
+
entries = dir_content(@input_dir_name)
|
37
|
+
p "excluding specified components from deployment"
|
38
|
+
p "filtering deployment files removing specified XML elements"
|
39
|
+
write_entries(entries, "")
|
40
|
+
ensure
|
41
|
+
@zip_io.close # close before deleting tmpdir, or NOT_FOUND exception
|
42
|
+
p "deleting temporary copy of project folder"
|
43
|
+
FileUtils.remove_entry(tmpdir)
|
44
|
+
end
|
45
|
+
|
46
|
+
# FileUtils.cp_r(@output_file_name, "/Users/gt/Desktop/temp.zip")
|
47
|
+
return @output_file_name
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def find_source_dir
|
53
|
+
raise Exception unless Dir.exists?(@args[:source])
|
54
|
+
@input_dir_name = ""
|
55
|
+
Find.find(@args[:source]) do |entry|
|
56
|
+
if entry.end_with?("src") && File.directory?(entry)
|
57
|
+
@input_dir_name = entry
|
58
|
+
p "found 'src' directory"
|
59
|
+
break
|
60
|
+
end
|
61
|
+
end
|
62
|
+
raise Exception if @input_dir_name.empty?
|
63
|
+
end
|
64
|
+
|
65
|
+
def prepare_files_to_exclude()
|
66
|
+
exclude_filename = @args[:exclude_components]
|
67
|
+
if exclude_filename.nil? || exclude_filename.empty? || not(File.exists?(exclude_filename))
|
68
|
+
exclude_filename = File.expand_path("../exclude_components.yml", __FILE__)
|
69
|
+
end
|
70
|
+
|
71
|
+
@files_to_exclude = Set.new()
|
72
|
+
YAML.load_file(exclude_filename).each do |name|
|
73
|
+
@files_to_exclude.add(name.to_s.downcase)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def prepare_xml_nodes_to_exclude()
|
78
|
+
exclude_filename = @args[:exclude_xml]
|
79
|
+
if exclude_filename.nil? || exclude_filename.empty? || not(File.exists?(exclude_filename))
|
80
|
+
exclude_filename = File.expand_path("../exclude_xml_nodes.yml", __FILE__)
|
81
|
+
end
|
82
|
+
|
83
|
+
@snippets_to_exclude = YAML.load_file(exclude_filename)
|
84
|
+
# YAML.load_file(exclude_filename).each do |suffix, expressions|
|
85
|
+
# @snippets_to_exclude[key] << value
|
86
|
+
# pp "=== #{key} => #{value}"
|
87
|
+
# expressions.each do |exp, flag|
|
88
|
+
# pp "=== exp => #{exp}"
|
89
|
+
# pp "=== exp => #{flag}"
|
90
|
+
# end
|
91
|
+
# end
|
92
|
+
# pp "====== snippets => #{@snippets_to_exclude} ==== #{@snippets_to_exclude.class}"
|
93
|
+
end
|
94
|
+
|
95
|
+
# Opens file. Removes all bad xml snippets. Rewrites results back into original file
|
96
|
+
def filter_xml(filename)
|
97
|
+
doc = Nokogiri::XML(File.read(filename))
|
98
|
+
# if (filename.end_with?("package.xml"))
|
99
|
+
# p "======= errors of package.xml => #{doc.errors}"
|
100
|
+
# end
|
101
|
+
file_modified = false
|
102
|
+
@snippets_to_exclude.each do |suffix, expressions|
|
103
|
+
next unless filename.end_with?(suffix.to_s)
|
104
|
+
# p "==== processing suffix = #{suffix} vs #{filename}"
|
105
|
+
# p "==== processing snippets = #{snippets}"
|
106
|
+
expressions.each do |search_string, should_remove_parent|
|
107
|
+
# pp "==== processing snippet = #{search_string}"
|
108
|
+
nodes = doc.search(search_string.to_s)
|
109
|
+
unless nodes.empty?
|
110
|
+
file_modified = true
|
111
|
+
nodes.each do |n|
|
112
|
+
parent = n.parent
|
113
|
+
n.remove unless should_remove_parent
|
114
|
+
parent.remove if should_remove_parent # || parent.content.strip.empty?
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
File.open(filename, "w") do |file|
|
120
|
+
file.print(doc.to_xml)
|
121
|
+
end if file_modified
|
122
|
+
# if (filename.end_with?("Admin.profile"))
|
123
|
+
# FileUtils.cp(filename, "/Users/gt/Desktop/testAdmin.profile")
|
124
|
+
# end
|
125
|
+
end
|
126
|
+
|
127
|
+
def write_entries(entries, path)
|
128
|
+
entries.each do |entry|
|
129
|
+
# need relative local file path to use in new zip file too
|
130
|
+
zip_file_path = (path == "" ? entry : File.join(path, entry)) # maybe without if/else
|
131
|
+
next if @files_to_exclude.include?(zip_file_path.downcase) # avoid if directory/file excluded
|
132
|
+
|
133
|
+
# need full file path to use in copy/paste
|
134
|
+
disk_file_path = File.join(@input_dir_name, zip_file_path)
|
135
|
+
|
136
|
+
if File.directory?(disk_file_path)
|
137
|
+
@zip_io.mkdir(zip_file_path)
|
138
|
+
sub_dir = dir_content(disk_file_path)
|
139
|
+
write_entries(sub_dir, zip_file_path)
|
140
|
+
else
|
141
|
+
filter_xml(disk_file_path)
|
142
|
+
@zip_io.add(zip_file_path, disk_file_path)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Returns array of files for the specified directory (full_path) without current_dir "." and
|
148
|
+
# prev directory ".."
|
149
|
+
def dir_content(full_path)
|
150
|
+
content = Dir.entries(full_path)
|
151
|
+
content.delete("..")
|
152
|
+
content.delete(".")
|
153
|
+
return content
|
154
|
+
end
|
155
|
+
|
156
|
+
# Creates random string to guarantee uniqueness of filename
|
157
|
+
# Adds extension to filename
|
158
|
+
def random_filename(extension)
|
159
|
+
return "#{SecureRandom.urlsafe_base64}.#{extension}"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Creates unique filename including path to temporary directory
|
163
|
+
# Adds extension to filename. Default is "zip".
|
164
|
+
def tempfile_name(extension = "zip")
|
165
|
+
return "#{Dir.tmpdir}/#{random_filename(extension)}"
|
166
|
+
end
|
167
|
+
|
168
|
+
# Creates unique filename including path to temporary directory
|
169
|
+
# Adds extension to filename. Default is "zip".
|
170
|
+
def tempdir_name()
|
171
|
+
return "#{Dir.tmpdir}/#{random_filename("")}"
|
172
|
+
end
|
173
|
+
|
174
|
+
# check if exists or create if doesn't
|
175
|
+
def verify_package_xml
|
176
|
+
path = File.join(File.expand_path(@input_dir_name, __FILE__), "package.xml")
|
177
|
+
if File.exists?(path)
|
178
|
+
p "package.xml FOUND"
|
179
|
+
return true
|
180
|
+
else
|
181
|
+
# todo logic to create package.xml. use default file
|
182
|
+
return false
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end # class SfdcDirectoryService
|
186
|
+
end # module Metadata
|
187
|
+
|
188
|
+
# simple test
|
189
|
+
# test_generator = Metadata::SfdcDirectoryService.new("/Users/gt/Desktop/TestProject")
|
190
|
+
# test_generator.write
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
3
|
+
module Forcer
|
4
|
+
class ActionOptionsService
|
5
|
+
|
6
|
+
# attempts to read salesforce org information from yaml
|
7
|
+
def self.load_config_file(old_options = {})
|
8
|
+
p "attempting to load configuration.yml"
|
9
|
+
options = {}
|
10
|
+
old_options.each do |k, v|
|
11
|
+
options.store(k.to_sym, v)
|
12
|
+
end
|
13
|
+
|
14
|
+
config_file_path = File.join(Dir.pwd, "/configuration.yml")
|
15
|
+
return options unless File.exists?(config_file_path)
|
16
|
+
|
17
|
+
dest = options[:dest]
|
18
|
+
configuration = YAML.load_file(config_file_path).to_hash
|
19
|
+
|
20
|
+
return options if configuration[dest].nil?
|
21
|
+
|
22
|
+
configuration[dest].each do |key, value|
|
23
|
+
options.store(key.to_sym, value.to_s) unless value.to_s.empty?
|
24
|
+
end
|
25
|
+
options[:host] = "https://#{options[:host]}" unless options[:host].include?("http")
|
26
|
+
|
27
|
+
return options
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
|
2
|
+
module Forcer
|
3
|
+
class StatusPrintService
|
4
|
+
MAX_NUMBER_RETRIES = 3 # todo move to common constants
|
5
|
+
|
6
|
+
def initialize(suppress_periodic_requests = false)
|
7
|
+
@suppress_periodic_requests = suppress_periodic_requests
|
8
|
+
end
|
9
|
+
|
10
|
+
public
|
11
|
+
|
12
|
+
# run thread to check process status
|
13
|
+
def run_status_check(ids, lambda_metadata)
|
14
|
+
header = {
|
15
|
+
"tns:SessionHeader" => {
|
16
|
+
"tns:sessionId" => ids[:session_id]
|
17
|
+
}
|
18
|
+
}
|
19
|
+
body = {
|
20
|
+
asyncProcessId: ids[:id]
|
21
|
+
}
|
22
|
+
p "REQUESTING STATUS"
|
23
|
+
|
24
|
+
response = {}
|
25
|
+
number_retries = 0
|
26
|
+
status_thread = Thread.new do
|
27
|
+
begin
|
28
|
+
response = lambda_metadata.call(header, body)
|
29
|
+
response_details = response.body
|
30
|
+
print_status(response_details)
|
31
|
+
break if (response_details[:check_deploy_status_response][:result][:done] || @suppress_periodic_requests)
|
32
|
+
sleep(5)
|
33
|
+
rescue Exception => ex
|
34
|
+
if number_retries < MAX_NUMBER_RETRIES
|
35
|
+
p "==== exception => #{ex}"
|
36
|
+
p "==== retrying"
|
37
|
+
response = {}
|
38
|
+
number_retries += 1
|
39
|
+
sleep(4)
|
40
|
+
retry
|
41
|
+
else
|
42
|
+
p "EXCEEDED MAX_NUMBER_RETRIES (#{MAX_NUMBER_RETRIES}). EXITING NOW."
|
43
|
+
raise ex
|
44
|
+
end
|
45
|
+
end while(number_retries < MAX_NUMBER_RETRIES)
|
46
|
+
end
|
47
|
+
|
48
|
+
status_thread.join()
|
49
|
+
|
50
|
+
return response
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
def print_status(details)
|
55
|
+
# status = "DONE : #{details[:check_deploy_status_response][:result][:done]} | "
|
56
|
+
status = "STATUS : #{details[:check_deploy_status_response][:result][:status]} | "
|
57
|
+
status += "SUCCESS : #{details[:check_deploy_status_response][:result][:success]}"
|
58
|
+
p status
|
59
|
+
p "==============="
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|