terraform-wrapper 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/lib/terraform-wrapper.rb +34 -16
  3. data/lib/terraform-wrapper/common.rb +12 -23
  4. data/lib/terraform-wrapper/shared.rb +7 -1
  5. data/lib/terraform-wrapper/shared/auths.rb +9 -0
  6. data/lib/terraform-wrapper/shared/auths/azure.rb +179 -0
  7. data/lib/terraform-wrapper/shared/auths/common.rb +95 -0
  8. data/lib/terraform-wrapper/shared/backends/aws.rb +34 -29
  9. data/lib/terraform-wrapper/shared/backends/azure.rb +38 -39
  10. data/lib/terraform-wrapper/shared/backends/common.rb +18 -14
  11. data/lib/terraform-wrapper/shared/backends/local.rb +20 -18
  12. data/lib/terraform-wrapper/shared/binary.rb +16 -5
  13. data/lib/terraform-wrapper/shared/code.rb +15 -4
  14. data/lib/terraform-wrapper/shared/config.rb +61 -31
  15. data/lib/terraform-wrapper/shared/latest.rb +11 -7
  16. data/lib/terraform-wrapper/shared/logger.rb +80 -0
  17. data/lib/terraform-wrapper/shared/logging.rb +77 -0
  18. data/lib/terraform-wrapper/shared/runner.rb +59 -22
  19. data/lib/terraform-wrapper/shared/variables.rb +66 -0
  20. data/lib/terraform-wrapper/tasks/apply.rb +16 -14
  21. data/lib/terraform-wrapper/tasks/binary.rb +25 -21
  22. data/lib/terraform-wrapper/tasks/clean.rb +15 -11
  23. data/lib/terraform-wrapper/tasks/destroy.rb +16 -14
  24. data/lib/terraform-wrapper/tasks/init.rb +16 -14
  25. data/lib/terraform-wrapper/tasks/plan.rb +16 -14
  26. data/lib/terraform-wrapper/tasks/plandestroy.rb +16 -14
  27. data/lib/terraform-wrapper/tasks/validate.rb +7 -3
  28. data/lib/terraform-wrapper/version.rb +1 -1
  29. metadata +8 -3
  30. data/lib/terraform-wrapper/shared/identifiers.rb +0 -70
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ada2b6b6e65d6daedfc646d9f983894ada4061b649902252468bccaae76b4fee
4
- data.tar.gz: 4698b8bc21d9ab1ab2866a263645a2aa07955aeb03b2f10d15aed732da1d4553
3
+ metadata.gz: 8c1c8efca42bd473f3bbfa5c0e97f2782b8cc2652f96fa17b213c8fa87d4b0ae
4
+ data.tar.gz: df46cad597ad4101afc53b242a94e11452e9bd14b722a1e76eb91ad9a02909a3
5
5
  SHA512:
6
- metadata.gz: '095bd5f00cda0d8fa2e6e636bfaf4bf31e6dea0a03cbc49b3149c85b15c75e4928e5754dd56b9a71d9d0b33f5b8a7e033739e45e7746fe2fd97d28bb0139f62e'
7
- data.tar.gz: 36f56ccf800f33dba662e41f377688e753c409076417269a2b638a6a28b60980aea437736236ca1083602f2a1cf8d935c0cc7aaaa35b297ba11bde06e935efd2
6
+ metadata.gz: 97be709f764c69cd868834dfa6d6ffbe1917d40ede4ef0ceaf2a01fc26fdf84f9d5da0a45c8ac02f017f29eb42071cbf8ce50e3d8862123c2a96f4ce9adfcc04
7
+ data.tar.gz: e1f8420bc2165a6819bc08a85fbf5d3290d3e40154866d5d6e47ddabd51eb8124ad748668cae8dfb6fd4e7964b4d6cb68eec747d32aa92fc29991166713b7192
@@ -14,28 +14,46 @@ module TerraformWrapper
14
14
 
15
15
  ###############################################################################
16
16
 
17
- def self.define_tasks(backend: "local",
18
- binaries: File.join(Dir.pwd, "vendor", "terraform"),
19
- component:,
20
- configs: File.join(Dir.pwd, "config"),
21
- overrides: Hash.new,
22
- service:,
23
- terraform: File.join(Dir.pwd, "terraform"),
24
- version: TerraformWrapper::Shared::Latest.instance.version)
17
+ @logger = Shared::Logging.logger_for("TerraformWrapper")
25
18
 
26
- $logger.info("Building tasks for service: #{service}, component: #{component}...")
19
+ ###############################################################################
20
+
21
+ @logger.info("Terraform Wrapper for Ruby - version: #{TerraformWrapper::VERSION}")
22
+
23
+ ###############################################################################
24
+
25
+ def self.define_tasks(component:, options: Hash.new, service:)
26
+ @logger.info("Building tasks for service: #{service}, component: #{component}...")
27
+
28
+ @logger.fatal("Options must be specified as a hash!") unless options.kind_of?(Hash)
29
+
30
+ binary_options = Hash.new
31
+ binary_options["base"] = options.key?("binary-base") ? options["binary-base"] : File.join(Dir.pwd, "vendor", "terraform")
32
+ binary_options["version"] = options.key?("binary-version") ? options["binary-version"] : Shared::Latest.instance.version
33
+
34
+ code_options = Hash.new
35
+ code_options["base"] = options.key?("code-base") ? options["code-base"] : File.join(Dir.pwd, "terraform")
36
+ code_options["name"] = component
37
+
38
+ config_options = Hash.new
39
+ config_options["auth-azure"] = options.key?("config-auth-azure") ? options["config-auth-azure"] : false
40
+ config_options["auth-azure-options"] = options.key?("config-auth-azure-options") ? options["config-auth-azure-options"] : Hash.new
41
+ config_options["base"] = options.key?("config-base") ? options["config-base"] : File.join(Dir.pwd, "config")
42
+ config_options["backend"] = options.key?("config-backend") ? options["config-backend"] : "local"
43
+ config_options["backend-options"] = options.key?("config-backend-options") ? options["config-backend-options"] : Hash.new
44
+ config_options["service"] = service
27
45
 
28
- binary = TerraformWrapper::Shared::Binary.new(base: binaries, version: version)
29
- code = TerraformWrapper::Shared::Code.new(base: terraform, name: component)
46
+ binary = TerraformWrapper::Shared::Binary.new(options: binary_options)
47
+ code = TerraformWrapper::Shared::Code.new(options: code_options)
30
48
 
31
49
  tasks = Array.new
32
- tasks << TerraformWrapper::Tasks::Apply.new(backend: backend, binary: binary, code: code, configs: configs, overrides: overrides, service: service)
50
+ tasks << TerraformWrapper::Tasks::Apply.new(binary: binary, code: code, options: config_options)
33
51
  tasks << TerraformWrapper::Tasks::Binary.new(binary: binary)
34
52
  tasks << TerraformWrapper::Tasks::Clean.new(code: code)
35
- tasks << TerraformWrapper::Tasks::Destroy.new(backend: backend, binary: binary, code: code, configs: configs, overrides: overrides, service: service)
36
- tasks << TerraformWrapper::Tasks::Init.new(backend: backend, binary: binary, code: code, configs: configs, overrides: overrides, service: service)
37
- tasks << TerraformWrapper::Tasks::Plan.new(backend: backend, binary: binary, code: code, configs: configs, overrides: overrides, service: service)
38
- tasks << TerraformWrapper::Tasks::PlanDestroy.new(backend: backend, binary: binary, code: code, configs: configs, overrides: overrides, service: service)
53
+ tasks << TerraformWrapper::Tasks::Destroy.new(binary: binary, code: code, options: config_options)
54
+ tasks << TerraformWrapper::Tasks::Init.new(binary: binary, code: code, options: config_options)
55
+ tasks << TerraformWrapper::Tasks::Plan.new(binary: binary, code: code, options: config_options)
56
+ tasks << TerraformWrapper::Tasks::PlanDestroy.new(binary: binary, code: code, options: config_options)
39
57
  tasks << TerraformWrapper::Tasks::Validate.new(binary: binary, code: code)
40
58
  return tasks
41
59
  end
@@ -1,38 +1,27 @@
1
1
  ###############################################################################
2
2
 
3
- require 'logger'
3
+ module TerraformWrapper
4
4
 
5
5
  ###############################################################################
6
6
 
7
- $logger = Logger.new(STDOUT,
8
- level: "INFO",
9
- progname: "TerraformWrapper")
7
+ def self.create_directory(directory:, purpose: nil, exists: true)
8
+ return exists if File.directory?(directory)
10
9
 
11
- $logger.formatter = proc do |severity, datetime, progname, msg|
12
- sevId = severity.chars.first.upcase
13
- "#{progname} [#{sevId}] #{msg}\n"
14
- end
15
-
16
- ###############################################################################
10
+ result = false
17
11
 
18
- $logger.info("Terraform Wrapper for Ruby - version: #{TerraformWrapper::VERSION}")
12
+ if not purpose.nil? and purpose.kind_of?(String) and not purpose.strip.empty? then
13
+ @logger.info("Creating directory for: #{purpose}, path: #{directory}")
14
+ else
15
+ @logger.info("Creating directory: #{directory}")
16
+ end
19
17
 
20
- ###############################################################################
18
+ FileUtils.mkdir_p(directory)
21
19
 
22
- def create_directory(directory:, purpose: nil, exists: true)
23
- return exists if File.directory?(directory)
24
-
25
- result = false
26
-
27
- if not purpose.nil? and purpose.kind_of?(String) and not purpose.strip.empty? then
28
- $logger.info("Creating directory for: #{purpose}, path: #{directory}")
29
- else
30
- $logger.info("Creating directory: #{directory}")
20
+ result = File.directory?(directory)
31
21
  end
32
22
 
33
- FileUtils.mkdir_p(directory)
23
+ ###############################################################################
34
24
 
35
- result = File.directory?(directory)
36
25
  end
37
26
 
38
27
  ###############################################################################
@@ -1,11 +1,17 @@
1
1
  ###############################################################################
2
2
 
3
+ require_relative 'shared/logger'
4
+ require_relative 'shared/logging'
5
+
6
+ ###############################################################################
7
+
8
+ require_relative 'shared/auths'
3
9
  require_relative 'shared/backends'
4
10
  require_relative 'shared/binary'
5
11
  require_relative 'shared/code'
6
12
  require_relative 'shared/config'
7
- require_relative 'shared/identifiers'
8
13
  require_relative 'shared/latest'
9
14
  require_relative 'shared/runner'
15
+ require_relative 'shared/variables'
10
16
 
11
17
  ###############################################################################
@@ -0,0 +1,9 @@
1
+ ###############################################################################
2
+
3
+ require_relative 'auths/common'
4
+
5
+ ###############################################################################
6
+
7
+ require_relative 'auths/azure'
8
+
9
+ ###############################################################################
@@ -0,0 +1,179 @@
1
+ ###############################################################################
2
+
3
+ module TerraformWrapper
4
+
5
+ ###############################################################################
6
+
7
+ module Shared
8
+
9
+ ###############################################################################
10
+
11
+ module Auths
12
+
13
+ ###############################################################################
14
+
15
+ class Azure < Common
16
+
17
+ ###############################################################################
18
+
19
+ include TerraformWrapper::Shared::Logging
20
+
21
+ ###############################################################################
22
+
23
+ @@az = "az"
24
+ @@type = "azure"
25
+
26
+ ###############################################################################
27
+
28
+ @password = nil
29
+
30
+ ###############################################################################
31
+
32
+ attr_reader :subscription
33
+ attr_reader :tenant
34
+ attr_reader :username
35
+
36
+ ###############################################################################
37
+
38
+ def initialize(code:, config:, options:, service:, variables:)
39
+ construct(code: code, config: config, options: options, service: service, variables: variables)
40
+ end
41
+
42
+ ###############################################################################
43
+
44
+ def auth()
45
+ ENV["ARM_SUBSCRIPTION_ID"] = @subscription
46
+ ENV["ARM_TENANT_ID"] = @tenant
47
+ ENV["ARM_CLIENT_ID"] = @username unless @username.nil?
48
+ ENV["ARM_CLIENT_SECRET"] = @password unless @password.nil?
49
+ logger.success("Azure authenticator environment variables set!")
50
+ end
51
+
52
+ ###############################################################################
53
+
54
+ def clear()
55
+ ENV.delete("ARM_SUBSCRIPTION_ID")
56
+ ENV.delete("ARM_TENANT_ID")
57
+ logger.info("Azure authenticator environment variables cleared!")
58
+ end
59
+
60
+ ###############################################################################
61
+
62
+ private
63
+
64
+ ###############################################################################
65
+
66
+ def cli()
67
+ output = logger.colour ? "jsonc" : "json"
68
+ cmdline = "\"#{@@az}\" version --output \"#{output}\""
69
+ return(system(cmdline) || false)
70
+ end
71
+
72
+ ###############################################################################
73
+
74
+ def secret(vault:, name:)
75
+ logger.info("Getting secret: #{name}, from key vault: #{vault}...")
76
+
77
+ cmdline = "\"#{@@az}\" keyvault secret show --vault-name \"#{vault}\" --name \"#{name}\" --query \"value\" --output \"tsv\""
78
+ stdout = `#{cmdline}`
79
+ code = $?.exitstatus
80
+
81
+ logger.fatal("Failed to get secret: #{name} from key vault: #{vault}!") if (code != 0 and stdout.strip.empty?)
82
+
83
+ return(stdout.strip)
84
+ end
85
+
86
+ ###############################################################################
87
+
88
+ def subscription_details(subscription:)
89
+ logger.info("Looking up details for subscription: #{subscription}...")
90
+
91
+ cmdline = "\"#{@@az}\" account show --subscription \"#{subscription}\" --query \"{id:id,tenant:tenantId}\" --output \"yaml\""
92
+ stdout = `#{cmdline}`
93
+ code = $?.exitstatus
94
+
95
+ logger.fatal("Failed to get details for subscription: #{subscription}!") if code != 0
96
+
97
+ details = YAML.load(stdout.strip)
98
+
99
+ logger.fatal("Returned details did not include the subscription ID!") unless details.key?("id")
100
+ logger.fatal("Returned subscription ID is not a String!") unless details["id"].kind_of?(String)
101
+ logger.fatal("Returned subscription ID is empty!") if details["id"].strip.empty?
102
+
103
+ logger.fatal("Returned details did not include the tenant ID!") unless details.key?("tenant")
104
+ logger.fatal("Returned tenant ID is not a String!") unless details["tenant"].kind_of?(String)
105
+ logger.fatal("Returned tenant ID is empty!") if details["tenant"].strip.empty?
106
+
107
+ details.transform_values! { |value| value.strip }
108
+
109
+ logger.debug("Returned subscription ID: #{details["id"]}")
110
+ logger.debug("Returned tenant ID: #{details["tenant"]}")
111
+
112
+ return(details)
113
+ end
114
+
115
+ ###############################################################################
116
+
117
+ def specific()
118
+ keyvault = nil
119
+
120
+ logger.fatal("Azure CLI must be installed and accessible to use the Azure authenticator.") unless cli
121
+
122
+ logger.fatal("Azure authenticator mandatory option 'subscription' has not been set!") unless @options.key?("subscription")
123
+
124
+ subscription = @options["subscription"]
125
+
126
+ logger.fatal("Azure authenticator subscription must be a String!") unless subscription.kind_of?(String)
127
+ logger.fatal("Azure authenticator subscription must not be blank!") if subscription.strip.empty?
128
+
129
+ if @options.key?("keyvault") then
130
+ keyvault = @options["keyvault"]
131
+
132
+ logger.fatal("Azure authenticator keyvault name must be a String if specified!") unless keyvault.kind_of?(String)
133
+ logger.fatal("Azure authenticator keyvault name must not be blank if specified!") if keyvault.strip.empty?
134
+
135
+ username = @options.key?("username-secret") ? @options["username-secret"] : "terraform-username"
136
+
137
+ logger.fatal("Azure authenticator keyvault secret for username must be a String if keyvault name is specified!") unless username.kind_of?(String)
138
+ logger.fatal("Azure authenticator keyvault secret for username must not be blank if keyvault name is specified!") if username.strip.empty?
139
+
140
+ password = @options.key?("username-password") ? @options["username-password"] : "terraform-password"
141
+
142
+ logger.fatal("Azure authenticator keyvault secret for password must be a String if keyvault name is specified!") unless password.kind_of?(String)
143
+ logger.fatal("Azure authenticator keyvault secret for password must not be blank if keyvault name is specified!") if password.strip.empty?
144
+ end
145
+
146
+ begin
147
+ subscription = subscription % @variables
148
+ keyvault = keyvault % @variables unless keyvault.nil?
149
+ username = username % @variables unless keyvault.nil?
150
+ password = password % @variables unless keyvault.nil?
151
+ rescue
152
+ logger.fatal("Azure authenticator options contain variables that are not included in the configuration file!")
153
+ end
154
+
155
+ details = subscription_details(subscription: subscription)
156
+
157
+ @subscription = details["id"]
158
+ @tenant = details["tenant"]
159
+ @username = keyvault.nil? ? nil : secret(vault: keyvault, name: username)
160
+ @password = keyvault.nil? ? nil : secret(vault: keyvault, name: password)
161
+ end
162
+
163
+ ###############################################################################
164
+
165
+ end
166
+
167
+ ###############################################################################
168
+
169
+ end
170
+
171
+ ###############################################################################
172
+
173
+ end
174
+
175
+ ###############################################################################
176
+
177
+ end
178
+
179
+ ###############################################################################
@@ -0,0 +1,95 @@
1
+ ###############################################################################
2
+
3
+ module TerraformWrapper
4
+
5
+ ###############################################################################
6
+
7
+ module Shared
8
+
9
+ ###############################################################################
10
+
11
+ module Auths
12
+
13
+ ###############################################################################
14
+
15
+ class Common
16
+
17
+ ###############################################################################
18
+
19
+ include TerraformWrapper::Shared::Logging
20
+
21
+ ###############################################################################
22
+
23
+ @@type
24
+
25
+ ###############################################################################
26
+
27
+ @options
28
+ @variables
29
+
30
+ ###############################################################################
31
+
32
+ def initialize(code:, config:, options:, service:, variables:)
33
+ logger.fatal("This class should not be used directly! Please create an authenticator-specific class instead!")
34
+ end
35
+
36
+ ###############################################################################
37
+
38
+ def auth()
39
+ logger.fatal("The authenticator specific class should override the 'auth' method to complete the authentication process!")
40
+ end
41
+
42
+ ###############################################################################
43
+
44
+ def clear()
45
+ logger.fatal("The authenticator specific class should override the 'auth' method to clear any authentication details!")
46
+ end
47
+
48
+ ###############################################################################
49
+
50
+ def type()
51
+ logger.fatal("The authenticator specific class should set the 'type' class variable to a string!") unless @@type.kind_of?(String)
52
+
53
+ return @@type
54
+ end
55
+
56
+ ###############################################################################
57
+
58
+ private
59
+
60
+ ###############################################################################
61
+
62
+ def construct(code:, config:, options:, service:, variables:)
63
+ @options = options
64
+ @variables = variables.values.merge({
65
+ component: code.name,
66
+ config: config,
67
+ service: service
68
+ })
69
+
70
+ specific
71
+ end
72
+
73
+ ###############################################################################
74
+
75
+ def specific()
76
+ logger.fatal("The authenticator specific class should override the 'specific' method to include authenticator specific validation and setup, or simply return 'true' if it is not required.")
77
+ end
78
+
79
+ ###############################################################################
80
+
81
+ end
82
+
83
+ ###############################################################################
84
+
85
+ end
86
+
87
+ ###############################################################################
88
+
89
+ end
90
+
91
+ ###############################################################################
92
+
93
+ end
94
+
95
+ ###############################################################################
@@ -14,6 +14,10 @@ module TerraformWrapper
14
14
 
15
15
  class AWS < Common
16
16
 
17
+ ###############################################################################
18
+
19
+ include TerraformWrapper::Shared::Logging
20
+
17
21
  ###############################################################################
18
22
 
19
23
  @@default_class = "terraform-state"
@@ -25,14 +29,13 @@ module TerraformWrapper
25
29
  ###############################################################################
26
30
 
27
31
  attr_reader :bucket
28
- attr_reader :class
29
32
  attr_reader :key
30
33
  attr_reader :region
31
34
 
32
35
  ###############################################################################
33
36
 
34
- def initialize(service:, code:, identifiers:, overrides: Hash.new)
35
- construct(service: service, code: code, identifiers: identifiers, overrides: overrides)
37
+ def initialize(code:, config:, options:, service:, variables:)
38
+ construct(code: code, config: config, options: options, service: service, variables: variables)
36
39
  end
37
40
 
38
41
  ###############################################################################
@@ -52,39 +55,41 @@ module TerraformWrapper
52
55
  ###############################################################################
53
56
 
54
57
  def specific()
55
- if @overrides.key?("class") and @overrides["class"].kind_of?(String) and not @overrides["class"].strip.empty? then
56
- @class = @overrides["class"]
57
- else
58
- @class = @@default_class
59
- end
58
+ logger.fatal("AWS backend mandatory option 'bucket' has not been set!") unless @options.key?("bucket")
59
+ logger.fatal("AWS backend mandatory option 'region' has not been set!") unless @options.key?("region")
60
60
 
61
- if @overrides.key?("region") and @overrides["region"].kind_of?(String) and not @overrides["region"].strip.empty? then
62
- @region = @overrides["region"]
63
- else
64
- raise "Mandatory identifier 'region' or override 'region' must be set in provided configuration to backend of type: #{@@type}" unless @identifier.key?("region")
65
- raise "Mandatory identifier 'region' is not a string in provided configuration to backend of type: #{@@type}" unless @identifier["region"].kind_of?(String)
66
- raise "Mandatory identifier 'region' is empty in provided configuration to backend of type: #{@@type}" if @identifier["region"].strip.empty?
61
+ bucket = @options["bucket"]
67
62
 
68
- @region = @identifiers["account"].strip.downcase
69
- end
63
+ logger.fatal("AWS backend S3 bucket name must be a String!") unless bucket.kind_of?(String)
64
+ logger.fatal("AWS backend S3 bucket name must not be blank!") if bucket.strip.empty?
70
65
 
71
- if @overrides.key?("bucket") and @overrides["bucket"].kind_of?(String) and not @overrides["bucket"].strip.empty? then
72
- @bucket = @overrides["bucket"]
73
- else
74
- raise "Mandatory identifier 'account' or override 'bucket' must be set in provided configuration to backend of type: #{@@type}" unless @identifier.key?("account")
75
- raise "Mandatory identifier 'account' is not a string in provided configuration to backend of type: #{@@type}" unless @identifier["account"].kind_of?(String)
76
- raise "Mandatory identifier 'account' is empty in provided configuration to backend of type: #{@@type}" if @identifier["account"].strip.empty?
66
+ region = @options["region"]
77
67
 
78
- @bucket = @class + "-" + @region + "-" + @identifiers["account"].strip.downcase
79
- end
68
+ logger.fatal("AWS backend S3 bucket region must be a String!") unless region.kind_of?(String)
69
+ logger.fatal("AWS backend S3 bucket region must not be blank!") if region.strip.empty?
70
+
71
+ key = @options.key?("key") ? @options["key"] : File.join("%{service}", "%{config}", "%{component}" + @@ext)
72
+
73
+ logger.fatal("AWS backend S3 bucket key must be a String!") unless key.kind_of?(String)
74
+ logger.fatal("AWS backend S3 bucket key must not be blank!") if key.strip.empty?
80
75
 
81
- key = File.join(@identifiers.path, @service, @component + @@ext)
76
+ logger.fatal("AWS backend S3 bucket name or key must include %{service}.") unless (bucket.include?("%{service}") or key.include?("%{service}"))
77
+ logger.fatal("AWS backend S3 bucket name or key must include %{config}.") unless (bucket.include?("%{config}") or key.include?("%{config}"))
78
+ logger.fatal("AWS backend S3 bucket name or key must include %{component}.") unless (bucket.include?("%{component}") or key.include?("%{component}"))
82
79
 
83
- if key.length > 1024 then
84
- raise "Key: #{key} is too long for backend of type: #{@@type}"
85
- else
86
- @key = key
80
+ begin
81
+ bucket = bucket % @variables
82
+ region = region % @variables
83
+ key = key % @variables
84
+ rescue
85
+ logger.fatal("AWS backend options contain variables that are not included in the configuration file!")
87
86
  end
87
+
88
+ logger.fatal("Key: #{key} is too long for backend of type: #{@@type}") if key.length > 1024
89
+
90
+ @bucket = bucket
91
+ @region = region
92
+ @key = key
88
93
  end
89
94
 
90
95
  ###############################################################################