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.
@@ -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