diff --git a/applicationFE/scripts/test-catalog-grouping.mjs b/applicationFE/scripts/test-catalog-grouping.mjs new file mode 100644 index 0000000..45ee7f2 --- /dev/null +++ b/applicationFE/scripts/test-catalog-grouping.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import ts from 'typescript' +const source = await readFile(new URL('../src/views/softwareCatalog/catalogGrouping.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { groupCatalogs, catalogDisplayName, loadGroupedDeploymentStatus } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +const vm = { id: 3, name: 'Nginx', packageInfo: { packageName: 'nginx' }, downloadCount: 1, ratingCount: 2, averageRating: 4 } +const k8s = { id: 12, name: 'Nginx for Kubernetes', helmChart: { chartName: 'nginx', chartRepositoryUrl: 'https://cloudpirates-io.github.io/helm-charts' }, downloadCount: 1, ratingCount: 1, averageRating: 1 } +const jupyter = { id: 11, name: 'JupyterLab for Object Storage', packageInfo: { packageName: 'quay.io/jupyter/scipy-notebook' } } +const catalogs = [vm, jupyter, k8s] +const original = JSON.stringify(catalogs) +const grouped = groupCatalogs(catalogs) +assert.deepEqual(grouped.map(c => c.name), ['Nginx', jupyter.name]) +assert.deepEqual(grouped[0].deploymentCatalogIds, [3, 12]) +assert.equal(grouped[0].downloadCount, 2) +assert.equal(grouped[0].averageRating, 3) +assert.equal(grouped[0].ratingCount, 3) +assert.equal(catalogDisplayName(k8s, catalogs), 'Nginx') +assert.equal(k8s.id, 12) +assert.equal(JSON.stringify(catalogs), original) +assert.equal(groupCatalogs([vm]).length, 1) +assert.equal(groupCatalogs([k8s]).length, 1) +assert.equal(catalogDisplayName(k8s, [k8s]), k8s.name) +assert.equal(groupCatalogs([vm, {...vm, id: 99}, k8s]).length, 3) +assert.equal(groupCatalogs([vm, {...k8s, helmChart: {...k8s.helmChart, chartRepositoryUrl: 'https://custom.example'}}]).length, 2) +const calls = [] +const status = await loadGroupedDeploymentStatus([3, 12, 3], async id => { + calls.push(id) + return { data: { deploymentHistories: [{ id: id + 20 }], applicationStatuses: [{id, deploymentHistoryId: id + 20, status: id === 3 ? 'RUNNING' : 'UNINSTALLED'}] } } +}) +assert.deepEqual(calls, [3, 12]) +assert.deepEqual(status.applicationStatuses.map(s => s.deploymentHistoryId), [23, 32]) +assert.equal(status.applicationStatuses[1].status, 'UNINSTALLED') +await assert.rejects(loadGroupedDeploymentStatus([3, 12], async id => { if (id === 12) throw Error('offline'); return { data: {} } }), /offline/) +console.log('Catalog grouping: preservation, aggregation, custom/ambiguous catalogs, names and refresh failure checks passed') diff --git a/applicationFE/scripts/test-deployment-submission.mjs b/applicationFE/scripts/test-deployment-submission.mjs new file mode 100644 index 0000000..f0d710b --- /dev/null +++ b/applicationFE/scripts/test-deployment-submission.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import ts from 'typescript' +const source=await readFile(new URL('../src/utils/deploymentSubmission.ts',import.meta.url),'utf8') +const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText +const {submitAndTrackDeployment,DeploymentStatusUnknown,deploymentOperationId}=await import('data:text/javascript;base64,'+Buffer.from(js).toString('base64')) +const state=(s)=>({id:'op1',namespace:'ns1',state:s,message:s,deploymentId:22}) +const sleep=async()=>{} +let submits=0,polls=0 +const result=await submitAndTrackDeployment('op1','ns1',async()=>{submits++;throw {response:{status:504}}},async()=>{polls++;return state(polls===1?'RUNNING':'SUCCEEDED')},()=>true,sleep) +assert.equal(result.data.id,22);assert.equal(submits,1);assert.equal(polls,2) +for(const s of ['FAILED','PARTIAL_SUCCESS']) await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state(s),async()=>assert.fail(),()=>true,sleep),new RegExp(s)) +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('INTERRUPTED'),async()=>assert.fail(),()=>true,sleep),DeploymentStatusUnknown) +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('QUEUED'),async()=>{throw Error('network')},()=>true,sleep),DeploymentStatusUnknown) +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('QUEUED'),async()=>assert.fail(),()=>false,sleep),DeploymentStatusUnknown) +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>({...state('SUCCEEDED'),id:'other'}),async()=>assert.fail(),()=>true,sleep),DeploymentStatusUnknown) +console.log('PASS: gateway504 single submit recovery, success/failure/partial propagation, interrupted/network/cancel/mismatched operation handling') + +assert.match(deploymentOperationId({getRandomValues: array => array.fill(7)}), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) +console.log('PASS: HTTP-compatible operation ID uses getRandomValues only') + +try { + await submitAndTrackDeployment('op1','ns1',async()=>state('INTERRUPTED'),async()=>assert.fail(),()=>true,sleep) + assert.fail('expected interrupted tracking') +} catch(error) { assert.deepEqual(error.interruptedOperation,{id:'op1',namespace:'ns1'}) } +let closed=false +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('ABANDONED'),async()=>assert.fail(),()=>true,sleep,10,()=>{closed=true}),/ABANDONED/) +assert.equal(closed,true) +console.log('PASS: interrupted operation can be explicitly closed without reporting success') + +for(const prior of ['SUCCEEDED','FAILED','PARTIAL_SUCCESS','ABANDONED']) { + let cleared=false,starts=0 + await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>{starts++;throw {response:{status:409}}},async()=>state(prior),()=>true,sleep,10,()=>{cleared=true}),/previous operation finished/i) + assert.equal(cleared,true);assert.equal(starts,1) +} +let cleared=false +await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>{throw {response:{status:409}}},async()=>state('RUNNING'),()=>true,sleep,10,()=>{cleared=true}),DeploymentStatusUnknown) +assert.equal(cleared,false) +console.log('PASS: changed-payload409 clears only terminal receipts and never automatically resubmits') diff --git a/applicationFE/scripts/test-ingress-preparation.mjs b/applicationFE/scripts/test-ingress-preparation.mjs index cdb2520..d0cc8c4 100644 --- a/applicationFE/scripts/test-ingress-preparation.mjs +++ b/applicationFE/scripts/test-ingress-preparation.mjs @@ -58,7 +58,7 @@ return { runInstall, cancel: () => { preparationEpoch++ }, invalidate: () => { s function harness(options = {}) { const calls = [] const values = Object.fromEntries(Object.entries({ - deploying: false, deploymentCompleted: false, modalTitle: 'Application Installation', selectInfra: options.infra || 'K8S', specCheckFlag: false, + jupyterInstallationUnsupported: false, deploying: false, deploymentCompleted: false, modalTitle: 'Application Installation', selectInfra: options.infra || 'K8S', specCheckFlag: false, specChecking: false, projectScopeError: '', servicePortCidr: '203.0.113.4/32', ingressData: { ingressEnabled: options.ingress !== false, ingressHost: 'app.company.com', ingressPath: '/', ingressTlsEnabled: Boolean(options.tls) }, isJupyterObjectStorageCatalog: Boolean(options.jupyter), projectContextKey: 'workspace/project-a', diff --git a/applicationFE/src/api/softwareCatalog.ts b/applicationFE/src/api/softwareCatalog.ts index 783a8bf..095963a 100644 --- a/applicationFE/src/api/softwareCatalog.ts +++ b/applicationFE/src/api/softwareCatalog.ts @@ -2,6 +2,8 @@ import request, { getProjectContextHeaders } from "../common/request"; import axios from "axios"; import { getApiBaseUrl } from "@/common/url"; +import { submitAndTrackDeployment, deploymentOperationId } from "@/utils/deploymentSubmission"; + const apiBaseUrl = getApiBaseUrl(import.meta.env.VITE_API_URL).replace(/\/$/, '') const standardPolicyAnalysisDays = [90, 30, 7] @@ -31,6 +33,31 @@ export const searchArtifacthubhub = (keyword: string) => { return request.get(`/search/artifacthub/${keyword}`) } +// Retain only operation IDs in session storage, never deployment settings or credentials. +async function trackInstallation(type: 'VM' | 'K8S', params: any, isCurrent?: () => boolean) { + const headers = getProjectContextHeaders() + const key = 'am-deployment:' + JSON.stringify([headers['X-MCMP-Workspace-ID'], headers['X-MCMP-Project-ID'], + params.namespace, type, params.catalogId, type === 'VM' ? params.mciId : params.clusterName, + type === 'VM' ? params.vmNodeGroupId || [...(params.vmIds || [])].sort() : null]) + let id = sessionStorage.getItem(key) + if (!id) { id = deploymentOperationId(); sessionStorage.setItem(key, id) } + const operationId = id + return submitAndTrackDeployment(operationId, params.namespace, + async () => (await request.post(`/applications/deployment-submissions/${type}`, params, + { headers: { 'Idempotency-Key': operationId }, timeout: 30000 })).data, + async () => (await request.get(`/applications/deployment-submissions/${operationId}`, + { params: { namespace: params.namespace }, timeout: 30000 })).data, + isCurrent, undefined, undefined, () => sessionStorage.removeItem(key)) +} + +export async function closeInterruptedDeployment(id: string, namespace: string) { + const response = await request.post(`/applications/deployment-submissions/${id}/close-interrupted`, null, { params: { namespace } }) + for (const key of Object.keys(sessionStorage)) { + if (key.startsWith('am-deployment:') && sessionStorage.getItem(key) === id) sessionStorage.removeItem(key) + } + return response +} + // Application 설치 (VM) export const runVmInstall = (params: { namespace: string, @@ -47,8 +74,8 @@ export const runVmInstall = (params: { vmDeploymentMode: string, resourceType: string, additionalConfig?: Record, -}) => { - return request.post(`/applications/vm/deploy`, params) +}, isCurrent?: () => boolean) => { + return trackInstallation('VM', params, isCurrent) } // Application Action (VM -> INSTALL, UNINSTALL, RUN, RESTART, STOP) @@ -77,8 +104,8 @@ export const runK8SInstall = (params: { ingressTlsEnabled?: boolean, ingressTlsSecret?: string, additionalConfig?: Record -}) => { - return request.post(`/applications/k8s/deploy`, params) +}, isCurrent?: () => boolean) => { + return trackInstallation('K8S', params, isCurrent) } export const objectStorageSmokeCheck = (params: { diff --git a/applicationFE/src/common/request.ts b/applicationFE/src/common/request.ts index 869ff96..e1309cd 100644 --- a/applicationFE/src/common/request.ts +++ b/applicationFE/src/common/request.ts @@ -84,7 +84,7 @@ service.interceptors.response.use( if (res.code === 200) { return res; } else { - toast.error(res.detail) + if (!response.config?.url?.startsWith('/applications/deployment-submissions/')) toast.error(res.detail) return Promise.reject(new Error(res.message || "Error")); } }, @@ -95,7 +95,7 @@ service.interceptors.response.use( status: res?.status, message: error?.message }) - if (res?.status === 404) { + if (res?.status === 404 && !error?.config?.url?.startsWith('/applications/deployment-submissions/')) { toast.error('API Call Fail :: Code 404') } if (axios.isCancel(error)) { diff --git a/applicationFE/src/utils/deploymentSubmission.ts b/applicationFE/src/utils/deploymentSubmission.ts new file mode 100644 index 0000000..a21a364 --- /dev/null +++ b/applicationFE/src/utils/deploymentSubmission.ts @@ -0,0 +1,68 @@ +/** getRandomValues remains available on HTTP origins where subtle/randomUUID are unavailable. */ +export function deploymentOperationId(random: Pick = globalThis.crypto): string { + const bytes = random.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +export interface DeploymentSubmissionStatus { + id: string + namespace: string + state: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'PARTIAL_SUCCESS' | 'FAILED' | 'INTERRUPTED' | 'ABANDONED' + message: string + deploymentId?: number +} + +export class DeploymentStatusUnknown extends Error { + readonly deploymentStatusUnknown = true + constructor(message: string, readonly interruptedOperation?: { id: string, namespace: string }) { super(message) } +} + +/** A transport failure is not an installation failure. Never automatically submit a second operation. */ +export async function submitAndTrackDeployment( + id: string, + namespace: string, + submit: () => Promise, + getStatus: () => Promise, + isCurrent: () => boolean = () => true, + sleep: () => Promise = () => new Promise(resolve => setTimeout(resolve, 2000)), + maxPolls = 3600, + terminal: () => void = () => {} +): Promise<{ data: { id?: number, status: string } }> { + let status: DeploymentSubmissionStatus | undefined + try { status = await submit() } catch (error: any) { + const http = error?.response?.status + if (http === 409) { + let previous: DeploymentSubmissionStatus | undefined + try { previous = await getStatus() } catch { throw error } + if (previous.id !== id || previous.namespace !== namespace) throw new DeploymentStatusUnknown('Existing operation did not match this target. Check Apps Status.') + if (['SUCCEEDED', 'FAILED', 'PARTIAL_SUCCESS', 'ABANDONED'].includes(previous.state)) { + terminal() + throw new Error('The previous operation finished. Review Apps Status, then click Deploy again if you intend to use the changed settings. No new installation was started.') + } + if (previous.state === 'INTERRUPTED') throw new DeploymentStatusUnknown(previous.message, { id, namespace }) + throw new DeploymentStatusUnknown('An installation with the previous settings is still active. Check Apps Status before changing settings; no new installation was started.') + } + if (http && http >= 400 && http < 500 && http !== 408 && http !== 429) throw error + // Submission might have committed despite a gateway timeout: recover by its original ID. + } + let failures = 0 + for (let attempt = 0; attempt < maxPolls; attempt++) { + if (!isCurrent()) throw new DeploymentStatusUnknown('Tracking stopped because the form or project changed. Installation may continue; check Apps Status.') + if (status) { + if (status.id !== id || status.namespace !== namespace) throw new DeploymentStatusUnknown('Deployment response did not match this operation. Check Apps Status before retrying.') + if (status.state === 'SUCCEEDED') { terminal(); return { data: { id: status.deploymentId, status: 'SUCCESS' } } } + if (status.state === 'FAILED' || status.state === 'PARTIAL_SUCCESS' || status.state === 'ABANDONED') { terminal(); throw new Error(status.message || 'Installation failed. Open Apps Status for details.') } + if (status.state === 'INTERRUPTED') throw new DeploymentStatusUnknown(status.message, { id, namespace }) + if (!['QUEUED', 'RUNNING'].includes(status.state)) throw new DeploymentStatusUnknown('Unknown deployment state. Check Apps Status before retrying.') + } + await sleep() + if (!isCurrent()) throw new DeploymentStatusUnknown('Tracking stopped. Closing the dialog does not cancel the installation; check Apps Status.') + try { status = await getStatus(); failures = 0 } catch { + if (++failures >= 5) throw new DeploymentStatusUnknown('Could not retrieve installation status. The operation may still be running. Check Apps Status or retry tracking; do not start a second installation.') + } + } + throw new DeploymentStatusUnknown('Installation is taking longer than expected. Check Apps Status; no replacement installation was submitted.') +} diff --git a/applicationFE/src/views/softwareCatalog/catalogGrouping.ts b/applicationFE/src/views/softwareCatalog/catalogGrouping.ts new file mode 100644 index 0000000..63b5e22 --- /dev/null +++ b/applicationFE/src/views/softwareCatalog/catalogGrouping.ts @@ -0,0 +1,34 @@ +// Group only the unique AM-provided Docker/Helm pair. Keep persistent IDs intact. +export function nginxCatalogPair(catalogs: any[]) { + const vm = catalogs.filter(c => c.name === 'Nginx' && c.packageInfo?.packageName === 'nginx' && !c.helmChart) + const k8s = catalogs.filter(c => c.name === 'Nginx for Kubernetes' && !c.packageInfo + && c.helmChart?.chartName === 'nginx' + && c.helmChart?.chartRepositoryUrl?.replace(/\/$/, '') === 'https://cloudpirates-io.github.io/helm-charts') + return vm.length === 1 && k8s.length === 1 ? { vm: vm[0], k8s: k8s[0] } : null +} + +export function catalogDisplayName(catalog: any, catalogs: any[]): string { + const pair = nginxCatalogPair(catalogs) + return pair && catalog.id === pair.k8s.id ? pair.vm.name : catalog.name +} + +export function groupCatalogs(catalogs: any[]) { + const pair = nginxCatalogPair(catalogs) + return catalogs.filter(c => !pair || c.id !== pair.k8s.id).map(c => { + const members = pair && c.id === pair.vm.id ? [pair.vm, pair.k8s] : [c] + const ratingCount = members.reduce((sum, m) => sum + Number(m.ratingCount || 0), 0) + return { ...c, catalogMembers: members, deploymentCatalogIds: members.map(m => m.id), + downloadCount: members.reduce((sum, m) => sum + Number(m.downloadCount || 0), 0), + ratingCount, averageRating: ratingCount + ? members.reduce((sum, m) => sum + Number(m.averageRating || 0) * Number(m.ratingCount || 0), 0) / ratingCount : 0 } + }) +} + +// Fail the refresh as a whole if one source fails, rather than hiding its deployments. +export async function loadGroupedDeploymentStatus(ids: number[], load: (id: number) => Promise) { + const responses = await Promise.all([...new Set(ids)].map(load)) + return { + deploymentHistories: responses.flatMap(r => r.data?.deploymentHistories || []), + applicationStatuses: responses.flatMap(r => r.data?.applicationStatuses || []) + } +} diff --git a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue index 974a4c3..f29bae3 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue @@ -437,6 +437,8 @@ Enabled Host + Access Port + Public IP (Worker / IBM Load Balancer) Path Class TLS Enabled @@ -451,6 +453,8 @@ {{ applicationDetail.ingressHost || 'N/A' }} + {{ applicationDetail.ingressAccessPorts?.join(', ') || '-' }} + {{ applicationDetail.ingressPublicIps?.join(', ') || '-' }} {{ applicationDetail.ingressPath || 'N/A' }} {{ applicationDetail.ingressClass || 'N/A' }} diff --git a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue index 444636a..b92fdcc 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue @@ -29,6 +29,11 @@ Deployment completed.

Check the application's running status in Apps Status.

+
+ AM restarted while tracking this installation. Check Apps Status before starting another installation. + +
Deployment targets are scoped to @@ -274,7 +279,7 @@ v-model="inputApplications" @change="onChangeCatalog">
@@ -477,7 +482,7 @@

Select the application

@@ -909,6 +914,7 @@ + + + + + + + App + - - -
- - + + +
+ + diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/DeploymentSubmissionControllerTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/DeploymentSubmissionControllerTest.java new file mode 100644 index 0000000..259d09b --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/DeploymentSubmissionControllerTest.java @@ -0,0 +1,26 @@ +package kr.co.mcmp.softwarecatalog.application.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.mcmp.security.project.ProjectScopeAuthorizationService; +import kr.co.mcmp.softwarecatalog.application.constants.DeploymentType; +import kr.co.mcmp.softwarecatalog.application.service.DeploymentSubmissionService; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class DeploymentSubmissionControllerTest { + @ParameterizedTest @EnumSource(value=DeploymentType.class,names={"VM","K8S"}) + void bothVmAndK8sSubmitAsyncWithAuthorizedNamespace(DeploymentType type) throws Exception { + var auth=mock(ProjectScopeAuthorizationService.class);var jobs=mock(DeploymentSubmissionService.class); + when(auth.authorizeNamespace(any(),eq("default"))).thenReturn("default"); + when(jobs.submit(any(),eq("some-operation"),any())).thenReturn(new DeploymentSubmissionService.Status("some-operation","default","QUEUED","accepted",null)); + var mvc=MockMvcBuilders.standaloneSetup(new DeploymentSubmissionController(auth,jobs,new ObjectMapper())).build(); + mvc.perform(post("/applications/deployment-submissions/"+type.name()).header("Idempotency-Key","some-operation") + .contentType("application/json").content("{\"namespace\":\"default\",\"catalogId\":11}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.state").value("QUEUED")); + verify(auth).authorizeNamespace(any(),eq("default")); + verify(jobs).submit(any(),eq("some-operation"),argThat(r->r.getDeploymentType()==type)); + } +} diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/application/service/DeploymentSubmissionServiceTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/application/service/DeploymentSubmissionServiceTest.java new file mode 100644 index 0000000..396504a --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/application/service/DeploymentSubmissionServiceTest.java @@ -0,0 +1,123 @@ +package kr.co.mcmp.softwarecatalog.application.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import java.util.concurrent.*; +import kr.co.mcmp.softwarecatalog.application.constants.DeploymentType; +import kr.co.mcmp.softwarecatalog.application.dto.DeploymentRequestDTO; +import kr.co.mcmp.softwarecatalog.application.model.DeploymentHistory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeploymentSubmissionServiceTest { + JdbcTemplate jdbc; + ApplicationOrchestrationService orchestration; + DeploymentSubmissionService jobs; + QueueExecutor executor; + DeploymentSubmissionService.Scope scope = new DeploymentSubmissionService.Scope("w1","p1","default","user1"); + @BeforeEach void setup() { + jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=PostgreSQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE deployment_history(id BIGINT, namespace VARCHAR, catalog_id BIGINT, deployment_type VARCHAR, mci_id VARCHAR, cluster_name VARCHAR, action_type VARCHAR, status VARCHAR)"); + orchestration=mock(ApplicationOrchestrationService.class); executor=new QueueExecutor(); + jobs=new DeploymentSubmissionService(jdbc,orchestration,new ObjectMapper(),executor); jobs.initialize(); + } + DeploymentRequestDTO request(DeploymentType type) { + return DeploymentRequestDTO.builder().namespace("default").catalogId(11L).deploymentType(type) + .mciId("test-mci").vmIds(List.of("vm1")).clusterName("test-cluster").build(); + } + @Test void returnsReceiptBeforeWorkAndRetriesSameIdWithoutDuplicateInstall() { + String id=UUID.randomUUID().toString(); var req=request(DeploymentType.VM); + when(orchestration.deployApplication(any())).thenReturn(DeploymentHistory.builder().id(22L).status("SUCCESS").build()); + assertThat(jobs.submit(scope,id,req).state()).isEqualTo("QUEUED"); + assertThat(jobs.submit(scope,id,req).id()).isEqualTo(id); + verifyNoInteractions(orchestration); assertThat(executor.tasks).hasSize(1); + executor.runNext(); + assertThat(jobs.get(scope,id).state()).isEqualTo("SUCCEEDED"); + assertThat(jobs.get(scope,id).deploymentId()).isEqualTo(22L); + jobs.submit(scope,id,req); verify(orchestration,times(1)).deployApplication(any()); + } + @Test void concurrentTargetAndChangedPayloadAreRejected() { + var req=request(DeploymentType.K8S); String id=UUID.randomUUID().toString();jobs.submit(scope,id,req); + assertThatThrownBy(()->jobs.submit(scope,UUID.randomUUID().toString(),req)).isInstanceOf(ResponseStatusException.class); + req.setServicePort(8088); + assertThatThrownBy(()->jobs.submit(scope,id,req)).isInstanceOf(ResponseStatusException.class); + assertThat(executor.tasks).hasSize(1); + } + @Test void propagatesFailedAndPartialResultsInsteadOfTreatingAnyHistoryAsSuccess() { + for(String state:List.of("FAILED","PARTIAL_SUCCESS")) { + when(orchestration.deployApplication(any())).thenReturn(DeploymentHistory.builder().id(23L).status(state).build()); + String id=UUID.randomUUID().toString();jobs.submit(scope,id,request(DeploymentType.K8S));executor.runNext(); + assertThat(jobs.get(scope,id).state()).isEqualTo(state); + } + } + @Test void persistsRestartStateAndRetainsGuardRatherThanReplayingRemoteWork() { + String id=UUID.randomUUID().toString();jobs.submit(scope,id,request(DeploymentType.VM)); + jdbc.update("UPDATE application_deployment_submission SET state='RUNNING' WHERE id=?",id); + var restarted=new DeploymentSubmissionService(jdbc,orchestration,new ObjectMapper(),new QueueExecutor());restarted.initialize(); + assertThat(restarted.get(scope,id).state()).isEqualTo("INTERRUPTED"); + assertThatThrownBy(()->restarted.submit(scope,UUID.randomUUID().toString(),request(DeploymentType.VM))).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(orchestration); + } + @Test void isolatesOwnerAndProjectAndDoesNotPersistCredentialsOrExceptionText() { + String id=UUID.randomUUID().toString();var req=request(DeploymentType.VM);req.setAdditionalConfig(Map.of("password","sensitive-test-value")); + when(orchestration.deployApplication(any())).thenThrow(new IllegalStateException("sensitive-test-value")); + jobs.submit(scope,id,req); + assertThatThrownBy(()->jobs.get(new DeploymentSubmissionService.Scope("w1","p1","default","user2"),id)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->jobs.get(new DeploymentSubmissionService.Scope("w1","p2","default","user1"),id)).isInstanceOf(ResponseStatusException.class); + executor.runNext();assertThat(jobs.get(scope,id).state()).isEqualTo("FAILED"); + assertThat(jdbc.queryForList("SELECT * FROM application_deployment_submission").toString()).doesNotContain("sensitive-test-value"); + } + @Test void restartClosesNeverStartedQueueAndReconcilesOnlyUniqueTerminalHistory() { + String queued=UUID.randomUUID().toString();jobs.submit(scope,queued,request(DeploymentType.VM));jobs.initialize(); + assertThat(jobs.get(scope,queued).state()).isEqualTo("FAILED"); + String running=UUID.randomUUID().toString();jobs.submit(scope,running,request(DeploymentType.VM)); + jdbc.update("UPDATE application_deployment_submission SET state='RUNNING' WHERE id=?",running);jobs.initialize(); + jdbc.update("INSERT INTO deployment_history VALUES(22,'default',11,'VM','test-mci',NULL,'INSTALL','SUCCESS')"); + assertThat(jobs.get(scope,running).state()).isEqualTo("SUCCEEDED"); + assertThat(jobs.get(scope,running).deploymentId()).isEqualTo(22L); + verifyNoInteractions(orchestration); + } + @Test void returnsWhileDeploymentIsStillBlockedOnRemoteWork() throws Exception { + var real=Executors.newSingleThreadExecutor();var started=new CountDownLatch(1);var release=new CountDownLatch(1); + when(orchestration.deployApplication(any())).thenAnswer(invocation->{started.countDown();release.await(5,TimeUnit.SECONDS);return DeploymentHistory.builder().id(24L).status("SUCCESS").build();}); + var async=new DeploymentSubmissionService(jdbc,orchestration,new ObjectMapper(),real); + String id=UUID.randomUUID().toString(); + try { + var receipt=async.submit(scope,id,request(DeploymentType.K8S)); + assertThat(started.await(2,TimeUnit.SECONDS)).isTrue(); + assertThat(receipt.state()).isIn("QUEUED","RUNNING"); + assertThat(async.get(scope,id).state()).isEqualTo("RUNNING"); + } finally {release.countDown();real.shutdown();assertThat(real.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} + assertThat(async.get(scope,id).state()).isEqualTo("SUCCEEDED"); + } + @Test void multiVmPartialHistoryCannotReconcileAndOnlyOwnerCanCloseInterruptedTracking() { + String id=UUID.randomUUID().toString();var req=request(DeploymentType.VM);req.setVmIds(List.of("vm1","vm2")); + jobs.submit(scope,id,req);jdbc.update("UPDATE application_deployment_submission SET state='RUNNING' WHERE id=?",id);jobs.initialize(); + jdbc.update("INSERT INTO deployment_history VALUES(25,'default',11,'VM','test-mci',NULL,'INSTALL','SUCCESS')"); + assertThat(jobs.get(scope,id).state()).isEqualTo("INTERRUPTED"); + assertThatThrownBy(()->jobs.closeInterrupted(new DeploymentSubmissionService.Scope("w1","p1","default","other"),id)).isInstanceOf(ResponseStatusException.class); + assertThat(jobs.closeInterrupted(scope,id).state()).isEqualTo("ABANDONED"); + assertThat(jdbc.queryForObject("SELECT status FROM deployment_history WHERE id=25",String.class)).isEqualTo("SUCCESS"); + jobs.submit(scope,UUID.randomUUID().toString(),req); + verifyNoInteractions(orchestration); + } + @Test void runningTrackingCannotBeClosedAndDeletedHistoryDoesNotReportSuccess() { + String id=UUID.randomUUID().toString();jobs.submit(scope,id,request(DeploymentType.K8S)); + assertThatThrownBy(()->jobs.closeInterrupted(scope,id)).isInstanceOf(ResponseStatusException.class); + jdbc.update("UPDATE application_deployment_submission SET state='RUNNING' WHERE id=?",id);jobs.initialize(); + jdbc.update("INSERT INTO deployment_history VALUES(26,'default',11,'K8S',NULL,'test-cluster','INSTALL','DELETED')"); + assertThat(jobs.get(scope,id).state()).isEqualTo("FAILED"); + } + static class QueueExecutor extends AbstractExecutorService { + List tasks=new ArrayList<>(); public void execute(Runnable task){tasks.add(task);} + void runNext(){tasks.remove(0).run();} + public void shutdown(){} public List shutdownNow(){return List.of();} + public boolean isShutdown(){return false;} public boolean isTerminated(){return false;} + public boolean awaitTermination(long timeout,TimeUnit unit){return true;} + } +} diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/CloudPiratesNginxHelmTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/CloudPiratesNginxHelmTest.java new file mode 100644 index 0000000..cd8c715 --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/CloudPiratesNginxHelmTest.java @@ -0,0 +1,76 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import kr.co.mcmp.softwarecatalog.application.dto.DeploymentConfigDTO; +import kr.co.mcmp.softwarecatalog.application.model.HelmChart; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.yaml.snakeyaml.Yaml; +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "AM_HELM_BIN", matches = ".+") +class CloudPiratesNginxHelmTest { + @TempDir Path temp; + + @ParameterizedTest + @CsvSource({"false,nginx", "true,nginx", "false,public-iks-k8s-nginx", "true,public-iks-k8s-nginx"}) + void rendersTheActualAmIngressAdapterAndCidr(boolean tls, String ingressClass) throws Exception { + var chart = HelmChart.builder().chartName("nginx").chartRepositoryUrl("https://cloudpirates-io.github.io/helm-charts").build(); + var config = DeploymentConfigDTO.builder().ingressEnabled(true).ingressHost("nginx.example.com") + .servicePort(8088).ingressClass(ingressClass).ingressPath("/").ingressTlsEnabled(tls).ingressTlsSecret("nginx-tls").build(); + Map values = new HashMap<>(HelmIngressValues.from(chart, config)); + K8sIngressPolicy.configureValues("nginx", new HashMap<>(), values, config, "210.217.178.130/32"); + Path file = temp.resolve("values.yaml"); + Files.writeString(file, new Yaml().dump(values)); + Path output = temp.resolve("manifest.yaml"); + // Pull into an isolated directory: Helm otherwise prefers a local directory named nginx. + Path pullLog = temp.resolve("pull.log"); + var pull = new ProcessBuilder(System.getenv("AM_HELM_BIN"), "pull", "nginx", + "--repo", chart.getChartRepositoryUrl(), "--version", "0.16.8", "--destination", temp.toString()) + .directory(temp.toFile()).redirectErrorStream(true).redirectOutput(pullLog.toFile()).start(); + assertThat(pull.waitFor(120, TimeUnit.SECONDS)).isTrue(); + assertThat(pull.exitValue()).as(Files.readString(pullLog)).isZero(); + var process = new ProcessBuilder(System.getenv("AM_HELM_BIN"), "template", "nginx-test", + temp.resolve("nginx-0.16.8.tgz").toString(), + "--values", file.toString(), "--set", "autoscaling.enabled=true") + .redirectErrorStream(true).redirectOutput(output.toFile()).start(); + assertThat(process.waitFor(120, TimeUnit.SECONDS)).isTrue(); + String manifest = Files.readString(output); + assertThat(process.exitValue()).as(manifest).isZero(); + K8sIngressPolicy.verifyManifest(manifest, "210.217.178.130/32", "nginx.example.com", ingressClass); + assertThat(manifest).contains("kind: HorizontalPodAutoscaler", "nginx:1.31.5", "port: 8088") + .doesNotContain("PersistentVolumeClaim"); + List> resources = new ArrayList<>(); + for (Object document : new Yaml().loadAll(manifest)) { + if (document instanceof Map) resources.add(asMap(document)); + } + var deployment = resources.stream().filter(r -> "Deployment".equals(r.get("kind"))).findFirst().orElseThrow(); + var pod = asMap(asMap(asMap(deployment.get("spec")).get("template")).get("spec")); + var container = asMap(((List) pod.get("containers")).get(0)); + var ports = (List) container.get("ports"); + assertThat(ports).hasSize(1); + assertThat(asMap(ports.get(0))).containsEntry("containerPort", 8080).containsEntry("name", "http"); + assertThat(asMap(container.get("securityContext"))).containsEntry("runAsUser", 101) + .containsEntry("runAsNonRoot", true).containsEntry("allowPrivilegeEscalation", false); + var service = resources.stream().filter(r -> "Service".equals(r.get("kind"))).findFirst().orElseThrow(); + var servicePort = asMap(((List) asMap(service.get("spec")).get("ports")).get(0)); + assertThat(servicePort).containsEntry("port", 8088).containsEntry("targetPort", "http"); + var serverConfig = resources.stream().filter(r -> "ConfigMap".equals(r.get("kind"))) + .flatMap(r -> asMap(r.get("data")).values().stream()).map(String::valueOf) + .filter(value -> value.contains("server_name")).findFirst().orElseThrow(); + assertThat(serverConfig).contains("listen 8080;").doesNotContain("listen 80;"); + if (tls) assertThat(manifest).contains("secretName: nginx-tls"); + } + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return (Map) value; + } +} diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/IngressAddressServiceTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/IngressAddressServiceTest.java new file mode 100644 index 0000000..702ac77 --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/IngressAddressServiceTest.java @@ -0,0 +1,156 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import io.fabric8.kubernetes.api.model.*; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.PodResource; +import io.fabric8.kubernetes.client.dsl.ServiceResource; +import io.fabric8.kubernetes.client.dsl.Resource; +import kr.co.mcmp.softwarecatalog.application.constants.DeploymentType; +import kr.co.mcmp.softwarecatalog.application.model.DeploymentHistory; +import kr.co.mcmp.softwarecatalog.kubernetes.config.KubernetesClientFactory; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class IngressAddressServiceTest { + private final KubernetesClient client = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); + private final KubernetesClientFactory factory = mock(KubernetesClientFactory.class); + private final IngressAddressService service = new IngressAddressService(factory, mock(kr.co.mcmp.ape.cbtumblebug.api.CbtumblebugRestApi.class)); + + @SuppressWarnings("unchecked") + IngressAddressServiceTest() { + MixedOperation podOperation = mock(MixedOperation.class); + when(client.pods()).thenReturn(podOperation); + when(podOperation.inNamespace("default")).thenReturn(podOperation); + when(podOperation.withLabel(anyString(), anyString())).thenReturn(podOperation); + MixedOperation> serviceOperation = mock(MixedOperation.class); + when(client.services()).thenReturn(serviceOperation); + when(serviceOperation.withLabel(anyString(), anyString())).thenReturn(serviceOperation); + when(serviceOperation.inNamespace("default")).thenReturn(serviceOperation); + when(serviceOperation.inNamespace("kube-system")).thenReturn(serviceOperation); + when(serviceOperation.withName(anyString())).thenReturn(mock(ServiceResource.class)); + MixedOperation> nodeOperation = mock(MixedOperation.class); + when(client.nodes()).thenReturn(nodeOperation); + when(nodeOperation.withName(anyString())).thenReturn(mock(Resource.class)); + clearInvocations(client); + } + + private void pods(Pod... pods) { + when(client.pods().inNamespace("default") + .withLabel("app.kubernetes.io/instance", "nginx-ingress-ns") + .withLabel("app.kubernetes.io/component", "controller").list()) + .thenReturn(new PodListBuilder().withItems(pods).build()); + } + + private Pod pod(String node, String phase) { + return new PodBuilder().withNewMetadata().endMetadata() + .withNewSpec().withNodeName(node).endSpec() + .withNewStatus().withPhase(phase).endStatus().build(); + } + + @Test void onlyRunningControllerNodesAreUsedAndAddressesAreDeduplicated() { + pods(pod("worker", "Running"), pod("worker", "Running"), pod("pending", "Pending")); + when(client.nodes().withName("worker").get()).thenReturn(new NodeBuilder().withNewStatus() + .addNewAddress().withType("InternalIP").withAddress("10.0.0.1").endAddress() + .addNewAddress().withType("ExternalIP").withAddress("54.1.2.3").endAddress().endStatus().build()); + assertEquals(List.of("54.1.2.3"), service.resolve(client, "ns", "AWS", "nginx")); + verify(client.nodes().withName("worker"), times(1)).get(); + } + + @Test void privateOnlyWorkerReturnsNoAddress() { + pods(pod("worker", "Running")); + when(client.nodes().withName("worker").get()).thenReturn(new NodeBuilder().withNewStatus() + .addNewAddress().withType("InternalIP").withAddress("10.0.0.1").endAddress().endStatus().build()); + assertEquals(List.of(), service.resolve(client, "ns", "ALIBABA", "nginx")); + assertEquals(List.of(), service.resolve(client, "ns", "NHN", "nginx")); + } + + @Test void ibmUsesManagedPublicLoadBalancerInsteadOfWorker() { + when(client.services().inNamespace("kube-system").list()) + .thenReturn(new ServiceListBuilder().withItems(new ServiceBuilder() + .withNewMetadata().withName("public-alb1").addToLabels("app.kubernetes.io/part-of", "managed-ingress").endMetadata() + .withNewSpec().withType("LoadBalancer").endSpec().withNewStatus().withNewLoadBalancer() + .addNewIngress().withIp("161.1.2.3").endIngress() + .endLoadBalancer().endStatus().build()).build()); + assertEquals(List.of("161.1.2.3"), service.resolve(client, "ns", "IBM", "public-iks-k8s-nginx")); + verify(client, never()).nodes(); + } + + @Test void pendingLoadBalancerAndMissingControllerReturnNoAddress() { + when(client.services().inNamespace("kube-system").list()).thenReturn(new ServiceListBuilder().build()); + assertTrue(service.resolve(client, "ns", "IBM", "public-iks-k8s-nginx").isEmpty()); + pods(); + assertTrue(service.resolve(client, "ns", "AWS", "nginx").isEmpty()); + } + + @Test void dnsAddressesExcludePrivateAndSharedAddresses() throws Exception { + var resolving = spy(service); + doReturn(new java.net.InetAddress[]{java.net.InetAddress.getByName("161.1.2.3"), + java.net.InetAddress.getByName("10.1.2.3"), java.net.InetAddress.getByName("100.64.0.1")}) + .when(resolving).lookup("alb.example.test"); + var addresses = new java.util.TreeSet(); + resolving.addPublicAddresses(addresses, "alb.example.test"); + assertEquals(java.util.Set.of("161.1.2.3"), addresses); + assertTrue(service.resolve(client, "ns", "IBM", "private-iks-k8s-nginx").isEmpty()); + } + + @Test void disabledIngressDoesNotConnectAndConnectionFailureIsContained() { + DeploymentHistory history = new DeploymentHistory(); + history.setDeploymentType(DeploymentType.K8S); + history.setNamespace("ns"); + history.setClusterName("cluster"); + assertTrue(service.getPublicIps(history, false, "nginx").isEmpty()); + verifyNoInteractions(factory); + when(factory.getClient("ns", "cluster")).thenThrow(new IllegalStateException("unavailable")); + assertTrue(service.getPublicIps(history, true, "nginx").isEmpty()); + } + + @Test void workerAccessUsesNodePortAndSelectsTheRouteProtocol() { + when(client.services().inNamespace("default").withLabel("app.kubernetes.io/instance", "nginx-ingress-ns") + .withLabel("app.kubernetes.io/component", "controller").list()) + .thenReturn(new ServiceListBuilder().withItems(new ServiceBuilder().withNewMetadata().withName("controller").endMetadata() + .withNewSpec().withType("NodePort") + .addNewPort().withName("http").withPort(80).withNodePort(30880).endPort() + .addNewPort().withName("https").withPort(443).withNodePort(30443).endPort() + .addNewPort().withName("metrics").withPort(10254).withNodePort(30254).endPort() + .endSpec().build()).build()); + assertEquals(List.of(30880), service.resolvePorts(client, "ns", "AWS", "nginx", false)); + assertEquals(List.of(30443), service.resolvePorts(client, "ns", "AWS", "nginx", true)); + } + + @Test void ibmAccessUsesLoadBalancerPortNotNodePortAndDeduplicates() { + var alb = new ServiceBuilder().withNewMetadata().withName("public-alb1") + .addToLabels("app.kubernetes.io/part-of", "managed-ingress").endMetadata() + .withNewSpec().withType("LoadBalancer") + .addNewPort().withName("http").withPort(80).withNodePort(31080).endPort() + .addNewPort().withName("https").withPort(443).withNodePort(31443).endPort().endSpec().build(); + when(client.services().inNamespace("kube-system").list()).thenReturn(new ServiceListBuilder() + .withItems(alb, new ServiceBuilder(alb).editMetadata().withName("public-alb2").endMetadata().build()).build()); + assertEquals(List.of(80), service.resolvePorts(client, "ns", "IBM", "public-iks-k8s-nginx", false)); + assertEquals(List.of(443), service.resolvePorts(client, "ns", "IBM", "public-iks-k8s-nginx", true)); + assertEquals(List.of(), service.resolvePorts(client, "ns", "IBM", "private-iks-k8s-nginx", false)); + } + + @Test void missingNodePortDoesNotExposeInternalServicePort() { + when(client.services().inNamespace("default").withLabel(anyString(), anyString()).withLabel(anyString(), anyString()).list()) + .thenReturn(new ServiceListBuilder().withItems(new ServiceBuilder().withNewMetadata().withName("controller").endMetadata() + .withNewSpec().withType("ClusterIP").addNewPort().withName("http").withPort(80).endPort().endSpec().build()).build()); + assertTrue(service.resolvePorts(client, "ns", "NHN", "nginx", false).isEmpty()); + } + + @Test void endpointUsesOneClientAndDisabledIngressSkipsAllLookups() { + DeploymentHistory history = new DeploymentHistory(); + history.setDeploymentType(DeploymentType.K8S); history.setNamespace("ns"); + history.setClusterName("cluster"); history.setCloudProvider("AWS"); + assertEquals(new IngressAddressService.Endpoint(List.of(), List.of()), service.getEndpoint(history, false, "nginx", false)); + verifyNoInteractions(factory); + when(factory.getClient("ns", "cluster")).thenReturn(client); + pods(); + when(client.services().inNamespace("default").withLabel(anyString(), anyString()).withLabel(anyString(), anyString()).list()) + .thenReturn(new ServiceListBuilder().build()); + assertEquals(new IngressAddressService.Endpoint(List.of(), List.of()), service.getEndpoint(history, true, "nginx", false)); + verify(factory, times(1)).getClient("ns", "cluster"); + } +} diff --git a/src/test/java/kr/co/mcmp/util/NginxHelmCatalogTest.java b/src/test/java/kr/co/mcmp/util/NginxHelmCatalogTest.java new file mode 100644 index 0000000..ff64309 --- /dev/null +++ b/src/test/java/kr/co/mcmp/util/NginxHelmCatalogTest.java @@ -0,0 +1,86 @@ +package kr.co.mcmp.util; + +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThat; + +class NginxHelmCatalogTest { + private JdbcTemplate jdbc; + private DatabaseInitializer initializer; + + @BeforeEach + void setUp() { + jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute(""" + CREATE TABLE SOFTWARE_CATALOG ( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + TITLE VARCHAR, DESCRIPTION VARCHAR, SUMMARY VARCHAR, CATEGORY VARCHAR, + LOGO_URL_LARGE VARCHAR, LOGO_URL_SMALL VARCHAR, MIN_CPU DOUBLE PRECISION, + RECOMMENDED_CPU DOUBLE PRECISION, MIN_MEMORY DOUBLE PRECISION, + RECOMMENDED_MEMORY DOUBLE PRECISION, MIN_DISK INTEGER, RECOMMENDED_DISK INTEGER, + CPU_THRESHOLD DOUBLE PRECISION, MEMORY_THRESHOLD DOUBLE PRECISION, + MIN_REPLICAS INTEGER, MAX_REPLICAS INTEGER, HPA_ENABLED BOOLEAN, + DEFAULT_PORT INTEGER, INGRESS_ENABLED BOOLEAN, CREATED_AT TIMESTAMP, UPDATED_AT TIMESTAMP) + """); + jdbc.execute(""" + CREATE TABLE HELM_CHART (CATALOG_ID BIGINT, CHART_NAME VARCHAR, CHART_VERSION VARCHAR, + CHART_REPOSITORY_URL VARCHAR, REPOSITORY_NAME VARCHAR, REPOSITORY_DISPLAY_NAME VARCHAR, + REPOSITORY_OFFICIAL BOOLEAN, PACKAGE_ID VARCHAR, NORMALIZED_NAME VARCHAR, + APP_VERSION VARCHAR, DESCRIPTION VARCHAR, CATEGORY VARCHAR, IMAGE_REPOSITORY VARCHAR, + TAG VARCHAR, HAS_VALUES_SCHEMA BOOLEAN) + """); + jdbc.execute("CREATE TABLE SOFTWARE_CATALOG_REF (CATALOG_ID BIGINT, REF_IDX INTEGER, REF_VALUE VARCHAR, REF_DESC VARCHAR, REF_TYPE VARCHAR)"); + initializer = new DatabaseInitializer(); + ReflectionTestUtils.setField(initializer, "jdbcTemplate", jdbc); + } + + @Test + void createsExternalChartAndIsIdempotent() { + synchronize(); + synchronize(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM HELM_CHART", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT CHART_REPOSITORY_URL FROM HELM_CHART", String.class)) + .isEqualTo("https://cloudpirates-io.github.io/helm-charts"); + assertThat(jdbc.queryForObject("SELECT CHART_VERSION FROM HELM_CHART", String.class)).isEqualTo("0.16.8"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG_REF", Integer.class)).isEqualTo(1); + } + + @Test + void migratesExactLegacyMappingWithoutDuplicatingIt() { + legacy(11, "Nginx for Kubernetes", "http://localhost:18084/charts", "0.1.0"); + synchronize(); + synchronize(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM HELM_CHART WHERE CATALOG_ID=11", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT PACKAGE_ID FROM HELM_CHART WHERE CATALOG_ID=11", String.class)).isEqualTo("cloudpirates-nginx"); + assertThat(jdbc.queryForObject("SELECT APP_VERSION FROM HELM_CHART WHERE CATALOG_ID=11", String.class)).isEqualTo("1.31.5"); + } + + @Test + void preservesCustomizedRepositoryAndVersionAndOtherCatalog() { + legacy(11, "Nginx for Kubernetes", "https://example.org/custom", "0.1.0"); + legacy(12, "Nginx for Kubernetes", "http://localhost:18084/charts", "9.0.0"); + legacy(13, "Another catalog", "http://localhost:18084/charts", "0.1.0"); + synchronize(); + assertThat(jdbc.queryForList("SELECT PACKAGE_ID FROM HELM_CHART", String.class)) + .containsExactly("mcmp-builtin-nginx", "mcmp-builtin-nginx", "mcmp-builtin-nginx"); + assertThat(jdbc.queryForObject("SELECT CHART_REPOSITORY_URL FROM HELM_CHART WHERE CATALOG_ID=11", String.class)) + .isEqualTo("https://example.org/custom"); + } + + private void legacy(long id, String title, String url, String version) { + jdbc.update("INSERT INTO SOFTWARE_CATALOG (ID, TITLE) VALUES (?, ?)", id, title); + jdbc.update(""" + INSERT INTO HELM_CHART (CATALOG_ID, CHART_NAME, CHART_VERSION, CHART_REPOSITORY_URL, REPOSITORY_NAME, PACKAGE_ID) + VALUES (?, 'nginx', ?, ?, 'mcmp-builtin', 'mcmp-builtin-nginx') + """, id, version, url); + } + + private void synchronize() { + ReflectionTestUtils.invokeMethod(initializer, "ensureNginxHelmCatalog"); + } +}