#!/usr/bin/python
# Copyright (C) 2012 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Background daemon to refresh OAuth access tokens.
Tokens are written to ~/.git-credential-cache/cookie
Git config variable http.cookiefile is updated.

Runs only on Google Compute Engine (GCE). On GCE Windows '--nofork' option
is needed. '--debug' option is available.
"""

from __future__ import print_function

import atexit
import contextlib
import json
import os
import platform
import subprocess
import sys
import time

if sys.version_info[0] > 2:
  # Python 3 imports
  from urllib.request import urlopen, Request
  from urllib.error import URLError
  from urllib.error import HTTPError
  import http.cookiejar as cookielib
else:
  # Python 2 imports
  from urllib2 import urlopen, Request, HTTPError, URLError
  import cookielib

REFRESH = 25  # seconds remaining when starting refresh
RETRY_INTERVAL = 5 # seconds between retrying a failed refresh

META_URL = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/'
SUPPORTED_SCOPES = [
  'https://www.googleapis.com/auth/cloud-platform',
  'https://www.googleapis.com/auth/gerritcodereview',
  'https://www.googleapis.com/auth/source.full_control',
  'https://www.googleapis.com/auth/source.read_write',
  'https://www.googleapis.com/auth/source.read_only',
]
COOKIE_JAR = None
IS_WINDOWS = platform.system() == 'Windows'

def read_meta(part):
  r = Request(META_URL + part)
  r.add_header('Metadata-Flavor', 'Google')
  return contextlib.closing(urlopen(r))

def select_scope():
  with read_meta('scopes') as d:
    avail = set(map(lambda s: s.decode('utf-8'), d.read().split()))
  scopes = [s for s in SUPPORTED_SCOPES if s in avail]
  if scopes:
    return next(iter(scopes))
  sys.stderr.write('error: VM must have one of these scopes:\n\n')
  for s in SUPPORTED_SCOPES:
    sys.stderr.write('  %s\n' % (s))
  sys.exit(1)

def configure_git():
  global COOKIE_JAR

  if IS_WINDOWS:
    # Git for Windows reads %HOMEPATH%/.gitconfig in Command Prompt,
    # but $HOME/.gitconfig in Cygwin. The two paths can be different.
    # Cygwin sets env var $HOMEPATH accordingly to %HOMEPATH%.
    # Set cookie file as %HOMEPATH%/.git-credential-cache/cookie,
    # so it can be used in both cases.
    if 'HOMEPATH' in os.environ:
      homepath = os.path.join(os.environ.get('HOMEDRIVE'), os.environ['HOMEPATH'])
    else:
      # When launched as a scheduled task at machine startup
      # HOMEPATH may not be set.
      sys.stderr.write('HOMEPATH is not set.\n')
      sys.exit(1)
  else:
    homepath = os.environ['HOME']
  dir = os.path.join(homepath, '.git-credential-cache')

  COOKIE_JAR = os.path.join(dir, 'cookie')
  if '--debug' in sys.argv:
    print('Cookie file: %s' % COOKIE_JAR)

  if os.path.exists(dir):
    os.chmod(dir, 0o700)
  else:
    os.mkdir(dir, 0o700)
  subprocess.call([
    'git', 'config', '--global',
    'http.cookiefile', COOKIE_JAR
  ])

def acquire_token(scope, retry):
  while True:
    try:
      with read_meta('token?scopes=' + scope) as d:
        return json.load(d)
    except URLError:
      if not retry:
        raise
    time.sleep(RETRY_INTERVAL)

def update_cookie(scope, retry):
  now = int(time.time())
  token = acquire_token(scope, retry)
  access_token = token['access_token']
  expires = now + int(token['expires_in'])  # Epoch in sec

  tmp_jar = COOKIE_JAR + '.lock'
  cj = cookielib.MozillaCookieJar(tmp_jar)

  for d in ['source.developers.google.com', '.googlesource.com']:
    cj.set_cookie(cookielib.Cookie(
      version = 0,
      name = 'o',
      value = access_token,
      port = None,
      port_specified = False,
      domain = d,
      domain_specified = True,
      domain_initial_dot = d.startswith('.'),
      path = '/',
      path_specified = True,
      secure = True,
      expires = expires,
      discard = False,
      comment = None,
      comment_url = None,
      rest = {}))

  cj.save()
  if '--debug' in sys.argv:
    print('Updating %s.' % COOKIE_JAR)
    print('Expires: %d, %s, in %d seconds'% (
      expires, time.ctime(expires), expires - now))
    sys.stdout.flush()
  if IS_WINDOWS:
    # os.rename() below on Windows will raise OSError when dst exists.
    # See https://docs.python.org/2/library/os.html#os.rename
    if os.path.isfile(COOKIE_JAR):
      os.remove(COOKIE_JAR)
  os.rename(tmp_jar, COOKIE_JAR)
  return expires

def cleanup():
  if COOKIE_JAR:
    for p in [COOKIE_JAR, COOKIE_JAR + '.lock']:
      if os.path.exists(p):
        os.remove(p)

def refresh_loop(scope, expires):
  atexit.register(cleanup)
  expires = expires - REFRESH
  while True:
    now = time.time()
    expires = max(expires, now + RETRY_INTERVAL)
    while now < expires:
      time.sleep(expires - now)
      now = time.time()
    expires = update_cookie(scope, retry=True) - REFRESH

def main():
  scope = select_scope()
  configure_git()

  expires = update_cookie(scope, retry=False)

  if '--nofork' not in sys.argv:
    if IS_WINDOWS:
      # os.fork() is not supported on Windows.
      sys.stderr.write('Add \'--nofork\' on Windows\n')
      sys.exit(1)

    if os.fork() > 0:
      sys.exit(0)

    os.chdir('/')
    os.setsid()
    os.umask(0)

    pid = os.fork()
    if pid > 0:
      print('git-cookie-authdaemon PID %d' % (pid))
      sys.exit(0)

  refresh_loop(scope, expires)

if __name__ == '__main__':
  main()
