From 1c600c8617ea2fe8502e9acbb12d3d4c898e700e Mon Sep 17 00:00:00 2001 From: Anya Helene Bagge <anya@ii.uib.no> Date: Wed, 15 Feb 2023 09:06:55 +0100 Subject: [PATCH] some setup scripts --- .gitignore | 4 + Dockerfile | 3 +- find_gitlab_users.py | 83 ++++++++++ get-tests | 4 +- print_gitlab_users.py | 7 + student_project_setup.py | 342 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100755 find_gitlab_users.py create mode 100755 print_gitlab_users.py create mode 100755 student_project_setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b025a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.txt +*.csv.old +*.csv +__pycache__ diff --git a/Dockerfile b/Dockerfile index b8ec90d..97f287b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ ARG GHC RUN rm -rf /opt/ghc/*/share/doc FROM debian:buster-slim AS haskell-main # common haskell + stack dependencies -RUN apt-get update && \ +RUN echo deb http://deb.debian.org/debian bullseye main >> /etc/apt/sources.list && \ + apt-get update && \ apt-get install -y --no-install-recommends tzdata \ ca-certificates curl dpkg-dev git gcc gnupg g++ libc6-dev \ libffi-dev libgmp-dev libnuma-dev libtinfo-dev make netbase xz-utils zlib1g-dev && \ diff --git a/find_gitlab_users.py b/find_gitlab_users.py new file mode 100755 index 0000000..b130ddb --- /dev/null +++ b/find_gitlab_users.py @@ -0,0 +1,83 @@ +#! /usr/bin/python + +import re +import gitlab +import sys +from gitlab import Gitlab, GitlabError +from gitlab.exceptions import GitlabGetError +from gitlab.v4.objects import User, CurrentUser, Project, Group +import csv +from shutil import copyfile + +import logging +logging.basicConfig() +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +logger.info('hello!') + +TOKEN = open('../uib-git.api_token').read().strip() + +with open('students.csv', 'r') as csvfile: + reader = csv.DictReader(csvfile) + rows = [row for row in reader] + +gl = Gitlab(url='https://git.app.uib.no/', private_token=TOKEN) +gl.auth() +missing=[] +unknown=[] + +def format_user(row): + return f"{row['sortable_name']} <{row['email']}> ({row['kind']})" +for row in rows: + name = row['sortable_name'] + (lastname, firstname) = [s.strip() for s in name.split(',', 1)] + (first_firstname,*more_firstnames) = firstname.split() + if row.get('userid','') == '': + row['gituser'] = '' + + users = gl.users.list(username=row['email'].replace('@uib.no','').replace('@student.uib.no','')) + user = None + if len(users) == 1: + user = users[0] + else: + more_users = gl.users.list(username=f'{first_firstname}.{lastname}') + if len(more_users) == 1: + user = more_users[0] + + if user: + row['gituser'] = user.username + row['gitid'] = user.id + elif len(users) == 0: + if 'uib.no' in row['email']: + missing.append(row) + print("Maybe not registered?", format_user(row)) + else: + unknown.append(row) + print("Not found", format_user(row)) + else: + unknown.append(row) + print('Ambiguous:', format_user(row), users) + +if len(missing) > 0: + with open('missing.txt', 'w') as f: + f.writelines([format_user(row)+'\n' for row in missing]) +if len(unknown) > 0: + with open('unknown.txt', 'w') as f: + f.writelines([format_user(row)+'\n' for row in unknown]) + +try: + copyfile('students.csv', 'students.csv.old') +except: + pass + +fieldnames = [*reader.fieldnames] +if 'gituser' not in fieldnames: + fieldnames.append('gituser') +if 'gitid' not in fieldnames: + fieldnames.append('gitid') + +with open('students.csv','w') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames) + writer.writeheader() + writer.writerows(rows) diff --git a/get-tests b/get-tests index 9d9d438..3dfd5e6 100755 --- a/get-tests +++ b/get-tests @@ -17,5 +17,5 @@ fi TEST_PROJECT_NAME=`basename "$TEST_PROJECT_NAME"` git --version -git clone https://$TEST_ACCESS_TOKEN@git.app.uib.no/ii/inf222/v23/assignments/$TEST_PROJECT_NAME.git testcode -git clone https://$TEST_ACCESS_TOKEN@git.app.uib.no/ii/inf222/v23/assinments/$TEST_PROJECT_NAME.git testcode2 || true +git clone https://token:$TEST_ACCESS_TOKEN@git.app.uib.no/ii/inf222/v23/assignments/$TEST_PROJECT_NAME.git testcode 2> /dev/null +#git clone https://token:$TEST_ACCESS_TOKEN@git.app.uib.no/ii/inf222/v23/assinments/$TEST_PROJECT_NAME.git testcode2 || true diff --git a/print_gitlab_users.py b/print_gitlab_users.py new file mode 100755 index 0000000..199f50d --- /dev/null +++ b/print_gitlab_users.py @@ -0,0 +1,7 @@ +import csv + +with open('students.csv', 'r') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + print(f'{row["gituser"]:30s}: {row["name"]} <{row["email"]}>') + diff --git a/student_project_setup.py b/student_project_setup.py new file mode 100755 index 0000000..8f63746 --- /dev/null +++ b/student_project_setup.py @@ -0,0 +1,342 @@ +#! /usr/bin/python + +import csv +from pprint import pformat +import re +import time +import gitlab +import sys +from gitlab import Gitlab, GitlabError +from gitlab.exceptions import GitlabGetError +from gitlab.v4.objects import User, CurrentUser, Project, Group + +import logging +logging.basicConfig() +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +logger.info('hello!') + +TOKEN = open('../uib-git.api_token').read().strip() + +STATUS_BADGE_LINK_URL = 'https://git.app.uib.no/%{project_path}/-/pipelines/latest' +STATUS_BADGE_IMAGE_URL = 'https://git.app.uib.no/%{project_path}/badges/%{default_branch}/pipeline.svg' + +CI_CONFIG_PATH = '.gitlab-ci.yml@ii/inf222/v23/assignments/test-runner' +DEFAULT_ACCESS_LEVEL = gitlab.const.DEVELOPER_ACCESS + +FATAL_ERRORS = [gitlab.GitlabBanError, gitlab.GitlabAuthenticationError] +# Set these to 'disabled' to disabled the feature, +# 'enabled' to enable (for everyone with access – makes no sense for private project), or +# 'private' to enable only for project members +FEATURE_ACCESS = { + 'analytics_access_level': 'disabled', + 'builds_access_level': 'enabled', + 'container_registry_access_level': 'disabled', + 'environments_access_level': 'disabled', + 'feature_flags_access_level': 'disabled', + 'forking_access_level': 'enabled', + 'infrastructure_access_level': 'disabled', + 'merge_requests_access_level': 'enabled', + 'monitor_access_level': 'disabled', + 'pages_access_level': 'disabled', + 'releases_access_level': 'disabled', + 'repository_access_level': 'private', + 'security_and_compliance_access_level': 'disabled', + 'snippets_access_level': 'private', + 'wiki_access_level': 'disabled', + # 'requirements_access_level': 'disabled', +} +REPO_SUB_FEATURES = ['merge_requests_access_level', + 'builds_access_level', 'forking_access_level'] +DEFAULT_PROJECT_CONFIG = { + 'visibility': gitlab.const.VISIBILITY_PRIVATE, + 'ci_config_path': CI_CONFIG_PATH, + 'auto_cancel_pending_pipelines': 'enabled', + 'auto_devops_enabled': False, + 'lfs_enabled': False, + 'packages_enabled': False, + 'request_access_enabled': False, + 'service_desk_enabled': False, + 'shared_runners_enabled': False, + # 'group_runners_enabled': True, + 'keep_latest_artifact': True, + 'build_git_strategy': 'fetch', + 'autoclose_referenced_issues': True, + 'build_timeout': 600 +} + + +class AssignmentFork: + def __init__(self, assignment: Project | str | int, test_project: Project | str = None, gitlab_config: dict = None, access_level: int = DEFAULT_ACCESS_LEVEL, gitlab: gitlab.Gitlab = None, gitlab_token=TOKEN): + self.gl = Gitlab(url='https://git.app.uib.no/', + private_token=gitlab_token) if gitlab == None else gitlab + if self.gl.user == None and TOKEN != None and not TOKEN.isspace(): + self.gl.auth() + self.assignment = self.get_project(assignment) + self.changelog: list = [] + self.errors = [] + self.access_level = access_level + self.namespace = self.assignment.namespace + self.variables = {'ASSIGNMENT': self.assignment.path} + test_path = self.__find_test_project(test_project) + if test_path != None: + self.variables['TEST_PROJECT_NAME'] = test_path + config = FEATURE_ACCESS.copy() + config.update(DEFAULT_PROJECT_CONFIG) + if gitlab_config != None: + config.update(gitlab_config) + for key in REPO_SUB_FEATURES: + if config['repository_access_level'] == 'private' and config[key] == 'enabled': + config[key] = 'private' + if config['repository_access_level'] == 'disabled': + config[key] = 'disabled' + self.gitlab_config = config + self.commit = True + + def __find_test_project(self, test_project) -> str: + if isinstance(test_project, Project): + self.test_project = test_project + return test_project.path + + try: + var: str = test_project if isinstance( + test_project, str) else self.assignment.variables.get('TEST_PROJECT_NAME').value + except GitlabGetError: + var = f"{self.assignment.path_with_namespace}-tests" # fallback + if '/' not in var and '$PROJECT_PATH' not in var: + var = f"{self.namespace['full_path']}/{var}" # looks unqualified + if '$' in var: # a variable we can't expand + self.test_project = var + return var + else: + try: + self.test_project = self.get_project(var) + return self.test_project.path_with_namespace + except GitlabGetError: + self.errors.append(('test project not found', var)) + return None + + def check_membership(self, resource: Project | Group, user: User | CurrentUser | int): + user_id = getattr(user, 'id', user) + try: + membership = resource.members.get(user_id) + if membership.access_level != gitlab.const.OWNER_ACCESS and membership.access_level != self.access_level: + print( + f'Correcting member level for {self.get_user_name(user)} on {resource.name}: {membership.access_level} → {self.access_level}') + membership.access_level = self.access_level + if self.commit: + membership.save() + self.__change('set_user', resource, user=user, access_level=self.access_level) + # TODO: check active and expiry + except GitlabGetError: + try: + # maybe user is member through group? + resource.members_all.get(user_id) + except GitlabGetError: + print( + f'Adding user {self.get_user_name(user)} to {resource.name}: 0 → {self.access_level}') + if self.commit: + resource.members.create( + {'user_id': user_id, 'access_level': self.access_level}) + self.__change('add_user', resource, user=user, access_level=self.access_level) + + def check_push_access(self, project): + branch = project.default_branch + + def protect(): + payload = {'name': branch, 'push_access_level': self.access_level, + 'merge_access_level': self.access_level, 'allow_force_push': False} + print( + f'Setting branch permissions for {project.name} to {self.access_level}') + if self.commit: + project.protectedbranches.create(payload) + self.__change('protect_branch', project, branch=branch, access_level=self.access_level) + + try: + prot = project.protectedbranches.get(project.default_branch) + if all([lvl['access_level'] != self.access_level for lvl in prot.push_access_levels]) \ + or all([lvl['access_level'] != self.access_level for lvl in prot.merge_access_levels]): + if self.commit: + prot.delete() + protect() + except GitlabGetError: + protect() + + def __change(self, what:str, object:Project|User|Group, **changes): + change = {what:object} + change.update(changes) + self.changelog.append(change) + + def __failed(self, e:GitlabError, subject=None): + self._last_error = e + self.__change('failed', subject, error_message=e.error_message, response_code=e.response_code, response_body=e.response_body) + self.errors.append((e.error_message, subject, e)) + + + def check_variables(self, project: Project): + vars = self.variables.copy() + changes = {} + for var in project.variables.list(iterator=True): + if var.key in vars: + if var.value != vars[var.key]: + var.value = vars[var.key] + if self.commit: + var.save() + changes[var.key] = var.value + del vars[var.key] + if len(vars) == 0: + break + for key in vars: + if self.commit: + project.variables.create({'key': key, 'value': vars[key]}) + changes[key] = vars[key] + if len(changes) > 0: + self.__change('variables', project, **changes) + + def check_badges(self, project: Project): + for badge in project.badges.list(iterator=True): + if badge.image_url == STATUS_BADGE_IMAGE_URL: + if badge.link_url != STATUS_BADGE_LINK_URL: + badge.link_url = STATUS_BADGE_LINK_URL + if self.commit: + badge.save() + self.__change('set_badge_link', project) + return + project.badges.create( + {'image_url': STATUS_BADGE_IMAGE_URL, 'link_url': STATUS_BADGE_LINK_URL}) + self.__change('add_badge', project) + + def check_project_setup(self, user: User | CurrentUser, project: Project, config: dict): + for key in config: + if getattr(project, key) != config[key]: + setattr(project, key, config[key]) + self.__change('set_attr', project, **{key: config[key]}) + + if self.commit and project._get_updated_data() != None: + project.save() + + if project.id != self.assignment.id \ + and not (hasattr(project, 'forked_from_project') or project.forked_from_project['id'] != self.assignment.id): + logger.info( + f'Updating fork relationship: {project.name} ↠{self.assignment.name}') + if self.commit: + project.create_fork_relation(self.assignment.id) + self.__change('set_fork', project, source=self.assignment) + + self.check_push_access(project) + self.check_variables(project) + self.check_badges(project) + + if user != None: + self.check_membership(project, user) + + def check_assignment(self): + try: + self.check_project_setup( + None, self.assignment, self.gitlab_config.copy()) + except gitlab.GitlabError as e: + self.__failed(e, self.assignment) + + def check_user_project(self, user: User | CurrentUser | str | int): + try: + return self.__check_user_project(self.get_user(user)) + except gitlab.GitlabError as e: + self.__failed(e, user) + + def __check_user_project(self, user: User | CurrentUser): + config = self.gitlab_config.copy() + + try: + config['name'] = f'{user.username} – {self.assignment.name}' + project_slug = f'{user.username}_{self.assignment.path}' + self._last_user = user + self._last_project = project = self.gl.projects.get( + f'{self.namespace["full_path"]}/{project_slug}') + logger.debug('Found project %s for user %s', project.path_with_namespace, user.username) + except GitlabGetError: + url = self.assignment.ssh_url_to_repo.replace( + f'/{self.assignment.path}.git', f'/{project_slug}.git') + desc = self.assignment.description + desc = re.sub(r'\r?\n\r?\n.*$', '', desc, re.DOTALL) + if desc != '' and not desc.isspace(): + desc = desc + '\n\n' + logger.info( + f'Creating fork of {self.assignment.name} for {user.username}: {self.namespace["full_path"]}/{project_slug}') + if self.commit: + self._last_fork = self.assignment.forks.create({ + 'namespace_id': self.namespace['id'], + 'path': project_slug, + 'name': config['name'], + 'visibility': config['visibility'], + 'description': f'{desc}{self.assignment.name} for {user.name}\n\n*Clone →* `git clone {url} {self.assignment.path}`' + }) + time.sleep(1) + if self.commit: + self._last_project = project = self.gl.projects.get(self._last_fork.id) + else: + project = None + self.__change('add_fork', project,source=self.assignment) + if project != None: + self.check_project_setup(user, project, config) + return project + + def get_user(self, user: User | CurrentUser | str | int) -> User | CurrentUser: + if isinstance(user, int): + user = self.gl.users.get(user) + elif isinstance(user, str): + user, = self.gl.users.list(username=user) + return user + + def get_user_name(self, user: User | CurrentUser | str | int) -> str: + if isinstance(user, int): + return str(user) + elif isinstance(user, User) or isinstance(user, CurrentUser): + return user.username + return user + + def get_project(self, project: Project | str | int) -> Project: + if isinstance(project, int) or isinstance(project, str): + project = self.gl.projects.get(project) + if isinstance(project, Project): + return project + else: + raise ValueError(f"Not a project: {project}") + + +if __name__ == '__main__': + args = sys.argv[1:] + file = None + assignment = None + while len(args) > 1: + if args[0] == '-f': + file = args[1] + args = args[2:] + elif args[0] == '-a': + assignment = args[1] + args = args[2:] + else: + raise ValueError(args[0]) + + if file == None: + print("Missing input file") + elif assignment == None: + print("Missing assignment") + else: + assignment = AssignmentFork(assignment) + print(assignment.assignment.name_with_namespace) + try: + with open('students.csv', 'r') as csvfile: + reader = csv.DictReader(csvfile) + n = 0 + for row in reader: + if row['gitid'].isdigit(): + assignment.check_user_project(int(row['gitid'])) + time.sleep(1) + n = n + 1 + finally: + with open('changes.txt', 'w') as f: + f.write(pformat(assignment.changelog, sort_dicts=False)) + with open('errors.txt', 'w') as f: + f.write(pformat(assignment.errors, sort_dicts=False)) + -- GitLab