21059: Testing email notification WIP
[arvados.git] / services / api / script / arvados-git-sync.rb
1 #!/usr/bin/env ruby
2 # Copyright (C) The Arvados Authors. All rights reserved.
3 #
4 # SPDX-License-Identifier: AGPL-3.0
5
6 require 'rubygems'
7 require 'pp'
8 require 'arvados'
9 require 'tempfile'
10 require 'yaml'
11 require 'fileutils'
12
13 # This script does the actual gitolite config management on disk.
14 #
15 # Ward Vandewege <ward@curii.com>
16
17 # Default is development
18 production = ARGV[0] == "production"
19
20 ENV["RAILS_ENV"] = "development"
21 ENV["RAILS_ENV"] = "production" if production
22
23 DEBUG = 1
24
25 # load and merge in the environment-specific application config info
26 # if present, overriding base config parameters as specified
27 path = File.absolute_path('../../config/arvados-clients.yml', __FILE__)
28 if File.exist?(path) then
29   cp_config = File.open(path) do |f|
30     YAML.safe_load(f, filename: path)[ENV['RAILS_ENV']]
31   end
32 else
33   puts "Please create a\n #{path}\n file"
34   exit 1
35 end
36
37 gitolite_url = cp_config['gitolite_url']
38 gitolite_arvados_git_user_key = cp_config['gitolite_arvados_git_user_key']
39
40 gitolite_tmpdir = cp_config['gitolite_tmp']
41 gitolite_admin = File.join(gitolite_tmpdir, 'gitolite-admin')
42 gitolite_admin_keydir = File.join(gitolite_admin, 'keydir')
43 gitolite_keydir = File.join(gitolite_admin, 'keydir', 'arvados')
44
45 ENV['ARVADOS_API_HOST'] = cp_config['arvados_api_host']
46 ENV['ARVADOS_API_TOKEN'] = cp_config['arvados_api_token']
47 if cp_config['arvados_api_host_insecure']
48   ENV['ARVADOS_API_HOST_INSECURE'] = 'true'
49 else
50   ENV.delete('ARVADOS_API_HOST_INSECURE')
51 end
52
53 def ensure_directory(path, mode)
54   begin
55     Dir.mkdir(path, mode)
56   rescue Errno::EEXIST
57   end
58 end
59
60 def replace_file(path, contents)
61   unlink_now = true
62   dirname, basename = File.split(path)
63   FileUtils.mkpath(dirname)
64   new_file = Tempfile.new([basename, ".tmp"], dirname)
65   begin
66     new_file.write(contents)
67     new_file.flush
68     File.rename(new_file, path)
69     unlink_now = false
70   ensure
71     new_file.close(unlink_now)
72   end
73 end
74
75 def file_has_contents?(path, contents)
76   begin
77     IO.read(path) == contents
78   rescue Errno::ENOENT
79     false
80   end
81 end
82
83 module TrackCommitState
84   module ClassMethods
85     # Note that all classes that include TrackCommitState will have
86     # @@need_commit = true if any of them set it.  Since this flag reports
87     # a boolean state of the underlying git repository, that's OK in the
88     # current implementation.
89     @@need_commit = false
90
91     def changed?
92       @@need_commit
93     end
94
95     def ensure_in_git(path, contents)
96       unless file_has_contents?(path, contents)
97         replace_file(path, contents)
98         system("git", "add", path)
99         @@need_commit = true
100       end
101     end
102   end
103
104   def ensure_in_git(path, contents)
105     self.class.ensure_in_git(path, contents)
106   end
107
108   def self.included(base)
109     base.extend(ClassMethods)
110   end
111 end
112
113 class UserSSHKeys
114   include TrackCommitState
115
116   def initialize(user_keys_map, key_dir)
117     @user_keys_map = user_keys_map
118     @key_dir = key_dir
119     @installed = {}
120   end
121
122   def install(filename, pubkey)
123     unless pubkey.nil?
124       key_path = File.join(@key_dir, filename)
125       ensure_in_git(key_path, pubkey)
126     end
127     @installed[filename] = true
128   end
129
130   def ensure_keys_for_user(user_uuid)
131     return unless key_list = @user_keys_map.delete(user_uuid)
132     key_list.map { |k| k[:public_key] }.compact.each_with_index do |pubkey, ii|
133       # Handle putty-style ssh public keys
134       pubkey.sub!(/^(Comment: "r[^\n]*\n)(.*)$/m,'ssh-rsa \2 \1')
135       pubkey.sub!(/^(Comment: "d[^\n]*\n)(.*)$/m,'ssh-dss \2 \1')
136       pubkey.gsub!(/\n/,'')
137       pubkey.strip!
138       install("#{user_uuid}@#{ii}.pub", pubkey)
139     end
140   end
141
142   def installed?(filename)
143     @installed[filename]
144   end
145 end
146
147 class Repository
148   include TrackCommitState
149
150   @@aliases = {}
151
152   def initialize(arv_repo, user_keys)
153     @arv_repo = arv_repo
154     @user_keys = user_keys
155   end
156
157   def self.ensure_system_config(conf_root)
158     ensure_in_git(File.join(conf_root, "conf", "gitolite.conf"),
159                   %Q{include "auto/*.conf"\ninclude "admin/*.conf"\n})
160     ensure_in_git(File.join(conf_root, "arvadosaliases.pl"), alias_config)
161
162     conf_path = File.join(conf_root, "conf", "admin", "arvados.conf")
163     conf_file = %Q{
164 @arvados_git_user = arvados_git_user
165
166 repo gitolite-admin
167      RW           = @arvados_git_user
168
169 }
170     ensure_directory(File.dirname(conf_path), 0755)
171     ensure_in_git(conf_path, conf_file)
172   end
173
174   def ensure_config(conf_root)
175     if name and (File.exist?(auto_conf_path(conf_root, name)))
176       # This gitolite installation knows the repository by name, rather than
177       # UUID.  Leave it configured that way until a separate migration is run.
178       basename = name
179     else
180       basename = uuid
181       @@aliases[name] = uuid unless name.nil?
182     end
183     conf_file = "\nrepo #{basename}\n"
184     @arv_repo[:user_permissions].sort.each do |user_uuid, perm|
185       conf_file += "\t#{perm[:gitolite_permissions]}\t= #{user_uuid}\n"
186       @user_keys.ensure_keys_for_user(user_uuid)
187     end
188     ensure_in_git(auto_conf_path(conf_root, basename), conf_file)
189   end
190
191   private
192
193   def auto_conf_path(conf_root, basename)
194     File.join(conf_root, "conf", "auto", "#{basename}.conf")
195   end
196
197   def uuid
198     @arv_repo[:uuid]
199   end
200
201   def name
202     if @arv_repo[:name].nil?
203       nil
204     else
205       @clean_name ||=
206         @arv_repo[:name].sub(/^[^A-Za-z]+/, "").gsub(/[^\w\.\/]/, "")
207     end
208   end
209
210   def self.alias_config
211     conf_s = "{\n"
212     @@aliases.sort.each do |(repo_name, repo_uuid)|
213       conf_s += "\t'#{repo_name}' \t=> '#{repo_uuid}',\n"
214     end
215     conf_s += "};\n"
216     conf_s
217   end
218 end
219
220 begin
221   # Get our local gitolite-admin repo up to snuff
222   if not File.exist?(gitolite_admin) then
223     ensure_directory(gitolite_tmpdir, 0700)
224     Dir.chdir(gitolite_tmpdir)
225     `git clone #{gitolite_url}`
226     Dir.chdir(gitolite_admin)
227   else
228     Dir.chdir(gitolite_admin)
229     `git pull`
230   end
231
232   arv = Arvados.new
233   permissions = arv.repository.get_all_permissions
234
235   ensure_directory(gitolite_keydir, 0700)
236   admin_user_ssh_keys = UserSSHKeys.new(permissions[:user_keys], gitolite_admin_keydir)
237   # Make sure the arvados_git_user key is installed; put it in gitolite_admin_keydir
238   # because that is where gitolite will try to put it if we do not.
239   admin_user_ssh_keys.install('arvados_git_user.pub', gitolite_arvados_git_user_key)
240
241   user_ssh_keys = UserSSHKeys.new(permissions[:user_keys], gitolite_keydir)
242   permissions[:repositories].each do |repo_record|
243     repo = Repository.new(repo_record, user_ssh_keys)
244     repo.ensure_config(gitolite_admin)
245   end
246   Repository.ensure_system_config(gitolite_admin)
247
248   # Clean up public key files that should not be present
249   Dir.chdir(gitolite_keydir)
250   stale_keys = Dir.glob('*.pub').reject do |key_file|
251     user_ssh_keys.installed?(key_file)
252   end
253   if stale_keys.any?
254     stale_keys.each { |key_file| puts "Extra file #{key_file}" }
255     system("git", "rm", "--quiet", *stale_keys)
256   end
257
258   if UserSSHKeys.changed? or Repository.changed? or stale_keys.any?
259     message = "#{Time.now().to_s}: update from API"
260     Dir.chdir(gitolite_admin)
261     `git add --all`
262     `git commit -m '#{message}'`
263     `git push`
264   end
265
266 rescue => bang
267   puts "Error: " + bang.to_s
268   puts bang.backtrace.join("\n")
269   exit 1
270 end
271