From acb2763db30cc898655aed72961932757722e458 Mon Sep 17 00:00:00 2001 From: Sander Rodenhuis <53382213+srodenhuis@users.noreply.github.com> Date: Mon, 6 Mar 2023 15:44:05 +0100 Subject: [PATCH] feat: create robot accounts with push creds for teams (#77) --- src/k8s.test.ts | 12 ++-- src/k8s.ts | 6 +- src/tasks/harbor/harbor.ts | 114 ++++++++++++++++++++++++++++++------- 3 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/k8s.test.ts b/src/k8s.test.ts index 6b7b4775..61f98df8 100644 --- a/src/k8s.test.ts +++ b/src/k8s.test.ts @@ -4,7 +4,7 @@ import http from 'http' import { cloneDeep } from 'lodash' import fetch from 'node-fetch' import sinon from 'sinon' -import { createPullSecret, deletePullSecret, k8s } from './k8s' +import { createK8sSecret, deleteSecret, k8s } from './k8s' import './test-init' describe('k8s', () => { @@ -64,7 +64,7 @@ describe('k8s', () => { sandbox.stub(k8s.core(), 'createNamespacedSecret').returns(secretPromise) sandbox.stub(k8s.core(), 'readNamespacedServiceAccount').returns(newServiceAccountPromise) const patchSpy = sandbox.stub(k8s.core(), 'patchNamespacedServiceAccount').returns(undefined as any) - await createPullSecret({ namespace, name, server, password: data.password, username: data.username }) + await createK8sSecret({ namespace, name, server, password: data.password, username: data.username }) expect(patchSpy).to.have.been.calledWith('default', namespace, saWithExistingSecret) }) @@ -72,7 +72,7 @@ describe('k8s', () => { sandbox.stub(k8s.core(), 'createNamespacedSecret').returns(secretPromise) sandbox.stub(k8s.core(), 'readNamespacedServiceAccount').returns(newEmptyServiceAccountPromise) const patchSpy = sandbox.stub(k8s.core(), 'patchNamespacedServiceAccount').returns(undefined as any) - await createPullSecret({ namespace, name, server, password: data.password, username: data.username }) + await createK8sSecret({ namespace, name, server, password: data.password, username: data.username }) expect(patchSpy).to.have.been.calledWith('default', namespace, saWithExistingSecret) }) @@ -80,13 +80,13 @@ describe('k8s', () => { sandbox.stub(k8s.core(), 'createNamespacedSecret').returns(secretPromise) sandbox.stub(k8s.core(), 'readNamespacedServiceAccount').returns(withOtherSecretServiceAccountPromise) const patchSpy = sandbox.stub(k8s.core(), 'patchNamespacedServiceAccount').returns(undefined as any) - await createPullSecret({ namespace, name, server, password: data.password, username: data.username }) + await createK8sSecret({ namespace, name, server, password: data.password, username: data.username }) expect(patchSpy).to.have.been.calledWith('default', namespace, saCombinedWithOtherSecret) }) it('should throw exception on secret creation for existing name', () => { sandbox.stub(k8s.core(), 'createNamespacedSecret').throws(409) - const check = createPullSecret({ + const check = createK8sSecret({ namespace, name, server, @@ -100,7 +100,7 @@ describe('k8s', () => { sandbox.stub(k8s.core(), 'readNamespacedServiceAccount').returns(withExistingSecretServiceAccountPromise) const patchSpy = sandbox.stub(k8s.core(), 'patchNamespacedServiceAccount').returns(undefined as any) const deleteSpy = sandbox.stub(k8s.core(), 'deleteNamespacedSecret').returns(undefined as any) - await deletePullSecret(namespace, name) + await deleteSecret(namespace, name) expect(patchSpy).to.have.been.calledWith('default', namespace, saNewEmpty) expect(deleteSpy).to.have.been.calledWith(name, namespace) }) diff --git a/src/k8s.ts b/src/k8s.ts index 5969e161..6b3ee76b 100644 --- a/src/k8s.ts +++ b/src/k8s.ts @@ -80,7 +80,7 @@ export async function getSecret(name: string, namespace: string): Promise> { +export async function getSecrets(namespace: string): Promise> { const client = k8s.core() const saRes = await client.readNamespacedServiceAccount('default', namespace) const { body: sa }: { body: V1ServiceAccount } = saRes return (sa.imagePullSecrets || []) as Array } -export async function deletePullSecret(namespace: string, name: string): Promise { +export async function deleteSecret(namespace: string, name: string): Promise { const client = k8s.core() const saRes = await client.readNamespacedServiceAccount('default', namespace) const { body: sa }: { body: V1ServiceAccount } = saRes diff --git a/src/tasks/harbor/harbor.ts b/src/tasks/harbor/harbor.ts index 5f0ede0c..fbf5bb91 100644 --- a/src/tasks/harbor/harbor.ts +++ b/src/tasks/harbor/harbor.ts @@ -17,7 +17,7 @@ import { // eslint-disable-next-line no-unused-vars RobotCreated, } from '@redkubes/harbor-client-node' -import { createPullSecret, createSecret, getSecret, k8s } from '../../k8s' +import { createK8sSecret, createSecret, getSecret, k8s } from '../../k8s' import { doApiCall, handleErrors, waitTillAvailable } from '../../utils' import { cleanEnv, @@ -100,7 +100,8 @@ const config: any = { const systemNamespace = 'harbor' const systemSecretName = 'harbor-robot-admin' -const projectSecretName = 'harbor-pullsecret' +const projectPullSecretName = 'harbor-pullsecret' +const projectPushSecretName = 'harbor-pushsecret' const harborBaseUrl = `${env.HARBOR_BASE_URL}/api/v2.0` const harborHealthUrl = `${harborBaseUrl}/systeminfo` const robotApi = new RobotApi(env.HARBOR_USER, env.HARBOR_PASSWORD, harborBaseUrl) @@ -135,7 +136,7 @@ async function createSystemRobotSecret(): Promise { * Create Harbor system robot account that is scoped to a given Harbor project * @param projectName Harbor project name */ -async function createTeamRobotAccount(projectName: string): Promise { +async function createTeamPullRobotAccount(projectName: string): Promise { const projectRobot: RobotCreate = { name: `${projectName}-pull`, duration: -1, @@ -162,18 +163,70 @@ async function createTeamRobotAccount(projectName: string): Promise robotApi.deleteRobot(existingId)) + await doApiCall(errors, `Deleting previous pull robot account ${fullName}`, () => robotApi.deleteRobot(existingId)) } - const robotAccount = (await doApiCall(errors, `Creating robot account ${fullName} with project level perms`, () => - robotApi.createRobot(projectRobot), + const robotPullAccount = (await doApiCall( + errors, + `Creating pull robot account ${fullName} with project level perms`, + () => robotApi.createRobot(projectRobot), + )) as RobotCreated + if (!robotPullAccount?.id) { + throw new Error( + `RobotPullAccount already exists and should have been deleted beforehand. This happens when more than 100 robot accounts exist.`, + ) + } + return robotPullAccount +} + +/** + * Create Harbor system robot account that is scoped to a given Harbor project + * @param projectName Harbor project name + */ +async function ensureTeamPushRobotAccount(projectName: string): Promise { + const projectRobot: RobotCreate = { + name: `${projectName}-push`, + duration: -1, + description: 'Allow team to push to its own registry', + disable: false, + level: 'system', + permissions: [ + { + kind: 'project', + namespace: projectName, + access: [ + { + resource: 'repository', + action: 'push', + }, + { + resource: 'repository', + action: 'pull', + }, + ], + }, + ], + } + const fullName = `${robotPrefix}${projectRobot.name}` + + const { body: robotList } = await robotApi.listRobot(undefined, undefined, undefined, undefined, 100) + const existing = robotList.find((i) => i.name === fullName) + + if (existing?.name) { + return existing + } + + const robotPushAccount = (await doApiCall( + errors, + `Creating push robot account ${fullName} with project level perms`, + () => robotApi.createRobot(projectRobot), )) as RobotCreated - if (!robotAccount?.id) { + if (!robotPushAccount?.id) { throw new Error( - `RobotAccount already exists and should have been deleted beforehand. This happens when more than 100 robot accounts exist.`, + `RobotPushAccount already exists and should have been deleted beforehand. This happens when more than 100 robot accounts exist.`, ) } - return robotAccount + return robotPushAccount } /** @@ -213,24 +266,44 @@ async function getBearerToken(): Promise { * @param namespace Kubernetes namespace where pull secret is created * @param projectName Harbor project name */ -async function ensureTeamRobotAccountSecret(namespace: string, projectName): Promise { - const k8sSecret = await getSecret(projectSecretName, namespace) +async function ensureTeamPullRobotAccountSecret(namespace: string, projectName): Promise { + const k8sSecret = await getSecret(projectPullSecretName, namespace) if (k8sSecret) { - console.debug(`Deleting secret/${projectSecretName} from ${namespace} namespace`) - await k8s.core().deleteNamespacedSecret(projectSecretName, namespace) + console.debug(`Deleting pull secret/${projectPullSecretName} from ${namespace} namespace`) + await k8s.core().deleteNamespacedSecret(projectPullSecretName, namespace) } - const robotAccount = await createTeamRobotAccount(projectName) - console.debug(`Creating secret/${projectSecretName} at ${namespace} namespace`) - await createPullSecret({ + const robotPullAccount = await createTeamPullRobotAccount(projectName) + console.debug(`Creating pull secret/${projectPullSecretName} at ${namespace} namespace`) + await createK8sSecret({ namespace, - name: projectSecretName, + name: projectPullSecretName, server: `${env.HARBOR_BASE_REPO_URL}`, - username: robotAccount.name!, - password: robotAccount.secret!, + username: robotPullAccount.name!, + password: robotPullAccount.secret!, }) } +/** + * Ensure that Harbor robot account and corresponding Kubernetes pull secret exist + * @param namespace Kubernetes namespace where push secret is created + * @param projectName Harbor project name + */ +async function ensureTeamPushRobotAccountSecret(namespace: string, projectName): Promise { + const k8sSecret = await getSecret(projectPushSecretName, namespace) + if (!k8sSecret) { + const robotPushAccount = await ensureTeamPushRobotAccount(projectName) + console.debug(`Creating push secret/${projectPushSecretName} at ${namespace} namespace`) + await createK8sSecret({ + namespace, + name: projectPushSecretName, + server: `${env.HARBOR_BASE_REPO_URL}`, + username: robotPushAccount.name!, + password: robotPushAccount.secret!, + }) + } +} + async function main(): Promise { // harborHealthUrl is an in-cluster http svc, so no multiple external dns confirmations are needed await waitTillAvailable(harborHealthUrl, undefined, { confirmations: 1 }) @@ -281,7 +354,8 @@ async function main(): Promise { () => memberApi.createProjectMember(projectId, undefined, undefined, projAdminMember), ) - await ensureTeamRobotAccountSecret(teamNamespce, projectName) + await ensureTeamPullRobotAccountSecret(teamNamespce, projectName) + await ensureTeamPushRobotAccountSecret(teamNamespce, projectName) return null }),