blob: c935ebf32a209f02f7bf3c7aa7b626655c89c89a [file] [edit]
#!/usr/bin/env python3
# Copyright (C) 2025 The Android Open Source Project
#
# 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.
''' Runs one GitHub (Ephemeral) Action Runner and quits at the end.
The Action Runner is executed in its own sandboxed docker container, which has
no access to the metadata server.
It also handles graceful container termination upon shutdown.
'''
import logging
import os
import signal
import socket
import subprocess
import time
import sys
from config import SANDBOX_IMG, GITHUB_REPO, SANDBOX_SVC_ACCOUNT
from common_utils import get_github_registration_token
from pathlib import Path
CUR_DIR = os.path.dirname(__file__)
# The container name will be GCE_HostName-N.
SANDBOX_ID = os.environ.get('SANDBOX_ID', '')
SANDBOX_NAME = '%s-%s' % (socket.gethostname(), SANDBOX_ID)
def sig_handler(_, __):
logging.warning('Interrupted by signal, exiting worker')
subprocess.call(['docker', 'stop', SANDBOX_NAME])
sys.exit(0)
def create_sandbox_token(token_path):
# Impersonate the sandbox service account. This creates a short-lived 1h token
# for a downgraded service account that we pass to the sandbox. The sandbox
# service account is allowed only storage object creation for untrusted CI
# artifacts.
sandbox_svc_token = subprocess.check_output([
'gcloud',
'auth',
'application-default',
'print-access-token',
'--impersonate-service-account=%s' % SANDBOX_SVC_ACCOUNT,
# TODO(primiano): uncomment below after b/406184705 is fixed.
# '--lifetime=21600',
]).decode().strip()
with open(token_path + '.tmp', 'w') as f:
f.write(sandbox_svc_token)
os.rename(token_path + '.tmp', token_path)
def main():
logging.basicConfig(
format='%(levelname)-8s %(asctime)s ' + SANDBOX_NAME + ' %(message)s',
level=logging.DEBUG if os.getenv('VERBOSE') else logging.INFO,
datefmt=r'%Y-%m-%d %H:%M:%S')
logging.info('sandbox_runner started')
signal.signal(signal.SIGTERM, sig_handler)
signal.signal(signal.SIGINT, sig_handler)
# Update the mtime of perfetto_ci_lastrun. This is used to shutdown the
# GCE vm when idle for too long.
Path('/tmp/perfetto_ci_lastrun').touch()
# Remove stale sandbox from previous runs, if any.
subprocess.call(['docker', 'rm', '-f', SANDBOX_NAME],
stderr=subprocess.DEVNULL)
# Run the nested docker container that will execute the ephemeral GitHub
# action runner in the sandbox image.
cmd = [
'docker', 'run', '--rm', '--name', SANDBOX_NAME, '--hostname',
SANDBOX_NAME, '--stop-timeout', '60', '--cap-add', 'SYS_PTRACE',
'--network', 'sandbox', '--dns', '8.8.8.8', '--log-driver', 'gcplogs'
]
# We use the tmpfs mount created by gce-startup-script.sh. The problem is that
# Docker doesn't allow to both override the tmpfs-size and prevent the
# "-o noexec".
tmp_dir = '/tmp/' + SANDBOX_NAME
subprocess.call(['rm', '-rf', tmp_dir])
os.makedirs(tmp_dir, exist_ok=True)
os.chmod(tmp_dir, 0o777)
cmd += ['-v', '%s:/tmp' % tmp_dir]
# Obtain the (short-lived) token to register the Github Action Runner and
# pass it to the sandbox.
github_token = get_github_registration_token()
with open(os.path.join(tmp_dir, '.github_token'), 'w') as f:
f.write(github_token)
# Note: the path we see (/tmp/sandbox-N/.github_token) is different than what
# the sandbox sees (which is just /tmp/.github_token due to the mount).
cmd += ['--env', 'GITHUB_TOKEN_PATH=/tmp/.github_token']
cmd += ['--env', 'GITHUB_REPO=%s' % GITHUB_REPO]
svc_token_path = os.path.join(tmp_dir, '.svc_token')
create_sandbox_token(svc_token_path)
cmd += ['--env', 'SVC_TOKEN_PATH=/tmp/.svc_token']
# The image name must be the last arg. Anything else would be interpreted as
# a command to pass to the container.
cmd += [SANDBOX_IMG]
# This spawns the sandbox that runs one ephemeral GitHub Action job and
# terminates when done.
proc = subprocess.Popen(cmd)
# Refresh the delegated service token every 50 minutes as its TTL=1h. The
# sandbox could be up for several hours if there are no jobs pending.
last_refresh_time = time.time()
REFRESH_SECS = 50 * 60
while True:
try:
sys.exit(proc.wait(60))
except subprocess.TimeoutExpired:
if time.time() - last_refresh_time > REFRESH_SECS:
create_sandbox_token(svc_token_path)
last_refresh_time = time.time()
if __name__ == '__main__':
main()