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