gitmaild 9.82 KB
Newer Older
1
2
3
4
5
6
#!/usr/bin/perl -w

#
# gitmaild - simple gitmail wrapper for use with gitlab
# Note: *must* be run on the same host, and have permission to read the
# git repos in gitlab's home
Robert Ricci's avatar
Robert Ricci committed
7
8
# TODO: Support ignoring commits from repo you forked from
# TODO: Better error handling; try really hard not to die
9
10
11
12
13
14
#

use HTTP::Daemon;
use HTTP::Request;
use HTTP::Status;
use HTTP::Response;
15
use URI;
16
use LWP::UserAgent;
Robert Ricci's avatar
Robert Ricci committed
17
use IPC::Open3;
18
use Getopt::Long;
19
use JSON;
Robert Ricci's avatar
Robert Ricci committed
20
21
use Cwd 'abs_path';
use File::Basename;
22
use POSIX "setsid";
Robert Ricci's avatar
Robert Ricci committed
23

24
25
use strict;

26
27
#
# Options that can be passed in the URL
28
# (eg.  # http://localhost:4577/?foo=1&bar=1)
29
30
31
32
33
34
35
#
# mailmembers=1 : If set, automatically sends mail to all members of the
#                 project
# noarchive=1   : If set, does *not* send mail to the default archive list,
#                 and turns on mailmembers=1
#

Robert Ricci's avatar
Robert Ricci committed
36
#
37
38
39
# Settable options - these can be set here, or by passing a config file on the
# command line. This config file is simple a perl file that will be eval'ed, so
# just set variables there as you would here, they will override the defaults
Robert Ricci's avatar
Robert Ricci committed
40
41
#

42
43
44
45
46
47
48
# The only mandatory option: Token to use when calling gitmail API - should
# belong to an administrator
my $TOKEN = "";

# Unless specified, send mail to this email address on all commits
my $DEFAULT_ARCHIVEMAIL = 'commits@flux.utah.edu';

Robert Ricci's avatar
Robert Ricci committed
49
50
# Path to gitmail - defaults to same directory as gmaild lives in
my $GITMAIL = dirname(abs_path($0)) . "/gitmail";
51

Robert Ricci's avatar
Robert Ricci committed
52
53
# Port to run on - made up crap
my $PORT = 4577;
54

Robert Ricci's avatar
Robert Ricci committed
55
# URL used to make gitlab API calls
56
my $BASEURL = "https://gitlab.flux.utah.edu/";
Robert Ricci's avatar
Robert Ricci committed
57
58
59

# Path where gitlab repos live in the FS - doesn't seem available through
# the gitlab API
60
my $BASEPATH = "/home/git/repositories";
Robert Ricci's avatar
Robert Ricci committed
61

62
63
64
# Path where logging info will be sent
my $LOGFILE = "/home/git/gitmaild.log";

65
66
67
68
# If set, send to members, not archivemail, by default - ie. act as if 
# noarchive=1 was passed in the URL
my $NOARCHIVE_DEFAULT = 0;

69
70
71
72
# If set, aways mail the following address (see gitmail for difference between
# alwaysmail and archivemail)
my $ALWAYSMAIL_ADDRESS = "";

Robert Ricci's avatar
Robert Ricci committed
73
74
75
76
#
# End settable options
#

77
78
79
80
#
# Command line options
#
my $verbose = 0;
81
my $debug   = 0;
82
my $configfile = undef;
83

84
my $result = GetOptions("v" => \$verbose, "d" => \$debug, "c:s" => \$configfile);
85

86
sub run_gitmail($$);
Robert Ricci's avatar
Robert Ricci committed
87
88
sub format_options(@);
sub call_gitlab_api($);
89
sub get_member_addresses($);
90

91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#
# Parse config file if given
#
if ($configfile) {
    open CF, "<$configfile" || die "Unable to open $configfile: $!\n";
    my $configdata = "";
    while (my $data = <CF>) {
        $configdata .= $data;
    }
    close CF;
    if (!defined(eval $configdata)) {
        die "Error in $configfile: $!\n";
    }
}

#
# Make sure they gave a key, ain't gonna work without one
#
if ($TOKEN eq "") {
    die "Must set a \$TOKEN!\n";
}

Robert Ricci's avatar
Robert Ricci committed
113
114
115
116
117
118
119
#
# Open up a new socket - runs only on localhost, this thing is not nearly
# secure enough to be open to the world
#
my $listen_socket = HTTP::Daemon->new(LocalAddr => 'localhost',
                                      LocalPort => $PORT) || die;
print "gitmaild running at: ", $listen_socket->url, "\n";
120

121
122
123
# This causes children to be auto-reaped
$SIG{CHLD}="IGNORE";

124
125
126
127
# Daemonize
if (!$debug) {
    chdir("/")                      || die "can't chdir to /: $!";
    open(STDIN,  "< /dev/null")     || die "can't read /dev/null: $!";
128
    open(STDOUT, ">> $LOGFILE")      || die "can't write to $LOGFILE: $!";
129
130
131
132
133
134
    defined(my $pid = fork())       || die "can't fork: $!";
    exit if $pid;                   # non-zero now means I am the parent
    (setsid() != -1)                || die "Can't start a new session: $!";
    open(STDERR, ">&STDOUT")        || die "can't dup stdout: $!";
}

135
136
137
138
139
140
141
#
# Main loop - pretty simple!
#
while (my $connection = $listen_socket->accept()) {

    while (my $request = $connection->get_request()) {
        if ($verbose) {
142
            print "\n" . "="x80 . "\n";
143
            print $request->as_string();
144
            print "\n" . "="x80 . "\n";
145
146
        }
        # Fork off child
147
148
149
        if (fork()) {
            # Parent
        } else {
150
151
152
153
154
155
156
157
158
159
            #
            # Pull out variables passed in the GET request
            #
            my %get_vars = $request->uri->query_form;
            if ($verbose) {
                print "Request URL is " . $request->uri. "\n";
                print "Query part is " . $request->uri->query . "\n";
                print "GET vars are: " . join(",",keys %get_vars) . "\n";
            }

160
            # Child
Robert Ricci's avatar
Robert Ricci committed
161
162
163
164
165
166
167
168
169
170
171
            my $rv = run_gitmail(decode_json($request->content()),\%get_vars);

            if ($rv == 0) {
                $connection->send_error(RC_INTERNAL_SERVER_ERROR);
                $connection->close();
                exit 1;
            } else {
                # This means it worked, but we are not going to return any
                # content to the caller
                $connection->send_status_line(RC_NO_CONTENT);
                $connection->close();
Robert Ricci's avatar
Robert Ricci committed
172
                exit 0;
Robert Ricci's avatar
Robert Ricci committed
173
            }
174
        }
175
    }
176
177
178
179

    # Both are necessary to make sure the connection is really closed
    $connection->close();
    undef($connection);
180
181
182

}

183
184
185
186
#
# Actually run gitmail, using the data that we pulled out of the JSON that
# was passed to us
#
187
188
sub run_gitmail($$) {
    my ($data,$get_vars) = @_;
189
190
191
192

    if ($verbose) {
        print "Running gitmail\n";
    }
193

194
    # TODO: Error handling (or at least reporting)
195

196
197
198
199
    #
    # Get information about the user so that we can set, eg., 'from'
    # appropriately
    #
200
201
    my $userinfo = call_gitlab_api("/users/" . $data->{"user_id"});

Robert Ricci's avatar
Robert Ricci committed
202
    #
203
204
    # Get information about the project, so that we know where the repo lives,
    # etc.
Robert Ricci's avatar
Robert Ricci committed
205
206
207
    # NB: This depends on the Utah patch to gitlab that adds the project ID
    # to the data in the hook
    #
208
    my $repoinfo = call_gitlab_api("/projects/" . $data->{"project_id"});
209

Robert Ricci's avatar
Robert Ricci committed
210
211
212
213
214
215
216
    #
    # Change to the directory where the repo lives - this is the simplest way
    # to make all tools happy
    #
    my $repodir = $BASEPATH . "/" . $repoinfo->{"path_with_namespace"} . ".git";
    chdir $repodir;

217
218
219
    #
    # Build up options that we'll pass to gitmail
    #
Robert Ricci's avatar
Robert Ricci committed
220
221
    my %options;

222
    # Address to always send mail to
223
    if ((!exists $get_vars->{'noarchive'}) && (!$NOARCHIVE_DEFAULT)) {
224
225
        $options{'archivemail'} = $DEFAULT_ARCHIVEMAIL;
    }
226
227

    # Who the mail comes from - user doing the push
Robert Ricci's avatar
Robert Ricci committed
228
    $options{'mailfrom'} = $userinfo->{name} . " <" . $userinfo->{email} . ">";
229
230

    # Name of the repo
231
    $options{'reponame'} = $repoinfo->{path_with_namespace};
232
233

    # URLs to push/pull from
234
235
236
237
    $options{'sshcloneurl'} = $repoinfo->{ssh_url_to_repo};
    if (exists $repoinfo->{http_url_to_repo}) {
        $options{'rocloneurl'} = $repoinfo->{http_url_to_repo};
    }
238

239
    # If requested, send mail to everyone who's listed on the project
240
241
    if (exists $get_vars->{'mailmembers'} || exists $get_vars->{'noarchive'} ||
        $NOARCHIVE_DEFAULT) {
242
        $options{'alwaysmail'} =
243
            get_member_addresses($data->{"project_id"});
244
    }
245
246
247
248
249
250
251
252
    if (defined($ALWAYSMAIL_ADDRESS) && $ALWAYSMAIL_ADDRESS ne "") {
        if (exists($options{'alwaysmail'})) {
            push @{$options{'alwaysmail'}}, $ALWAYSMAIL_ADDRESS;
        } else {
            $options{'alwaysmail'} = $ALWAYSMAIL_ADDRESS;
        }
    }

253

254
    # gitlab's URL for this project
255
    $options{'weburl'} = $repoinfo->{web_url};
Robert Ricci's avatar
Robert Ricci committed
256

257
    # Turn all of this into a string to pass on the command line
Robert Ricci's avatar
Robert Ricci committed
258
    my $optionstr = format_options(%options);
259
260
261
    if ($verbose) {
        print "optionstr is '$optionstr'\n";
    }
Robert Ricci's avatar
Robert Ricci committed
262

263
264
265
266
267
268
269
270
271
272
    # 
    # Build up a refline that makes it look like gitmail got called
    # as a regular post-recieve hook
    #
    my $refline = $data->{"before"} . " " . $data->{"after"} . " " .
        $data->{"ref"};
    if ($verbose) {
        print $refline . "\n";
    }

Robert Ricci's avatar
Robert Ricci committed
273
    #
274
    # Actually run gitmail
Robert Ricci's avatar
Robert Ricci committed
275
    #
Robert Ricci's avatar
Robert Ricci committed
276
277
278
279
280
281
282
283
284
285
    my $pid = open3(\*CHILD_IN, \*CHILD_OUT, \*CHILD_ERR,
                "$GITMAIL $optionstr");

    #
    # Pass along the commit information we were given, in the form expected
    # by a git commit hook
    #
    print CHILD_IN $refline . "\n";
    close CHILD_IN;

286
287
288
    #
    # Wait for the child to finish
    #
Robert Ricci's avatar
Robert Ricci committed
289
290
291
    waitpid $pid,0;
    print STDOUT <CHILD_OUT>;
    print STDOUT <CHILD_ERR>;
292

293
294
295
    #
    # Done!
    #
Robert Ricci's avatar
Robert Ricci committed
296
    return 1;
297

298
299
}

Robert Ricci's avatar
Robert Ricci committed
300
301
302
303
#
# Call the function given in the argument, and put the JSON result into a
# perl hash
#
304
305
306
# TODO: Error checking
sub call_gitlab_api($) {
    my ($call) = @_;
Robert Ricci's avatar
Robert Ricci committed
307
308

    # Hardcode API v3 for now
309
310
    my $url = $BASEURL . "api/v3" . $call . "?private_token=" . $TOKEN;

311
312
    print "Calling '$url'\n";

Robert Ricci's avatar
Robert Ricci committed
313
    # Super simple, make the call
314
315
    my $request = HTTP::Request->new(GET => $url);
    my $ua = LWP::UserAgent->new;
316
317
318
    # Hack to make this work even if one has a self-signed cert, a cert signed
    # by a less well known authority, etc.
    $ua->ssl_opts( verify_hostnames => 0 );
319
    my $response = $ua->request($request);
320
    if ($verbose) {
321
322
323
        print "\n" . "="x80 . "\n";
        print $response->as_string . "\n";
        print "\n" . "="x80 . "\n";
324
    }
Robert Ricci's avatar
Robert Ricci committed
325
326

    # TODO: Error checking
327
328
    return decode_json($response->content);
}
Robert Ricci's avatar
Robert Ricci committed
329
330
331
332
333
334
335

#
# Re-format a hash as a string sutable for passing to gitmail
# TODO: proper command line escaping
#
sub format_options(@) {
    my %opt = @_;
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
    # This is some evil-wizard level shit right here
    return join(" ",map {
        if (ref($opt{$_}) eq "ARRAY") {
            my @vals = @{$opt{$_}};
            my $name = $_;
            join(" ", map { "-o " . $name . "='$_'" } @vals)
        } else {
            "-o " . $_ . "='$opt{$_}'"
        }
    } keys %opt);
}

#
# Get email addresses for all memebers of the given project
#
sub get_member_addresses($) {
    my ($repo_id) = @_;
    my $memberinfo = call_gitlab_api("/projects/" . $repo_id . "/members");

    my @addresses;
    foreach my $member (@$memberinfo) {
        my $address = $member->{'email'};
        if ($verbose) {
            print "Member: $address\n";
        }
        push @addresses, $address;
    }

    return \@addresses;
Robert Ricci's avatar
Robert Ricci committed
365
}