forcer 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|