From d276195c9c2c93026e2345a5d5b4e172ff2e21af Mon Sep 17 00:00:00 2001 From: Yuxiang Zhu Date: Aug 01 2019 08:05:21 +0000 Subject: add C3I-as-a-Service job c3iaas-request-project This will add an OpenShift pipeline job to allow a user to request a new project in a configured OpenShift cloud. Instead of referencing an external Jenkinsfile in the BuildConfig, we define the pipeline job in `vars/c3iaasRequestProjectJob.groovy` and call it from an inline Jenkinsfile. The reason is that we want to "hardcode" some configurations that is produced from the OpenShift template so that users can't override them through build parameters. See https://jenkins.io/doc/book/pipeline/shared-libraries/#defining-declarative-pipelines for more information. --- diff --git a/c3iaas/Makefile b/c3iaas/Makefile new file mode 100644 index 0000000..cf10dd9 --- /dev/null +++ b/c3iaas/Makefile @@ -0,0 +1,35 @@ +OC:=oc +OCFLAGS:= +JOBS_DIR:=jobs +JOB_PARAM_FILES:=$(wildcard $(JOBS_DIR)/*.env) +JOBS:=$(patsubst $(JOBS_DIR)/%.env,%,$(JOB_PARAM_FILES)) + +OC_CMD=$(OC) $(OCFLAGS) + +help: + @echo TARGETS + @echo -e "\tinstall\t\tInstall or update pipelines to OpenShift" + @echo -e "\tuninstall\tDelete installed pipelines from OpenShift" + @echo + @echo VARIABLES + @echo -e "\tJOBS\t\tSpace seperated list of pipeline jobs to install" + @echo -e "\tJOBS_DIR\tLooking for pipeline job definitions in an alternate directory." + @echo -e "\tOC\t\tUse this oc command" + @echo -e "\tOCFLAGS\t\tOptions to append to the oc command arguments" +install: + @for job in $(JOBS); do \ + echo "[PIPELINE] Updating pipeline job \"$${job}\"..." ; \ + template_file="./$(JOBS_DIR)/$${job}.tmpl"; \ + $(OC_CMD) process --local -f $${template_file} \ + --param-file ./$(JOBS_DIR)/$${job}.env | $(OC_CMD) apply -f -; \ + echo "[PIPELINE] Pipeline job \"$${job}\" updated" ; \ + done +uninstall: + @for job in $(JOBS); do \ + echo "[PIPELINE] Deleting pipeline job \"$${job}\"..." ; \ + template_file="./$(JOBS_DIR)/$${job}.tmpl"; \ + $(OC_CMD) process --local -f $${template_file} \ + --param-file ./$(JOBS_DIR)/$${job}.env | $(OC_CMD) delete -f -; \ + echo "[PIPELINE] Pipeline job \"$${job}\" deleted" ; \ + done +.PHONY: help install uninstall diff --git a/c3iaas/jobs/c3iaas-request-project.env b/c3iaas/jobs/c3iaas-request-project.env new file mode 100644 index 0000000..5c16a46 --- /dev/null +++ b/c3iaas/jobs/c3iaas-request-project.env @@ -0,0 +1 @@ +NAME=c3iaas-request-project diff --git a/c3iaas/jobs/c3iaas-request-project.tmpl b/c3iaas/jobs/c3iaas-request-project.tmpl new file mode 120000 index 0000000..d62103b --- /dev/null +++ b/c3iaas/jobs/c3iaas-request-project.tmpl @@ -0,0 +1 @@ +../templates/c3iaas-request-project-template.yaml \ No newline at end of file diff --git a/c3iaas/templates/c3iaas-request-project-template.yaml b/c3iaas/templates/c3iaas-request-project-template.yaml new file mode 100644 index 0000000..340e440 --- /dev/null +++ b/c3iaas/templates/c3iaas-request-project-template.yaml @@ -0,0 +1,83 @@ +apiVersion: v1 +kind: Template +metadata: + name: c3iaas-request-project +parameters: +- name: NAME + displayName: Short unique identifier for the templated instances + required: true + value: c3iaas-request-project +- name: GIT_REPO + displayName: Pipeline Git repository + required: true + value: "https://pagure.io/c3i-library.git" +- name: GIT_REF + displayName: Pipeline Git ref + required: true + value: master +- name: JENKINS_AGENT_IMAGE + displayName: Jenkins agent container image + required: true + value: docker-registry.engineering.redhat.com/factory2/factory2-integration-test-jenkins-slave:latest +- name: JENKINS_AGENT_SERVICE_ACCOUNT + displayName: service account for a Jenkins agent to interact with OpenShift API server + required: true + value: jenkins +- name: JENKINS_AGENT_CLOUD + displayName: OpenShift cloud to run a Jenkins agent + required: true + value: openshift +- name: JENKINS_AGENT_NAMESPACE + displayName: OpenShift namespace to run a Jenkins agent +labels: + template: c3ipaas-request-project + app: c3iaas + component: project-manager +objects: +- kind: "BuildConfig" + apiVersion: "v1" + metadata: + name: "${NAME}" + spec: + runPolicy: "Parallel" + completionDeadlineSeconds: 300 + source: + git: + uri: "${GIT_REPO}" + ref: "${GIT_REF}" + strategy: + type: JenkinsPipeline + jenkinsPipelineStrategy: + env: + - name: PROJECT_NAME + value: "" + - name: DELETE_PROJECT_IF_EXISTS + value: "true" + - name: ADMIN_USERS + value: "" + - name: ADMIN_GROUPS + value: "" + - name: VIEW_USERS + value: "" + - name: VIEW_GROUPS + value: "system:authenticated" + - name: LIFETIME_IN_MINUTES + value: "30" + jenkinsfile: |- + library identifier: 'c3i@${GIT_REF}', changelog: false, + retriever: modernSCM([$class: 'GitSCMSource', remote: '${GIT_REPO}']) + c3iaasRequestProjectJob( + agent: [ + cloud: '${JENKINS_AGENT_CLOUD}', + namespace: '${JENKINS_AGENT_NAMESPACE}', + serviceAccount: '${JENKINS_AGENT_SERVICE_ACCOUNT}', + image: '${JENKINS_AGENT_IMAGE}', + ], + quota: [ + requestsCpu: "8", + limitsCpu: "16", + requestsMemory: "16Gi", + limitsMemory: "32Gi", + requestsStorage: "64Gi", + ], + ) diff --git a/vars/c3iaasRequestProjectJob.groovy b/vars/c3iaasRequestProjectJob.groovy new file mode 100644 index 0000000..e44a260 --- /dev/null +++ b/vars/c3iaasRequestProjectJob.groovy @@ -0,0 +1,241 @@ +import groovy.transform.Field +import groovy.json.JsonOutput +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Field final PROJECT_NAME_PATTERN = /c3i\-[a-z0-9\-]{0,48}[a-z0-9]/ +@Field final USER_NAME_PATTERN = /[\w:\-]+/ + +def call(Map args=[:]) { + String[] projectAdminUsers = [] + String[] projectAdminGroups = [] + String[] projectViewUsers = [] + String[] projectViewGroups = [] + Instant projectExpirationInstant; + boolean projectCreated; + pipeline { + agent { + kubernetes { + cloud args.agent.cloud + label "jenkins-slave-${UUID.randomUUID().toString()}" + namespace args.agent.namespace + serviceAccount args.agent.serviceAccount + defaultContainer 'jnlp' + yaml """ + apiVersion: v1 + kind: Pod + metadata: + labels: + app: c3iaas + component: project-manager + pipeline-job-name: "${env.JOB_BASE_NAME}" + pipeline-build-number: "${env.BUILD_NUMBER}" + spec: + containers: + - name: jnlp + image: ${args.agent.image} + imagePullPolicy: Always + tty: true + resources: + requests: + memory: 256Mi + cpu: 200m + limits: + memory: 512Mi + cpu: 356m + """ + } + } + stages { + stage("prepare") { + steps { + script { + if (!env.PROJECT_NAME) { + error("Please specify a PROJECT_NAME prefixed with `c3i-`.") + } else if (!isProjectNameValid(env.PROJECT_NAME)) { + error("Requested project name ${env.PROJECT_NAME} is invalid: Is your project name prefixed with 'c3i-'?") + } + echo "New project name is ${env.PROJECT_NAME}" + + def lifetime = 30 // minutes + if (env.LIFETIME_IN_MINUTES) { + lifetime = Integer.parseInt(env.LIFETIME_IN_MINUTES) + } + if (lifetime < 1 || lifetime > 60 * 24) { + error("Project lifetime $lifetime is out of range (0, 1440)") + } + + def now = Instant.now() + projectExpirationInstant = now.plus(lifetime, ChronoUnit.MINUTES) + echo "New project will expire at $projectExpirationInstant (after $lifetime minutes)" + + projectAdminUsers = env.ADMIN_USERS ? env.ADMIN_USERS.split(',') : [] + validateUserNames(projectAdminUsers) + echo "Project admin users: $projectAdminUsers" + + projectAdminGroups = env.ADMIN_GROUPS ? env.ADMIN_GROUPS.split(',') : [] + validateUserNames(projectAdminGroups) + echo "Project admin groups: $projectAdminGroups" + + projectViewUsers = env.VIEW_USERS ? env.VIEW_USERS.split(',') : [] + validateUserNames(projectViewUsers) + echo "Project view users: $projectViewUsers" + + projectViewGroups = env.VIEW_GROUPS ? env.VIEW_GROUPS.split(',') : [] + validateUserNames(projectViewGroups) + echo "Project view groups: $projectViewGroups" + } + } + } + stage("Delete project if exists") { + when { + environment name: 'DELETE_PROJECT_IF_EXISTS', value: 'true' + } + steps { + script { + openshift.withCluster() { + try { + deleteProjectIfExists(env.PROJECT_NAME) + } catch (e) { + echo "deleteProjectIfExists failed: ${e.toString()}" + } + } + } + } + } + stage('Create project') { + steps { + script { + openshift.withCluster() { + createProject(env.PROJECT_NAME, projectExpirationInstant, projectAdminUsers, projectAdminGroups) + projectCreated = true + } + } + } + } + stage('Apply quota') { + steps { + script { + openshift.withCluster() { + openshift.withProject(env.PROJECT_NAME) { + applyQuota(args.quota) + } + } + } + } + } + stage('Grant permissions') { + steps { + script { + openshift.withCluster() { + openshift.withProject(env.PROJECT_NAME) { + assignRole('admin', projectAdminUsers, projectAdminGroups) + assignRole('view', projectViewUsers, projectViewGroups) + } + } + } + } + } + } + post { + success { + echo "OpenShift project '$PROJECT_NAME' is successfully created." + } + failure { + script { + if (!projectCreated) + return + echo "Deleting project ${env.PROJECT_NAME}..." + openshift.withCluster() { + deleteProjectIfExists(env.PROJECT_NAME) + } + } + } + } + } +} + +def isProjectNameValid(String projectName) { + return projectName ==~ PROJECT_NAME_PATTERN +} + +def validateUserNames(String[] userNames) { + userNames.each { user -> + if (!(user ==~ USER_NAME_PATTERN)) + error("Invalid user/group name $user") + } +} + +def deleteProjectIfExists(String projectName) { + def projectSelector = openshift.selector('project', env.PROJECT_NAME) + echo "Checking if project ${env.PROJECT_NAME} exists..." + if (projectSelector.count() < 1) { + echo "Project ${env.PROJECT_NAME} doesn't exist" + return false + } + // check if the existing project is managed by C3IaaS + def project = projectSelector.object() + + def projectMetadataString = project.metadata.annotations["openshift.io/description"] + try { + projectMetadata = readJSON text: projectMetadataString + if (projectMetadata["c3iaas.redhat.com/managed_project"] != "true") { + throw 'c3iaas.redhat.com/managed_project is not "true"' + } + } catch (e) { + error("Aborting becasue project $projectName does not seem to be managed by C3IaaS: $e") + } + + echo "Deleting project ${env.PROJECT_NAME}..." + projectSelector.delete() + echo "Project ${env.PROJECT_NAME} deleted" + return true +} + +def createProject(String projectName, Instant expirationInstant, String[] adminUsers, String[] adminGroups) { + // We usually don't have the permission to label a project or namespace object in PSI. + // Let's keep our metadata in project's description field. + def projectMetadata = JsonOutput.toJson([ + 'c3iaas.redhat.com/managed_project': 'true', + 'c3iaas.redhat.com/expiration_datetime': expirationInstant.toString(), + 'c3iaas.redhat.com/admin_user': adminGroups.join(','), + 'c3iaas.redhat.com/admin_group': adminUsers.join(','), + ]) + echo "Creating project $projectName with metadata $projectMetadata..." + openshift.newProject( + projectName, + "'--display-name=C3IaaS managed project'", + "'--description=${projectMetadata}'", + ) + echo "Project $projectName is created" + def projectSelector = openshift.selector('project', projectName) + projectSelector.describe() + return projectSelector +} + +def applyQuota(Map quota) { + echo 'Deleting exiting quotas' + openshift.selector('quota').delete() + echo "Create quota $quota" + openshift.create('quota', 'c3iaas-managed-quota', + "--hard=requests.cpu=${quota.requestsCpu},limits.cpu=${quota.limitsCpu},requests.memory=${quota.requestsMemory},limits.memory=${quota.limitsMemory},requests.storage=${quota.requestsStorage}" + ) + echo 'Describe quota' + openshift.selector('quota').describe() +} + +def assignRole(String role, String[] users, String[] groups) { + def pattern = USER_NAME_PATTERN + users.each { user -> + if (!(user ==~ pattern)) error("Invalid user name $user") + echo "Assigning role $role to user $user..." + openshift.raw('adm', 'policy', 'add-role-to-user', '--', role, user) + echo 'done' + } + groups.each { group -> + if (!(group ==~ pattern)) error("Invalid group name $group") + echo "Assigning role $role to group $group..." + openshift.raw('adm', 'policy', 'add-role-to-group', '--', role, group) + echo 'done' + } +}