Skip to content

Commit

Permalink
feat: create robot accounts with push creds for teams (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
srodenhuis committed Mar 6, 2023
1 parent f5fce16 commit acb2763
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 29 deletions.
12 changes: 6 additions & 6 deletions src/k8s.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -64,29 +64,29 @@ 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)
})

it('should create a valid pull secret and attach it to an SA that has an empty pullsecrets array', async () => {
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)
})

it('should create a valid pull secret and attach it to an SA that already has a pullsecret', async () => {
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,
Expand All @@ -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)
})
Expand Down
6 changes: 3 additions & 3 deletions src/k8s.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export async function getSecret(name: string, namespace: string): Promise<unknow
* @param namespace Kubernetes namespace
* @param data Secret data (non encoded with base64)
*/
export async function createPullSecret({
export async function createK8sSecret({
namespace,
name,
server,
Expand Down Expand Up @@ -134,14 +134,14 @@ export async function createPullSecret({
}
}

export async function getPullSecrets(namespace: string): Promise<Array<any>> {
export async function getSecrets(namespace: string): Promise<Array<any>> {
const client = k8s.core()
const saRes = await client.readNamespacedServiceAccount('default', namespace)
const { body: sa }: { body: V1ServiceAccount } = saRes
return (sa.imagePullSecrets || []) as Array<any>
}

export async function deletePullSecret(namespace: string, name: string): Promise<void> {
export async function deleteSecret(namespace: string, name: string): Promise<void> {
const client = k8s.core()
const saRes = await client.readNamespacedServiceAccount('default', namespace)
const { body: sa }: { body: V1ServiceAccount } = saRes
Expand Down
114 changes: 94 additions & 20 deletions src/tasks/harbor/harbor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -135,7 +136,7 @@ async function createSystemRobotSecret(): Promise<RobotSecret> {
* Create Harbor system robot account that is scoped to a given Harbor project
* @param projectName Harbor project name
*/
async function createTeamRobotAccount(projectName: string): Promise<RobotCreated> {
async function createTeamPullRobotAccount(projectName: string): Promise<RobotCreated> {
const projectRobot: RobotCreate = {
name: `${projectName}-pull`,
duration: -1,
Expand All @@ -162,18 +163,70 @@ async function createTeamRobotAccount(projectName: string): Promise<RobotCreated

if (existing?.id) {
const existingId = existing.id
await doApiCall(errors, `Deleting previous robot account ${fullName}`, () => 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<any> {
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
}

/**
Expand Down Expand Up @@ -213,24 +266,44 @@ async function getBearerToken(): Promise<HttpBearerAuth> {
* @param namespace Kubernetes namespace where pull secret is created
* @param projectName Harbor project name
*/
async function ensureTeamRobotAccountSecret(namespace: string, projectName): Promise<void> {
const k8sSecret = await getSecret(projectSecretName, namespace)
async function ensureTeamPullRobotAccountSecret(namespace: string, projectName): Promise<void> {
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<void> {
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<void> {
// harborHealthUrl is an in-cluster http svc, so no multiple external dns confirmations are needed
await waitTillAvailable(harborHealthUrl, undefined, { confirmations: 1 })
Expand Down Expand Up @@ -281,7 +354,8 @@ async function main(): Promise<void> {
() => memberApi.createProjectMember(projectId, undefined, undefined, projAdminMember),
)

await ensureTeamRobotAccountSecret(teamNamespce, projectName)
await ensureTeamPullRobotAccountSecret(teamNamespce, projectName)
await ensureTeamPushRobotAccountSecret(teamNamespce, projectName)

return null
}),
Expand Down

0 comments on commit acb2763

Please sign in to comment.