Add ud-sync-accounts-to-afs, a script to sync accounts to an AFS protection database
[mirror/userdir-ldap.git] / ud-sync-accounts-to-afs
1 #!/usr/bin/python
2
3 # Copyright (c) 2010 Peter Palfrader
4
5 # reads a list of active accounts from /var/lib/misc/thishost/all-accounts.json
6 # and creates those accounts in AFS's protection database.
7 # Furthermore it creates per-user scratch directories in
8 # /afs/debian.org/scratch/eu/grnet (or whatever path is specified in a command
9 # line option), owned by that user.
10
11 import optparse
12 import os
13 import os.path
14 import pwd
15 import re
16 import subprocess
17 import sys
18 import tempfile
19
20
21 import json
22 if not '__author__' in json.__dict__:
23    sys.stderr.write("Warning: This is probably the wrong json module.  We want python 2.6's json\n")
24    sys.stderr.write("module, or simplejson on python 2.5.  Let's see if/how stuff blows up.\n")
25    import simplejson as json
26
27 class UserEntries:
28    def __init__(self):
29       self.entries = []
30       self.by_name = {}
31       self.by_id = {}
32
33    def append(self, name, idnumber, owner=None, creator=None):
34       if name in self.by_name: raise Exception("Name '%s' is not unique."%(name))
35       if idnumber in self.by_id: raise Exception("ID '%d' is not unique."%(idnumber))
36
37       h = { 'name': name, 'id': idnumber, 'owner': owner, 'creator': creator }
38       self.entries.append( h )
39       self.by_name[name] = h
40       self.by_id[idnumber] = h
41
42    def del_id(self, i):
43       h = self.by_id[i]
44       del self.by_id[i]
45       del self.by_name[h['name']]
46       self.entries.remove(h)
47
48    def del_name(self, n):
49       self.del_id(self.by_name[n]['id'])
50
51 def load_expected():
52    accountsfile = '/var/lib/misc/thishost/all-accounts.json'
53
54    if not os.path.isfile(accountsfile):
55       print >> sys.stderr, "Accountsfile %s not found."%(accountsfile)
56    accounts_json = open(accountsfile, 'r').read()
57    accounts = json.loads(accounts_json)
58
59    entries = UserEntries()
60    for a in accounts:
61       if a['active']:
62          entries.append(a['uid'], a['uidNumber'])
63    return entries
64
65 def load_existing():
66    entries = UserEntries()
67    l = subprocess.Popen(('pts', 'listentries', '-users'), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None)
68    l.stdin.close()
69    l.stdout.readline() # headers
70    # Name                          ID  Owner Creator
71    for line in l.stdout:
72       line = line.strip()
73       m = re.match('([0-9a-z.-]+) +(\d+) +(-?\d+) +(-?\d+)$', line)
74       if m is None:
75          raise Exception("Cannot parse pts listentries line '%s'."%(line))
76       (name, afsid, owner, creator) = m.groups()
77       entries.append(name, int(afsid), int(owner), int(creator))
78    l.wait()
79    exitcode = l.returncode
80    if not exitcode == 0:
81       raise Exception("pts listentries -users exited with non-zero exit code %d."%(exitcode))
82    return entries
83
84 class Krb:
85    def __init__(self, keytab, principal):
86       (fd_dummy, self.ccachefile) = tempfile.mkstemp(prefix='krb5cc')
87       os.environ['KRB5CCNAME'] = self.ccachefile
88       self.kinit(keytab, principal)
89
90    def kinit(self, keytab, principal):
91       subprocess.check_call( ('kinit', '-t', keytab, principal) )
92    def klist(self):
93       subprocess.check_call( ('klist') )
94    def kdestroy(self):
95       if os.path.exists(self.ccachefile):
96          subprocess.check_call( ('kdestroy') )
97       if os.path.exists(self.ccachefile):
98          os.unlink(self.ccachefile)
99
100 def filter_common(a, b):
101    ids = a.by_id.keys()
102    for i in ids:
103       if i in b.by_id:
104          if a.by_id[i]['name'] == b.by_id[i]['name']:
105             #print "Common: %s (%d)"%(a.by_id[i]['name'], i)
106             a.del_id(i)
107             b.del_id(i)
108          else:
109             print >> sys.stderr, Excetption("ID %d has different names in our two databases ('%s' vs. '%s')."%(i, a.by_id[i]['name'], b.by_id[i]['name']))
110             sys.exit(1)
111
112    # just make sure there are not same names on both sides
113    # but with differend uids:
114    for n in a.by_name:
115       if n in b.by_name:
116          print >> sys.stderr, Excetption("Name %n has different IDs in our two databases ('%d' vs. '%d')."%(n, a.by_name[n]['id'], b.by_name[n]['id']))
117          sys.exit(1)
118
119 def filter_have(a):
120    # removing from the list means we keep the account and
121    # do not delete it later on.
122    names = a.by_name.keys()
123    for n in names:
124       if n == 'anonymous': # keep account, so remove from the have list
125          a.del_name(n)
126          continue
127       m = re.match('[0-9a-z]+$', n)
128       if not m: # weird name, probably has dots like weasel.admin etc.
129          a.del_name(n)
130          continue
131
132 def remove_extra(have, ifownedby):
133    for name in have.by_name:
134       if have.by_name[name]['creator'] == ifownedby:
135          subprocess.check_call( ('pts', 'delete', name) )
136          print "Deleted user %s(%d)."%(name, have.by_name[name]['id'])
137       else:
138          print >> sys.stderr, "Did not delete %s because it was not created by me(%d) but by %d."%(name, ifownedby, have.by_name[name]['creator'])
139
140 def add_new(want):
141    #for name in want.by_name:
142    #   subprocess.check_call( ('pts', 'createuser', '-name', name, '-id', '%d'%(want.by_name[name]['id'])) )
143    #   print "Added user %s(%d)."%(name, want.by_name[name]['id'])
144    names = []
145    ids = []
146    for name in want.by_name:
147       names.append(name)
148       ids.append('%d'%(want.by_name[name]['id']))
149
150    if len(names) == 0: return
151
152    args = ['pts', 'createuser']
153    for n in names:
154       args.append('-name')
155       args.append(n)
156    for i in ids:
157       args.append('-id')
158       args.append(i)
159    subprocess.check_call(args)
160
161
162 def do_accounts():
163    want = load_expected()
164    have = load_existing()
165
166    if not options.user in have.by_name:
167       print >> sys.stderr, "Cannot find our user, '%s', in pts listentries"%(options.user)
168       sys.exit(1)
169    me = have.by_name[options.user]
170
171    filter_common(have, want)
172    filter_have(have)
173    # just for the sake of it, make sure 'want' does not have weird names either.
174    # this gets rid of a few accounts with underscores in them, like buildd_$ARCH
175    # but we might not care about them in AFS anyway
176    filter_have(want)
177
178    remove_extra(have, me['id'])
179    add_new(want)
180
181    created_some = len(want.by_id) > 0
182    return created_some
183
184 def do_scratchdir(d):
185    have = load_existing()
186    filter_have(have)
187
188    if not os.path.isdir(d):
189       print >> sys.stderr, "Path '%s' is not a directory"%(d)
190
191    for n in have.by_name:
192       tree = ( n[0], n[0:2] )
193
194       p = d
195       for t in tree:
196          p = os.path.join(p, t)
197          if not os.path.exists(p): os.mkdir(p)
198
199       p = os.path.join(p, n)
200       if os.path.exists(p): continue
201
202       print "Making directory %s"%(p)
203       os.mkdir(p)
204       subprocess.check_call(('fs', 'sa', '-dir', p, '-acl', n, 'all'))
205
206
207 parser = optparse.OptionParser()
208 parser.add_option("-p", "--principal", dest="principal", metavar="name",
209   help="Principal to auth as")
210 parser.add_option("-k", "--keytab", dest="keytab", metavar="file",
211   help="keytab file location")
212 parser.add_option("-P", "--PAGed", action="store_true",
213   help="already running in own PAG")
214 parser.add_option("-s", "--self", dest="user", metavar="ownafsuser",
215   help="This principal's AFS user")
216 parser.add_option("-d", "--dir", dest="scratchdir", action="append",
217   help="scratchdir to create directories in.")
218 parser.add_option("-D", "--dircheck-force", dest="dircheck", action="store_true", default=False,
219   help="Check if all user scratch dirs exist even if no new users were created")
220
221 (options, args) = parser.parse_args()
222 if len(args) > 0:
223    parser.print_help()
224    sys.exit(1)
225
226 if not options.PAGed:
227    #print >> sys.stderr, "running self in new PAG"
228    os.execlp('pagsh', 'pagsh', '-c', ' '.join(sys.argv)+" -P")
229
230 if options.principal is None:
231    options.principal = "%s/admin"%( pwd.getpwuid(os.getuid())[0] )
232 if options.keytab is None:
233    options.keytab = "/etc/userdir-ldap/keytab.%s"%(pwd.getpwuid(os.getuid())[0] )
234 if options.user is None:
235    options.user = options.principal.replace('/', '.')
236 if options.scratchdir is None:
237    options.scratchdir = ['/afs/debian.org/scratch/eu/grnet']
238
239 k = None
240 try:
241    k = Krb(options.keytab, options.principal)
242    subprocess.check_call( ('aklog') )
243    #k.klist()
244    #subprocess.check_call( ('tokens') )
245
246    created_some = do_accounts()
247    if created_some or options.dircheck:
248       for d in options.scratchdir:
249          do_scratchdir(d)
250 finally:
251    try:
252       subprocess.check_call( ('unlog') )
253    except Exception, e:
254       print >> sys.stderr, "During unlog: %s"%(e)
255       pass
256    if k is not None: k.kdestroy()
257
258
259 # vim:set et:
260 # vim:set ts=3:
261 # vim:set shiftwidth=3: