diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index b5860e5265..3a25cf8840 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,17 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Candidate `1.6.174` changes VM image guidance and defaults only: it does not +implement a VM runtime or image whitelist. Existing/custom/last-used VM images, +ordinary container defaults and required validation remain supported. Shared +image-required errors are localized in English and Traditional Chinese, with +the existing English fallback for other locales. Backend capabilities, +payloads, permissions and authentication contracts are unchanged; dependency +versions and the graph are unchanged. The five added Ember regressions and +formal release gates remain pending; 817 is only an expected test count. +Historical releases and HOLDs are not promoted, and the full matrix remains +INCOMPLETE. See the [candidate release note](docs/releases/web-console-1.6.174.md). + Published `1.6.173` reads native ProjectTemplate card labels from the model's existing `name`, not `localizedName`, which native ProjectTemplate does not implement. Name-dependent sorting, rename reactivity and exact-ID selection remain native; diff --git a/README.md b/README.md index 9ba2929097..5ba5834a82 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Candidate `1.6.174` removes the ordinary-container default and quick picks from +the VM image field. Custom images, existing image values and the last-used VM +image remain supported; ordinary container defaults are unchanged. A blank +image still blocks the existing save lifecycle. VM boot-image guidance and +required errors for both VM and container images use reviewed English and +Traditional Chinese copy with the existing English fallback. +Dependency versions and the dependency graph are unchanged. Five added Ember +regressions have not yet run; 817 total tests is an expectation, not a result. +Exact-source CI, reproducible artifacts, signed publication, Server packaging +and packaged VM acceptance remain pending. Historical releases and HOLDs are +unchanged; the full matrix remains INCOMPLETE. See the +[candidate release note](docs/releases/web-console-1.6.174.md). + Published `1.6.173` repairs empty environment-template choice labels. These choices are native `ProjectTemplate` resources, not Catalog templates: their authoritative label is `name`, and selection remains bound to the template ID. diff --git a/app/components/form-image/component.js b/app/components/form-image/component.js index 07e4499f70..0713b4c53e 100644 --- a/app/components/form-image/component.js +++ b/app/components/form-image/component.js @@ -6,10 +6,11 @@ import ManageLabels from 'ui/mixins/manage-labels'; // Remember the last value and use that for new one var lastContainer = 'ubuntu:26.04'; -var lastVm = 'ubuntu:26.04'; +var lastVm = null; var lastWindows = 'microsoft/nanoserver'; export default Component.extend(ManageLabels, { + intl: service(), settings: service(), projects: service(), @@ -44,10 +45,12 @@ export default Component.extend(ManageLabels, { if ( !initial ) { - if ( this.get('projects.current.isWindows') ) { + if ( this.get('isVm') ) { + initial = lastVm; + } else if ( this.get('projects.current.isWindows') ) { initial = lastWindows; } else { - initial = ( this.get('isVm') ? lastVm : lastContainer); + initial = lastContainer; } } @@ -83,11 +86,11 @@ export default Component.extend(ManageLabels, { } else if ( input && input.length ) { - if ( this.get('projects.current.isWindows') ) { - lastWindows = input; - } else if ( this.get('isVm') ) - { + if ( this.get('isVm') ) { lastVm = input; + } else if ( this.get('projects.current.isWindows') ) + { + lastWindows = input; } else { @@ -105,13 +108,13 @@ export default Component.extend(ManageLabels, { this.validate(); }.observes('userInput'), - validate() { + validate: function() { var errors = []; if ( !this.get('value') ) { - errors.push('Image is required'); + errors.push(this.get('intl').t(this.get('isVm') ? 'formImage.vm.bootImageRequired' : 'formImage.container.imageRequired')); } this.set('errors', errors); - }, + }.observes('intl._locale'), }); diff --git a/app/components/form-image/template.hbs b/app/components/form-image/template.hbs index 28bf9ea8e1..c89d6c34b5 100644 --- a/app/components/form-image/template.hbs +++ b/app/components/form-image/template.hbs @@ -10,17 +10,8 @@
{{#if this.isVm}} -
- {{input type="text" value=this.userInput placeholder=(t 'formImage.vm.placeholder') class="form-control"}} -
- - -
-
+ {{input type="text" value=this.userInput placeholder=(t 'formImage.vm.bootImagePlaceholder') class="form-control"}} +

{{t 'formImage.vm.bootImageHelp'}}

{{else}} {{input type="text" class="form-control" value=this.userInput placeholder=(t 'formImage.container.placeholder')}} {{/if}} diff --git a/config/translation-fallback-prefixes.js b/config/translation-fallback-prefixes.js index 2ee3a6e226..21c9862054 100644 --- a/config/translation-fallback-prefixes.js +++ b/config/translation-fallback-prefixes.js @@ -43,5 +43,8 @@ module.exports = Object.freeze([ 'newSecret.refreshFailed', 'accountsPage.new.error.', 'editAccount.error.', - 'viewEditProject.error.' + 'viewEditProject.error.', + // VM boot-image guidance uses reviewed English fallback outside zh-tw. + 'formImage.vm.bootImage', + 'formImage.container.imageRequired' ]); diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 70f107e71c..42faa1ed01 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.173", + "version": "1.6.174", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.173", + "version": "1.6.174", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.174.md b/docs/releases/web-console-1.6.174.md new file mode 100644 index 0000000000..f9f6eebf68 --- /dev/null +++ b/docs/releases/web-console-1.6.174.md @@ -0,0 +1,53 @@ +# Web Console 1.6.174 + +Candidate only. Exact-source CI, reproducible production artifacts, signed +publication, anonymous public readback and Server packaging have not yet been +completed for this version. No VM lifecycle PASS is claimed. + +## Root cause and bounded repair + +The shared image form supplied `ubuntu:26.04` as a fresh VM default and offered +`ubuntu:26.04` and `alpine:3.22` as VM quick picks. These ordinary-container +choices do not establish a compatible VM boot image. The VM launch contract +passes `-m` and `-smp` and uses the `/image` boot-disk interface; merely choosing +an operating-system container image does not supply that VM runtime contract. + +Remove the fresh VM default and both quick picks, without substituting another +unverified image or restricting custom image references. Preserve an existing +image and the last-used VM image. Ordinary Linux and Windows container defaults +remain unchanged. Blank image input still reaches the existing required +validation and cancels the native save lifecycle before resource persistence. + +English and Traditional Chinese explain the boot-image contract and ask the +operator to verify the target host meets the image's virtualization needs, +for example KVM (`/dev/kvm`). This guidance does not add a new KVM gate or claim +that every custom VM image forbids software emulation. VM and ordinary-container +required errors are localized and react to locale changes. New messages use +the existing English fallback outside the two reviewed locales. + +No VM engine, host configuration, hardware flags, API schema, payload, +authorization, MFA or resource lifecycle is changed. Dependency versions, +overrides and the installed dependency graph are unchanged; the two lock files +change only their root package version metadata to `1.6.174`. + +## Verification and release boundaries + +Five new real-component Ember regressions cover existing and last-used images, +blank-image save cancellation, custom VM input without misleading quick picks, +VM guidance/required-error locale changes and ordinary-container required-error +locale changes on the same form. They have not yet been executed. The expected +full test count is 817 (the previous 812 plus these five), not a verified result. +Offline method controls and syntax checks do not replace Chrome rendering, +exact-source CI or packaged native VM acceptance. + +No source commit, formal run, archive checksum or publication coordinate is +asserted for this candidate. Reuse only the eventual reviewed exact-source CI +artifact for publication; do not rebuild or overwrite an older release. +The existing vendor-pending advisory and audit policy remain unchanged; this +candidate is not a zero-CVE claim. + +The [published Web173 record](web-console-1.6.173.md) and its scoped results +remain historical evidence, not proof of this candidate's VM lifecycle. +Original HOLD receipts remain HOLD. The complete permission/resource/locale +matrix remains INCOMPLETE. Preserve rollback artifacts, configuration and +volumes; no company deployment is authorized by this source change. diff --git a/package-lock.json b/package-lock.json index 70f107e71c..42faa1ed01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.173", + "version": "1.6.174", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.173", + "version": "1.6.174", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 9ed5e6b3ee..12c62eee76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.173", + "version": "1.6.174", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 5252a4815d..3b8fba134a 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.173" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.173" +if [[ "$version" != "1.6.174" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.174" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 19e8ab7bc4..f3a6ca34f2 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.173 browser-session broker-broadcast + 1.6.174 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 6d93fa02fe..f138722f6e 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -70,7 +70,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.173": +if package.get("version") != "1.6.174": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/scripts/check-ui-ember-api-store-fetch-upgrade b/scripts/check-ui-ember-api-store-fetch-upgrade index 84d18eaa17..78f2413db6 100755 --- a/scripts/check-ui-ember-api-store-fetch-upgrade +++ b/scripts/check-ui-ember-api-store-fetch-upgrade @@ -416,7 +416,8 @@ for marker in [ if marker not in page_header_sources: fail(f"transient service collection regression marker missing: {marker}") -if template_action_count != 563: +# The two ordinary-container VM quick-pick actions were removed in Web174. +if template_action_count != 561: fail(f"unexpected template action count: {template_action_count}") if power_select_count != 15: fail(f"unexpected modern PowerSelect count: {power_select_count}") diff --git a/tests/integration/form-image-vm-boot-test.js b/tests/integration/form-image-vm-boot-test.js new file mode 100644 index 0000000000..e2872c089b --- /dev/null +++ b/tests/integration/form-image-vm-boot-test.js @@ -0,0 +1,107 @@ +import { module, test } from 'qunit'; +import { render, find, findAll, fillIn, settled, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import { run } from '@ember/runloop'; +import resolver from '../helpers/resolver'; +import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts'; +import { initialize as initializeIntl } from 'ui/instance-initializers/intl'; + +module('Integration | Component | VM boot image', function(hooks) { + hooks.beforeEach(async function() { + this.testRoot = document.createElement('div'); + this.testRoot.id = 'ember-testing'; + document.body.appendChild(this.testRoot); + await setupContext(this, {resolver}); + initializePodLayouts(); + initializeIntl(this.owner); + this.owner.register('service:projects', Service.extend({ + current: EmberObject.create({isWindows: false}), + })); + this.owner.register('service:settings', Service.extend({appName: 'PastureStack'})); + this.intl = this.owner.lookup('service:intl'); + + for (let locale of ['en-us', 'zh-tw', 'fr-fr']) { + let response = await fetch('/translations/' + locale + '.json'); + + if (!response.ok) { + throw new Error('Local translation fixture failed: ' + locale + ' ' + response.status); + } + this.intl.addTranslations(locale, await response.json()); + } + this.intl.setLocale(['en-us']); + this.changed = (value) => { this.set('selectedImage', value); }; + this.setLabels = () => {}; + await setupRenderingContext(this); + }); + + hooks.afterEach(async function() { + await teardownContext(this); + this.testRoot.remove(); + }); + + async function renderVm() { + await render(precompileTemplate('{{form-image isVm=true initialValue="docker:example.test/boot:qa" errors=this.imageErrors changed=this.changed setLabels=this.setLabels}}{{top-errors errors=this.imageErrors}}')); + } + + test('VM accepts custom boot-image text with no ordinary-container quick picks', async function(assert) { + await renderVm(); + assert.strictEqual(find('input[type="text"]').value, 'example.test/boot:qa', 'existing image is preserved'); + assert.strictEqual(find('input[type="text"]').placeholder, 'VM boot image'); + assert.strictEqual(findAll('.dropdown-toggle, .dropdown-menu').length, 0, 'no VM quick picks imply that ordinary container images can boot a VM'); + assert.ok(find('.vm-boot-image-help').textContent.includes('/dev/kvm')); + + await fillIn('input[type="text"]', 'registry.example/custom-boot:qa'); + assert.strictEqual(this.selectedImage, 'docker:registry.example/custom-boot:qa', 'arbitrary custom VM images remain accepted'); + await fillIn('input[type="text"]', ''); + assert.deepEqual(this.imageErrors, ['Enter a compatible VM boot image.']); + assert.ok(this.testRoot.textContent.includes('Enter a compatible VM boot image.'), 'the actual top-errors component displays the required error'); + }); + + test('blank VM required copy and guidance react to locale, with English fallback', async function(assert) { + await renderVm(); + await fillIn('input[type="text"]', ''); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.deepEqual(this.imageErrors, ['請填入相容的 VM 專用開機映像。']); + assert.strictEqual(find('input[type="text"]').placeholder, 'VM 專用開機映像'); + assert.ok(this.testRoot.textContent.includes('確認目標主機符合映像的虛擬化需求')); + + run(() => this.intl.setLocale(['fr-fr', 'en-us'])); + await settled(); + assert.deepEqual(this.imageErrors, ['Enter a compatible VM boot image.'], 'missing non-primary locale keys use loaded English translations'); + assert.strictEqual(find('input[type="text"]').placeholder, 'VM boot image'); + let help = find('.vm-boot-image-help').textContent; + + for (let capability of ['-m', '-smp', '/image', '/dev/kvm']) { + assert.ok(help.includes(capability), capability); + } + assert.notOk(this.testRoot.textContent.includes('Missing translation'), 'fallback has no missing-key marker'); + }); + + test('blank container required copy rerenders on the same form with zh-tw and English fallback', async function(assert) { + await render(precompileTemplate('{{form-image isVm=false initialValue="docker:example.test/container:qa" errors=this.imageErrors changed=this.changed setLabels=this.setLabels}}{{top-errors errors=this.imageErrors}}')); + let input = find('input[type="text"]'); + + await fillIn('input[type="text"]', ''); + assert.strictEqual(this.selectedImage, null); + assert.deepEqual(this.imageErrors, ['Image is required']); + assert.ok(this.testRoot.textContent.includes('Image is required')); + assert.strictEqual(findAll('.vm-boot-image-help').length, 0, 'no VM guidance is added to the container form'); + + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.strictEqual(find('input[type="text"]'), input, 'the same input remains mounted'); + assert.deepEqual(this.imageErrors, ['請填入容器映像。']); + assert.ok(this.testRoot.textContent.includes('請填入容器映像。')); + + run(() => this.intl.setLocale(['fr-fr', 'en-us'])); + await settled(); + assert.strictEqual(find('input[type="text"]'), input, 'fallback does not replace the form'); + assert.deepEqual(this.imageErrors, ['Image is required']); + assert.ok(this.testRoot.textContent.includes('Image is required')); + assert.strictEqual(this.selectedImage, null, 'locale changes do not substitute an image'); + assert.notOk(this.testRoot.textContent.includes('Missing translation')); + }); +}); diff --git a/tests/unit/components/form-image-vm-boot-test.js b/tests/unit/components/form-image-vm-boot-test.js new file mode 100644 index 0000000000..9db55d81de --- /dev/null +++ b/tests/unit/components/form-image-vm-boot-test.js @@ -0,0 +1,92 @@ +import { A } from '@ember/array'; +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; + +import FormImageComponent from 'ui/components/form-image/component'; +import NewContainerComponent from 'ui/components/new-container/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | VM boot image'); + +function createImage(properties = {}) { + let component; + + run(() => { + component = createOwned(FormImageComponent, Object.assign({ + renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), + projects: EmberObject.create({current: EmberObject.create({isWindows: false})}), + settings: EmberObject.create({appName: 'PastureStack'}), + sendAction() {}, + }, properties), 'component'); + }); + + return component; +} + +test('existing and last-used VM images stay custom and separate from container images', function(assert) { + let original = createImage({isVm: true, initialValue: 'docker:example.test/custom-vm:qa'}); + let remembered = createImage({isVm: true}); + let container = createImage({isVm: false, initialValue: 'docker:example.test/container:qa'}); + let nextVm = createImage({isVm: true}); + let windowsVm = createImage({ + isVm: true, + projects: EmberObject.create({current: EmberObject.create({isWindows: true})}), + }); + + try { + assert.strictEqual(original.get('value'), 'docker:example.test/custom-vm:qa', 'existing image is preserved, without a whitelist'); + assert.strictEqual(remembered.get('value'), original.get('value'), 'last-used VM image is retained'); + assert.strictEqual(nextVm.get('value'), original.get('value'), 'ordinary container input does not change the VM cache'); + assert.strictEqual(windowsVm.get('value'), original.get('value'), 'a Windows container default cannot replace the VM image'); + assert.strictEqual(container.get('value'), 'docker:example.test/container:qa', 'container input is unchanged'); + } finally { + [original, remembered, container, nextVm, windowsVm].forEach(destroyOwned); + } +}); + +test('blank VM image errors cancel native save before doSave', async function(assert) { + let image = createImage({isVm: true}); + let launchConfig = EmberObject.create({labels: {}, ports: A(), secrets: A()}); + let model = EmberObject.create({ + name: 'qa-vm', scale: 1, launchConfig, secondaryLaunchConfigs: A(), + validationErrors() { return A(); }, + }); + let saveCalls = 0; + let callbackResult; + let parent; + + run(() => { + image.set('userInput', ''); + image.userInputDidChange(); + parent = createOwned(NewContainerComponent, { + renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), + service: model, + primaryResource: model, + primaryService: model, + launchConfig, + isService: true, + imageErrors: image.get('errors'), + doSave() { saveCalls++; }, + }, 'component'); + }); + + try { + // Ember's send() dispatches the action but does not return its Promise. + // Await the actual action, as the shared save-lifecycle tests do. + let outcome = await parent.get('actions').save.call(parent, (result) => { callbackResult = result; }); + + assert.strictEqual(image.get('value'), null, 'no misleading image is substituted for empty input'); + assert.deepEqual(image.get('errors'), ['formImage.vm.bootImageRequired']); + assert.strictEqual(outcome.saved, false, 'real save lifecycle is cancelled by validation'); + assert.strictEqual(saveCalls, 0, 'native resource save is not entered'); + assert.strictEqual(callbackResult, false); + assert.ok(parent.get('errors').includes('formImage.vm.bootImageRequired'), 'the image error reaches parent validation'); + } finally { + destroyOwned(image); + destroyOwned(parent); + } +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index dbd4679af3..0302b7e84e 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2413,9 +2413,13 @@ formKeyValue: formImage: label: Select Image vm: + bootImagePlaceholder: VM boot image + bootImageHelp: "Use a VM boot image compatible with -m, -smp and /image. Verify that the target host meets the image's virtualization requirements, for example KVM (/dev/kvm). An ordinary container image is not sufficient." + bootImageRequired: Enter a compatible VM boot image. placeholder: e.g. ubuntu:26.04 dropdownLabel: "{appName} Images" container: + imageRequired: Image is required placeholder: "e.g. ubuntu:trusty" pullImage: label: Always pull image before creating diff --git a/translations/zh-tw.yaml b/translations/zh-tw.yaml index fe6ef43c7f..0666aedc19 100644 --- a/translations/zh-tw.yaml +++ b/translations/zh-tw.yaml @@ -2308,9 +2308,13 @@ formKeyValue: formImage: label: 選擇映像 vm: + bootImagePlaceholder: VM 專用開機映像 + bootImageHelp: '請使用相容 -m、-smp 與 /image 開機磁碟契約的 VM 專用映像。請確認目標主機符合映像的虛擬化需求,例如 KVM(/dev/kvm);一般容器映像不足以啟動 VM。' + bootImageRequired: 請填入相容的 VM 專用開機映像。 placeholder: '例如:ubuntu:26.04' dropdownLabel: '{appName} 映像' container: + imageRequired: 請填入容器映像。 placeholder: '例如:ubuntu:trusty' pullImage: label: 建立前總是拉取映像