Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create robot accounts with push creds for teams #77

Merged
merged 19 commits into from
Mar 6, 2023
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
115 changes: 95 additions & 20 deletions src/tasks/harbor/harbor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// eslint-disable @typescript-eslint/camelcase
// es
dennisvankekem marked this conversation as resolved.
Show resolved Hide resolved

import {
ConfigureApi,
Expand All @@ -17,7 +18,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 +101,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 +137,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 +164,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(
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
`RobotPullAccount already exists and should have been deleted beforehand. This happens when more than 100 robot accounts exist.`,
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
)
}
return robotPullAccount
}

/**
* Create Harbor system robot account that is scoped to a given Harbor project
* @param projectName Harbor project name
*/
async function createTeamPushRobotAccount(projectName: string): Promise<any> {
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
const projectRobot: RobotCreate = {
name: `${projectName}-push`,
duration: -1,
description: 'Allow team to push from its own registry',
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
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 +267,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 createTeamPushRobotAccount(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 +355,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