gitolite-rugged 1.2.pre.devel

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +9 -0
  4. data/.travis.yml +14 -0
  5. data/Gemfile +5 -0
  6. data/Guardfile +13 -0
  7. data/LICENSE.txt +26 -0
  8. data/README.md +108 -0
  9. data/Rakefile +59 -0
  10. data/gitolite.gemspec +36 -0
  11. data/lib/gitolite/config/group.rb +62 -0
  12. data/lib/gitolite/config/repo.rb +107 -0
  13. data/lib/gitolite/config.rb +284 -0
  14. data/lib/gitolite/dirty_proxy.rb +32 -0
  15. data/lib/gitolite/gitolite_admin.rb +276 -0
  16. data/lib/gitolite/ssh_key.rb +103 -0
  17. data/lib/gitolite/version.rb +3 -0
  18. data/lib/gitolite.rb +10 -0
  19. data/spec/config_spec.rb +498 -0
  20. data/spec/dirty_proxy_spec.rb +66 -0
  21. data/spec/fixtures/configs/complicated-output.conf +72 -0
  22. data/spec/fixtures/configs/complicated.conf +311 -0
  23. data/spec/fixtures/configs/simple.conf +5 -0
  24. data/spec/fixtures/keys/bob+joe@test.zilla.com@desktop.pub +1 -0
  25. data/spec/fixtures/keys/bob-ins@zilla-site.com@desktop.pub +1 -0
  26. data/spec/fixtures/keys/bob.joe@test.zilla.com@desktop.pub +1 -0
  27. data/spec/fixtures/keys/bob.pub +1 -0
  28. data/spec/fixtures/keys/bob@desktop.pub +1 -0
  29. data/spec/fixtures/keys/bob@foo-bar.pub +1 -0
  30. data/spec/fixtures/keys/bob@zilla.com.pub +1 -0
  31. data/spec/fixtures/keys/bob@zilla.com@desktop.pub +1 -0
  32. data/spec/fixtures/keys/jakub123.pub +1 -0
  33. data/spec/fixtures/keys/jakub123@foo.net.pub +1 -0
  34. data/spec/fixtures/keys/joe-bob@god-zilla.com@desktop.pub +1 -0
  35. data/spec/fixtures/keys/joe@sch.ool.edu.pub +1 -0
  36. data/spec/fixtures/keys/joe@sch.ool.edu@desktop.pub +1 -0
  37. data/spec/gitolite_admin_spec.rb +40 -0
  38. data/spec/group_spec.rb +125 -0
  39. data/spec/repo_spec.rb +202 -0
  40. data/spec/spec_helper.rb +21 -0
  41. data/spec/ssh_key_spec.rb +355 -0
  42. metadata +280 -0
@@ -0,0 +1,284 @@
1
+ require 'tempfile'
2
+
3
+ module Gitolite
4
+
5
+ class Config
6
+
7
+ attr_accessor :repos, :groups, :filename
8
+
9
+ def self.init(filename = "gitolite.conf")
10
+ file = Tempfile.new(filename)
11
+ conf = self.new(file.path)
12
+ conf.filename = filename #kill suffix added by Tempfile
13
+ file.close(unlink_now = true)
14
+ conf
15
+ end
16
+
17
+
18
+ def initialize(config)
19
+ @repos = {}
20
+ @groups = {}
21
+ @filename = File.basename(config)
22
+ process_config(config)
23
+ end
24
+
25
+
26
+ # TODO: merge repo unless overwrite = true
27
+ def add_repo(repo, overwrite = false)
28
+ raise ArgumentError, "Repo must be of type Gitolite::Config::Repo!" unless repo.instance_of? Gitolite::Config::Repo
29
+ @repos[repo.name] = repo
30
+ end
31
+
32
+
33
+ def rm_repo(repo)
34
+ name = normalize_repo_name(repo)
35
+ @repos.delete(name)
36
+ end
37
+
38
+
39
+ def has_repo?(repo)
40
+ name = normalize_repo_name(repo)
41
+ @repos.has_key?(name)
42
+ end
43
+
44
+
45
+ def get_repo(repo)
46
+ name = normalize_repo_name(repo)
47
+ @repos[name]
48
+ end
49
+
50
+
51
+ def add_group(group, overwrite = false)
52
+ raise ArgumentError, "Group must be of type Gitolite::Config::Group!" unless group.instance_of? Gitolite::Config::Group
53
+ @groups[group.name] = group
54
+ end
55
+
56
+
57
+ def rm_group(group)
58
+ name = normalize_group_name(group)
59
+ @groups.delete(name)
60
+ end
61
+
62
+
63
+ def has_group?(group)
64
+ name = normalize_group_name(group)
65
+ @groups.has_key?(name)
66
+ end
67
+
68
+
69
+ def get_group(group)
70
+ name = normalize_group_name(group)
71
+ @groups[name]
72
+ end
73
+
74
+
75
+ def to_file(path=".", filename=@filename)
76
+ raise ArgumentError, "Path contains a filename or does not exist" unless File.directory?(path)
77
+
78
+ new_conf = File.join(path, filename)
79
+ File.open(new_conf, "w") do |f|
80
+ f.sync = true
81
+
82
+ # Output groups
83
+ dep_order = build_groups_depgraph
84
+ dep_order.each {|group| f.write group.to_s }
85
+
86
+ gitweb_descs = []
87
+ @repos.sort.each do |k, v|
88
+ f.write "\n"
89
+ f.write v.to_s
90
+
91
+ gwd = v.gitweb_description
92
+ gitweb_descs.push(gwd) unless gwd.nil?
93
+ end
94
+
95
+ f.write "\n"
96
+ f.write gitweb_descs.join("\n")
97
+ end
98
+
99
+ new_conf
100
+ end
101
+
102
+
103
+ private
104
+
105
+
106
+ # Based on
107
+ # https://github.com/sitaramc/gitolite/blob/pu/src/gl-compile-conf#cleanup_conf_line
108
+ def cleanup_config_line(line)
109
+ # remove comments, even those that happen inline
110
+ line.gsub!(/^((".*?"|[^#"])*)#.*/) {|m| m=$1}
111
+
112
+ # fix whitespace
113
+ line.gsub!('=', ' = ')
114
+ line.gsub!(/\s+/, ' ')
115
+ line.strip
116
+ end
117
+
118
+
119
+ def process_config(config)
120
+ context = [] #will store our context for permissions or config declarations
121
+
122
+ #Read each line of our config
123
+ File.open(config, 'r').each do |l|
124
+
125
+ line = cleanup_config_line(l)
126
+ next if line.empty? #lines are empty if we killed a comment
127
+
128
+ case line
129
+
130
+ # found a repo definition
131
+ when /^repo (.*)/
132
+ #Empty our current context
133
+ context = []
134
+
135
+ repos = $1.split
136
+ repos.each do |r|
137
+ context << r
138
+
139
+ @repos[r] = Repo.new(r) unless has_repo?(r)
140
+ end
141
+
142
+ # repo permissions
143
+ when /^(-|C|R|RW\+?(?:C?D?|D?C?)M?) (.* )?= (.+)/
144
+ perm = $1
145
+ refex = $2 || ""
146
+ users = $3.split
147
+
148
+ context.each do |c|
149
+ @repos[c].add_permission(perm, refex, users)
150
+ end
151
+
152
+ # repo git config
153
+ when /^config (.+) = ?(.*)/
154
+ key = $1
155
+ value = $2
156
+
157
+ context.each do |c|
158
+ @repos[c].set_git_config(key, value)
159
+ end
160
+
161
+ # repo gitolite option
162
+ when /^option (.+) = (.*)/
163
+ key = $1
164
+ value = $2
165
+
166
+ raise ParseError, "Missing gitolite option value for repo: #{repo} and key: #{key}" if value.nil?
167
+
168
+ context.each do |c|
169
+ @repos[c].set_gitolite_option(key, value)
170
+ end
171
+
172
+ # group definition
173
+ when /^#{Group::PREPEND_CHAR}(\S+) = ?(.*)/
174
+ group = $1
175
+ users = $2.split
176
+
177
+ @groups[group] = Group.new(group) unless has_group?(group)
178
+ @groups[group].add_users(users)
179
+
180
+ # gitweb definition
181
+ when /^(\S+)(?: "(.*?)")? = "(.*)"$/
182
+ repo = $1
183
+ owner = $2
184
+ description = $3
185
+
186
+ #Check for missing description
187
+ raise ParseError, "Missing Gitweb description for repo: #{repo}" if description.nil?
188
+
189
+ #Check for groups
190
+ raise ParseError, "Gitweb descriptions cannot be set for groups" if repo =~ /@.+/
191
+
192
+ if has_repo? repo
193
+ r = @repos[repo]
194
+ else
195
+ r = Repo.new(repo)
196
+ add_repo(r)
197
+ end
198
+
199
+ r.owner = owner
200
+ r.description = description
201
+
202
+ when /^include "(.+)"/
203
+ #TODO: implement includes
204
+ #ignore includes for now
205
+
206
+ when /^subconf (\S+)$/
207
+ #TODO: implement subconfs
208
+ #ignore subconfs for now
209
+
210
+ else
211
+ raise ParseError, "'#{line}' cannot be processed"
212
+ end
213
+ end
214
+ end
215
+
216
+
217
+ # Normalizes the various different input objects to Strings
218
+ def normalize_name(context, constant = nil)
219
+ case context
220
+ when constant
221
+ context.name
222
+ when Symbol
223
+ context.to_s
224
+ else
225
+ context
226
+ end
227
+ end
228
+
229
+
230
+ def method_missing(meth, *args, &block)
231
+ if meth.to_s =~ /normalize_(\w+)_name/
232
+ # Could use Object.const_get to figure out the constant here
233
+ # but for only two cases, this is more readable
234
+ case $1
235
+ when "repo"
236
+ normalize_name(args[0], Gitolite::Config::Repo)
237
+ when "group"
238
+ normalize_name(args[0], Gitolite::Config::Group)
239
+ end
240
+ else
241
+ super
242
+ end
243
+ end
244
+
245
+
246
+ # Builds a dependency tree from the groups in order to ensure all groups
247
+ # are defined before they are used
248
+ def build_groups_depgraph
249
+ dp = ::GRATR::Digraph.new
250
+
251
+ # Add each group to the graph
252
+ @groups.each_value do |group|
253
+ dp.add_vertex! group
254
+
255
+ # Select group names from the users
256
+ subgroups = group.users.select {|u| u =~ /^#{Group::PREPEND_CHAR}.*$/}.map{|g| get_group g.gsub(Group::PREPEND_CHAR, '') }
257
+
258
+ subgroups.each do |subgroup|
259
+ dp.add_edge! subgroup, group
260
+ end
261
+ end
262
+
263
+ # Figure out if we have a good depedency graph
264
+ dep_order = dp.topsort
265
+
266
+ if dep_order.empty?
267
+ raise GroupDependencyError unless @groups.empty?
268
+ end
269
+
270
+ dep_order
271
+ end
272
+
273
+
274
+ #Raised when something in a config fails to parse properly
275
+ class ParseError < RuntimeError
276
+ end
277
+
278
+
279
+ # Raised when group dependencies cannot be suitably resolved for output
280
+ class GroupDependencyError < RuntimeError
281
+ end
282
+
283
+ end
284
+ end
@@ -0,0 +1,32 @@
1
+ module Gitolite
2
+
3
+ # Very simple proxy object for checking if the proxied object was modified
4
+ # since the last clean_up! method called. It works correctly only for objects
5
+ # with proper hash method!
6
+
7
+ class DirtyProxy < BasicObject
8
+
9
+ def initialize(target)
10
+ @target = target
11
+ clean_up!
12
+ end
13
+
14
+ def method_missing(method, *args, &block)
15
+ @target.send(method, *args, &block)
16
+ end
17
+
18
+ def respond_to?(symbol, include_private=false)
19
+ super || [:dirty?, :clean_up!].include?(symbol.to_sym)
20
+ end
21
+
22
+ def dirty?
23
+ @clean_hash != @target.hash
24
+ end
25
+
26
+ def clean_up!
27
+ @clean_hash = @target.hash
28
+ end
29
+
30
+ end
31
+
32
+ end
@@ -0,0 +1,276 @@
1
+ module Gitolite
2
+ class GitoliteAdmin
3
+
4
+ attr_accessor :repo
5
+
6
+ CONF_DIR = "conf"
7
+ KEY_DIR = "keydir"
8
+
9
+ CONFIG_FILE = "gitolite.conf"
10
+ CONFIG_PATH = File.join(CONF_DIR, "gitolite.conf")
11
+
12
+
13
+ # Default settings
14
+ DEFAULT_SETTINGS = {
15
+ # clone/push url settings
16
+ git_user: 'git',
17
+ hostname: 'localhost',
18
+
19
+ # Commit settings
20
+ author_name: 'gitolite-rugged gem',
21
+ author_email: 'gitolite-rugged@localhost',
22
+ commit_msg: 'Commited by the gitolite-rugged gem'
23
+ }
24
+
25
+ class << self
26
+
27
+ # Checks if the given path is a gitolite-admin repository
28
+ # A valid repository contains a conf folder, keydir folder,
29
+ # and a configuration file within the conf folder
30
+ def is_gitolite_admin_repo?(dir)
31
+ # First check if it is a git repository
32
+ begin
33
+ repo = Rugged::Repository.new(dir)
34
+ return false if repo.empty?
35
+ rescue Rugged::RepositoryError
36
+ return false
37
+ end
38
+
39
+ # Check if config file, key directory exist
40
+ [ File.join(dir, CONF_DIR), File.join(dir, KEY_DIR),
41
+ File.join(dir, CONFIG_PATH)
42
+ ].each { |f| return false unless File.exists?(f) }
43
+
44
+ true
45
+ end
46
+
47
+ def admin_url(settings)
48
+ [settings[:git_user], '@', settings[:host], '/gitolite-admin.git'].join
49
+ end
50
+ end
51
+
52
+ # Intialize with the path to
53
+ # the gitolite-admin repository
54
+ #
55
+ # Settings:
56
+ # :git_user: The git user to SSH to (:git_user@localhost:gitolite-admin.git), defaults to 'git'
57
+ # :private_key: The key file containing the private SSH key for :git_user
58
+ # :public_key: The key file containing the public SSH key for :git_user
59
+ # :host: Hostname for clone url. Defaults to 'localhost'
60
+ # The settings hash is forwarded to +GitoliteAdmin.new+ as options.
61
+ def initialize(path, settings = {})
62
+ @path = path
63
+ @settings = DEFAULT_SETTINGS.merge(settings)
64
+
65
+ # Ensure SSH key settings exist
66
+ @settings.fetch(:public_key)
67
+ @settings.fetch(:private_key)
68
+
69
+ # setup credentials
70
+ @credentials = Rugged::Credentials::SshKey.new(
71
+ username: settings[:git_user], publickey: settings[:public_key],
72
+ privatekey: settings[:private_key] )
73
+
74
+ @repo =
75
+ if self.class.is_gitolite_admin_repo?(path)
76
+ Rugged::Repository.new(path)
77
+ else
78
+ clone
79
+ end
80
+
81
+ @config_file_path = File.join(@path, CONF_DIR, CONFIG_FILE)
82
+ @conf_dir_path = File.join(@path, CONF_DIR)
83
+ @key_dir_path = File.join(@path, KEY_DIR)
84
+
85
+ @commit_author = { email: settings[:author_email], name: settings[:author_name] }
86
+
87
+ reload!
88
+ end
89
+
90
+ def config
91
+ @config ||= load_config
92
+ end
93
+
94
+
95
+ def config=(config)
96
+ @config = config
97
+ end
98
+
99
+
100
+ def ssh_keys
101
+ @ssh_keys ||= load_keys
102
+ end
103
+
104
+
105
+ def add_key(key)
106
+ unless key.instance_of? Gitolite::SSHKey
107
+ raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
108
+ end
109
+
110
+ ssh_keys[key.owner] << key
111
+ end
112
+
113
+
114
+ def rm_key(key)
115
+ unless key.instance_of? Gitolite::SSHKey
116
+ raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
117
+ end
118
+
119
+ ssh_keys[key.owner].delete key
120
+ end
121
+
122
+
123
+ # This method will destroy all local tracked changes, resetting the local gitolite
124
+ # git repo to HEAD and reloading the entire repository
125
+ def reset!
126
+ @repo.reset('origin/master', :hard)
127
+ reload!
128
+ end
129
+
130
+
131
+ # This method will destroy the in-memory data structures and reload everything
132
+ # from the file system
133
+ def reload!
134
+ @ssh_keys = load_keys
135
+ @config = load_config
136
+ end
137
+
138
+
139
+ # Writes all changed aspects out to the file system
140
+ # will also stage all changes then commit
141
+ def save()
142
+
143
+ # Add all changes to index (staging area)
144
+ index = @repo.index
145
+
146
+ #Process config file (if loaded, i.e. may be modified)
147
+ if @config
148
+ new_conf = @config.to_file(@conf_dir_path)
149
+
150
+ # Rugged wants relative paths
151
+ index.add(CONFIG_PATH)
152
+ end
153
+
154
+ #Process ssh keys (if loaded, i.e. may be modified)
155
+ if @ssh_keys
156
+ files = list_keys.map{|f| File.basename f}
157
+ keys = @ssh_keys.values.map{|f| f.map {|t| t.filename}}.flatten
158
+
159
+ to_remove = (files - keys).map { |f| File.join(@key_dir, f) }
160
+ to_remove.each do |key|
161
+ File.unlink key
162
+ index.remove key
163
+ end
164
+
165
+ @ssh_keys.each_value do |key|
166
+ # Write only keys from sets that has been modified
167
+ next if key.respond_to?(:dirty?) && !key.dirty?
168
+ key.each do |k|
169
+ new_key = k.to_file(@key_dir_path)
170
+ index.add new_key
171
+ end
172
+ end
173
+ end
174
+
175
+ # Write index to git and resync fs
176
+ commit_tree = index.write_tree @repo
177
+ index.write
178
+
179
+ commit_author = { email: 'wee@example.org', name: 'gitolite-rugged gem', time: Time.now }
180
+
181
+ Rugged::Commit.create(@repo,
182
+ author: commit_author,
183
+ committer: commit_author,
184
+ message: @settings[:commit_msg],
185
+ parents: [repo.head.target],
186
+ tree: commit_tree,
187
+ update_ref: 'HEAD'
188
+ )
189
+ end
190
+
191
+
192
+ # Push back to origin
193
+ def apply
194
+ @repo.push 'origin', ['refs/heads/master']
195
+ end
196
+
197
+
198
+ # Commits all staged changes and pushes back to origin
199
+ def save_and_apply()
200
+ save
201
+ apply
202
+ end
203
+
204
+
205
+ # Updates the repo with changes from remote master
206
+ # Warning: This resets the repo before pulling in the changes.
207
+ def update(settings = {})
208
+ reset!
209
+
210
+ # Currently, this only supports merging origin/master into master.
211
+ master = repo.branches["master"].target
212
+ origin_master = repo.branches["origin/master"].target
213
+
214
+ # Create the merged index in memory
215
+ merge_index = repo.merge_commits(master, origin_master)
216
+
217
+ # Complete the merge by comitting it
218
+ merge_commit = Rugged::Commit.create(@repo,
219
+ parents: [ master, origin_master ],
220
+ tree: merge_index.write_tree(@repo),
221
+ message: '[gitolite-rugged] Merged `origin/master` into `master`',
222
+ author: @commit_author,
223
+ committer: @commit_author,
224
+ update_ref: 'master'
225
+ )
226
+
227
+ reload!
228
+ end
229
+
230
+
231
+ private
232
+
233
+
234
+ # Clone the gitolite-admin repo
235
+ # to the given path.
236
+ #
237
+ # The repo is cloned from the url
238
+ # +(:git_user)@(:hostname)/gitolite-admin.git+
239
+ #
240
+ # The hostname may use an optional :port to allow for custom SSH ports.
241
+ # E.g., +git@localhost:2222/gitolite-admin.git+
242
+ #
243
+ def clone()
244
+ Rugged::Repository.clone_at(admin_url(@settings), File.expand_path(@path), credentials: @creds)
245
+ end
246
+
247
+
248
+ def load_config
249
+ Config.new(@config_file_path)
250
+ end
251
+
252
+
253
+ def list_keys
254
+ Dir.glob(@key_dir_path + '/**/*.pub')
255
+ end
256
+
257
+
258
+ # Loads all .pub files in the gitolite-admin
259
+ # keydir directory
260
+ def load_keys
261
+ keys = Hash.new {|k,v| k[v] = DirtyProxy.new([])}
262
+
263
+ list_keys.each do |key|
264
+ new_key = SSHKey.from_file(key)
265
+ owner = new_key.owner
266
+
267
+ keys[owner] << new_key
268
+ end
269
+
270
+ # Mark key sets as unmodified (for dirty checking)
271
+ keys.values.each{|set| set.clean_up!}
272
+
273
+ keys
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,103 @@
1
+ module Gitolite
2
+
3
+ # Models an SSH key within gitolite
4
+ # provides support for multikeys
5
+ #
6
+ # Types of multi keys:
7
+ # bob.pub => username: bob
8
+ # bob@desktop.pub => username: bob, location: desktop
9
+ # bob@email.com.pub => username: bob@email.com
10
+ # bob@email.com@desktop.pub => username: bob@email.com, location: desktop
11
+
12
+ class SSHKey
13
+
14
+ attr_accessor :owner, :location, :type, :blob, :email
15
+
16
+ class << self
17
+
18
+ def from_file(key)
19
+ raise "#{key} does not exist!" unless File.exists?(key)
20
+
21
+ # TODO this is old-style locations, use folders instead.
22
+ # Get our owner and location
23
+ File.basename(key) =~ /^([\+\w\.-]+(?:@(?:[\w-]+\.)+\D{2,4})?)(?:@([\w-]+))?.pub$/i
24
+ owner = $1
25
+ location = $2 || ""
26
+
27
+ # Use string key constructor
28
+ self.from_string(File.read(key), owner, location)
29
+ end
30
+
31
+
32
+ # Construct a SSHKey from a string
33
+ def from_string(key_string, owner, location = "")
34
+ if owner.nil?
35
+ raise ArgumentError, "owner was nil, you must specify an owner"
36
+ end
37
+
38
+ # Get parts of the key
39
+ type, blob, email = key_string.split
40
+
41
+ # We need at least a type or blob
42
+ if type.nil? || blob.nil?
43
+ raise ArgumentError, "'#{key_string}' is not a valid SSH key string"
44
+ end
45
+
46
+ # If the key didn't have an email, just use the owner
47
+ if email.nil?
48
+ email = owner
49
+ end
50
+
51
+ self.new(type, blob, email, owner, location)
52
+ end
53
+
54
+ end
55
+
56
+
57
+ def initialize(type, blob, email, owner = nil, location = "")
58
+ @type = type
59
+ @blob = blob
60
+ @email = email
61
+
62
+ @owner = owner || email
63
+ @location = location
64
+ end
65
+
66
+
67
+ def to_s
68
+ [@type, @blob, @email].join(' ')
69
+ end
70
+
71
+
72
+ def to_file(path)
73
+ key_file = File.join(path, self.filename)
74
+ File.open(key_file, "w") do |f|
75
+ f.sync = true
76
+ f.write(self.to_s)
77
+ end
78
+ key_file
79
+ end
80
+
81
+
82
+ def filename
83
+ file = @owner
84
+ file += "@#{@location}" unless @location.empty?
85
+ file += ".pub"
86
+ end
87
+
88
+
89
+ def ==(key)
90
+ @type == key.type &&
91
+ @blob == key.blob &&
92
+ @email == key.email &&
93
+ @owner == key.owner &&
94
+ @location == key.location
95
+ end
96
+
97
+
98
+ def hash
99
+ [@owner, @location, @type, @blob, @email].hash
100
+ end
101
+
102
+ end
103
+ end
@@ -0,0 +1,3 @@
1
+ module Gitolite
2
+ VERSION = "1.2-devel"
3
+ end
data/lib/gitolite.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Gitolite
2
+ require 'rugged'
3
+ require 'gratr'
4
+ require 'gitolite/gitolite_admin'
5
+ require 'gitolite/dirty_proxy'
6
+ require 'gitolite/ssh_key'
7
+ require 'gitolite/config'
8
+ require 'gitolite/config/repo'
9
+ require 'gitolite/config/group'
10
+ end