diff --git a/.github/workflows/continuous-delivery.yaml b/.github/workflows/continuous-delivery.yaml index 705960a4..c908d62b 100644 --- a/.github/workflows/continuous-delivery.yaml +++ b/.github/workflows/continuous-delivery.yaml @@ -94,8 +94,17 @@ jobs: run: chmod +x gradlew shell: bash + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build current frontend + working-directory: applicationFE + run: npm ci && npm run build + - name: Build with Gradle Wrapper - run: ./gradlew clean build --stacktrace + run: ./gradlew clean build -PincludeFrontend --stacktrace - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index 8d5fb10f..e4ed2a77 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -61,8 +61,17 @@ jobs: run: chmod +x gradlew shell: bash + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build current frontend + working-directory: applicationFE + run: npm ci && npm run build + - name: Build with Gradle Wrapper - run: ./gradlew clean build --stacktrace + run: ./gradlew clean build -PincludeFrontend --stacktrace - name: chk directory run: ls -al diff --git a/Dockerfile b/Dockerfile index 6402b85e..50725993 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ FROM eclipse-temurin:17-jdk-noble # Install curl RUN apt-get update && apt-get install -y --no-install-recommends \ - curl openssh-client && \ + curl openssh-client skopeo && \ rm -rf /var/lib/apt/lists/* # Copy binaries from builder @@ -32,7 +32,7 @@ COPY --from=builder /usr/local/bin/docker /usr/local/bin/docker COPY --from=builder /usr/local/bin/helm /usr/local/bin/helm # Optional: verify versions -RUN docker --version && helm version +RUN docker --version && helm version && skopeo --version # Copy application COPY ./build/libs/am.jar /am.jar diff --git a/applicationFE/package.json b/applicationFE/package.json index 42dea9a8..66f6719d 100644 --- a/applicationFE/package.json +++ b/applicationFE/package.json @@ -15,6 +15,8 @@ "test:ibm-ingress-tls": "node scripts/test-ibm-ingress-tls.mjs", "test:ingress-preparation": "node scripts/test-ingress-preparation.mjs", "test:nhn-cinder-addon": "node scripts/test-nhn-cinder-addon.mjs", + "test:detail-modal": "node scripts/test-application-detail-modal.mjs", + "test:failed-uninstall": "node scripts/test-failed-uninstall.mjs", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "format": "prettier --write src/" }, diff --git a/applicationFE/scripts/test-application-detail-modal.mjs b/applicationFE/scripts/test-application-detail-modal.mjs new file mode 100644 index 00000000..6c2b401a --- /dev/null +++ b/applicationFE/scripts/test-application-detail-modal.mjs @@ -0,0 +1,136 @@ +// Run against the real Vite app: AM_UI_TEST_URL=http://127.0.0.1:4179 +// PLAYWRIGHT_MODULE_PATH may point to an existing Playwright installation. +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright') +const origin = process.env.AM_UI_TEST_URL || 'http://127.0.0.1:4179' +const browser = await chromium.launch({ headless: true, channel: 'chrome' }) +const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }) +const errors = [] +page.on('pageerror', error => { errors.push(error.message); console.error('Browser error:', error.message) }) +let state = 'FAILED', delay = false, release, deny = false, scenarios = 0 +await page.route('**/*', async route => { + const path = new URL(route.request().url()).pathname + if (!['/api/applications/', '/applications/', '/catalog/', '/cbtumblebug/'].some(p => path.startsWith(p))) return route.continue() + let data = path.startsWith('/catalog/') || path.startsWith('/cbtumblebug/') ? [] : null + if (path.includes('/status/groups')) data = [{ + id: 1, deploymentHistoryId: 42, applicationName: 'Nginx', status: state, + deploymentType: 'K8S', namespace: 'default', clusterName: 'k5', checkedAt: '2026-10-13T08:00:00' + }] + if (path.includes('/integrated/deployment/')) { + if (delay) await new Promise(resolve => { release = resolve }) + if (deny) return route.fulfill({ status: 401, json: { code: 401, message: 'Unauthorized' } }) + data = { integratedInfo: { + deploymentId: 42, catalogName: 'Nginx', status: state, applicationStatus: state === 'SUCCESS' ? 'RUNNING' : state, + deploymentType: 'K8S', namespace: 'default', clusterName: 'k5', operationHistories: [], errorLogs: [], + deploymentLogs: [{ id: 1, logType: state === 'SUCCESS' ? 'INFO' : 'ERROR', logMessage: 'Deployment log: ' + state }] + } } + } + return route.fulfill({ json: { code: 200, data } }) +}) +const modal = page.locator('#application-status-detail-modal') +const openStatus = async () => { + await page.locator('.tabulator-row').first().getByText('Nginx', { exact: true }).click() + await modal.locator('.modal-body').waitFor({ state: 'visible' }) +} +const assertClosed = async () => { + await modal.waitFor({ state: 'hidden' }) + await page.waitForTimeout(200) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.equal(await page.evaluate(() => document.body.classList.contains('modal-open')), false) + assert.equal(await page.evaluate(() => document.body.style.overflow), '') + scenarios++ +} +try { + await page.goto(origin + '/web/applications/status') + await page.locator('.tabulator-row').first().waitFor() + // A second Bootstrap bundle/global must not own this component's lifecycle. + await page.evaluate(() => { window.bootstrap = { Modal: class { constructor() { throw Error('Foreign Bootstrap must not be used') } } } }) + for (state of ['SUCCESS', 'FAILED', 'PARTIAL_SUCCESS']) { + for (const action of ['footer', 'header', 'escape', 'backdrop']) { + await openStatus() + await modal.getByText('Deployment log: ' + state, { exact: true }).last().waitFor() + await page.waitForTimeout(400) + if (action === 'footer') await modal.locator('button.btn-secondary').click() + if (action === 'header') await modal.locator('button.btn-close').click() + if (action === 'escape') await page.keyboard.press('Escape') + if (action === 'backdrop') await page.mouse.click(5, 5) + await assertClosed() + } + } + // Close immediately during animation/loading; a late response must not resurrect the dialog. + delay = true + await openStatus() + await modal.locator('button.btn-close').click({ force: true }) + await assertClosed() + while (!release) await page.waitForTimeout(20) + delay = false; release() + await page.waitForTimeout(300) + assert(!(await modal.isVisible())) + assert.equal(await modal.locator('.alert-danger').count(), 0) + // A denied API is not a reason to trap the user in an empty dialog. + deny = true + await openStatus() + await modal.getByText('No data available', { exact: true }).waitFor() + await page.waitForTimeout(400) + await modal.locator('button.btn-secondary').click() + await assertClosed(); deny = false + await openStatus() + await page.waitForTimeout(400) + await page.evaluate(async () => { + await document.querySelector('#app').__vue_app__.config.globalProperties.$router.push('/web/applications/swcatalog') + }) + await page.getByRole('button', { name: 'Deploy', exact: true }).first().waitFor() + await page.waitForTimeout(600) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.equal(await page.evaluate(() => document.body.classList.contains('modal-open')), false) + scenarios++ + // Catalog and deployment-failure entry points share the exact same detail component. + await page.evaluate(() => { + const seen = new Set() + const find = vnode => { + if (!vnode || seen.has(vnode)) return + seen.add(vnode) + if (vnode.component?.type?.__file?.endsWith('/softwareCatalogList.vue')) return vnode.component + const nodes = [vnode.component?.subTree, ...(Array.isArray(vnode.children) ? vnode.children : [])] + for (const node of nodes) { const found = find(node); if (found) return found } + } + const catalog = find(document.querySelector('#app').__vue_app__._instance.subTree) + if (!catalog) throw Error('Catalog component not found') + catalog.setupState.openApplicationDetail(42) + }) + const catalogModal = page.locator('#application-detail-modal') + await catalogModal.locator('.modal-body').waitFor({ state: 'visible' }) + await page.waitForTimeout(400) + await catalogModal.locator('button.btn-secondary').click() + await catalogModal.waitFor({ state: 'hidden' }); scenarios++ + await page.getByRole('button', { name: 'Deploy', exact: true }).first().click() + await page.waitForTimeout(500) + await page.evaluate(() => { + const s = document.querySelector('#install-form').__vueParentComponent.setupState + window.installState = s + s.deploymentFailure = { message: 'Test failure', deploymentId: 42, unknown: false } + s.selectMci = 'preserved-input' + }) + for (const action of ['footer', 'escape']) { + await page.getByRole('button', { name: 'View deployment logs' }).click() + const detail = page.locator('#install-form-failure-detail') + await detail.locator('.alert-danger').waitFor() + await page.waitForTimeout(400) + if (action === 'footer') await detail.locator('button.btn-secondary').click() + else await page.keyboard.press('Escape') + await page.locator('#install-form.show').waitFor() + await page.waitForTimeout(400) + assert.equal(await page.evaluate(() => window.installState.selectMci), 'preserved-input') + assert.equal(await page.locator('.modal-backdrop').count(), 1) + scenarios++ + } + await page.locator('#install-form button.btn-close').click() + await page.locator('#install-form').waitFor({ state: 'hidden' }) + await page.waitForTimeout(300) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.deepEqual(errors, []) + console.log('PASS: ' + scenarios + ' real-browser modal scenarios; close/reopen, transitions, late responses, auth failure, route cleanup, catalog, failure logs and retained inputs') +} finally { + await browser.close() +} diff --git a/applicationFE/scripts/test-builtin-helm.mjs b/applicationFE/scripts/test-builtin-helm.mjs index 07c83d12..bb310568 100644 --- a/applicationFE/scripts/test-builtin-helm.mjs +++ b/applicationFE/scripts/test-builtin-helm.mjs @@ -36,7 +36,7 @@ for (const app of ['redis','mariadb','postgresql','apache','tomcat']) { h.selectedCatalogChartName.value=app h.selectedCatalogInfo.value={helmChart:{chartName:app,repositoryName:'mcmp-builtin',chartRepositoryUrl:'classpath:helm',chartVersion:version,packageId:'mcmp-builtin-'+app}} await nextTick() - const persistent=version==='0.1.0' && target==='K8S' && ['redis','mariadb','postgresql'].includes(app) + const persistent=['0.1.0', '0.1.1'].includes(version) && target==='K8S' && ['redis','mariadb','postgresql'].includes(app) assert.equal(h.isBuiltInPersistentCatalog.value,persistent) assert.equal(h.storageClassRequired.value,persistent) if (persistent) { diff --git a/applicationFE/scripts/test-deployment-submission.mjs b/applicationFE/scripts/test-deployment-submission.mjs index f0d710b9..503f7df0 100644 --- a/applicationFE/scripts/test-deployment-submission.mjs +++ b/applicationFE/scripts/test-deployment-submission.mjs @@ -3,13 +3,17 @@ 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 {submitAndTrackDeployment,DeploymentStatusUnknown,DeploymentFailed,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)) +for (const deploymentId of [22, undefined]) { + await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>({...state('FAILED'), deploymentId}),async()=>assert.fail(),()=>true,sleep), + error => error instanceof DeploymentFailed && error.deploymentId === deploymentId && error.message === 'FAILED') +} 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) diff --git a/applicationFE/scripts/test-failed-uninstall.mjs b/applicationFE/scripts/test-failed-uninstall.mjs new file mode 100644 index 00000000..0cb36ace --- /dev/null +++ b/applicationFE/scripts/test-failed-uninstall.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import ts from 'typescript' +import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' + +const source = await readFile(new URL('../src/views/softwareCatalog/applicationStatusDisplay.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText +const { isApplicationActionDisabledStatus: lifecycleDisabled, isApplicationUninstallDisabledStatus: uninstallDisabled } = await import('data:text/javascript;base64,' + Buffer.from(js).toString('base64')) + +for (const status of ['FAILED', 'failed', ' Failed ']) { + assert.equal(lifecycleDisabled(status), true, status) + assert.equal(uninstallDisabled(status), false, status) +} +for (const status of ['PREPARING_RUNTIME', 'PREPARING_METRICS_SERVER', 'PREPARING_INGRESS_NGINX', 'DEPLOYING', 'IN_PROGRESS', 'INSTALL', 'START', 'STARTING', 'RESTART', 'RESTARTING', 'UNINSTALL', 'UNINSTALLED']) { + assert.equal(lifecycleDisabled(status), true, status) + assert.equal(uninstallDisabled(status), true, status) +} +for (const status of ['RUNNING', 'SUCCESS', 'STOPPED', 'ERROR', 'IMAGE_PULL_ERROR', 'DELETE_PENDING', 'UNKNOWN', '', null, undefined]) { + assert.equal(uninstallDisabled(status), lifecycleDisabled(status), String(status)) +} +const component = await readFile(new URL('../src/views/softwareCatalog/components/applicationStatusList.vue', import.meta.url), 'utf8') +assert.match(component, /btnFlag === 'uninstall-btn'\s*\? isApplicationUninstallDisabledStatus\(status\)/) +assert.match(component, /id='uninstall-btn'[\s\S]*?\$\{uninstallDisabledAttr\}/) +assert.match(component, /id='rating-btn'[\s\S]*?\$\{disabledAttr\}/) +const { descriptor, errors } = parse(component) +assert.deepEqual(errors, []) +compileScript(descriptor, { id: 'failed-uninstall-regression' }) +const template = compileTemplate({ source: descriptor.template.content, filename: 'applicationStatusList.vue', id: 'failed-uninstall-regression' }) +assert.deepEqual(template.errors, []) +console.log('PASS: failed cleanup enabled; lifecycle/rating and in-progress/uninstalled guards preserved across 26 varied inputs') diff --git a/applicationFE/scripts/test-ingress-preparation.mjs b/applicationFE/scripts/test-ingress-preparation.mjs index d0cc8c48..f67405a3 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({ - jupyterInstallationUnsupported: false, deploying: false, deploymentCompleted: false, modalTitle: 'Application Installation', selectInfra: options.infra || 'K8S', specCheckFlag: false, + interruptedDeployment: null, deploymentFailure: null, 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', @@ -70,7 +70,8 @@ function harness(options = {}) { }).map(([k,v]) => [k,ref(v)])) const args = { ...values, - toast: { success: m => calls.push(['success',m]), error: m => calls.push(['error',m]) }, + toast: { success: m => calls.push(['success',m]), error: m => calls.push(['error',m]), warning: m => calls.push(['warning',m]) }, + DeploymentFailed: class extends Error { constructor(message,id) { super(message); this.deploymentId=id } }, validateStorageClassSelection: () => true, emitDeploymentEvent: (...e) => calls.push(['event',...e]), getDeploymentId: () => 123, buildK8sAdditionalConfig: () => ({}), @@ -97,6 +98,23 @@ await test('actual IBM Deploy uses prepare -> poll READY -> application API with for(const key of ['namespace','clusterName','catalogId','ingressHost','servicePortCidr']) assert.equal(prepared[key],deployed[key]) assert.equal(deployed.openServicePort,false); assert.equal(h.deploying.value,false) }) +await test('actual form retains failure reason and history ID without reporting success', async () => { + const h=harness({ibm:false,deploy:async()=>{throw Object.assign(new Error('Enable Ingress to configure external CIDR access.'),{deploymentId:42})}}); + await h.runInstall(); + assert.deepEqual(h.deploymentFailure.value,{message:'Enable Ingress to configure external CIDR access.',deploymentId:42,unknown:false}); + assert.equal(h.calls.some(c=>c[0]==='success'),false); + assert.equal(h.calls.find(c=>c[0]==='event' && c[1]==='DEPLOY_FAILED')[2].deploymentId,42); +}) +await test('legacy FAILED result is not treated as success and transport uncertainty stays separate', async () => { + const failed=harness({ibm:false,deploy:async()=>({data:{id:123,status:'FAILED'}})}); + await failed.runInstall(); + assert.equal(failed.deploymentFailure.value.deploymentId,123); + assert.equal(failed.deploymentCompleted.value,false); + const unknown=harness({ibm:false,deploy:async()=>{throw Object.assign(new Error('May still be running'),{deploymentStatusUnknown:true})}}); + await unknown.runInstall(); + assert.equal(unknown.deploymentFailure.value.unknown,true); + assert.equal(unknown.calls.some(c=>c[0]==='event' && c[1]==='DEPLOY_FAILED'),false); +}) for(const event of ['cancel','invalidate','unmount','project']) { await test(`actual form ${event} during preparation does not call application deployment`, async () => { let h diff --git a/applicationFE/src/common/request.ts b/applicationFE/src/common/request.ts index e1309cdf..74a23b93 100644 --- a/applicationFE/src/common/request.ts +++ b/applicationFE/src/common/request.ts @@ -85,7 +85,10 @@ service.interceptors.response.use( return res; } else { if (!response.config?.url?.startsWith('/applications/deployment-submissions/')) toast.error(res.detail) - return Promise.reject(new Error(res.message || "Error")); + const error = Object.assign(new Error(res.detail || res.message || "Error"), { + deploymentId: res.data?.deploymentId + }) + return Promise.reject(error); } }, error => { @@ -101,6 +104,10 @@ service.interceptors.response.use( if (axios.isCancel(error)) { return Promise.reject(error); } + if (res?.data?.data?.deploymentId) { + error.deploymentId = res.data.data.deploymentId + error.message = res.data.detail || res.data.message || error.message + } // toast.error(error.message) return Promise.reject(error); } diff --git a/applicationFE/src/utils/deploymentSubmission.ts b/applicationFE/src/utils/deploymentSubmission.ts index a21a3649..45039f6c 100644 --- a/applicationFE/src/utils/deploymentSubmission.ts +++ b/applicationFE/src/utils/deploymentSubmission.ts @@ -20,6 +20,10 @@ export class DeploymentStatusUnknown extends Error { constructor(message: string, readonly interruptedOperation?: { id: string, namespace: string }) { super(message) } } +export class DeploymentFailed extends Error { + constructor(message: string, readonly deploymentId?: number, readonly state = 'FAILED') { super(message) } +} + /** A transport failure is not an installation failure. Never automatically submit a second operation. */ export async function submitAndTrackDeployment( id: string, @@ -54,7 +58,7 @@ export async function submitAndTrackDeployment( 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 === 'FAILED' || status.state === 'PARTIAL_SUCCESS' || status.state === 'ABANDONED') { terminal(); throw new DeploymentFailed(status.message || 'Installation failed. Open Apps Status for details.', status.deploymentId, status.state) } 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.') } diff --git a/applicationFE/src/views/softwareCatalog/applicationStatusDisplay.ts b/applicationFE/src/views/softwareCatalog/applicationStatusDisplay.ts index 8e71343f..9337a8d2 100644 --- a/applicationFE/src/views/softwareCatalog/applicationStatusDisplay.ts +++ b/applicationFE/src/views/softwareCatalog/applicationStatusDisplay.ts @@ -114,6 +114,12 @@ export const isApplicationActionDisabledStatus = (status: string | null | undefi return ACTION_DISABLED_STATUSES.has(normalizeStatus(status)) } +// A failed installation can leave managed resources pending cleanup. Keep +// lifecycle actions disabled, but allow the existing authorized uninstall flow. +export const isApplicationUninstallDisabledStatus = (status: string | null | undefined) => { + return normalizeStatus(status) !== 'FAILED' && isApplicationActionDisabledStatus(status) +} + export const getApplicationHealthDisplay = ( healthCheck: boolean | null | undefined, status: string | null | undefined diff --git a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue index f29bae3b..1ed67166 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue @@ -4,7 +4,7 @@
@@ -511,9 +516,10 @@