Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions applicationFE/scripts/test-catalog-grouping.mjs
Original file line number Diff line number Diff line change
@@ -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')
39 changes: 39 additions & 0 deletions applicationFE/scripts/test-deployment-submission.mjs
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 1 addition & 1 deletion applicationFE/scripts/test-ingress-preparation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 31 additions & 4 deletions applicationFE/src/api/softwareCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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,
Expand All @@ -47,8 +74,8 @@ export const runVmInstall = (params: {
vmDeploymentMode: string,
resourceType: string,
additionalConfig?: Record<string, any>,
}) => {
return request.post(`/applications/vm/deploy`, params)
}, isCurrent?: () => boolean) => {
return trackInstallation('VM', params, isCurrent)
}

// Application Action (VM -> INSTALL, UNINSTALL, RUN, RESTART, STOP)
Expand Down Expand Up @@ -77,8 +104,8 @@ export const runK8SInstall = (params: {
ingressTlsEnabled?: boolean,
ingressTlsSecret?: string,
additionalConfig?: Record<string, any>
}) => {
return request.post(`/applications/k8s/deploy`, params)
}, isCurrent?: () => boolean) => {
return trackInstallation('K8S', params, isCurrent)
}

export const objectStorageSmokeCheck = (params: {
Expand Down
4 changes: 2 additions & 2 deletions applicationFE/src/common/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
},
Expand All @@ -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)) {
Expand Down
68 changes: 68 additions & 0 deletions applicationFE/src/utils/deploymentSubmission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** getRandomValues remains available on HTTP origins where subtle/randomUUID are unavailable. */
export function deploymentOperationId(random: Pick<Crypto, 'getRandomValues'> = 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<DeploymentSubmissionStatus>,
getStatus: () => Promise<DeploymentSubmissionStatus>,
isCurrent: () => boolean = () => true,
sleep: () => Promise<void> = () => 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.')
}
34 changes: 34 additions & 0 deletions applicationFE/src/views/softwareCatalog/catalogGrouping.ts
Original file line number Diff line number Diff line change
@@ -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<any>) {
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 || [])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@
<tr>
<th>Enabled</th>
<th>Host</th>
<th>Access Port</th>
<th>Public IP (Worker / IBM Load Balancer)</th>
<th>Path</th>
<th>Class</th>
<th>TLS Enabled</th>
Expand All @@ -451,6 +453,8 @@
</span>
</td>
<td>{{ applicationDetail.ingressHost || 'N/A' }}</td>
<td>{{ applicationDetail.ingressAccessPorts?.join(', ') || '-' }}</td>
<td>{{ applicationDetail.ingressPublicIps?.join(', ') || '-' }}</td>
<td>{{ applicationDetail.ingressPath || 'N/A' }}</td>
<td>{{ applicationDetail.ingressClass || 'N/A' }}</td>
<td>
Expand Down
Loading
Loading