Skip to content
Closed
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
11 changes: 10 additions & 1 deletion .github/workflows/continuous-delivery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/continuous-integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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
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
Expand Down
2 changes: 2 additions & 0 deletions applicationFE/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
},
Expand Down
136 changes: 136 additions & 0 deletions applicationFE/scripts/test-application-detail-modal.mjs
Original file line number Diff line number Diff line change
@@ -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()
}
2 changes: 1 addition & 1 deletion applicationFE/scripts/test-builtin-helm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion applicationFE/scripts/test-deployment-submission.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions applicationFE/scripts/test-failed-uninstall.mjs
Original file line number Diff line number Diff line change
@@ -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')
22 changes: 20 additions & 2 deletions 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({
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',
Expand All @@ -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: () => ({}),
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion applicationFE/src/common/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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);
}
Expand Down
Loading
Loading