diff --git a/src/operators/harbor/harbor.test.ts b/src/operators/harbor/harbor.test.ts index 873566a7..af4450cc 100644 --- a/src/operators/harbor/harbor.test.ts +++ b/src/operators/harbor/harbor.test.ts @@ -1,7 +1,7 @@ import { ProjectReq, RobotCreate, RobotCreated } from '@linode/harbor-client-node' import * as k8s from '../../k8s' -import { __setApiClients, manageHarborProjectsAndRobotAccounts } from './harbor' -import { createRobotAccount, createSystemRobotSecret, ensureRobotAccount } from './lib/managers/harbor-robots' +import manageHarborProjectsAndRobotAccounts from './harbor' +import { createSystemRobotSecret, creatingRobotAccount, ensureRobotAccount } from './lib/managers/harbor-robots' jest.mock('@kubernetes/client-node', () => ({ KubeConfig: jest.fn().mockImplementation(() => ({ @@ -59,6 +59,7 @@ describe('harborOperator', () => { deleteRobot: jest.fn(), updateRobot: jest.fn(), setDefaultAuthentication: jest.fn(), + refreshSec: jest.fn(), } const mockConfigureApi = { @@ -69,11 +70,14 @@ describe('harborOperator', () => { const mockProjectsApi = { createProject: jest.fn(), getProject: jest.fn(), + updateProject: jest.fn(), setDefaultAuthentication: jest.fn(), } const mockMemberApi = { createProjectMember: jest.fn(), + listProjectMembers: jest.fn(), + updateProjectMember: jest.fn(), setDefaultAuthentication: jest.fn(), } @@ -93,6 +97,13 @@ describe('harborOperator', () => { teamNamespaces: [], } + const mockApis = { + robotApi: mockRobotApi, + configureApi: mockConfigureApi, + projectsApi: mockProjectsApi, + memberApi: mockMemberApi, + } + beforeEach(() => { jest.clearAllMocks() @@ -100,13 +111,7 @@ describe('harborOperator', () => { mockK8s.createSecret.mockResolvedValue(undefined) mockK8s.replaceSecret.mockResolvedValue(undefined) mockK8s.createK8sSecret.mockResolvedValue(undefined) - - __setApiClients( - mockRobotApi as any, - mockConfigureApi as any, - mockProjectsApi as any, - mockMemberApi as any, - ) + mockK8s.createBuildsK8sSecret.mockResolvedValue(undefined) }) afterEach(() => { @@ -206,7 +211,7 @@ describe('harborOperator', () => { it('should create a robot account successfully', async () => { const projectRobot: RobotCreate = { name: 'team-demo-pull', - level: 'project', + level: 'system', duration: -1, description: 'Allow to pull from project container registry', disable: false, @@ -227,16 +232,16 @@ describe('harborOperator', () => { mockRobotApi.createRobot.mockResolvedValue({ body: mockRobotCreated }) - const result = await createRobotAccount(projectRobot, mockRobotApi as any) + await creatingRobotAccount(projectRobot, mockRobotApi as any) - expect(result).toEqual(mockRobotCreated) expect(mockRobotApi.createRobot).toHaveBeenCalledWith(projectRobot) + expect(mockRobotApi.refreshSec).toHaveBeenCalled() }) it('should throw error if robot creation fails', async () => { const projectRobot: RobotCreate = { name: 'team-demo-pull', - level: 'project', + level: 'system', duration: -1, disable: false, permissions: [], @@ -244,7 +249,7 @@ describe('harborOperator', () => { mockRobotApi.createRobot.mockRejectedValue(new Error('Robot creation failed')) - await expect(createRobotAccount(projectRobot, mockRobotApi as any)).rejects.toThrow('Robot creation failed') + await expect(creatingRobotAccount(projectRobot, mockRobotApi as any)).rejects.toThrow('Robot creation failed') }) }) @@ -260,13 +265,15 @@ describe('harborOperator', () => { expect.objectContaining({ namespace: 'team-demo', name: 'harbor-pullsecret', - username: 'team-demo-pull', + username: 'otomi-team-demo-pull', + server: 'harbor.example.com', + password: expect.any(String), }), ) expect(mockRobotApi.createRobot).toHaveBeenCalledWith( expect.objectContaining({ name: 'team-demo-pull', - level: 'project', + level: 'system', permissions: expect.arrayContaining([ expect.objectContaining({ kind: 'project', @@ -384,19 +391,57 @@ describe('harborOperator', () => { secret: 'robot-secret-123', } - mockProjectsApi.createProject.mockResolvedValue({}) - mockProjectsApi.getProject.mockResolvedValue({ body: mockProject }) + mockProjectsApi.createProject.mockResolvedValue({body: { + projectId: 1 + }}) + mockProjectsApi.getProject.mockRejectedValue({body: { + errors : [{ code: 'PROJECT_NOT_FOUND', message: 'Project not found' }], + }}) + mockMemberApi.listProjectMembers.mockResolvedValue({ + body: [], + }) mockMemberApi.createProjectMember.mockResolvedValue({}) mockRobotApi.listRobot.mockResolvedValue({ body: [] }) mockRobotApi.createRobot.mockResolvedValue({ body: mockRobotCreated }) - const result = await manageHarborProjectsAndRobotAccounts(namespace) + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) expect(mockProjectsApi.createProject).toHaveBeenCalledWith(mockProjectReq) expect(mockProjectsApi.getProject).toHaveBeenCalledWith(namespace) expect(mockMemberApi.createProjectMember).toHaveBeenCalledTimes(2) expect(result).toBe('1') }) + it('should update project members if they exist already, associate team roles, and set up robot accounts', async () => { + const namespace = 'team-demo' + const mockProject = { projectId: 1, name: namespace } + const mockProjectReq: ProjectReq = { projectName: namespace } + const mockRobotCreated: RobotCreated = { + id: 1, + name: 'otomi-team-demo-pull', + secret: 'robot-secret-123', + } + + mockProjectsApi.createProject.mockResolvedValue({body: { + projectId: 1 + }}) + mockProjectsApi.getProject.mockRejectedValue({body: { + errors : [{ code: 'PROJECT_NOT_FOUND', message: 'Project not found' }], + }}) + mockMemberApi.listProjectMembers.mockResolvedValue({ + body: [{ id: 1, entityName: 'team-demo', roleId: 2 }], + }) + mockMemberApi.createProjectMember.mockResolvedValue({}) + mockRobotApi.listRobot.mockResolvedValue({ body: [] }) + mockRobotApi.createRobot.mockResolvedValue({ body: mockRobotCreated }) + + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) + + expect(mockProjectsApi.createProject).toHaveBeenCalledWith(mockProjectReq) + expect(mockProjectsApi.getProject).toHaveBeenCalledWith(namespace) + expect(mockMemberApi.updateProjectMember).toHaveBeenCalledTimes(2) + expect(mockMemberApi.createProjectMember).not.toHaveBeenCalled() + expect(result).toBe('1') + }) it('should return null if project not found', async () => { const namespace = 'team-demo' @@ -404,7 +449,7 @@ describe('harborOperator', () => { mockProjectsApi.createProject.mockResolvedValue({}) mockProjectsApi.getProject.mockResolvedValue({ body: null }) - const result = await manageHarborProjectsAndRobotAccounts(namespace) + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) expect(result).toBeNull() }) @@ -415,7 +460,7 @@ describe('harborOperator', () => { mockProjectsApi.createProject.mockRejectedValue(new Error('Project creation failed')) mockProjectsApi.getProject.mockResolvedValue({ body: null }) - const result = await manageHarborProjectsAndRobotAccounts(namespace) + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) expect(result).toBe(null) }) @@ -431,11 +476,15 @@ describe('harborOperator', () => { mockProjectsApi.createProject.mockResolvedValue({}) mockProjectsApi.getProject.mockResolvedValue({ body: mockProject }) + mockProjectsApi.updateProject.mockResolvedValue({ body: mockProject }) + mockMemberApi.listProjectMembers.mockResolvedValue({ + body: [], + }) mockMemberApi.createProjectMember.mockRejectedValue(new Error('Member creation failed')) mockRobotApi.listRobot.mockResolvedValue({ body: [] }) mockRobotApi.createRobot.mockResolvedValue({ body: mockRobotCreated }) - const result = await manageHarborProjectsAndRobotAccounts(namespace) + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) expect(mockMemberApi.createProjectMember).toHaveBeenCalled() expect(result).toBe('1') @@ -450,7 +499,7 @@ describe('harborOperator', () => { mockMemberApi.createProjectMember.mockResolvedValue({}) mockRobotApi.listRobot.mockRejectedValue(new Error('Robot API error')) - const result = await manageHarborProjectsAndRobotAccounts(namespace) + const result = await manageHarborProjectsAndRobotAccounts(namespace, mockHarborConfig as any, mockApis as any) expect(result).toBeNull() }) diff --git a/src/operators/harbor/harbor.ts b/src/operators/harbor/harbor.ts index 22837c8c..0c988c8d 100644 --- a/src/operators/harbor/harbor.ts +++ b/src/operators/harbor/harbor.ts @@ -1,17 +1,15 @@ import * as k8s from '@kubernetes/client-node' import { KubeConfig } from '@kubernetes/client-node' -import Operator, { ResourceEventType } from '@linode/apl-k8s-operator' -import { ConfigureApi, MemberApi, ProjectApi, RobotApi } from '@linode/harbor-client-node' -import { handleErrors, waitTillAvailable } from '../../utils' -// full list of robot permissions which are needed because we cannot do *:* anymore to allow all actions for all resources +import { ConfigureApi, HttpError, MemberApi, ProjectApi, RobotApi } from '@linode/harbor-client-node' +import { getSecret } from '../../k8s' +import { waitTillAvailable } from '../../utils' import { errors } from './lib/globals' -import { manageHarborOidcConfig } from './lib/managers/harbor-oidc' +import manageHarborOidcConfig from './lib/managers/harbor-oidc' import manageHarborProject from './lib/managers/harbor-project' import { ensureRobotAccount, getBearerToken } from './lib/managers/harbor-robots' -import { HarborConfig } from './lib/types/oidc' -import { HarborState } from './lib/types/project' +import { HarborConfig, validateConfigMapData, validateSecretData } from './lib/types/oidc' -import { debug, error, log } from 'console' +import { error, log } from 'console' import { HARBOR_ROBOT_BUILD_SUFFIX, HARBOR_ROBOT_PULL_SUFFIX, @@ -23,49 +21,14 @@ import { PROJECT_PUSH_SECRET_NAME, } from './lib/consts' import { env } from './lib/env' +import { handleErrors } from './lib/helpers' -let lastState: HarborState = {} -let setupSuccess = false - -const harborConfig: HarborConfig = { - harborBaseRepoUrl: '', - harborUser: '', - harborPassword: '', - oidcClientId: '', - oidcClientSecret: '', - oidcEndpoint: '', - oidcVerifyCert: true, - oidcUserClaim: 'email', - oidcAutoOnboard: true, - oidcGroupsClaim: 'groups', - oidcName: 'keycloak', - oidcScope: 'openid', - teamNamespaces: [], -} +const OPERATOR_SECRET_NAME = 'apl-harbor-operator-secret' +const OPERATOR_CONFIGMAP_NAME = 'apl-harbor-operator-cm' const harborBaseUrl = `${env.HARBOR_BASE_URL}:${env.HARBOR_BASE_URL_PORT}/api/v2.0` const harborHealthUrl = `${harborBaseUrl}/systeminfo` const harborOperatorNamespace = env.HARBOR_OPERATOR_NAMESPACE -let robotApi: RobotApi -let configureApi: ConfigureApi -let projectsApi: ProjectApi -let memberApi: MemberApi - -// Test helper function to inject mocked API clients (for testing only) -// Needed because we dont use the api's as function parameters -export function __setApiClients( - robot: RobotApi, - configure: ConfigureApi, - projects: ProjectApi, - member: MemberApi, -): void { - if (process.env.NODE_ENV === 'test') { - robotApi = robot - configureApi = configure - projectsApi = projects - memberApi = member - } -} const kc = new KubeConfig() // loadFromCluster when deploying on cluster @@ -76,81 +39,66 @@ if (process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT) kc.loadFromDefault() } const k8sApi = kc.makeApiClient(k8s.CoreV1Api) +let reconciling = false + +function formatHttpError(err: HttpError): string { + const responseWithRequest = err.response as unknown as { + url?: string + req?: { + method?: string + path?: string + host?: string + } + } + const request = responseWithRequest.req + const method = request?.method ?? 'unknown' + const path = request?.path ?? responseWithRequest.url ?? 'unknown' + const host = request?.host ?? 'unknown' + const response = typeof err.body === 'object' ? JSON.stringify(err.body) : String(err.body) + return `Request: ${method} ${host} ${path}. Response: status code: ${err.statusCode} - ${response}` +} -// Utility function to compare states -function hasStateChanged(currentState: HarborState, _lastState: HarborState): boolean { - return Object.entries(currentState).some(([key, value]) => !value || value !== _lastState[key]) +interface HarborApis { + robotApi: RobotApi + configureApi: ConfigureApi + projectsApi: ProjectApi + memberApi: MemberApi } -async function setupHarborApis(): Promise { - robotApi = new RobotApi(harborConfig.harborUser, harborConfig.harborPassword, harborBaseUrl) - configureApi = new ConfigureApi(harborConfig.harborUser, harborConfig.harborPassword, harborBaseUrl) - projectsApi = new ProjectApi(harborConfig.harborUser, harborConfig.harborPassword, harborBaseUrl) - memberApi = new MemberApi(harborConfig.harborUser, harborConfig.harborPassword, harborBaseUrl) +async function setupHarborApis(config: HarborConfig): Promise { + const robotApi = new RobotApi(config.harborUser, config.harborPassword, harborBaseUrl) + const configureApi = new ConfigureApi(config.harborUser, config.harborPassword, harborBaseUrl) + const projectsApi = new ProjectApi(config.harborUser, config.harborPassword, harborBaseUrl) + const memberApi = new MemberApi(config.harborUser, config.harborPassword, harborBaseUrl) const bearerAuth = await getBearerToken(robotApi, env.HARBOR_SYSTEM_ROBOTNAME, env.HARBOR_SYSTEM_NAMESPACE, k8sApi) robotApi.setDefaultAuthentication(bearerAuth) configureApi.setDefaultAuthentication(bearerAuth) projectsApi.setDefaultAuthentication(bearerAuth) memberApi.setDefaultAuthentication(bearerAuth) + return { robotApi, configureApi, projectsApi, memberApi } } -// Callbacks -const secretsAndConfigmapsCallback = async (e: any) => { - const { object } = e - const { metadata, data } = object +async function syncConfig(): Promise { + const rawSecret = (await getSecret(OPERATOR_SECRET_NAME, harborOperatorNamespace)) as Record + const secretData = validateSecretData(rawSecret) - if (object.kind === 'Secret' && metadata.name === 'apl-harbor-operator-secret') { - harborConfig.harborPassword = Buffer.from(data.harborPassword, 'base64').toString() - harborConfig.harborUser = Buffer.from(data.harborUser, 'base64').toString() - harborConfig.oidcEndpoint = Buffer.from(data.oidcEndpoint, 'base64').toString() - harborConfig.oidcClientId = Buffer.from(data.oidcClientId, 'base64').toString() - harborConfig.oidcClientSecret = Buffer.from(data.oidcClientSecret, 'base64').toString() - } else if (object.kind === 'ConfigMap' && metadata.name === 'apl-harbor-operator-cm') { - harborConfig.harborBaseRepoUrl = data.harborBaseRepoUrl - harborConfig.oidcAutoOnboard = data.oidcAutoOnboard === 'true' - harborConfig.oidcUserClaim = data.oidcUserClaim - harborConfig.oidcGroupsClaim = data.oidcGroupsClaim - harborConfig.oidcName = data.oidcName - harborConfig.oidcScope = data.oidcScope - harborConfig.oidcVerifyCert = data.oidcVerifyCert === 'true' - harborConfig.teamNamespaces = JSON.parse(data.teamNamespaces) - } else return + const configMap = await k8sApi.readNamespacedConfigMap({ + name: OPERATOR_CONFIGMAP_NAME, + namespace: harborOperatorNamespace, + }) + const configMapData = validateConfigMapData(configMap) - switch (e.type) { - case ResourceEventType.Added: - case ResourceEventType.Modified: { - try { - await runSetupHarbor() - } catch (err) { - debug(err) - } - break - } - default: - break - } -} -export default class MyOperator extends Operator { - protected async init() { - // Watch apl-harbor-operator-secret - try { - await this.watchResource('', 'v1', 'secrets', secretsAndConfigmapsCallback, harborOperatorNamespace) - } catch (e) { - debug(e) - } - // Watch apl-harbor-operator-cm - try { - await this.watchResource('', 'v1', 'configmaps', secretsAndConfigmapsCallback, harborOperatorNamespace) - } catch (e) { - debug(e) - } - } + return new HarborConfig(secretData, configMapData) } -export async function manageHarborProjectsAndRobotAccounts(namespace: string): Promise { +export default async function manageHarborProjectsAndRobotAccounts( + namespace: string, + harborConfig: HarborConfig, + apis: HarborApis, +): Promise { + const projectName = namespace try { - const projectName = namespace - const projectId = await manageHarborProject(projectName, projectsApi, memberApi) + const projectId = await manageHarborProject(projectName, apis.projectsApi, apis.memberApi) if (!projectId) { error(`Failed to manage the project ${projectName}, skipping robot account setup`) return null @@ -160,7 +108,7 @@ export async function manageHarborProjectsAndRobotAccounts(namespace: string): P namespace, projectName, harborConfig, - robotApi, + apis.robotApi, HARBOR_ROBOT_PULL_SUFFIX, HARBOR_TOKEN_TYPE_PULL, PROJECT_PULL_SECRET_NAME, @@ -169,7 +117,7 @@ export async function manageHarborProjectsAndRobotAccounts(namespace: string): P namespace, projectName, harborConfig, - robotApi, + apis.robotApi, HARBOR_ROBOT_PUSH_SUFFIX, HARBOR_TOKEN_TYPE_PUSH, PROJECT_PUSH_SECRET_NAME, @@ -178,100 +126,68 @@ export async function manageHarborProjectsAndRobotAccounts(namespace: string): P namespace, projectName, harborConfig, - robotApi, + apis.robotApi, HARBOR_ROBOT_BUILD_SUFFIX, HARBOR_TOKEN_TYPE_PUSH, PROJECT_BUILD_PUSH_SECRET_NAME, ) return projectId } catch (e) { - error(`Error processing project ${namespace}:`, e) - return null - } -} - -async function setupHarbor(): Promise { - // harborHealthUrl is an in-cluster http svc, so no multiple external dns confirmations are needed - await waitTillAvailable(harborHealthUrl, undefined, { confirmations: 1 }) - if (!harborConfig.harborUser) return - - try { - await setupHarborApis() - try { - await manageHarborOidcConfig(configureApi, harborConfig) - setupSuccess = true - } catch (err) { - error('Failed to update Harbor configuration:', err) + if (e instanceof HttpError) { + error(`Error processing project ${projectName}: ${formatHttpError(e)}`) + } else { + error(`Error processing project ${projectName}:`, e) } - if (errors.length > 0) handleErrors(errors) - } catch (e) { - error('Failed to set bearer Token for Harbor Api :', e) + return null } } -// Runners -async function checkAndExecute(): Promise { - const currentState: HarborState = { - harborBaseRepoUrl: harborConfig.harborBaseRepoUrl, - harborUser: harborConfig.harborUser, - harborPassword: harborConfig.harborPassword, - oidcClientId: harborConfig.oidcClientId, - oidcClientSecret: harborConfig.oidcClientSecret, - oidcEndpoint: harborConfig.oidcEndpoint, - oidcVerifyCert: harborConfig.oidcVerifyCert, - oidcUserClaim: harborConfig.oidcUserClaim, - oidcAutoOnboard: harborConfig.oidcAutoOnboard, - oidcGroupsClaim: harborConfig.oidcGroupsClaim, - oidcName: harborConfig.oidcName, - oidcScope: harborConfig.oidcScope, - teamNames: harborConfig.teamNamespaces, - } - - if (hasStateChanged(currentState, lastState)) { - await setupHarbor() - } - - if (!setupSuccess) await setupHarbor() - - if ( - setupSuccess && - currentState.teamNames && - currentState.teamNames.length > 0 && - currentState.teamNames !== lastState.teamNames - ) { - await Promise.all( - currentState.teamNames.map((namespace) => { - return manageHarborProjectsAndRobotAccounts(`team-${namespace}`) - }), - ) - lastState = { ...currentState } +async function reconcile(): Promise { + if (reconciling) { + log('Reconciliation already in progress, skipping this cycle') + return } -} - -async function runSetupHarbor(): Promise { + reconciling = true try { - await checkAndExecute() + const harborConfig = await syncConfig() + const apis = await setupHarborApis(harborConfig) + await manageHarborOidcConfig(apis.configureApi, harborConfig) + if (harborConfig.teamNamespaces.length > 0) { + await Promise.all( + harborConfig.teamNamespaces.map((namespace) => + manageHarborProjectsAndRobotAccounts(`team-${namespace}`, harborConfig, apis), + ), + ) + } } catch (e) { - debug('Error could not run setup harbor', e) - debug('Retrying in 30 seconds') - await new Promise((resolve) => setTimeout(resolve, 30000)) - debug('Retrying to setup harbor') - await runSetupHarbor() + if (e instanceof HttpError) { + error(`Reconciliation failed: ${formatHttpError(e)}`) + } else { + error('Reconciliation failed:', e) + } + } finally { + handleErrors(errors) + reconciling = false } } async function main(): Promise { - const operator = new MyOperator() - log(`Listening to secrets, configmaps and namespaces`) - await operator.start() - const exit = (reason: string) => { - operator.stop() + log(`Starting Harbor operator, reconciling every ${env.HARBOR_RECONCILE_INTERVAL}s`) + await waitTillAvailable(harborHealthUrl, undefined, { confirmations: 1 }) + await reconcile() + const intervalId = setInterval(() => { + void reconcile() + }, env.HARBOR_RECONCILE_INTERVAL * 1000) + process.on('SIGTERM', () => { + clearInterval(intervalId) process.exit(0) - } - - process.on('SIGTERM', () => exit('SIGTERM')).on('SIGINT', () => exit('SIGINT')) + }) + process.on('SIGINT', () => { + clearInterval(intervalId) + process.exit(130) + }) } if (typeof require !== 'undefined' && require.main === module) { - main() + void main() } diff --git a/src/operators/harbor/lib/consts.ts b/src/operators/harbor/lib/consts.ts index 46364d0f..cae3e062 100644 --- a/src/operators/harbor/lib/consts.ts +++ b/src/operators/harbor/lib/consts.ts @@ -1,4 +1,3 @@ -// consts export const HARBOR_ROLE = { admin: 1, developer: 2, diff --git a/src/operators/harbor/lib/env.ts b/src/operators/harbor/lib/env.ts index da9ac24a..d49a2913 100644 --- a/src/operators/harbor/lib/env.ts +++ b/src/operators/harbor/lib/env.ts @@ -1,5 +1,5 @@ import dotenv from 'dotenv' -import { cleanEnv, str } from 'envalid' +import { cleanEnv, num, str } from 'envalid' export const HARBOR_BASE_URL = str({ desc: 'The harbor core service URL' }) export const HARBOR_BASE_URL_PORT = str({ desc: 'The harbor core service URL port' }) @@ -9,6 +9,7 @@ export const HARBOR_SYSTEM_NAMESPACE = str({ desc: 'The harbor system namespace' export const HARBOR_SYSTEM_ROBOTNAME = str({ desc: 'The harbor system robot account name', default: 'harbor' }) export const HARBOR_PASSWORD = str({ desc: 'The harbor admin password' }) export const HARBOR_USER = str({ desc: 'The harbor admin username' }) +export const HARBOR_RECONCILE_INTERVAL = num({ desc: 'Reconciliation interval in seconds', default: 60 }) export const harborEnvValidators = { HARBOR_BASE_URL, @@ -16,6 +17,7 @@ export const harborEnvValidators = { HARBOR_OPERATOR_NAMESPACE, HARBOR_SYSTEM_NAMESPACE, HARBOR_SYSTEM_ROBOTNAME, + HARBOR_RECONCILE_INTERVAL, } if (process.env.NODE_ENV === 'test') { diff --git a/src/operators/harbor/lib/helpers.ts b/src/operators/harbor/lib/helpers.ts index 9888c782..434c022b 100644 --- a/src/operators/harbor/lib/helpers.ts +++ b/src/operators/harbor/lib/helpers.ts @@ -1,12 +1,19 @@ -import { warn } from 'console' +import { error, warn } from 'console' export function alreadyExistsError(e): boolean { if (e && e.body && e.body.errors && e.body.errors.length > 0) { - return e.body.errors[0].message.includes('already exists') + return e.body.errors[0].message.includes('already exist') } return false } +export function handleErrors(errors: string[]): void { + if (errors.length) { + error(`Errors found: ${JSON.stringify(errors, null, 2)}`) + errors.splice(0, errors.length) + } +} + export function handleApiError(errors: string[], action: string, e: unknown, statusCodeExists = 409): void { const err = e as { statusCode?: number diff --git a/src/operators/harbor/lib/managers/harbor-oidc.ts b/src/operators/harbor/lib/managers/harbor-oidc.ts index f18f284e..697620b4 100644 --- a/src/operators/harbor/lib/managers/harbor-oidc.ts +++ b/src/operators/harbor/lib/managers/harbor-oidc.ts @@ -3,7 +3,10 @@ import { log } from 'console' import { ROBOT_PREFIX } from '../consts' import { HarborConfig } from '../types/oidc' -export async function manageHarborOidcConfig(configureApi: ConfigureApi, harborConfig: HarborConfig): Promise { +export default async function manageHarborOidcConfig( + configureApi: ConfigureApi, + harborConfig: HarborConfig, +): Promise { const config: Configurations = { authMode: 'oidc_auth', oidcAdminGroup: 'platform-admin', diff --git a/src/operators/harbor/lib/managers/harbor-project.ts b/src/operators/harbor/lib/managers/harbor-project.ts index bfce8618..290705bb 100644 --- a/src/operators/harbor/lib/managers/harbor-project.ts +++ b/src/operators/harbor/lib/managers/harbor-project.ts @@ -1,9 +1,78 @@ -import { MemberApi, ProjectApi, ProjectMember, ProjectReq } from '@linode/harbor-client-node' +import { MemberApi, Project, ProjectApi, ProjectMember, ProjectReq } from '@linode/harbor-client-node' import { debug, error, log } from 'console' import { HARBOR_GROUP_TYPE, HARBOR_ROLE } from '../consts' import { errors } from '../globals' import { alreadyExistsError } from '../helpers' +function notFoundError(e): boolean { + if (e && e.body && e.body.errors && e.body.errors.length > 0) { + return e.body.errors[0].message.includes('not found') + } + return true +} + +async function createHarborProject(projectName: string, projectsApi: ProjectApi, projectReq: ProjectReq): Promise { + try { + debug(`Creating project for team ${projectName}`) + const response = await projectsApi.createProject(projectReq) + return response.body + } catch (e) { + if (!alreadyExistsError(e)) errors.push(`Error creating project for team ${projectName}: ${e}`) + return null + } +} + +const ALL_TEAMS_ADMIN = 'all-teams-admin' + +async function ensureProjectMember( + memberApi: MemberApi, + projectId: string, + projectName: string, + projMember: ProjectMember, +): Promise { + try { + const response = await memberApi.listProjectMembers( + projectId, + undefined, + undefined, + undefined, + undefined, + projectName, + ) + const existingMembers = response.body + if (existingMembers.length > 0) { + const [existingMember] = existingMembers + if (!existingMember.id) { + errors.push(`Error processing existing member for team ${projectName}: missing member ID`) + return + } + await memberApi.updateProjectMember(projectId, existingMember.id, undefined, undefined, projMember) + } else { + log(`Associating "developer" role for team "${projectName}" with harbor project "${projectName}"`) + await memberApi.createProjectMember(projectId, undefined, undefined, projMember) + } + } catch (e) { + if (!alreadyExistsError(e)) { + errors.push(`Error associating developer role for team ${projectName}: ${e}`) + } + } +} + +async function ensureProject(projectsApi: ProjectApi, projectName: string, projectReq: ProjectReq): Promise { + let project: Project = {} + try { + project = (await projectsApi.getProject(projectName)).body + await projectsApi.updateProject(projectName, projectReq) + } catch (e) { + if (notFoundError(e)) { + project = await createHarborProject(projectName, projectsApi, projectReq) + } else { + errors.push(`Error getting project for team ${projectName}: ${e}`) + } + } + return project +} + export default async function manageHarborProject( projectName: string, projectsApi: ProjectApi, @@ -13,20 +82,9 @@ export default async function manageHarborProject( const projectReq: ProjectReq = { projectName, } - try { - debug(`Creating project for team ${projectName}`) - await projectsApi.createProject(projectReq) - } catch (e) { - if (!alreadyExistsError(e)) errors.push(`Error creating project for team ${projectName}: ${e}`) - } + const project = await ensureProject(projectsApi, projectName, projectReq) - let project - try { - project = (await projectsApi.getProject(projectName)).body - } catch (e) { - errors.push(`Error getting project for team ${projectName}: ${e}`) - } - if (!project) return null + if (!project.projectId) return null const projectId = `${project.projectId}` const projMember: ProjectMember = { @@ -39,22 +97,12 @@ export default async function manageHarborProject( const projAdminMember: ProjectMember = { roleId: HARBOR_ROLE.admin, memberGroup: { - groupName: 'all-teams-admin', + groupName: ALL_TEAMS_ADMIN, groupType: HARBOR_GROUP_TYPE.http, }, } - try { - log(`Associating "developer" role for team "${projectName}" with harbor project "${projectName}"`) - await memberApi.createProjectMember(projectId, undefined, undefined, projMember) - } catch (e) { - if (!alreadyExistsError(e)) errors.push(`Error associating developer role for team ${projectName}: ${e}`) - } - try { - log(`Associating "project-admin" role for "all-teams-admin" with harbor project "${projectName}"`) - await memberApi.createProjectMember(projectId, undefined, undefined, projAdminMember) - } catch (e) { - if (!alreadyExistsError(e)) errors.push(`Error associating project-admin role for all-teams-admin: ${e}`) - } + await ensureProjectMember(memberApi, projectId, projectName, projMember) + await ensureProjectMember(memberApi, projectId, ALL_TEAMS_ADMIN, projAdminMember) log(`Successfully processed project: ${projectName}`) return projectId diff --git a/src/operators/harbor/lib/managers/harbor-robots.test.ts b/src/operators/harbor/lib/managers/harbor-robots.test.ts new file mode 100644 index 00000000..ad20f84d --- /dev/null +++ b/src/operators/harbor/lib/managers/harbor-robots.test.ts @@ -0,0 +1,107 @@ +jest.mock('../../../../k8s', () => ({ + createBuildsK8sSecret: jest.fn(), + createK8sSecret: jest.fn(), + createSecret: jest.fn(), + getSecret: jest.fn(), + replaceSecret: jest.fn(), +})) + +import { parseDockerConfigJson } from './harbor-robots' + +describe('parseDockerConfigJson', () => { + const server = 'harbor.example.com' + + it('parses username/password from .dockerconfigjson', () => { + const secret = { + '.dockerconfigjson': JSON.stringify({ + auths: { + [server]: { + username: 'otomi-team-demo-pull', + password: 'secret-token', + email: 'not@val.id', + auth: Buffer.from('otomi-team-demo-pull:secret-token').toString('base64'), + }, + }, + }), + } + + expect(parseDockerConfigJson(secret, server)).toEqual({ + username: 'otomi-team-demo-pull', + password: 'secret-token', + }) + }) + + + it('parses auth token from .dockerconfigjson', () => { + const auth = Buffer.from('robot$team-build:build-token').toString('base64') + const secret = { + '.dockerconfigjson': JSON.stringify({ + auths: { + [server]: { + auth, + }, + }, + }), + } + + expect(parseDockerConfigJson(secret, server)).toEqual({ + username: 'robot$team-build', + password: 'build-token', + }) + }) + + it('falls back to first auth entry when server key is missing', () => { + const secret = { + '.dockerconfigjson': JSON.stringify({ + auths: { + 'other.registry.local': { + username: 'robot$default', + password: 'default-token', + }, + }, + }), + } + + expect(parseDockerConfigJson(secret, server)).toEqual({ + username: 'robot$default', + password: 'default-token', + }) + }) + + it('parses credentials from config.json (builds secret key)', () => { + const secret = { + 'config.json': JSON.stringify({ + auths: { + [server]: { + username: 'robot$builds', + password: 'builds-token', + }, + }, + }), + } + + expect(parseDockerConfigJson(secret, server)).toEqual({ + username: 'robot$builds', + password: 'builds-token', + }) + }) + + it('returns undefined for invalid or missing docker config', () => { + expect(parseDockerConfigJson({}, server)).toBeUndefined() + expect(parseDockerConfigJson({ '.dockerconfigjson': '{invalid-json' }, server)).toBeUndefined() + expect( + parseDockerConfigJson( + { + '.dockerconfigjson': JSON.stringify({ + auths: { + [server]: { + auth: Buffer.from('missing-separator').toString('base64'), + }, + }, + }), + }, + server, + ), + ).toBeUndefined() + }) +}) diff --git a/src/operators/harbor/lib/managers/harbor-robots.ts b/src/operators/harbor/lib/managers/harbor-robots.ts index 6ae074a7..23cbb34d 100644 --- a/src/operators/harbor/lib/managers/harbor-robots.ts +++ b/src/operators/harbor/lib/managers/harbor-robots.ts @@ -1,8 +1,8 @@ import { CoreV1Api } from '@kubernetes/client-node' import { HttpBearerAuth, Robot, RobotApi, RobotCreate, RobotCreated } from '@linode/harbor-client-node' import { debug, error, log } from 'console' -import { randomBytes } from 'crypto' -import { createK8sSecret, createSecret, getSecret, replaceSecret } from '../../../../k8s' +import { generate as generatePassword } from 'generate-password' +import { createBuildsK8sSecret, createK8sSecret, createSecret, getSecret, replaceSecret } from '../../../../k8s' import fullRobotPermissions from '../../harbor-full-robot-system-permissions.json' import { DEFAULT_ROBOT_PREFIX, @@ -10,6 +10,7 @@ import { DOCKER_CONFIG_KEY, HARBOR_TOKEN_TYPE_PULL, HARBOR_TOKEN_TYPE_PUSH, + PROJECT_BUILD_PUSH_SECRET_NAME, ROBOT_PREFIX, SYSTEM_SECRET_NAME, } from '../consts' @@ -19,7 +20,8 @@ import { HarborConfig } from '../types/oidc' import { DockerConfigCredentials, RobotAccess, RobotAccount, RobotSecret } from '../types/robot' function generateRobotToken(): string { - return randomBytes(32).toString('hex') + // For Harbor: Secret should be 8-128 characters long with at least 1 uppercase, 1 lowercase and 1 number. + return generatePassword({ length: 32, numbers: true, uppercase: true, lowercase: true, strict: true }) } async function updateRobotToken( @@ -45,6 +47,8 @@ async function updateRobotToken( try { await robotApi.updateRobot(robot.id, robot) + // the Harbor API does not apply the provided secret immediately, so we need to refresh it after creation to ensure the correct token is set + await robotApi.refreshSec(robot.id, { secret: robot.secret }) } catch (e) { handleApiError(errors, action, e) } @@ -75,29 +79,22 @@ export function parseDockerConfigJson( return undefined } -/** - * Create Harbor system robot account that is scoped to a given Harbor project with pull access only. - * @param projectName Harbor project name - */ -export async function createRobotAccount(projectRobot: RobotCreate, robotApi: RobotApi): Promise { - let robotAccount: RobotCreated +export async function creatingRobotAccount(projectRobot: RobotCreate, robotApi: RobotApi): Promise { try { - log(`Creating robot account ${projectRobot.name} with project level permsissions`) + log(`Creating robot account ${projectRobot.name} with project level permissions`) const { body } = await robotApi.createRobot(projectRobot) - robotAccount = body + // the Harbor API does not apply the provided secret immediately, so we need to refresh it after creation to ensure the correct token is set + await robotApi.refreshSec(body.id!, { secret: projectRobot.secret }) } catch (e) { errors.push(`Error creating robot account ${projectRobot.name}: ${e}`) throw e } - - return robotAccount } async function findRobotByName(robotApi: RobotApi, robotName: string, fullName: string): Promise { const query = `name=${robotName}` const { body: robotList } = await robotApi.listRobot(undefined, query, undefined, undefined, undefined) - const existing = robotList.find((i) => i.name === fullName) - return existing + return robotList.find((i) => i.name === fullName) } function createRobotPayload(name: string, namespace: string, token: string, tokenType: string): RobotCreate { @@ -108,7 +105,7 @@ function createRobotPayload(name: string, namespace: string, token: string, toke duration: -1, description: 'Allow to push to project container registry', disable: false, - level: 'project', + level: 'system', secret: token, permissions: [ { @@ -134,7 +131,7 @@ function createRobotPayload(name: string, namespace: string, token: string, toke duration: -1, description: 'Allow to pull from project container registry', disable: false, - level: 'project', + level: 'system', secret: token, permissions: [ { @@ -152,6 +149,33 @@ function createRobotPayload(name: string, namespace: string, token: string, toke } } +async function createHarborTeamSecret( + secretName: string, + namespace: string, + harborConfig: HarborConfig, + robotName: string, + robotToken: string, +): Promise { + debug(`Creating secret/${secretName} at ${namespace} namespace`) + if (secretName === PROJECT_BUILD_PUSH_SECRET_NAME) { + await createBuildsK8sSecret({ + namespace, + name: secretName, + server: `${harborConfig.harborBaseRepoUrl}`, + username: robotName, + password: robotToken, + }) + } else { + await createK8sSecret({ + namespace, + name: secretName, + server: `${harborConfig.harborBaseRepoUrl}`, + username: robotName, + password: robotToken, + }) + } +} + export async function ensureRobotAccount( namespace: string, projectName: string, @@ -161,24 +185,20 @@ export async function ensureRobotAccount( tokenType: string, secretName: string, ): Promise { - const k8sSecret = await getSecret(secretName, namespace) - const fullName = `${ROBOT_PREFIX}${projectName}-${suffix}` const robotName = `${projectName}-${suffix}` + const fullName = `${ROBOT_PREFIX}${projectName}-${suffix}` + log( + `Attempting to sync the ${robotName} (harbor robot account token) with content of the ${namespace}/${secretName} k8s secret`, + ) + const k8sSecret = await getSecret(secretName, namespace) const existingRobot = await findRobotByName(robotApi, robotName, fullName) let robotToken = generateRobotToken() if (!k8sSecret) { - debug(`Creating ${suffix} secret/${secretName} at ${namespace} namespace`) - await createK8sSecret({ - namespace, - name: secretName, - server: `${harborConfig.harborBaseRepoUrl}`, - username: robotName, - password: robotToken, - }) + await createHarborTeamSecret(secretName, namespace, harborConfig, fullName, robotToken) } else { const credentials = parseDockerConfigJson(k8sSecret, harborConfig.harborBaseRepoUrl) if (!credentials || !credentials.password) { - error(`Failed to parse credentials from existing ${suffix} secret/${secretName} in ${namespace} namespace`) + error(`Failed to parse credentials from existing secret/${secretName} in ${namespace} namespace`) return } robotToken = credentials.password @@ -188,7 +208,7 @@ export async function ensureRobotAccount( log(`Creating ${suffix} robot account ${fullName} with project level permsissions`) const robot = createRobotPayload(robotName, projectName, robotToken, tokenType) - await createRobotAccount(robot, robotApi) + await creatingRobotAccount(robot, robotApi) } else { existingRobot.secret = robotToken await updateRobotToken(robotApi, existingRobot, namespace, secretName) @@ -208,6 +228,47 @@ export async function ensureRobotSecretHasCorrectName( } } +export function generateRobotAccount( + name: string, + accessList: RobotAccess[], + options: { + description?: string + level: 'project' | 'system' + kind: 'project' | 'system' + namespace?: string + duration?: number + disable?: boolean + }, +): RobotAccount { + const { + description = options?.description || `Robot account for ${name}`, + level = options.level, + kind = options.kind, + namespace = options?.namespace || '/', + duration = options?.duration || -1, + disable = options?.disable || false, + } = options || {} + + return { + name, + duration, + description, + disable, + level, + permissions: [ + { + kind, + namespace, + access: accessList, + }, + ], + } +} + +export function isRobotCreated(obj: unknown): obj is RobotCreated { + return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj && 'secret' in obj +} + /** * Create Harbor robot account that is used by APL tasks * @note assumes OIDC is not yet configured, otherwise this operation is NOT possible @@ -291,44 +352,3 @@ export async function getBearerToken( bearerAuth.accessToken = robotSecret.secret return bearerAuth } - -export function isRobotCreated(obj: unknown): obj is RobotCreated { - return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj && 'secret' in obj -} - -export function generateRobotAccount( - name: string, - accessList: RobotAccess[], - options: { - description?: string - level: 'project' | 'system' - kind: 'project' | 'system' - namespace?: string - duration?: number - disable?: boolean - }, -): RobotAccount { - const { - description = options?.description || `Robot account for ${name}`, - level = options.level, - kind = options.kind, - namespace = options?.namespace || '/', - duration = options?.duration || -1, - disable = options?.disable || false, - } = options || {} - - return { - name, - duration, - description, - disable, - level, - permissions: [ - { - kind, - namespace, - access: accessList, - }, - ], - } -} diff --git a/src/operators/harbor/lib/types/oidc.ts b/src/operators/harbor/lib/types/oidc.ts index 5e4ad8c1..8df84fd1 100644 --- a/src/operators/harbor/lib/types/oidc.ts +++ b/src/operators/harbor/lib/types/oidc.ts @@ -1,4 +1,65 @@ -export interface HarborConfig { +import { V1ConfigMap } from '@kubernetes/client-node' + +export interface HarborSecretData { + harborPassword: string + harborUser: string + oidcEndpoint: string + oidcClientId: string + oidcClientSecret: string +} + +export interface HarborConfigMapData { + harborBaseRepoUrl: string + oidcAutoOnboard: boolean + oidcUserClaim: string + oidcGroupsClaim: string + oidcName: string + oidcScope: string + oidcVerifyCert: boolean + teamNamespaces: string[] +} + +export function validateSecretData(data: Record): HarborSecretData { + const required = ['harborUser', 'harborPassword', 'oidcEndpoint', 'oidcClientId', 'oidcClientSecret'] + const missing = required.filter((key) => !data[key]) + if (missing.length > 0) throw new Error(`Missing required Harbor secret fields: ${missing.join(', ')}`) + return { + harborUser: data.harborUser as string, + harborPassword: data.harborPassword as string, + oidcEndpoint: data.oidcEndpoint as string, + oidcClientId: data.oidcClientId as string, + oidcClientSecret: data.oidcClientSecret as string, + } +} + +export function validateConfigMapData(configMap: V1ConfigMap): HarborConfigMapData { + const required = [ + 'harborBaseRepoUrl', + 'oidcAutoOnboard', + 'oidcUserClaim', + 'oidcGroupsClaim', + 'oidcName', + 'oidcScope', + 'oidcVerifyCert', + 'teamNamespaces', + ] + const { data } = configMap + if (!data) throw new Error('Harbor configmap data is missing') + const missing = required.filter((key) => data[key] === undefined || data[key] === '') + if (missing.length > 0) throw new Error(`Missing required Harbor configmap fields: ${missing.join(', ')}`) + return { + harborBaseRepoUrl: data.harborBaseRepoUrl, + oidcAutoOnboard: data.oidcAutoOnboard === 'true', + oidcUserClaim: data.oidcUserClaim, + oidcGroupsClaim: data.oidcGroupsClaim, + oidcName: data.oidcName, + oidcScope: data.oidcScope, + oidcVerifyCert: data.oidcVerifyCert === 'true', + teamNamespaces: JSON.parse(data.teamNamespaces) as string[], + } +} + +export class HarborConfig { harborBaseRepoUrl: string harborUser: string harborPassword: string @@ -12,4 +73,20 @@ export interface HarborConfig { oidcName: string oidcScope: string teamNamespaces: string[] + + constructor(secretData: HarborSecretData, configMapData: HarborConfigMapData) { + this.harborUser = secretData.harborUser + this.harborPassword = secretData.harborPassword + this.oidcEndpoint = secretData.oidcEndpoint + this.oidcClientId = secretData.oidcClientId + this.oidcClientSecret = secretData.oidcClientSecret + this.harborBaseRepoUrl = configMapData.harborBaseRepoUrl + this.oidcAutoOnboard = configMapData.oidcAutoOnboard + this.oidcUserClaim = configMapData.oidcUserClaim + this.oidcGroupsClaim = configMapData.oidcGroupsClaim + this.oidcName = configMapData.oidcName + this.oidcScope = configMapData.oidcScope + this.oidcVerifyCert = configMapData.oidcVerifyCert + this.teamNamespaces = configMapData.teamNamespaces + } } diff --git a/src/operators/harbor/lib/types/project.ts b/src/operators/harbor/lib/types/project.ts deleted file mode 100644 index d8eea256..00000000 --- a/src/operators/harbor/lib/types/project.ts +++ /dev/null @@ -1,4 +0,0 @@ -// interfaces -export interface HarborState { - [key: string]: any -} diff --git a/src/operators/harbor/lib/types/robot.ts b/src/operators/harbor/lib/types/robot.ts index 06e31e27..6f53693f 100644 --- a/src/operators/harbor/lib/types/robot.ts +++ b/src/operators/harbor/lib/types/robot.ts @@ -1,4 +1,3 @@ -// Interfaces export interface RobotSecret { id: number name: string diff --git a/src/utils.test.ts b/src/utils.test.ts index d7d68519..cb04016a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -58,6 +58,7 @@ describe('utils', () => { let spyFetch: jest.SpyInstance beforeEach(() => { + jest.useFakeTimers() spyFetch = jest.spyOn(global, 'fetch') }) @@ -66,19 +67,20 @@ describe('utils', () => { spyFetch.mockRestore() }) - const successResp = Promise.resolve({ status: 200 }) - const failResp = Promise.resolve({ status: 500 }) + const successResp = { status: 200 } + const failResp = { status: 500 } const url = 'https://bla.com' it('should pass after x successful requests', async () => { spyFetch.mockResolvedValue(successResp) const confirmations = 3 const res = waitTillAvailable(url, undefined, { confirmations }) - spyFetch = jest.spyOn(global, 'fetch') + + await jest.runAllTimersAsync() await expect(res).resolves.toBeUndefined() expect(spyFetch).toHaveBeenCalledTimes(confirmations) - }, 10000) + }) it('should bail when a request returns an unexpected status code', async () => { spyFetch.mockResolvedValue(failResp) @@ -95,30 +97,30 @@ describe('utils', () => { const maxTimeout = 30000 const res = waitTillAvailable(url, undefined, { retries, maxTimeout }) - await expect(res).rejects.toThrow(`Max retries (${retries}) has been reached!`) + const rejection = expect(res).rejects.toThrow(`Max retries (${retries}) has been reached!`) + await jest.runAllTimersAsync() + await rejection expect(spyFetch).toHaveBeenCalledTimes(3) - }, 30000) + }) it('should retry x times after encountering connection issues, then get y confirmations', async () => { - spyFetch.mockRejectedValue(new Error('ECONNREFUSED')) + spyFetch + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValue(successResp) + const confirmations = 3 const retries = 1000 // large enough const maxTimeout = 1000 // same as minTimeout to be able to calculate attempts const res = waitTillAvailable(url, undefined, { confirmations, retries, maxTimeout, forever: true }) - // Simulate 5 failures - jest.advanceTimersByTime(5 * 1000) // Advance time by 5 seconds - await Promise.resolve() // Allow promises to resolve - - // Now start returning ok responses - spyFetch.mockRestore() - // @ts-ignore - spyFetch = jest.spyOn(global, 'fetch').mockResolvedValue(successResp) - - jest.advanceTimersByTime(confirmations * 1000) // Advance time by confirmations * 1000ms - await Promise.resolve() // Allow promises to resolve + await jest.runAllTimersAsync() await expect(res).resolves.toBeUndefined() + expect(spyFetch).toHaveBeenCalledTimes(8) }) }) diff --git a/src/utils.ts b/src/utils.ts index eeb9bd4d..f5840935 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -58,15 +58,6 @@ export type openapiResponse = { body?: any } -export function handleErrors(errors: string[]): void { - if (errors.length) { - console.error(`Errors found: ${JSON.stringify(errors, null, 2)}`) - process.exit(1) - } else { - console.info('Success!') - } -} - type WaitTillAvailableOptions = Options & { confirmations?: number status?: number