ghost 0.3.0 → 1.0.0.pre

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.
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