pgbundle 0.0.1
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 +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +11 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +45 -0
- data/Rakefile +7 -0
- data/bin/pgbundle +61 -0
- data/lib/pgbundle.rb +59 -0
- data/lib/pgbundle/base_source.rb +26 -0
- data/lib/pgbundle/database.rb +133 -0
- data/lib/pgbundle/definition.rb +70 -0
- data/lib/pgbundle/dsl.rb +44 -0
- data/lib/pgbundle/extension.rb +270 -0
- data/lib/pgbundle/github_source.rb +32 -0
- data/lib/pgbundle/path_source.rb +15 -0
- data/lib/pgbundle/version.rb +3 -0
- data/pgbundle.gemspec +30 -0
- data/spec/Pgfile +6 -0
- data/spec/definition_spec.rb +19 -0
- data/spec/dsl_spec.rb +8 -0
- data/spec/extension_spec.rb +76 -0
- data/spec/sample_extensions/bar/Makefile +5 -0
- data/spec/sample_extensions/bar/bar--0.0.2.sql +2 -0
- data/spec/sample_extensions/bar/bar.control +5 -0
- data/spec/sample_extensions/baz/Makefile +5 -0
- data/spec/sample_extensions/baz/baz--0.0.2.sql +2 -0
- data/spec/sample_extensions/baz/baz.control +5 -0
- data/spec/sample_extensions/foo/Makefile +5 -0
- data/spec/sample_extensions/foo/foo--0.0.1--0.0.2.sql +2 -0
- data/spec/sample_extensions/foo/foo--0.0.1.sql +2 -0
- data/spec/sample_extensions/foo/foo--0.0.2--0.0.1.sql +2 -0
- data/spec/sample_extensions/foo/foo--0.0.2.sql +2 -0
- data/spec/sample_extensions/foo/foo.control +4 -0
- data/spec/source_spec.rb +10 -0
- data/spec/spec_helper.rb +47 -0
- metadata +210 -0
data/lib/pgbundle/dsl.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
module PgBundle
|
2
|
+
# The Dsl class defines the Domain Specific Language for the PgFile
|
3
|
+
# it's mainly user to parse a PgFile and return a Definition Object
|
4
|
+
class Dsl
|
5
|
+
def initialize
|
6
|
+
@definition = Definition.new
|
7
|
+
@extensions = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def eval_pgfile(pgfile, contents=nil)
|
11
|
+
contents ||= File.read(pgfile.to_s)
|
12
|
+
instance_eval(contents)
|
13
|
+
@definition
|
14
|
+
rescue SyntaxError => e
|
15
|
+
syntax_msg = e.message.gsub("#{pgfile}:", 'on line ')
|
16
|
+
raise PgfileError, "Pgfile syntax error #{syntax_msg}"
|
17
|
+
rescue ScriptError, RegexpError, NameError, ArgumentError => e
|
18
|
+
e.backtrace[0] = "#{e.backtrace[0]}: #{e.message} (#{e.class})"
|
19
|
+
puts e.backtrace.join("\n ")
|
20
|
+
raise PgfileError, "There was an error in your Pgfile," \
|
21
|
+
" and pgbundle cannot continue. " \
|
22
|
+
+ e.message
|
23
|
+
end
|
24
|
+
|
25
|
+
def database(*args)
|
26
|
+
opts = extract_options!(args)
|
27
|
+
@definition.database = Database.new(args.first, opts)
|
28
|
+
end
|
29
|
+
|
30
|
+
def pgx(*args)
|
31
|
+
opts = extract_options!(args)
|
32
|
+
ext = Extension.new(*args, opts)
|
33
|
+
@definition.extensions[ext.name] = ext
|
34
|
+
end
|
35
|
+
|
36
|
+
def extract_options!(arr)
|
37
|
+
if arr.last.is_a? Hash
|
38
|
+
arr.pop
|
39
|
+
else
|
40
|
+
{}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,270 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
module PgBundle
|
3
|
+
# The Extension class provides the api for defining an Extension
|
4
|
+
# it installation source, and dependencies
|
5
|
+
# example:
|
6
|
+
# define an extension named 'foo' at version '0.1.1', with source on github depending on hstore
|
7
|
+
# Extension.new('foo', '0.1.1', github: 'me/foo', requires: 'hstore')
|
8
|
+
# you can then check if the Extension is available on a given database
|
9
|
+
# extension.available?(database)
|
10
|
+
# or install it along with it's dependencies
|
11
|
+
# extension.install(database)
|
12
|
+
class Extension
|
13
|
+
attr_accessor :name, :version, :source, :resolving_dependencies
|
14
|
+
def initialize(*args)
|
15
|
+
opts = args.last.is_a?(Hash) ? args.pop : {}
|
16
|
+
@name, @version = args
|
17
|
+
self.dependencies = opts[:requires]
|
18
|
+
set_source(opts)
|
19
|
+
end
|
20
|
+
|
21
|
+
def dependencies
|
22
|
+
@dependencies
|
23
|
+
end
|
24
|
+
|
25
|
+
# set dependency hash with different options
|
26
|
+
# dependencies= {foo: Extension.new('foo'), bar: Extension.new('bar')}
|
27
|
+
# => {'foo' => Extension.new('foo'), 'bar' Extension.new('bar')}
|
28
|
+
# dependencies= 'foo'
|
29
|
+
# => {foo: Extension.new('foo')}
|
30
|
+
# dependencies= Extension.new('foo')
|
31
|
+
# => {foo: Extension.new('foo')}
|
32
|
+
# dependencies= ['foo', 'bar']
|
33
|
+
# => {'foo' => Extension.new('foo'), 'bar' Extension.new('bar')}
|
34
|
+
def dependencies=(obj = nil)
|
35
|
+
@dependencies = case obj
|
36
|
+
when nil
|
37
|
+
{}
|
38
|
+
when Hash
|
39
|
+
Hash[obj.map { |k, v| [k.to_s, v] }]
|
40
|
+
when String, Symbol
|
41
|
+
{ obj.to_s => Extension.new(obj.to_s) }
|
42
|
+
when Extension
|
43
|
+
{ obj.name => obj }
|
44
|
+
when Array
|
45
|
+
hashable = obj.map do |o|
|
46
|
+
case o
|
47
|
+
when String, Symbol
|
48
|
+
[o.to_s, Extension.new(obj.to_s)]
|
49
|
+
when Extension
|
50
|
+
[o.name, o]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
Hash[hashable]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# returns true if extension is available for installation on a given database
|
58
|
+
def available?(database)
|
59
|
+
return false unless installed?(database)
|
60
|
+
return true if created?(database)
|
61
|
+
|
62
|
+
created_any_version?(database) ? updatable?(database) : creatable?(database)
|
63
|
+
rescue ExtensionCreateError
|
64
|
+
false
|
65
|
+
end
|
66
|
+
|
67
|
+
# returns true if extension is already created with the correct version in the given database
|
68
|
+
def created?(database)
|
69
|
+
if version
|
70
|
+
result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND version = '#{version}' AND installed").to_a
|
71
|
+
else
|
72
|
+
result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND installed").to_a
|
73
|
+
end
|
74
|
+
|
75
|
+
if result.empty?
|
76
|
+
false
|
77
|
+
else
|
78
|
+
true
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# returns if the extension is already installed on the database system
|
83
|
+
# if it is also already created it returns the installed version
|
84
|
+
def installed?(database)
|
85
|
+
if version
|
86
|
+
result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND version = '#{version}'").to_a
|
87
|
+
else
|
88
|
+
result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}'").to_a
|
89
|
+
end
|
90
|
+
|
91
|
+
if result.empty?
|
92
|
+
false
|
93
|
+
else
|
94
|
+
true
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# checks that all dependencies are installed on a given database
|
99
|
+
def dependencies_installed?(database)
|
100
|
+
dependencies.all?{|_, d| d.installed?(database)}
|
101
|
+
end
|
102
|
+
|
103
|
+
# installs extension and all dependencies using make install
|
104
|
+
# returns true if Extension can successfully be created using CREATE EXTENSION
|
105
|
+
def install(database)
|
106
|
+
unless dependencies.empty?
|
107
|
+
install_dependencies(database)
|
108
|
+
end
|
109
|
+
|
110
|
+
make_install(database)
|
111
|
+
raise ExtensionNotFound.new(name, version) unless installed?(database)
|
112
|
+
|
113
|
+
add_missing_required_dependencies(database)
|
114
|
+
|
115
|
+
creatable?(database)
|
116
|
+
end
|
117
|
+
|
118
|
+
# completely removes extension be running make uninstall and DROP EXTENSION
|
119
|
+
def uninstall!(database)
|
120
|
+
drop_extension(database)
|
121
|
+
make_uninstall(database)
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
# create extension on a given database connection
|
127
|
+
def create!(con)
|
128
|
+
con.exec create_stmt
|
129
|
+
end
|
130
|
+
|
131
|
+
# create the dependency graph on the given connection
|
132
|
+
def create_dependencies(con)
|
133
|
+
@resolving_dependencies = true
|
134
|
+
dependencies.each do |_, d|
|
135
|
+
fail CircularDependencyError.new(name, d.name) if d.resolving_dependencies
|
136
|
+
d.create_dependencies(con)
|
137
|
+
d.create!(con)
|
138
|
+
end
|
139
|
+
@resolving_dependencies = false
|
140
|
+
end
|
141
|
+
|
142
|
+
# checks if Extension is already installed at any version thus need ALTER EXTENSION to install
|
143
|
+
def created_any_version?(database)
|
144
|
+
result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND installed").to_a
|
145
|
+
if result.empty?
|
146
|
+
false
|
147
|
+
else
|
148
|
+
true
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# adds dependencies that are required but not defined yet
|
153
|
+
def add_missing_required_dependencies(database)
|
154
|
+
requires = requires(database)
|
155
|
+
requires.each do |name|
|
156
|
+
unless dependencies[name]
|
157
|
+
dependencies[name] = Extension.new(name)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# returns an array of required Extension specified in the extensions control file
|
163
|
+
def requires(database)
|
164
|
+
fail "Extension #{name} not (yet) installed" unless installed?(database)
|
165
|
+
|
166
|
+
stmt = if version
|
167
|
+
<<-SQL
|
168
|
+
SELECT unnest(requires) as name FROM
|
169
|
+
( SELECT requires FROM pg_available_extension_versions where name='#{name}' AND version ='#{version}') t
|
170
|
+
SQL
|
171
|
+
else
|
172
|
+
<<-SQL
|
173
|
+
SELECT unnest(requires) as name FROM
|
174
|
+
(SELECT requires FROM
|
175
|
+
pg_available_extensions a
|
176
|
+
JOIN pg_available_extension_versions v ON v.name = a.name AND a.default_version = v.version
|
177
|
+
WHERE v.name = '#{name}')t
|
178
|
+
SQL
|
179
|
+
end
|
180
|
+
|
181
|
+
result = database.execute(stmt).to_a
|
182
|
+
|
183
|
+
requires = result.map{|r| r['name']}
|
184
|
+
end
|
185
|
+
|
186
|
+
# loads the source and runs make uninstall
|
187
|
+
# returns: self
|
188
|
+
def make_uninstall(database)
|
189
|
+
database.make_uninstall(source, name)
|
190
|
+
self
|
191
|
+
end
|
192
|
+
|
193
|
+
def drop_extension(database)
|
194
|
+
database.drop_extension(name)
|
195
|
+
end
|
196
|
+
|
197
|
+
# loads the source and runs make install
|
198
|
+
# returns: self
|
199
|
+
def make_install(database)
|
200
|
+
return self if installed?(database)
|
201
|
+
|
202
|
+
fail SourceNotFound, name if source.nil?
|
203
|
+
|
204
|
+
database.make_install(source, name)
|
205
|
+
self
|
206
|
+
end
|
207
|
+
|
208
|
+
def create_stmt
|
209
|
+
stmt = "CREATE EXTENSION #{name}"
|
210
|
+
stmt += " VERSION '#{version}'" unless version.nil? || version.empty?
|
211
|
+
|
212
|
+
stmt
|
213
|
+
end
|
214
|
+
|
215
|
+
def install_dependencies(database)
|
216
|
+
begin
|
217
|
+
dependencies.each do |_, d|
|
218
|
+
d.install(database)
|
219
|
+
end
|
220
|
+
rescue InstallError, ExtensionCreateError => e
|
221
|
+
raise DependencyNotFound.new(name, e.message)
|
222
|
+
end
|
223
|
+
|
224
|
+
true
|
225
|
+
end
|
226
|
+
|
227
|
+
def creatable?(database)
|
228
|
+
dependencies_installed?(database) && installed?(database)
|
229
|
+
end
|
230
|
+
|
231
|
+
# hard checks that the dependency can be created running CREATE command in a transaction
|
232
|
+
def creatable!(database)
|
233
|
+
database.transaction_rollback do |con|
|
234
|
+
begin
|
235
|
+
create_dependencies(con)
|
236
|
+
create!(con)
|
237
|
+
rescue PG::UndefinedFile => err
|
238
|
+
raise ExtensionNotFound.new(name, version)
|
239
|
+
rescue PG::UndefinedObject => err
|
240
|
+
raise MissingDependency.new(name, err.message)
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
true
|
245
|
+
end
|
246
|
+
|
247
|
+
# checks that the extension can be updated running ALTER EXTENSION command in a transaction
|
248
|
+
def updatable?(database)
|
249
|
+
result = true
|
250
|
+
database.execute 'BEGIN'
|
251
|
+
begin
|
252
|
+
database.execute "ALTER EXTENSION #{name} UPDATE TO '#{version}'"
|
253
|
+
rescue PG::UndefinedFile, PG::UndefinedObject => err
|
254
|
+
@error = err.message
|
255
|
+
result = false
|
256
|
+
end
|
257
|
+
database.execute 'ROLLBACK'
|
258
|
+
|
259
|
+
result
|
260
|
+
end
|
261
|
+
|
262
|
+
def set_source(opts)
|
263
|
+
if opts[:path]
|
264
|
+
@source = PathSource.new(opts[:path])
|
265
|
+
elsif opts[:github]
|
266
|
+
@source = GithubSource.new(opts[:github])
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'tmpdir'
|
2
|
+
module PgBundle
|
3
|
+
# The GithubSource class defines a Github Source
|
4
|
+
class GithubSource < BaseSource
|
5
|
+
def load(host, user, dest)
|
6
|
+
clone(dest)
|
7
|
+
if host == 'localhost'
|
8
|
+
copy_local(clone_dir, dest)
|
9
|
+
else
|
10
|
+
copy_to_remote(host, user, clone_dir, dest)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def branch_name
|
15
|
+
@branch || 'master'
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def clone(dest)
|
21
|
+
# git clone user@git-server:project_name.git -b branch_name /some/folder
|
22
|
+
%x((git clone git@github.com:#{path}.git -b #{branch_name} --quiet --depth=1 #{clone_dir} && rm -rf #{clone_dir}/.git}) 2>&1)
|
23
|
+
unless $?.success?
|
24
|
+
fail GitCommandError
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def clone_dir
|
29
|
+
@clone_dir ||= Dir.mktmpdir
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'tmpdir'
|
2
|
+
require 'net/scp'
|
3
|
+
module PgBundle
|
4
|
+
# The PathSource class defines a local Path Source
|
5
|
+
# eg. PathSource.new('/my/local/path')
|
6
|
+
class PathSource < BaseSource
|
7
|
+
def load(host, user, dest)
|
8
|
+
if host == 'localhost'
|
9
|
+
copy_local(path, dest)
|
10
|
+
else
|
11
|
+
copy_to_remote(host, user, path, dest)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/pgbundle.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'pgbundle/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "pgbundle"
|
8
|
+
spec.version = Pgbundle::VERSION
|
9
|
+
spec.authors = ["Manuel Kniep"]
|
10
|
+
spec.email = ["manuel@adjust.com"]
|
11
|
+
spec.summary = %q{bundling postgres extension}
|
12
|
+
spec.description = %q{bundler like postgres extension manager}
|
13
|
+
spec.homepage = "http://github.com/adjust/pgbundle"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency 'thor'
|
22
|
+
spec.add_dependency 'net-ssh'
|
23
|
+
spec.add_dependency 'net-scp'
|
24
|
+
#https://bitbucket.org/ged/ruby-pg/wiki/Home
|
25
|
+
spec.add_dependency 'pg', '> 0.17'
|
26
|
+
spec.add_development_dependency 'rspec', '~> 2.14.0'
|
27
|
+
spec.add_development_dependency "bundler", "~> 1.5"
|
28
|
+
spec.add_development_dependency "rake"
|
29
|
+
spec.add_development_dependency "pry"
|
30
|
+
end
|
data/spec/Pgfile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
describe PgBundle::Definition do
|
3
|
+
subject do
|
4
|
+
d = PgBundle::Definition.new
|
5
|
+
d.database = database
|
6
|
+
d.extensions['bar'] = PgBundle::Extension.new('bar', path: './spec/sample_extensions//bar', requires: 'ltree')
|
7
|
+
d.extensions['baz'] = PgBundle::Extension.new('baz', path: './spec/sample_extensions/baz', requires: 'foo')
|
8
|
+
d.extensions['foo'] = PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo')
|
9
|
+
d
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'missing_extensions' do
|
13
|
+
subject.missing_extensions.map(&:name).should eq %w(bar baz foo)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should install missing extension' do
|
17
|
+
subject.install.map(&:name).should eq %w(bar baz foo)
|
18
|
+
end
|
19
|
+
end
|
data/spec/dsl_spec.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
describe PgBundle::Extension do
|
3
|
+
describe 'Basiscs' do
|
4
|
+
subject { PgBundle::Extension.new('foo', '1.2.3', github: 'bar/foo', requires: 'baz') }
|
5
|
+
|
6
|
+
its(:name) { should eq 'foo' }
|
7
|
+
its(:version) { should eq '1.2.3' }
|
8
|
+
its('dependencies.first.last') { should be_a PgBundle::Extension }
|
9
|
+
its(:source) { should be_a PgBundle::GithubSource }
|
10
|
+
end
|
11
|
+
|
12
|
+
describe 'Installation' do
|
13
|
+
|
14
|
+
context 'version available' do
|
15
|
+
subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo') }
|
16
|
+
|
17
|
+
it 'should not be available before install' do
|
18
|
+
subject.available?(database).should be false
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'should be available after install' do
|
22
|
+
subject.install(database)
|
23
|
+
subject.available?(database).should be true
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'wrong version installed' do
|
27
|
+
subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo') }
|
28
|
+
let(:wrong_version) { PgBundle::Extension.new('foo', '0.0.1', path: './spec/sample_extensions/foo') }
|
29
|
+
before do
|
30
|
+
wrong_version.install(database)
|
31
|
+
database.connection.exec "CREATE EXTENSION foo VERSION '0.0.2'"
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should be already installed' do
|
35
|
+
subject.should be_created_any_version(database)
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'should be available' do
|
39
|
+
subject.should be_available(database)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'with require' do
|
44
|
+
let(:dependend) { PgBundle::Extension.new('bar', path: './spec/sample_extensions/bar', requires: 'ltree') }
|
45
|
+
subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo', requires: dependend) }
|
46
|
+
|
47
|
+
it 'requires should be installable' do
|
48
|
+
dependend.should_not be_available(database)
|
49
|
+
subject.install(database).should be true
|
50
|
+
dependend.should be_available(database)
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
context 'version not available' do
|
57
|
+
subject { PgBundle::Extension.new('foo', '0.0.3', path: './spec/sample_extensions/foo') }
|
58
|
+
|
59
|
+
it 'should raise ExtensionNotFound' do
|
60
|
+
expect { subject.install(database) }.to raise_error PgBundle::ExtensionNotFound, 'specified Version 0.0.3 for Extension foo not available'
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'should not be available although it is installed' do
|
64
|
+
subject.available?(database).should be false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context 'require not found' do
|
69
|
+
subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo', requires: PgBundle::Extension.new('noope')) }
|
70
|
+
|
71
|
+
it 'should raise DependencyNotFound' do
|
72
|
+
expect { subject.install(database) }.to raise_error PgBundle::DependencyNotFound, "Can't install Dependency for Extension foo: Source for Extension noope not found"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|