hybrid_platforms_conductor 33.3.0 → 33.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +31 -2
- data/docs/config_dsl.md +43 -0
- data/lib/hybrid_platforms_conductor/bitbucket.rb +134 -90
- data/lib/hybrid_platforms_conductor/common_config_dsl/bitbucket.rb +12 -44
- data/lib/hybrid_platforms_conductor/common_config_dsl/github.rb +9 -31
- data/lib/hybrid_platforms_conductor/confluence.rb +93 -88
- data/lib/hybrid_platforms_conductor/credentials.rb +112 -95
- data/lib/hybrid_platforms_conductor/deployer.rb +2 -2
- data/lib/hybrid_platforms_conductor/github.rb +39 -0
- data/lib/hybrid_platforms_conductor/hpc_plugins/provisioner/proxmox.rb +4 -2
- data/lib/hybrid_platforms_conductor/hpc_plugins/report/confluence.rb +3 -1
- data/lib/hybrid_platforms_conductor/hpc_plugins/secrets_reader/keepass.rb +2 -1
- data/lib/hybrid_platforms_conductor/hpc_plugins/secrets_reader/thycotic.rb +3 -1
- data/lib/hybrid_platforms_conductor/hpc_plugins/test/bitbucket_conf.rb +4 -1
- data/lib/hybrid_platforms_conductor/hpc_plugins/test/github_ci.rb +4 -1
- data/lib/hybrid_platforms_conductor/hpc_plugins/test/jenkins_ci_conf.rb +6 -2
- data/lib/hybrid_platforms_conductor/hpc_plugins/test/jenkins_ci_masters_ok.rb +6 -2
- data/lib/hybrid_platforms_conductor/hpc_plugins/test_report/confluence.rb +3 -1
- data/lib/hybrid_platforms_conductor/logger_helpers.rb +7 -1
- data/lib/hybrid_platforms_conductor/thycotic.rb +80 -75
- data/lib/hybrid_platforms_conductor/version.rb +1 -1
- data/spec/hybrid_platforms_conductor_test.rb +6 -0
- data/spec/hybrid_platforms_conductor_test/api/credentials_spec.rb +247 -0
- data/spec/hybrid_platforms_conductor_test/api/deployer/secrets_reader_plugins/keepass_spec.rb +280 -319
- data/spec/hybrid_platforms_conductor_test/api/deployer/secrets_reader_plugins/thycotic_spec.rb +2 -2
- data/spec/hybrid_platforms_conductor_test/api/tests_runner/test_plugins/bitbucket_conf_spec.rb +49 -69
- data/spec/hybrid_platforms_conductor_test/api/tests_runner/test_plugins/github_ci_spec.rb +29 -39
- metadata +18 -2
@@ -106,10 +106,10 @@ module HybridPlatformsConductor
|
|
106
106
|
|
107
107
|
end
|
108
108
|
|
109
|
-
include LoggerHelpers
|
110
|
-
|
111
109
|
Config.extend_config_dsl_with ConfigDSLExtension, :init_deployer_config
|
112
110
|
|
111
|
+
include LoggerHelpers
|
112
|
+
|
113
113
|
# Do we use why-run mode while deploying? [default = false]
|
114
114
|
# Boolean
|
115
115
|
attr_accessor :use_why_run
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'octokit'
|
2
|
+
require 'hybrid_platforms_conductor/credentials'
|
3
|
+
|
4
|
+
module HybridPlatformsConductor
|
5
|
+
|
6
|
+
# Mixin used to access Github API
|
7
|
+
module Github
|
8
|
+
|
9
|
+
include Credentials
|
10
|
+
|
11
|
+
# Iterate over each Github repository
|
12
|
+
#
|
13
|
+
# Parameters::
|
14
|
+
# * Proc: Code called for each Github repository:
|
15
|
+
# * Parameters::
|
16
|
+
# * *github* (Octokit::Client): The client instance accessing the Github API
|
17
|
+
# * *repo_info* (Hash<Symbol, Object>): The repository info:
|
18
|
+
# * *name* (String): Repository name.
|
19
|
+
# * *slug* (String): Repository slug.
|
20
|
+
def for_each_github_repo
|
21
|
+
@config.known_github_repos.each do |repo_info|
|
22
|
+
Octokit.configure do |c|
|
23
|
+
c.api_endpoint = repo_info[:url]
|
24
|
+
end
|
25
|
+
with_credentials_for(:github, resource: repo_info[:url]) do |_github_user, github_token|
|
26
|
+
client = Octokit::Client.new(access_token: github_token)
|
27
|
+
(repo_info[:repos] == :all ? client.repositories(repo_info[:user]).map { |repo| repo[:name] } : repo_info[:repos]).each do |name|
|
28
|
+
yield client, {
|
29
|
+
name: name,
|
30
|
+
slug: "#{repo_info[:user]}/#{name}"
|
31
|
+
}
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
@@ -263,6 +263,8 @@ module HybridPlatformsConductor
|
|
263
263
|
|
264
264
|
private
|
265
265
|
|
266
|
+
include Credentials
|
267
|
+
|
266
268
|
# Connect to the Proxmox API
|
267
269
|
#
|
268
270
|
# Parameters::
|
@@ -273,7 +275,7 @@ module HybridPlatformsConductor
|
|
273
275
|
url = proxmox_test_info[:api_url]
|
274
276
|
raise 'No Proxmox server defined' if url.nil?
|
275
277
|
|
276
|
-
|
278
|
+
with_credentials_for(:proxmox, resource: url) do |user, password|
|
277
279
|
log_debug "[ #{@node}/#{@environment} ] - Connect to Proxmox #{url}"
|
278
280
|
proxmox_logs = StringIO.new
|
279
281
|
proxmox = ::Proxmox::Proxmox.new(
|
@@ -415,7 +417,7 @@ module HybridPlatformsConductor
|
|
415
417
|
extra_files[config_file] = './proxmox/config'
|
416
418
|
cmd << " --config ./proxmox/config/#{File.basename(config_file)}"
|
417
419
|
stdout = nil
|
418
|
-
|
420
|
+
with_credentials_for(:proxmox, resource: proxmox_test_info[:api_url]) do |user, password|
|
419
421
|
# To avoid too fine concurrent accesses on the sync node file system, make sure all threads of our process wait for their turn to upload their files.
|
420
422
|
# Otherwise there is a small probability that a directory scp makes previously copied files inaccessible for a short period of time.
|
421
423
|
self.class.proxmox_waiter_files_mutex.synchronize do
|
@@ -14,6 +14,8 @@ module HybridPlatformsConductor
|
|
14
14
|
|
15
15
|
extend_config_dsl_with CommonConfigDsl::Confluence, :init_confluence
|
16
16
|
|
17
|
+
include HybridPlatformsConductor::Confluence
|
18
|
+
|
17
19
|
# Give the list of supported locales by this report generator
|
18
20
|
# [API] - This method is mandatory.
|
19
21
|
#
|
@@ -34,7 +36,7 @@ module HybridPlatformsConductor
|
|
34
36
|
if confluence_info
|
35
37
|
if confluence_info[:inventory_report_page_id]
|
36
38
|
@nodes = nodes
|
37
|
-
|
39
|
+
with_confluence(confluence_info[:url]) do |confluence|
|
38
40
|
confluence.update_page(confluence_info[:inventory_report_page_id], render('confluence_inventory'))
|
39
41
|
end
|
40
42
|
out "Inventory report Confluence page updated. Please visit #{confluence_info[:url]}/pages/viewpage.action?pageId=#{confluence_info[:inventory_report_page_id]}"
|
@@ -17,6 +17,7 @@ module HybridPlatformsConductor
|
|
17
17
|
class Keepass < HybridPlatformsConductor::SecretsReader
|
18
18
|
|
19
19
|
include SafeMerge
|
20
|
+
include Credentials
|
20
21
|
|
21
22
|
# Extend the Config DSL
|
22
23
|
module ConfigDSLExtension
|
@@ -84,7 +85,7 @@ module HybridPlatformsConductor
|
|
84
85
|
unless @secrets.key?(secret_id)
|
85
86
|
raise 'Missing KPScript configuration. Please use use_kpscript_from to set it.' if @config.kpscript.nil?
|
86
87
|
|
87
|
-
|
88
|
+
with_credentials_for(:keepass, resource: keepass_secrets_info[:database]) do |_user, password|
|
88
89
|
Tempfile.create('hpc_keepass') do |xml_file|
|
89
90
|
key_file = ENV['hpc_key_file_for_keepass']
|
90
91
|
password_enc = ENV['hpc_password_enc_for_keepass']
|
@@ -42,6 +42,8 @@ module HybridPlatformsConductor
|
|
42
42
|
|
43
43
|
Config.extend_config_dsl_with ConfigDSLExtension, :init_thycotic_config
|
44
44
|
|
45
|
+
include HybridPlatformsConductor::Thycotic
|
46
|
+
|
45
47
|
# Return secrets for a given service to be deployed on a node.
|
46
48
|
# [API] - This method is mandatory
|
47
49
|
# [API] - The following API components are accessible:
|
@@ -62,7 +64,7 @@ module HybridPlatformsConductor
|
|
62
64
|
@nodes_handler.select_confs_for_node(node, @config.thycotic_secrets).each do |thycotic_secrets_info|
|
63
65
|
server_id = "#{thycotic_secrets_info[:thycotic_url]}:#{thycotic_secrets_info[:secret_id]}"
|
64
66
|
unless @secrets.key?(server_id)
|
65
|
-
|
67
|
+
with_thycotic(thycotic_secrets_info[:thycotic_url]) do |thycotic|
|
66
68
|
secret_file_item_id = thycotic.get_secret(thycotic_secrets_info[:secret_id]).dig(:secret, :items, :secret_item, :id)
|
67
69
|
raise "Unable to fetch secret file ID #{thycotic_secrets_info[:secret_id]} from #{thycotic_secrets_info[:thycotic_url]}" if secret_file_item_id.nil?
|
68
70
|
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'git'
|
2
|
+
require 'hybrid_platforms_conductor/bitbucket'
|
2
3
|
require 'hybrid_platforms_conductor/common_config_dsl/bitbucket'
|
3
4
|
|
4
5
|
module HybridPlatformsConductor
|
@@ -12,9 +13,11 @@ module HybridPlatformsConductor
|
|
12
13
|
|
13
14
|
extend_config_dsl_with CommonConfigDsl::Bitbucket, :init_bitbucket
|
14
15
|
|
16
|
+
include HybridPlatformsConductor::Bitbucket
|
17
|
+
|
15
18
|
# Check my_test_plugin.rb.sample documentation for signature details.
|
16
19
|
def test
|
17
|
-
|
20
|
+
for_each_bitbucket_repo do |bitbucket, repo_info|
|
18
21
|
# Test repo_info
|
19
22
|
repo_id = "#{repo_info[:project]}/#{repo_info[:name]}"
|
20
23
|
settings_pr = bitbucket.settings_pr(repo_info[:project], repo_info[:name])
|
@@ -1,3 +1,4 @@
|
|
1
|
+
require 'hybrid_platforms_conductor/github'
|
1
2
|
require 'hybrid_platforms_conductor/common_config_dsl/github'
|
2
3
|
|
3
4
|
module HybridPlatformsConductor
|
@@ -11,9 +12,11 @@ module HybridPlatformsConductor
|
|
11
12
|
|
12
13
|
extend_config_dsl_with CommonConfigDsl::Github, :init_github
|
13
14
|
|
15
|
+
include HybridPlatformsConductor::Github
|
16
|
+
|
14
17
|
# Check my_test_plugin.rb.sample documentation for signature details.
|
15
18
|
def test
|
16
|
-
|
19
|
+
for_each_github_repo do |client, repo_info|
|
17
20
|
log_debug "Checking CI for Github repository #{repo_info[:slug]}"
|
18
21
|
last_status = client.repository_workflow_runs(repo_info[:slug])[:workflow_runs].
|
19
22
|
select { |run| run[:head_branch] == 'master' }.
|
@@ -1,6 +1,7 @@
|
|
1
1
|
require 'open-uri'
|
2
2
|
require 'nokogiri'
|
3
3
|
require 'hybrid_platforms_conductor/credentials'
|
4
|
+
require 'hybrid_platforms_conductor/bitbucket'
|
4
5
|
require 'hybrid_platforms_conductor/common_config_dsl/bitbucket'
|
5
6
|
|
6
7
|
module HybridPlatformsConductor
|
@@ -14,13 +15,16 @@ module HybridPlatformsConductor
|
|
14
15
|
|
15
16
|
extend_config_dsl_with CommonConfigDsl::Bitbucket, :init_bitbucket
|
16
17
|
|
18
|
+
include Credentials
|
19
|
+
include HybridPlatformsConductor::Bitbucket
|
20
|
+
|
17
21
|
# Check my_test_plugin.rb.sample documentation for signature details.
|
18
22
|
def test
|
19
|
-
|
23
|
+
for_each_bitbucket_repo do |bitbucket, repo_info|
|
20
24
|
if repo_info[:jenkins_ci_url].nil?
|
21
25
|
error "Repository #{repo_info[:name]} does not have any Jenkins CI URL configured."
|
22
26
|
else
|
23
|
-
|
27
|
+
with_credentials_for(:jenkins_ci, resource: repo_info[:jenkins_ci_url]) do |jenkins_user, jenkins_password|
|
24
28
|
# Get its config
|
25
29
|
doc = Nokogiri::XML(URI.parse("#{repo_info[:jenkins_ci_url]}/config.xml").open(http_basic_authentication: [jenkins_user, jenkins_password]).read)
|
26
30
|
# Check that this job builds the correct Bitbucket repository
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'json'
|
2
2
|
require 'hybrid_platforms_conductor/credentials'
|
3
|
+
require 'hybrid_platforms_conductor/bitbucket'
|
3
4
|
require 'hybrid_platforms_conductor/common_config_dsl/bitbucket'
|
4
5
|
|
5
6
|
module HybridPlatformsConductor
|
@@ -13,6 +14,9 @@ module HybridPlatformsConductor
|
|
13
14
|
|
14
15
|
extend_config_dsl_with CommonConfigDsl::Bitbucket, :init_bitbucket
|
15
16
|
|
17
|
+
include Credentials
|
18
|
+
include HybridPlatformsConductor::Bitbucket
|
19
|
+
|
16
20
|
SUCCESS_STATUSES = [
|
17
21
|
# Add nil as the status of a currently running job (which is always the case for hybrid-platforms) is null
|
18
22
|
nil,
|
@@ -23,12 +27,12 @@ module HybridPlatformsConductor
|
|
23
27
|
|
24
28
|
# Check my_test_plugin.rb.sample documentation for signature details.
|
25
29
|
def test
|
26
|
-
|
30
|
+
for_each_bitbucket_repo do |_bitbucket, repo_info|
|
27
31
|
if repo_info[:jenkins_ci_url].nil?
|
28
32
|
error "Repository #{repo_info[:name]} does not have any Jenkins CI URL configured."
|
29
33
|
else
|
30
34
|
master_info_url = "#{repo_info[:jenkins_ci_url]}/job/master/api/json"
|
31
|
-
|
35
|
+
with_credentials_for(:jenkins_ci, resource: master_info_url) do |jenkins_user, jenkins_password|
|
32
36
|
# Get the master branch info from the API
|
33
37
|
master_info = JSON.parse(URI.parse(master_info_url).open(http_basic_authentication: [jenkins_user, jenkins_password]).read)
|
34
38
|
# Get the last build's URL
|
@@ -14,6 +14,8 @@ module HybridPlatformsConductor
|
|
14
14
|
|
15
15
|
extend_config_dsl_with CommonConfigDsl::Confluence, :init_confluence
|
16
16
|
|
17
|
+
include HybridPlatformsConductor::Confluence
|
18
|
+
|
17
19
|
# Maximum errors to be reported by item
|
18
20
|
MAX_ERROR_ITEMS_DISPLAYED = 10
|
19
21
|
|
@@ -28,7 +30,7 @@ module HybridPlatformsConductor
|
|
28
30
|
confluence_info = @config.confluence_info
|
29
31
|
if confluence_info
|
30
32
|
if confluence_info[:tests_report_page_id]
|
31
|
-
|
33
|
+
with_confluence(confluence_info[:url]) do |confluence|
|
32
34
|
# Get previous percentages for the evolution
|
33
35
|
@previous_success_percentages = confluence.page_storage_format(confluence_info[:tests_report_page_id]).
|
34
36
|
at('h1:contains("Evolution")').
|
@@ -88,7 +88,13 @@ module HybridPlatformsConductor
|
|
88
88
|
define_method("log_#{level}") do |message|
|
89
89
|
(LEVELS_TO_STDERR.include?(level) ? @logger_stderr : @logger).send(
|
90
90
|
level,
|
91
|
-
defined?(@log_component)
|
91
|
+
if defined?(@log_component)
|
92
|
+
@log_component
|
93
|
+
else
|
94
|
+
# Handle the case when the class is unnamed
|
95
|
+
class_name = self.class.name
|
96
|
+
class_name.nil? ? '<Unnamed class>' : class_name.split('::').last
|
97
|
+
end
|
92
98
|
) { message }
|
93
99
|
end
|
94
100
|
end
|
@@ -5,95 +5,100 @@ require 'hybrid_platforms_conductor/logger_helpers'
|
|
5
5
|
|
6
6
|
module HybridPlatformsConductor
|
7
7
|
|
8
|
-
#
|
9
|
-
|
8
|
+
# Mixin giving ways to query the Thycotic SOAP API at a given URL
|
9
|
+
module Thycotic
|
10
10
|
|
11
|
-
include
|
11
|
+
include Credentials
|
12
12
|
|
13
13
|
# Provide a Thycotic connector, and make sure the password is being cleaned when exiting.
|
14
14
|
#
|
15
15
|
# Parameters::
|
16
16
|
# * *thycotic_url* (String): The Thycotic URL
|
17
|
-
# * *logger* (Logger): Logger to be used
|
18
|
-
# * *logger_stderr* (Logger): Logger to be used for stderr
|
19
17
|
# * *domain* (String): Domain to use for authentication to Thycotic [default: ENV['hpc_domain_for_thycotic']]
|
20
18
|
# * Proc: Code called with the Thyctotic instance.
|
21
|
-
# * *thycotic* (
|
22
|
-
def
|
23
|
-
|
24
|
-
yield
|
19
|
+
# * *thycotic* (ThyctoticApi): The Thycotic instance to use.
|
20
|
+
def with_thycotic(thycotic_url, domain: ENV['hpc_domain_for_thycotic'])
|
21
|
+
with_credentials_for(:thycotic, resource: thycotic_url) do |thycotic_user, thycotic_password|
|
22
|
+
yield ThycoticApi.new(thycotic_url, thycotic_user, thycotic_password, domain: domain, logger: @logger, logger_stderr: @logger_stderr)
|
25
23
|
end
|
26
24
|
end
|
27
25
|
|
28
|
-
#
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
ssl_verify_mode: :none,
|
50
|
-
logger: @logger,
|
51
|
-
log: log_debug?
|
26
|
+
# Access to the Thycotic API
|
27
|
+
class ThycoticApi
|
28
|
+
|
29
|
+
include LoggerHelpers
|
30
|
+
|
31
|
+
# Constructor
|
32
|
+
#
|
33
|
+
# Parameters::
|
34
|
+
# * *url* (String): URL of the Thycotic Secret Server
|
35
|
+
# * *user* (String): User name to be used to connect to Thycotic
|
36
|
+
# * *password* (String): Password to be used to connect to Thycotic
|
37
|
+
# * *domain* (String): Domain to use for authentication to Thycotic [default: ENV['hpc_domain_for_thycotic']]
|
38
|
+
# * *logger* (Logger): Logger to be used [default: Logger.new(STDOUT)]
|
39
|
+
# * *logger_stderr* (Logger): Logger to be used for stderr [default: Logger.new(STDERR)]
|
40
|
+
def initialize(
|
41
|
+
url,
|
42
|
+
user,
|
43
|
+
password,
|
44
|
+
domain: ENV['hpc_domain_for_thycotic'],
|
45
|
+
logger: Logger.new($stdout),
|
46
|
+
logger_stderr: Logger.new($stderr)
|
52
47
|
)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
48
|
+
init_loggers(logger, logger_stderr)
|
49
|
+
# Get a token to this SOAP API
|
50
|
+
@client = Savon.client(
|
51
|
+
wsdl: "#{url}/webservices/SSWebservice.asmx?wsdl",
|
52
|
+
ssl_verify_mode: :none,
|
53
|
+
logger: @logger,
|
54
|
+
log: log_debug?
|
55
|
+
)
|
56
|
+
@token = @client.call(
|
57
|
+
:authenticate,
|
58
|
+
message: {
|
59
|
+
username: user,
|
60
|
+
password: password,
|
61
|
+
domain: domain
|
62
|
+
}
|
63
|
+
).to_hash.dig(:authenticate_response, :authenticate_result, :token)
|
64
|
+
raise "Unable to get token from SOAP authentication to #{url}" if @token.nil?
|
65
|
+
end
|
63
66
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
67
|
+
# Return secret corresponding to a given secret ID
|
68
|
+
#
|
69
|
+
# Parameters::
|
70
|
+
# * *secret_id* (Object): The secret ID
|
71
|
+
# Result::
|
72
|
+
# * Hash: The corresponding API result
|
73
|
+
def get_secret(secret_id)
|
74
|
+
@client.call(
|
75
|
+
:get_secret,
|
76
|
+
message: {
|
77
|
+
token: @token,
|
78
|
+
secretId: secret_id
|
79
|
+
}
|
80
|
+
).to_hash.dig(:get_secret_response, :get_secret_result)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Get a file attached to a given secret
|
84
|
+
#
|
85
|
+
# Parameters::
|
86
|
+
# * *secret_id* (Object): The secret ID
|
87
|
+
# * *secret_item_id* (Object): The secret item id
|
88
|
+
# Result::
|
89
|
+
# * String or nil: The file content, or nil if none
|
90
|
+
def download_file_attachment_by_item_id(secret_id, secret_item_id)
|
91
|
+
encoded_file = @client.call(
|
92
|
+
:download_file_attachment_by_item_id,
|
93
|
+
message: {
|
94
|
+
token: @token,
|
95
|
+
secretId: secret_id,
|
96
|
+
secretItemId: secret_item_id
|
97
|
+
}
|
98
|
+
).to_hash.dig(:download_file_attachment_by_item_id_response, :download_file_attachment_by_item_id_result, :file_attachment)
|
99
|
+
encoded_file.nil? ? nil : Base64.decode64(encoded_file)
|
100
|
+
end
|
79
101
|
|
80
|
-
# Get a file attached to a given secret
|
81
|
-
#
|
82
|
-
# Parameters::
|
83
|
-
# * *secret_id* (Object): The secret ID
|
84
|
-
# * *secret_item_id* (Object): The secret item id
|
85
|
-
# Result::
|
86
|
-
# * String or nil: The file content, or nil if none
|
87
|
-
def download_file_attachment_by_item_id(secret_id, secret_item_id)
|
88
|
-
encoded_file = @client.call(
|
89
|
-
:download_file_attachment_by_item_id,
|
90
|
-
message: {
|
91
|
-
token: @token,
|
92
|
-
secretId: secret_id,
|
93
|
-
secretItemId: secret_item_id
|
94
|
-
}
|
95
|
-
).to_hash.dig(:download_file_attachment_by_item_id_response, :download_file_attachment_by_item_id_result, :file_attachment)
|
96
|
-
encoded_file.nil? ? nil : Base64.decode64(encoded_file)
|
97
102
|
end
|
98
103
|
|
99
104
|
end
|