sfn 0.0.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +107 -0
- data/LICENSE +13 -0
- data/README.md +142 -61
- data/bin/sfn +43 -0
- data/lib/chef/knife/knife_plugin_seed.rb +117 -0
- data/lib/sfn.rb +17 -0
- data/lib/sfn/cache.rb +385 -0
- data/lib/sfn/command.rb +45 -0
- data/lib/sfn/command/create.rb +87 -0
- data/lib/sfn/command/describe.rb +87 -0
- data/lib/sfn/command/destroy.rb +74 -0
- data/lib/sfn/command/events.rb +98 -0
- data/lib/sfn/command/export.rb +103 -0
- data/lib/sfn/command/import.rb +117 -0
- data/lib/sfn/command/inspect.rb +160 -0
- data/lib/sfn/command/list.rb +59 -0
- data/lib/sfn/command/promote.rb +17 -0
- data/lib/sfn/command/update.rb +95 -0
- data/lib/sfn/command/validate.rb +34 -0
- data/lib/sfn/command_module.rb +9 -0
- data/lib/sfn/command_module/base.rb +150 -0
- data/lib/sfn/command_module/stack.rb +166 -0
- data/lib/sfn/command_module/template.rb +147 -0
- data/lib/sfn/config.rb +106 -0
- data/lib/sfn/config/create.rb +35 -0
- data/lib/sfn/config/describe.rb +19 -0
- data/lib/sfn/config/destroy.rb +9 -0
- data/lib/sfn/config/events.rb +25 -0
- data/lib/sfn/config/export.rb +29 -0
- data/lib/sfn/config/import.rb +24 -0
- data/lib/sfn/config/inspect.rb +37 -0
- data/lib/sfn/config/list.rb +25 -0
- data/lib/sfn/config/promote.rb +23 -0
- data/lib/sfn/config/update.rb +20 -0
- data/lib/sfn/config/validate.rb +49 -0
- data/lib/sfn/monkey_patch.rb +8 -0
- data/lib/sfn/monkey_patch/stack.rb +200 -0
- data/lib/sfn/provider.rb +224 -0
- data/lib/sfn/utils.rb +23 -0
- data/lib/sfn/utils/debug.rb +31 -0
- data/lib/sfn/utils/json.rb +37 -0
- data/lib/sfn/utils/object_storage.rb +28 -0
- data/lib/sfn/utils/output.rb +79 -0
- data/lib/sfn/utils/path_selector.rb +99 -0
- data/lib/sfn/utils/ssher.rb +29 -0
- data/lib/sfn/utils/stack_exporter.rb +275 -0
- data/lib/sfn/utils/stack_parameter_scrubber.rb +37 -0
- data/lib/sfn/utils/stack_parameter_validator.rb +124 -0
- data/lib/sfn/version.rb +4 -0
- data/sfn.gemspec +19 -0
- metadata +110 -4
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
require 'sfn'
|
4
|
+
|
5
|
+
module Sfn
|
6
|
+
class Command
|
7
|
+
# Validate command
|
8
|
+
class Validate < Command
|
9
|
+
|
10
|
+
include Sfn::CommandModule::Base
|
11
|
+
include Sfn::CommandModule::Template
|
12
|
+
|
13
|
+
def execute!
|
14
|
+
file = load_template_file
|
15
|
+
file.delete('sfn_nested_stack')
|
16
|
+
ui.info "#{ui.color("Template Validation (#{provider.connection.provider}): ", :bold)} #{config[:file].sub(Dir.pwd, '').sub(%r{^/}, '')}"
|
17
|
+
file = Sfn::Utils::StackParameterScrubber.scrub!(file)
|
18
|
+
file = translate_template(file)
|
19
|
+
begin
|
20
|
+
result = provider.connection.stacks.build(
|
21
|
+
:name => 'validation-stack',
|
22
|
+
:template => file
|
23
|
+
).validate
|
24
|
+
ui.info ui.color(' -> VALID', :bold, :green)
|
25
|
+
rescue => e
|
26
|
+
ui.info ui.color(' -> INVALID', :bold, :red)
|
27
|
+
ui.fatal e.message
|
28
|
+
failed = true
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
|
3
|
+
module Sfn
|
4
|
+
module CommandModule
|
5
|
+
# Base module for CLIs
|
6
|
+
module Base
|
7
|
+
|
8
|
+
# Instance methods for cloudformation command classes
|
9
|
+
module InstanceMethods
|
10
|
+
|
11
|
+
# @return [KnifeCloudformation::Provider]
|
12
|
+
def provider
|
13
|
+
memoize(:provider, :direct) do
|
14
|
+
Sfn::Provider.new(
|
15
|
+
:miasma => config[:credentials],
|
16
|
+
:async => false,
|
17
|
+
:fetch => false
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Write exception information if debug is enabled
|
23
|
+
#
|
24
|
+
# @param e [Exception]
|
25
|
+
# @param args [String] extra strings to output
|
26
|
+
def _debug(e, *args)
|
27
|
+
if(config[:verbose])
|
28
|
+
ui.fatal "Exception information: #{e.class}: #{e.message}"
|
29
|
+
if(ENV['DEBUG'])
|
30
|
+
puts "#{e.backtrace.join("\n")}\n"
|
31
|
+
if(e.is_a?(Miasma::Error::ApiError))
|
32
|
+
ui.fatal "Response body: #{e.response.body.to_s.inspect}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
args.each do |string|
|
36
|
+
ui.fatal string
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Format snake cased key to title
|
42
|
+
#
|
43
|
+
# @param string [String, Symbol]
|
44
|
+
# @return [String
|
45
|
+
def as_title(string)
|
46
|
+
string.to_s.split('_').map(&:capitalize).join(' ')
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get stack
|
50
|
+
#
|
51
|
+
# @param name [String] name of stack
|
52
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
53
|
+
def stack(name)
|
54
|
+
provider.stacks.get(name)
|
55
|
+
end
|
56
|
+
|
57
|
+
# @return [Array<String>] attributes to display
|
58
|
+
def allowed_attributes
|
59
|
+
opts.fetch(:attributes, config.fetch(:attributes, default_attributes))
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [Array<String>] default attributes to display
|
63
|
+
def default_attributes
|
64
|
+
%w(timestamp stack_name id)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Check if attribute is allowed for display
|
68
|
+
#
|
69
|
+
# @param attr [String]
|
70
|
+
# @return [TrueClass, FalseClass]
|
71
|
+
def attribute_allowed?(attr)
|
72
|
+
opts.fetch(:all_attributes, config[:all_attributes], allowed_attributes.include?(attr))
|
73
|
+
end
|
74
|
+
|
75
|
+
# Poll events on stack
|
76
|
+
#
|
77
|
+
# @param name [String] name of stack
|
78
|
+
def poll_stack(name)
|
79
|
+
provider.connection.stacks.reload
|
80
|
+
retry_attempts = 0
|
81
|
+
begin
|
82
|
+
events = Sfn::Command::Events.new({:poll => true}, [name]).execute!
|
83
|
+
rescue => e
|
84
|
+
if(retry_attempts < config.fetch(:max_poll_retries, 5).to_i)
|
85
|
+
retry_attempts += 1
|
86
|
+
warn "Unexpected error encountered (#{e.class}: #{e}) Retrying [retry count: #{retry_attempts}]"
|
87
|
+
sleep(1)
|
88
|
+
retry
|
89
|
+
else
|
90
|
+
raise
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Wrapper for information retrieval. Provides consistent error
|
96
|
+
# message for failures
|
97
|
+
#
|
98
|
+
# @param stack [String] stack name
|
99
|
+
# @param message [String] failure message
|
100
|
+
# @yield block to wrap error handling
|
101
|
+
# @return [Object] result of yield
|
102
|
+
def get_things(stack=nil, message=nil)
|
103
|
+
begin
|
104
|
+
yield
|
105
|
+
rescue => e
|
106
|
+
ui.fatal "#{message || 'Failed to retrieve information'}#{" for requested stack: #{stack}" if stack}"
|
107
|
+
ui.fatal "Reason: #{e}"
|
108
|
+
_debug(e)
|
109
|
+
exit 1
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Simple compat proxy method
|
114
|
+
#
|
115
|
+
# @return [Array<String>]
|
116
|
+
def name_args
|
117
|
+
arguments
|
118
|
+
end
|
119
|
+
|
120
|
+
# Fetches value from local configuration (#opts) and falls
|
121
|
+
# back to global configuration (#options)
|
122
|
+
#
|
123
|
+
# @return [Object]
|
124
|
+
def config
|
125
|
+
memoize(:config) do
|
126
|
+
options.deep_merge(opts)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
|
132
|
+
class << self
|
133
|
+
def included(klass)
|
134
|
+
klass.instance_eval do
|
135
|
+
|
136
|
+
include Sfn::CommandModule::Base::InstanceMethods
|
137
|
+
include Sfn::Utils::JSON
|
138
|
+
include Sfn::Utils::Output
|
139
|
+
include Bogo::AnimalStrings
|
140
|
+
include Bogo::Memoization
|
141
|
+
include Bogo::Constants
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
|
4
|
+
module Sfn
|
5
|
+
module CommandModule
|
6
|
+
# Stack handling helper methods
|
7
|
+
module Stack
|
8
|
+
|
9
|
+
module InstanceMethods
|
10
|
+
|
11
|
+
# unpacked stack name joiner/identifier
|
12
|
+
UNPACK_NAME_JOINER = '-sfn-'
|
13
|
+
# maximum number of attempts to get valid parameter value
|
14
|
+
MAX_PARAMETER_ATTEMPTS = 5
|
15
|
+
|
16
|
+
# Apply any defined remote stacks
|
17
|
+
#
|
18
|
+
# @param stack [Miasma::Models::Orchestration::Stack]
|
19
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
20
|
+
def apply_stacks!(stack)
|
21
|
+
remote_stacks = config.fetch(:apply_stacks, [])
|
22
|
+
remote_stacks.each do |stack_name|
|
23
|
+
remote_stack = provider.connection.stacks.get(stack_name)
|
24
|
+
if(remote_stack)
|
25
|
+
apply_nested_stacks!(remote_stack, stack)
|
26
|
+
stack.apply_stack(remote_stack)
|
27
|
+
else
|
28
|
+
apply_unpacked_stack!(stack_name, stack)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
stack
|
32
|
+
end
|
33
|
+
|
34
|
+
# Detect nested stacks and apply
|
35
|
+
#
|
36
|
+
# @param remote_stack [Miasma::Models::Orchestration::Stack] stack to inspect for nested stacks
|
37
|
+
# @param stack [Miasma::Models::Orchestration::Stack] current stack
|
38
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
39
|
+
def apply_nested_stacks(remote_stack, stack)
|
40
|
+
remote_stack.resources.all.each do |resource|
|
41
|
+
if(resource.type == 'AWS::CloudFormation::Stack')
|
42
|
+
nested_stack = resource.expand
|
43
|
+
apply_nested_stacks!(nested_stack, stack)
|
44
|
+
stack.apply_stack(nested_stack)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
stack
|
48
|
+
end
|
49
|
+
|
50
|
+
# Apply all stacks from an unpacked stack
|
51
|
+
#
|
52
|
+
# @param stack_name [String] name of parent stack
|
53
|
+
# @param stack [Miasma::Models::Orchestration::Stack]
|
54
|
+
# @return [Miasma::Models::Orchestration::Stack]
|
55
|
+
def apply_unpacked_stack!(stack_name, stack)
|
56
|
+
result = provider.connection.stacks.all.find_all do |remote_stack|
|
57
|
+
remote_stack.name.start_with?("#{stack_name}#{UNPACK_NAME_JOINER}")
|
58
|
+
end.sort_by(&:name).map do |remote_stack|
|
59
|
+
stack.apply_stack(remote_stack)
|
60
|
+
end
|
61
|
+
unless(result.empty?)
|
62
|
+
stack
|
63
|
+
else
|
64
|
+
ui.error "Failed to apply requested stack. Unable to locate. (#{stack_name})"
|
65
|
+
raise "Failed to locate stack: #{stack_name}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Unpack nested stack and run action on each stack, applying
|
70
|
+
# the previous stacks automatically
|
71
|
+
#
|
72
|
+
# @param name [String] container stack name
|
73
|
+
# @param file [Hash] stack template
|
74
|
+
# @param action [String] create or update
|
75
|
+
# @return [TrueClass]
|
76
|
+
def unpack_nesting(name, file, action)
|
77
|
+
config.apply_stacks ||= []
|
78
|
+
stack_count = 0
|
79
|
+
file['Resources'].each do |stack_resource_name, stack_resource|
|
80
|
+
|
81
|
+
nested_stack_name = "#{name}#{UNPACK_NAME_JOINER}#{Kernel.sprintf('%0.3d', stack_count)}-#{stack_resource_name}"
|
82
|
+
nested_stack_template = stack_resource['Properties']['Stack']
|
83
|
+
|
84
|
+
namespace.const_get(action.to_s.capitalize).new(
|
85
|
+
Smash.new(
|
86
|
+
:print_only => config[:print_only],
|
87
|
+
:template => nested_stack_template,
|
88
|
+
:parameters => config.fetch(:parameters, Smash.new).to_smash,
|
89
|
+
:apply_stacks => config[:apply_stacks],
|
90
|
+
:options => config[:options]
|
91
|
+
),
|
92
|
+
[nested_stack_name]
|
93
|
+
).execute!
|
94
|
+
unless(config[:print_only])
|
95
|
+
config[:apply_stacks].push(nested_stack_name).uniq!
|
96
|
+
end
|
97
|
+
config[:template] = nil
|
98
|
+
provider.connection.stacks.reload
|
99
|
+
stack_count += 1
|
100
|
+
end
|
101
|
+
|
102
|
+
true
|
103
|
+
end
|
104
|
+
|
105
|
+
# Prompt for parameter values and store result
|
106
|
+
#
|
107
|
+
# @param stack [Hash] stack template
|
108
|
+
# @return [Hash]
|
109
|
+
def populate_parameters!(stack, current_params={})
|
110
|
+
if(config[:interactive_parameters])
|
111
|
+
if(stack['Parameters'])
|
112
|
+
unless(config.get(:parameters))
|
113
|
+
config.set(:parameters, Smash.new)
|
114
|
+
end
|
115
|
+
stack.fetch('Parameters', {}).each do |k,v|
|
116
|
+
next if config[:parameters][k]
|
117
|
+
attempt = 0
|
118
|
+
valid = false
|
119
|
+
until(valid)
|
120
|
+
attempt += 1
|
121
|
+
default = config[:parameters].fetch(
|
122
|
+
k, current_params.fetch(
|
123
|
+
k, v['Default']
|
124
|
+
)
|
125
|
+
)
|
126
|
+
answer = ui.ask_question("#{k.split(/([A-Z]+[^A-Z]*)/).find_all{|s|!s.empty?}.join(' ')}", :default => default)
|
127
|
+
validation = Sfn::Utils::StackParameterValidator.validate(answer, v)
|
128
|
+
if(validation == true)
|
129
|
+
unless(answer == v['Default'])
|
130
|
+
config[:parameters][k] = answer
|
131
|
+
end
|
132
|
+
valid = true
|
133
|
+
else
|
134
|
+
validation.each do |validation_error|
|
135
|
+
ui.error validation_error.last
|
136
|
+
end
|
137
|
+
end
|
138
|
+
if(attempt > MAX_PARAMETER_ATTEMPTS)
|
139
|
+
ui.fatal 'Failed to receive allowed parameter!'
|
140
|
+
exit 1
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
stack
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
module ClassMethods
|
152
|
+
end
|
153
|
+
|
154
|
+
# Load methods into class and define options
|
155
|
+
#
|
156
|
+
# @param klass [Class]
|
157
|
+
def self.included(klass)
|
158
|
+
klass.class_eval do
|
159
|
+
extend Sfn::CommandModule::Stack::ClassMethods
|
160
|
+
include Sfn::CommandModule::Stack::InstanceMethods
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require 'sfn'
|
2
|
+
require 'sparkle_formation'
|
3
|
+
|
4
|
+
module Sfn
|
5
|
+
module CommandModule
|
6
|
+
# Template handling helper methods
|
7
|
+
module Template
|
8
|
+
|
9
|
+
# cloudformation directories that should be ignored
|
10
|
+
TEMPLATE_IGNORE_DIRECTORIES = %w(components dynamics registry)
|
11
|
+
|
12
|
+
module InstanceMethods
|
13
|
+
|
14
|
+
# Load the template file
|
15
|
+
#
|
16
|
+
# @param args [Symbol] options (:allow_missing)
|
17
|
+
# @return [Hash] loaded template
|
18
|
+
def load_template_file(*args)
|
19
|
+
unless(config[:template])
|
20
|
+
set_paths_and_discover_file!
|
21
|
+
unless(File.exists?(config[:file].to_s))
|
22
|
+
unless(args.include?(:allow_missing))
|
23
|
+
ui.fatal "Invalid formation file path provided: #{config[:file]}"
|
24
|
+
raise IOError.new "Failed to locate file: #{config[:file]}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
if(config[:template])
|
29
|
+
config[:template]
|
30
|
+
elsif(config[:file])
|
31
|
+
if(config[:processing])
|
32
|
+
sf = SparkleFormation.compile(config[:file], :sparkle)
|
33
|
+
if(sf.nested? && !sf.isolated_nests?)
|
34
|
+
raise TypeError.new('Template does not contain isolated stack nesting! Cannot process in existing state.')
|
35
|
+
end
|
36
|
+
if(sf.nested? && config[:apply_nesting])
|
37
|
+
sf.apply_nesting do |stack_name, stack_definition|
|
38
|
+
bucket = provider.connection.api_for(:storage).buckets.get(
|
39
|
+
config[:nesting_bucket]
|
40
|
+
)
|
41
|
+
if(config[:print_only])
|
42
|
+
"http://example.com/bucket/#{name_args.first}_#{stack_name}.json"
|
43
|
+
else
|
44
|
+
unless(bucket)
|
45
|
+
raise "Failed to locate configured bucket for stack template storage (#{bucket})!"
|
46
|
+
end
|
47
|
+
file = bucket.files.build
|
48
|
+
file.name = "#{name_args.first}_#{stack_name}.json"
|
49
|
+
file.content_type = 'text/json'
|
50
|
+
file.body = MultiJson.dump(Sfn::Utils::StackParameterScrubber.scrub!(stack_definition))
|
51
|
+
file.save
|
52
|
+
# TODO: what if we need extra params?
|
53
|
+
url = URI.parse(file.url)
|
54
|
+
"#{url.scheme}://#{url.host}#{url.path}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
else
|
58
|
+
sf.dump.merge('sfn_nested_stack' => !!sf.nested?)
|
59
|
+
end
|
60
|
+
else
|
61
|
+
_from_json(File.read(config[:file]))
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Apply template translation
|
67
|
+
#
|
68
|
+
# @param template [Hash]
|
69
|
+
# @return [Hash]
|
70
|
+
def translate_template(template)
|
71
|
+
if(klass_name = config[:translate])
|
72
|
+
klass = SparkleFormation::Translation.const_get(camel(klass_name))
|
73
|
+
args = {
|
74
|
+
:parameters => config.fetch(:options, :parameters, Smash.new)
|
75
|
+
}
|
76
|
+
if(chunk_size = config[:translate_chunk_size])
|
77
|
+
args.merge!(
|
78
|
+
:options => {
|
79
|
+
:serialization_chunk_size => chunk_size
|
80
|
+
}
|
81
|
+
)
|
82
|
+
end
|
83
|
+
translator = klass.new(template, args)
|
84
|
+
translator.translate!
|
85
|
+
template = translator.translated
|
86
|
+
ui.info "#{ui.color('Translation applied:', :bold)} #{ui.color(klass_name, :yellow)}"
|
87
|
+
end
|
88
|
+
template
|
89
|
+
end
|
90
|
+
|
91
|
+
# Set SparkleFormation paths and locate tempate
|
92
|
+
#
|
93
|
+
# @return [TrueClass]
|
94
|
+
def set_paths_and_discover_file!
|
95
|
+
if(config[:base_directory])
|
96
|
+
SparkleFormation.sparkle_path = config[:base_directory]
|
97
|
+
end
|
98
|
+
if(!config[:file] && config[:file_path_prompt])
|
99
|
+
root = File.expand_path(
|
100
|
+
config.fetch(:base_directory,
|
101
|
+
File.join(Dir.pwd, 'cloudformation')
|
102
|
+
)
|
103
|
+
).split('/')
|
104
|
+
bucket = root.pop
|
105
|
+
root = root.join('/')
|
106
|
+
directory = File.join(root, bucket)
|
107
|
+
config[:file] = prompt_for_file(directory,
|
108
|
+
:directories_name => 'Collections',
|
109
|
+
:files_name => 'Templates',
|
110
|
+
:ignore_directories => TEMPLATE_IGNORE_DIRECTORIES
|
111
|
+
)
|
112
|
+
else
|
113
|
+
unless(Pathname(config[:file].to_s).absolute?)
|
114
|
+
base_dir = config[:base_directory].to_s
|
115
|
+
file = config[:file].to_s
|
116
|
+
pwd = Dir.pwd
|
117
|
+
config[:file] = [
|
118
|
+
File.join(base_dir, file),
|
119
|
+
File.join(pwd, file),
|
120
|
+
File.join(pwd, 'cloudformation', file)
|
121
|
+
].detect do |file_path|
|
122
|
+
File.file?(file_path)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
true
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
module ClassMethods
|
132
|
+
end
|
133
|
+
|
134
|
+
# Load methods into class and define options
|
135
|
+
#
|
136
|
+
# @param klass [Class]
|
137
|
+
def self.included(klass)
|
138
|
+
klass.class_eval do
|
139
|
+
extend Sfn::CommandModule::Template::ClassMethods
|
140
|
+
include Sfn::CommandModule::Template::InstanceMethods
|
141
|
+
include Sfn::Utils::PathSelector
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|