From cea79efc6ecb4c33d868d7f5044ddc7825c69ed5 Mon Sep 17 00:00:00 2001 From: Mathieu LAUDE Date: Tue, 10 Dec 2024 15:25:49 +0100 Subject: [PATCH] feat: :sparkles: store cluster secret in Vault --- apps/server/src/resources/cluster/queries.ts | 10 +++ apps/server/src/utils/hook-wrapper.ts | 7 +- packages/hooks/src/hooks/index.ts | 1 + plugins/argocd/src/cluster.ts | 9 ++ plugins/argocd/src/functions.ts | 2 +- plugins/argocd/src/index.ts | 6 ++ plugins/argocd/src/zone.ts | 29 +++++++ plugins/gitlab/src/index.ts | 2 +- plugins/vault/src/class.ts | 91 +++++++++++++------- plugins/vault/src/functions.ts | 2 +- plugins/vault/src/index.ts | 10 ++- 11 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 plugins/argocd/src/zone.ts diff --git a/apps/server/src/resources/cluster/queries.ts b/apps/server/src/resources/cluster/queries.ts index 756ff994a..dbd6aef20 100644 --- a/apps/server/src/resources/cluster/queries.ts +++ b/apps/server/src/resources/cluster/queries.ts @@ -133,6 +133,16 @@ export function getPublicClusters() { }) } +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + export function getClusterByLabel(label: Cluster['label']) { return prisma.cluster.findUnique({ where: { label } }) } diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index 49138b0eb..7fc654ec4 100644 --- a/apps/server/src/utils/hook-wrapper.ts +++ b/apps/server/src/utils/hook-wrapper.ts @@ -1,10 +1,10 @@ import type { Cluster, Kubeconfig, Project, ProjectRole, Zone } from '@prisma/client' -import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store } from '@cpn-console/hooks' +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' import { hooks } from '@cpn-console/hooks' import type { AsyncReturnType } from '@cpn-console/shared' import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared' import { genericProxy } from './proxy.js' -import { archiveProject, getAdminPlugin, getClusterByIdOrThrow, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { archiveProject, getAdminPlugin, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' import type { ConfigRecords } from '@/resources/project-service/business.js' import { dbToObj } from '@/resources/project-service/business.js' @@ -121,7 +121,8 @@ const user = { const zone = { upsert: async (zoneId: Zone['id']) => { - const zone = await getZoneByIdOrThrow(zoneId) + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) const store = dbToObj(await getAdminPlugin()) return hooks.upsertZone.execute(zone, store) }, diff --git a/packages/hooks/src/hooks/index.ts b/packages/hooks/src/hooks/index.ts index 41fe06ff4..c6a253e10 100644 --- a/packages/hooks/src/hooks/index.ts +++ b/packages/hooks/src/hooks/index.ts @@ -46,6 +46,7 @@ export interface ZoneObject { id: string slug: string argocdUrl: string + clusterNames?: string[] } export interface ClusterObject { diff --git a/plugins/argocd/src/cluster.ts b/plugins/argocd/src/cluster.ts index da2f336d9..e0ab62050 100644 --- a/plugins/argocd/src/cluster.ts +++ b/plugins/argocd/src/cluster.ts @@ -6,11 +6,19 @@ import { getConfig, getK8sApi } from './utils.js' export const upsertCluster: StepCall = async (payload) => { try { const cluster = payload.args + const { vault } = payload.apis if (cluster.label === inClusterLabel) { await deleteClusterSecret(cluster.secretName) } else { await createClusterSecret(cluster) } + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) return { status: { result: 'OK', @@ -32,6 +40,7 @@ export const deleteCluster: StepCall = async (payload) => { try { const secretName = payload.args.secretName await deleteClusterSecret(secretName) + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) return { status: { result: 'OK', diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index 800eedf14..3dbb1bcf6 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -214,7 +214,7 @@ async function ensureInfraEnvValues(project: Project, environment: Environment, const cluster = getCluster(project, environment) const infraProject = await gitlabApi.getProjectById(repoId) const valueFilePath = getValueFilePath(project, cluster, environment) - const vaultCredentials = await vaultApi.getCredentials() + const vaultCredentials = await vaultApi.Project.getCredentials() const repositories: ArgoRepoSource[] = await Promise.all([ getArgoRepoSource('infra-apps', environment.name, gitlabApi), ...sourceRepos.map(repo => getArgoRepoSource(repo.internalRepoName, environment.name, gitlabApi)), diff --git a/plugins/argocd/src/index.ts b/plugins/argocd/src/index.ts index 0bf751134..c2d8134f3 100644 --- a/plugins/argocd/src/index.ts +++ b/plugins/argocd/src/index.ts @@ -3,6 +3,7 @@ import infos from './infos.js' import monitor from './monitor.js' import { deleteProject, upsertProject } from './functions.js' import { deleteCluster, upsertCluster } from './cluster.js' +import { upsertZone } from './zone.js' export const plugin: Plugin = { infos, @@ -27,6 +28,11 @@ export const plugin: Plugin = { main: deleteCluster, }, }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, }, monitor, } diff --git a/plugins/argocd/src/zone.ts b/plugins/argocd/src/zone.ts new file mode 100644 index 000000000..4329f1352 --- /dev/null +++ b/plugins/argocd/src/zone.ts @@ -0,0 +1,29 @@ +import { type StepCall, type ZoneObject, parseError } from '@cpn-console/hooks' +import { dump } from 'js-yaml' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + const { gitlab, vault } = payload.apis + const values = { + vault: await vault.getCredentials(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, dump(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error: parseError(error), + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index 7dc8d0d6f..11b685de9 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -52,7 +52,7 @@ export const plugin: Plugin = { upsertZone: { api: () => new GitlabZoneApi(), steps: { - main: upsertZone, + pre: upsertZone, post: commitFiles, }, }, diff --git a/plugins/vault/src/class.ts b/plugins/vault/src/class.ts index 6b434f468..c0f2a9069 100644 --- a/plugins/vault/src/class.ts +++ b/plugins/vault/src/class.ts @@ -37,6 +37,16 @@ export class VaultApi extends PluginApi { return this.token } + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) + path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + Kv = { upsert: async (kvName: string) => { const token = await this.getToken() @@ -132,7 +142,6 @@ export class VaultApi extends PluginApi { headers: { 'X-Vault-Token': await this.getToken() }, }) }, - delete: async (roleName: string) => { await this.axios.delete( `/v1/auth/approle/role/${roleName}`, @@ -141,6 +150,25 @@ export class VaultApi extends PluginApi { }, ) }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, } } @@ -233,35 +261,7 @@ export class VaultProjectApi extends VaultApi { public async destroy(path: string = '/') { if (path.startsWith('/')) path = path.slice(1) - return this.axios({ - method: 'delete', - url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}/${path}`, - headers: { 'X-Vault-Token': await this.getToken() }, - - }) - } - - public async getCredentials(): Promise { - const appRoleEnabled = await isAppRoleEnabled(this.axios, await this.getToken()) - if (!appRoleEnabled) return this.defaultAppRoleCredentials - const { data: dataRole } = await this.axios.get( - `/v1/auth/approle/role/${this.roleName}/role-id`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - const { data: dataSecret } = await this.axios.put( - `/v1/auth/approle/role/${this.roleName}/secret-id`, - 'null', // Force empty data - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - return { - ...this.defaultAppRoleCredentials, - roleId: dataRole.data?.role_id, - secretId: dataSecret.data?.secret_id, - } + return super.destroy(`${this.projectRootDir}/${this.basePath}/${path}`, this.coreKvName) } Project = { @@ -285,6 +285,15 @@ export class VaultProjectApi extends VaultApi { await this.Group.delete() await this.Role.delete(this.roleName) }, + getCredentials: async () => { + const appRoleEnabled = await isAppRoleEnabled(this.axios, await this.getToken()) + if (!appRoleEnabled) return this.defaultAppRoleCredentials + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, } Group = { @@ -370,4 +379,26 @@ export class VaultZoneApi extends VaultApi { ) return await response.data } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) + path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials() { + const appRoleEnabled = await isAppRoleEnabled(this.axios, await this.getToken()) + if (!appRoleEnabled) { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + } + } + const creds = await this.Role.getCredentials(this.roleName) + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...creds, + } + } } diff --git a/plugins/vault/src/functions.ts b/plugins/vault/src/functions.ts index 0acf4c532..d21bd89d6 100644 --- a/plugins/vault/src/functions.ts +++ b/plugins/vault/src/functions.ts @@ -30,7 +30,7 @@ export const deployAuth: StepCall = async (payload) => { if (!payload.apis.vault) { throw new Error('no Vault available') } - const appRoleCreds = await payload.apis.vault.getCredentials() + const appRoleCreds = await payload.apis.vault.Project.getCredentials() if (getConfig().disableVaultSecrets) { return okStatus } diff --git a/plugins/vault/src/index.ts b/plugins/vault/src/index.ts index 63caca760..017ecf14d 100644 --- a/plugins/vault/src/index.ts +++ b/plugins/vault/src/index.ts @@ -1,4 +1,4 @@ -import type { DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' import { archiveDsoProject, deleteZone, deployAuth, getSecrets, upsertProject, upsertZone } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' @@ -38,6 +38,12 @@ export const plugin: Plugin = { main: deleteZone, }, }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, }, monitor, } @@ -46,7 +52,7 @@ declare module '@cpn-console/hooks' { interface HookPayloadApis { vault: Args extends (ProjectLite | Project) ? VaultProjectApi - : Args extends (ZoneObject) + : Args extends (ZoneObject | ClusterObject) ? VaultZoneApi : never }