gitmaild 9.58 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://git.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
65
# If set, send to members, not archivemail, by default - ie. act as if 
# noarchive=1 was passed in the URL
my $NOARCHIVE_DEFAULT = 0;

66
67
68
69
# 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
70
71
72
73
#
# End settable options
#

74
75
76
77
#
# Command line options
#
my $verbose = 0;
78
my $debug   = 0;
79
my $configfile = undef;
80

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

83
sub run_gitmail($$);
Robert Ricci's avatar
Robert Ricci committed
84
85
sub format_options(@);
sub call_gitlab_api($);
86
sub get_member_addresses($);
87

88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#
# 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
110
111
112
113
114
115
116
#
# 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";
117

118
119
120
# This causes children to be auto-reaped
$SIG{CHLD}="IGNORE";

121
122
123
124
125
126
127
128
129
130
131
# Daemonize
if (!$debug) {
    chdir("/")                      || die "can't chdir to /: $!";
    open(STDIN,  "< /dev/null")     || die "can't read /dev/null: $!";
    open(STDOUT, "> /dev/null")     || die "can't write to /dev/null: $!";
    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: $!";
}

132
133
134
135
136
137
138
#
# Main loop - pretty simple!
#
while (my $connection = $listen_socket->accept()) {

    while (my $request = $connection->get_request()) {
        if ($verbose) {
139
            print "\n" . "="x80 . "\n";
140
            print $request->as_string();
141
            print "\n" . "="x80 . "\n";
142
143
        }
        # Fork off child
144
145
146
        if (fork()) {
            # Parent
        } else {
147
148
149
150
151
152
153
154
155
156
            #
            # 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";
            }

157
            # Child
Robert Ricci's avatar
Robert Ricci committed
158
159
160
161
162
163
164
165
166
167
168
            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
169
                exit 0;
Robert Ricci's avatar
Robert Ricci committed
170
            }
171
        }
172
    }
173
174
175
176

    # Both are necessary to make sure the connection is really closed
    $connection->close();
    undef($connection);
177
178
179

}

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

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

191
    # TODO: Error handling (or at least reporting)
192

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

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

Robert Ricci's avatar
Robert Ricci committed
207
208
209
210
211
212
213
    #
    # 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;

214
215
216
    #
    # Build up options that we'll pass to gitmail
    #
Robert Ricci's avatar
Robert Ricci committed
217
218
    my %options;

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

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

    # Name of the repo
228
    $options{'reponame'} = $repoinfo->{path_with_namespace};
229
230

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

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

250

251
    # gitlab's URL for this project
252
    $options{'weburl'} = $repoinfo->{web_url};
Robert Ricci's avatar
Robert Ricci committed
253

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

260
261
262
263
264
265
266
267
268
269
    # 
    # 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
270
    #
271
    # Actually run gitmail
Robert Ricci's avatar
Robert Ricci committed
272
    #
Robert Ricci's avatar
Robert Ricci committed
273
274
275
276
277
278
279
280
281
282
    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;

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

290
291
292
    #
    # Done!
    #
Robert Ricci's avatar
Robert Ricci committed
293
    return 1;
294

295
296
}

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

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

308
309
    print "Calling '$url'\n";

Robert Ricci's avatar
Robert Ricci committed
310
    # Super simple, make the call
311
312
313
    my $request = HTTP::Request->new(GET => $url);
    my $ua = LWP::UserAgent->new;
    my $response = $ua->request($request);
314
    if ($verbose) {
315
316
317
        print "\n" . "="x80 . "\n";
        print $response->as_string . "\n";
        print "\n" . "="x80 . "\n";
318
    }
Robert Ricci's avatar
Robert Ricci committed
319
320

    # TODO: Error checking
321
322
    return decode_json($response->content);
}
Robert Ricci's avatar
Robert Ricci committed
323
324
325
326
327
328
329

#
# Re-format a hash as a string sutable for passing to gitmail
# TODO: proper command line escaping
#
sub format_options(@) {
    my %opt = @_;
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
    # 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
359
}