ghost 0.3.0 → 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. data/LICENSE +1 -1
  2. data/bin/ghost +2 -130
  3. data/lib/ghost.rb +3 -16
  4. data/lib/ghost/cli.rb +60 -0
  5. data/lib/ghost/cli/task.rb +71 -0
  6. data/lib/ghost/cli/task/add.rb +25 -0
  7. data/lib/ghost/cli/task/delete.rb +30 -0
  8. data/lib/ghost/cli/task/empty.rb +18 -0
  9. data/lib/ghost/cli/task/export.rb +19 -0
  10. data/lib/ghost/cli/task/help.rb +41 -0
  11. data/lib/ghost/cli/task/import.rb +25 -0
  12. data/lib/ghost/cli/task/list.rb +40 -0
  13. data/lib/ghost/host.rb +34 -0
  14. data/lib/ghost/store.rb +12 -0
  15. data/lib/ghost/store/dscl_store.rb +71 -0
  16. data/lib/ghost/store/hosts_file_store.rb +123 -0
  17. data/lib/ghost/tokenized_file.rb +65 -0
  18. data/lib/ghost/version.rb +3 -0
  19. data/spec/ghost/cli/task/add_spec.rb +80 -0
  20. data/spec/ghost/cli/task/delete_spec.rb +20 -0
  21. data/spec/ghost/cli/task/empty_spec.rb +19 -0
  22. data/spec/ghost/cli/task/export_spec.rb +16 -0
  23. data/spec/ghost/cli/task/help_spec.rb +36 -0
  24. data/spec/ghost/cli/task/import_spec.rb +56 -0
  25. data/spec/ghost/cli/task/list_spec.rb +50 -0
  26. data/spec/ghost/cli_spec.rb +22 -0
  27. data/spec/ghost/host_spec.rb +36 -0
  28. data/spec/ghost/store/dscl_store_spec.rb +153 -0
  29. data/spec/ghost/store/hosts_file_store_spec.rb +316 -0
  30. data/spec/ghost/store_spec.rb +2 -0
  31. data/spec/ghost/tokenized_file_spec.rb +131 -0
  32. data/spec/spec_helper.rb +4 -2
  33. data/spec/support/cli.rb +29 -0
  34. data/spec/support/resolv.rb +15 -0
  35. metadata +91 -27
  36. data/Rakefile +0 -28
  37. data/TODO +0 -0
  38. data/bin/ghost-ssh +0 -132
  39. data/lib/ghost/linux-host.rb +0 -158
  40. data/lib/ghost/mac-host.rb +0 -116
  41. data/lib/ghost/ssh_config.rb +0 -110
  42. data/spec/etc_hosts_spec.rb +0 -190
  43. data/spec/ghost_spec.rb +0 -151
  44. data/spec/spec.opts +0 -1
  45. data/spec/ssh_config_spec.rb +0 -80
  46. data/spec/ssh_config_template +0 -11
@@ -0,0 +1,40 @@
1
+ Ghost::Cli.task :list do
2
+ desc "Show all (or a filtered) list of hosts"
3
+ def perform(filter = nil)
4
+ hosts = get_hosts(filter)
5
+
6
+ puts "Listing #{hosts.size} host(s):"
7
+
8
+ pad = hosts.map {|h| h.name.length }.max
9
+ hosts.each do |host|
10
+ puts "#{host.name.rjust(pad + 2)} -> #{host.ip}"
11
+ end
12
+ end
13
+
14
+ help do
15
+ <<-EOF.unindent
16
+ Usage: ghost list [<regex>]
17
+
18
+ #{desc}.
19
+
20
+ If no regular expression is provided, a summary of all hosts is
21
+ shown. If a regular expression is provided, only hosts whose
22
+ host names match the regular expression are listed.
23
+
24
+ Examples:
25
+ ghost list # will list every host name
26
+ ghost list /^fo+\\.com$/ # will list fo.com, fooooo.com, etc.
27
+ EOF
28
+ end
29
+
30
+ private
31
+
32
+ def get_hosts(filter)
33
+ hosts = if filter
34
+ filter = $1 if filter =~ %r|^/(.*)/$|
35
+ Ghost.store.find(/#{filter}/i)
36
+ else
37
+ Ghost.store.all
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,34 @@
1
+ require 'socket'
2
+
3
+ module Ghost
4
+ class Host < Struct.new(:name, :ip)
5
+ class NotResolvable < Exception; end
6
+
7
+ alias :to_s :name
8
+ alias :host :name
9
+ alias :hostname :name
10
+ alias :ip_address :ip
11
+
12
+ def initialize(host, ip = "127.0.0.1")
13
+ super(host, resolve_ip(ip))
14
+ end
15
+
16
+ def <=>(host)
17
+ if ip == host.ip
18
+ name <=> host.name
19
+ else
20
+ ip <=> host.ip
21
+ end
22
+ end
23
+
24
+ def match(name)
25
+ host.match(name)
26
+ end
27
+
28
+ def resolve_ip(ip_or_hostname)
29
+ IPSocket.getaddress(ip_or_hostname)
30
+ rescue SocketError
31
+ raise Ghost::Host::NotResolvable, "#{ip_or_hostname} is not resolvable."
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ module Ghost
2
+ class << self
3
+ attr_accessor :store
4
+ end
5
+ end
6
+
7
+
8
+ require 'ghost/store/hosts_file_store'
9
+ Ghost.store = Ghost::Store::HostsFileStore.new
10
+
11
+ # TODO: only load on OS X and make it default when compatible
12
+ require 'ghost/store/dscl_store'
@@ -0,0 +1,71 @@
1
+ require 'ghost/host'
2
+ require 'set'
3
+
4
+ module Ghost
5
+ module Store
6
+ class DsclStore
7
+ class Dscl
8
+ class << self
9
+ def list(domain)
10
+ `dscl % -readall /Local/Default/Hosts 2>&1` % domain
11
+ end
12
+
13
+ # TODO is shell injection a concern here?
14
+ def read(domain, host)
15
+ `dscl % -read /Local/Default/Hosts/%s 2>&1` % [domain, host]
16
+ end
17
+
18
+ def create(domain, host, ip)
19
+ `dscl % -create /Local/Default/Hosts/%s IPAddress %s 2>&1` % [domain, host, ip]
20
+ end
21
+
22
+ def delete(domain, host)
23
+ `dscl % -delete /Local/Default/Hosts/%s 2>&1` % [domain, host]
24
+ end
25
+ end
26
+ end
27
+
28
+ attr_accessor :domain
29
+
30
+ def initialize(domain = "localhost")
31
+ self.domain = domain
32
+ end
33
+
34
+ def add(host)
35
+ Dscl.create(domain, host.name, host.ip)
36
+ true
37
+ end
38
+
39
+ def all
40
+ Dscl.list(domain).map do |host|
41
+ name = host.scan(/^RecordName: (.+)$/).flatten.first
42
+ ip = host.scan(/^IPAddress: (.+)$/).flatten.first
43
+
44
+ Ghost::Host.new(name, ip)
45
+ end
46
+ end
47
+
48
+ def find(regex)
49
+ all.select { |h| h.name =~ regex }
50
+ end
51
+
52
+ def delete(host)
53
+ result = SortedSet.new
54
+
55
+ all.each do |existing_host|
56
+ next unless host.match(existing_host.name)
57
+ next if host.respond_to?(:ip) && host.ip != existing_host.ip
58
+
59
+ Dscl.delete(domain, existing_host.name)
60
+ result << existing_host
61
+ end
62
+
63
+ result.to_a
64
+ end
65
+
66
+ def empty
67
+ all.each { |host| Dscl.delete(domain, host.name) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,123 @@
1
+ require 'set'
2
+
3
+ require 'ghost/host'
4
+ require 'ghost/tokenized_file'
5
+
6
+ module Ghost
7
+ module Store
8
+ # TODO: A lot of this duplicates Resolv::Hosts in Ruby stdlib.
9
+ # Can that be modifiied to use tokens in place of this?
10
+ class HostsFileStore
11
+ attr_accessor :path, :file
12
+
13
+ # TODO: Support windows locations:
14
+ # Windows 95/98/Me c:\windows\hosts
15
+ # Windows NT/2000/XP Pro c:\winnt\system32\drivers\etc\hosts
16
+ # Windows XP Home c:\windows\system32\drivers\etc\hosts
17
+ def initialize(path = "/etc/hosts")
18
+ self.path = path
19
+ self.file = Ghost::TokenizedFile.new(path, "# ghost start", "# ghost end")
20
+ end
21
+
22
+ def add(host)
23
+ sync do |buffer|
24
+ buffer[host.ip] << host.name
25
+ buffer_changed!
26
+ end
27
+
28
+ true
29
+ end
30
+
31
+ def all
32
+ sync do |buffer|
33
+ buffer.map do |ip, hosts|
34
+ hosts.map { |h| Ghost::Host.new(h, ip) }
35
+ end.flatten.sort
36
+ end
37
+ end
38
+
39
+ def find(filter)
40
+ all.select { |host| host.name =~ filter }
41
+ end
42
+
43
+ def delete(host)
44
+ result = SortedSet.new
45
+ sync do |buffer|
46
+ buffer.each do |ip, names|
47
+ names.dup.each do |name|
48
+ next unless host.match(name)
49
+ next if host.respond_to?(:ip) && host.ip != ip
50
+
51
+ result << Ghost::Host.new(name, ip)
52
+ names.delete(name)
53
+ buffer_changed!
54
+ end
55
+ end
56
+ end
57
+ result.to_a
58
+ end
59
+
60
+ def empty
61
+ result = false
62
+ sync do |buffer|
63
+ unless buffer.empty?
64
+ result = true
65
+ buffer.replace({})
66
+ buffer_changed!
67
+ end
68
+ end
69
+ result
70
+ end
71
+
72
+ private # TODO: Add buffer management code to new class
73
+
74
+ def buffer_changed?
75
+ @buffer_changed
76
+ end
77
+
78
+ def buffer_changed!
79
+ @buffer_changed = true
80
+ end
81
+
82
+ def with_buffer
83
+ @buffer_changed = false
84
+ yield(Hash.new { |hash, key| hash[key] = SortedSet.new })
85
+ end
86
+
87
+ def parse_into_buffer(lines, buffer)
88
+ lines.split($/).each do |line|
89
+ ip, hosts = *line.scan(/^\s*([^\s]+)\s+([^#]*)/).first
90
+
91
+ return unless ip and hosts
92
+
93
+ hosts.split(/\s+/).each do |host|
94
+ buffer[ip] << host
95
+ end
96
+ end
97
+ end
98
+
99
+ def sync
100
+ result = nil
101
+
102
+ with_buffer do |buffer|
103
+ parse_into_buffer(file.read, buffer)
104
+ result = yield(buffer)
105
+ file.write(content(buffer)) if buffer_changed?
106
+ end
107
+
108
+ result
109
+ end
110
+
111
+ def content(buffer)
112
+ ips = buffer.keys.sort
113
+ lines = ips.map do |ip|
114
+ unless (hosts = buffer[ip]).empty?
115
+ "#{ip} #{buffer[ip].to_a.join(" ")}"
116
+ end
117
+ end
118
+
119
+ lines.compact.join($/)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,65 @@
1
+ module Ghost
2
+ # TODO: make it not necessarily line-based tokenization
3
+ # TODO: make it delegate or inherit from File/IO/StringIO to allow it to be a
4
+ # drop-in to things expecting an IO.
5
+ # - Allow consumer to manipulate a real IO, and sync the contents
6
+ # into the real file between the tokens?
7
+ # TODO: make this it's own gem/library. This has nothing to do (specifically)
8
+ # with hosts file management
9
+ class TokenizedFile
10
+ attr_accessor :path, :start_token, :end_token
11
+
12
+ def initialize(path, start_token, end_token)
13
+ self.path = path
14
+ self.start_token = start_token
15
+ self.end_token = end_token
16
+ end
17
+
18
+ def read
19
+ read_capturing
20
+ end
21
+
22
+ def write(content)
23
+ existing_lines = []
24
+
25
+ # TODO: how to do this without closing the file so
26
+ # we can reopen with write mode and maintain a
27
+ # lock
28
+ read_capturing { |line| existing_lines << line}
29
+
30
+ # TODO: lock file
31
+ File.open(path, 'w') do |file|
32
+ file.puts(existing_lines)
33
+
34
+ unless content.nil? || content.empty?
35
+ file.puts(start_token)
36
+ file.puts(content)
37
+ file.puts(end_token)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def read_capturing
45
+ between_tokens = false
46
+ lines = []
47
+
48
+ File.open(path, 'r') do |file|
49
+ file.each_line do |line|
50
+ if line =~ /^#{start_token}\s*$/
51
+ between_tokens = true
52
+ elsif line =~ /^#{end_token}\s*$/
53
+ between_tokens = false
54
+ elsif between_tokens
55
+ lines << line
56
+ else
57
+ yield(line) if block_given?
58
+ end
59
+ end
60
+ end
61
+
62
+ lines.join
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,3 @@
1
+ module Ghost
2
+ VERSION = "1.0.0.pre"
3
+ end
@@ -0,0 +1,80 @@
1
+ require File.expand_path("#{File.dirname(__FILE__)}/../../../spec_helper.rb")
2
+ require 'ghost/cli'
3
+
4
+ describe Ghost::Cli, :type => :cli do
5
+ # FIXME: these are petty silly tests to have. Find a nice way to check
6
+ # that documentation is accessible and printable for all meaninful tests
7
+ # but don't test actual contents...
8
+ describe "help add" do
9
+ specify do
10
+ ghost("help add").should == <<-EOF.unindent
11
+ Usage: ghost add <local host name> [<remote host name>|<IP address>]
12
+
13
+ Add a host.
14
+
15
+ If a second parameter is not provided, it defaults to 127.0.0.1
16
+
17
+ Examples:
18
+ ghost add my-localhost # points to 127.0.0.1
19
+ ghost add google.dev google.com # points to the IP of google.com
20
+ ghost add router 192.168.1.1 # points to 192.168.1.1
21
+ EOF
22
+ end
23
+ end
24
+
25
+ describe "add" do
26
+ context "with a local hostname" do
27
+ let(:host) { Ghost::Host.new("my-app.local") }
28
+
29
+ it "adds the host pointing to 127.0.0.1" do
30
+ ghost("add my-app.local")
31
+ store.all.should include(Ghost::Host.new("my-app.local", "127.0.0.1"))
32
+ end
33
+
34
+ it "outputs a summary of the operation" do
35
+ ghost("add my-app.local").should == "[Adding] my-app.local -> 127.0.0.1\n"
36
+ end
37
+
38
+ context "when an entry for that hostname already exists" do
39
+ it "outputs an error message"
40
+ end
41
+
42
+ context "and an IP address" do
43
+ let(:host) { Ghost::Host.new("my-app.local", "192.168.1.1") }
44
+
45
+ it "adds the host pointing to the IP address" do
46
+ ghost("add my-app.local 192.168.1.1")
47
+ store.all.should include(Ghost::Host.new("my-app.local", "192.168.1.1"))
48
+ end
49
+
50
+ it "outputs a summary of the operation" do
51
+ ghost("add my-app.local 192.168.1.1").should == "[Adding] my-app.local -> 192.168.1.1\n"
52
+ end
53
+ end
54
+
55
+ context "and a remote hostname" do
56
+ # TODO: replace this stub once DNS resolution works
57
+ let(:host) { Ghost::Host.new("my-app.local", "google.com") }
58
+ before { Ghost::Host.any_instance.stub(:resolve_ip => "74.125.225.99") }
59
+
60
+ it "adds the host pointing to the IP address" do
61
+ ghost("add my-app.local google.com")
62
+ store.all.should include(host)
63
+ end
64
+
65
+ it "outputs a summary of the operation" do
66
+ ghost("add my-app.local google.com").should ==
67
+ "[Adding] my-app.local -> 74.125.225.99\n"
68
+ end
69
+
70
+ context "when the remote hostname can not be resolved" do
71
+ before { Ghost::Host.stub(:new).and_raise(Ghost::Host::NotResolvable) }
72
+
73
+ it "outputs an error message" do
74
+ ghost("add my-app.local google.com").should == "Unable to resolve IP address for target host \"google.com\".\n"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,20 @@
1
+ require File.expand_path("#{File.dirname(__FILE__)}/../../../spec_helper.rb")
2
+ require 'ghost/cli'
3
+
4
+ describe Ghost::Cli, :type => :cli do
5
+ describe "delete" do
6
+ context 'with filtering pattern' do
7
+ it 'deletes only entries whose hostname matches the pattern' do
8
+ store.should_receive(:delete).with(/fo*.com?/i)
9
+ ghost("delete /fo*.com?/")
10
+ end
11
+ end
12
+
13
+ context 'without filtering pattern' do
14
+ it 'deletes the specified hostname' do
15
+ store.should_receive(:delete).with('foo.com')
16
+ ghost("delete foo.com")
17
+ end
18
+ end
19
+ end
20
+ end