diff --git a/README.md b/README.md index 949093b497..3a3eee125b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current compatibility release is `1.6.103`. It retains the existing Node 24, Ember, Sass, +The current compatibility release is `1.6.104`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with PKCE S256, staged configuration validation, a real test login before @@ -16,6 +16,11 @@ activation, and local-authentication recovery. Product-owned names, logos, icons, package metadata, and visible text use PastureStack branding. API models and protocol fields remain compatible. +Release `1.6.104` keeps the authenticated browser-session and OIDC corrections +from `1.6.103`. It also keeps the init-process checkbox inside its own resource +grid column, with the launch-configuration binding unchanged, so the control no +longer touches the adjacent process-limit input on create or upgrade forms. + Release `1.6.103` keeps an authenticated browser session when a non-auth API request fails during startup, displays the nested OIDC/API explanation instead of an empty alert, and activates a newly verified provider in unrestricted mode diff --git a/SECURITY.md b/SECURITY.md index 4766d55511..1aa6f87e3f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported state -The maintained compatibility release is the pure numeric `1.6.103` line used +The maintained compatibility release is the pure numeric `1.6.104` line used by the current PastureStack Server release. Earlier branded coordinates are historical records and are not current release or deployment targets. Authentication-provider combinations must still be validated by an diff --git a/app/components/form-resources/component.js b/app/components/form-resources/component.js index 871480c6af..4864cc9a4a 100644 --- a/app/components/form-resources/component.js +++ b/app/components/form-resources/component.js @@ -118,6 +118,7 @@ export default Component.extend({ let callback = this.get('setRequestedHost'); if ( typeof callback === 'function' ) { callback(hostId); } else { this.set('instance.requestedHostId', hostId); } }, + setBoolean(field, event) { this.set(`instance.${field}`, !!event.target.checked); }, setField(field, event) { this.set(`instance.${field}`, event.target.value || null); }, setShm(value) { updateHardwareDraft(this.get('instance'), {shmError: null}); diff --git a/app/components/form-resources/template.hbs b/app/components/form-resources/template.hbs index 66cc51fbfd..96c8eed377 100644 --- a/app/components/form-resources/template.hbs +++ b/app/components/form-resources/template.hbs @@ -148,10 +148,10 @@ {{/input-or-display}} -
- +
+ {{#input-or-display editable=this.editing value=this.instance.runInit}} - + {{/input-or-display}}
diff --git a/app/styles/components/_form-resources.scss b/app/styles/components/_form-resources.scss index 6694279039..330703f71a 100644 --- a/app/styles/components/_form-resources.scss +++ b/app/styles/components/_form-resources.scss @@ -166,6 +166,20 @@ $well-bg: #f5f5f5 !default; grid-column: span 4; } + .resource-inline-checkbox { + align-items: center; + display: flex; + gap: 8px; + min-height: 43px; + margin: 0; + + input[type="checkbox"] { + flex: 0 0 auto; + margin: 0; + position: static; + } + } + .resource-advanced-group { background: $well-bg; border: 1px solid $table-border-color; 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 b8810dbbc7..e619770b93 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.103", + "version": "1.6.104", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.103", + "version": "1.6.104", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package-lock.json b/package-lock.json index b8810dbbc7..e619770b93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.103", + "version": "1.6.104", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.103", + "version": "1.6.104", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 8ac8915566..18d8e01e39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.103", + "version": "1.6.104", "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 0fa9fdc51f..1f52ff5c1c 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.103" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.103" +if [[ "$version" != "1.6.104" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.104" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 48fecd08e2..4ccc507cf6 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.103 browser-session broker-broadcast + 1.6.104 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 3f8017d3c8..c29a5281ce 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.103": +if package.get("version") != "1.6.104": 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 c8d00e0555..2681ab2f0d 100755 --- a/scripts/check-ui-ember-api-store-fetch-upgrade +++ b/scripts/check-ui-ember-api-store-fetch-upgrade @@ -322,7 +322,7 @@ storage_template = (repo / "app/host/storage/template.hbs").read_text(encoding=" if 'pageSizeChanged=(action "storagePageSizeChanged")' not in storage_template: fail("storage page-size callback is missing from the host storage table") -if template_action_count != 586: +if template_action_count != 587: 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/scripts/check-ui-test-harness-blockers b/scripts/check-ui-test-harness-blockers index 5f7f125bd4..5d05e131ec 100755 --- a/scripts/check-ui-test-harness-blockers +++ b/scripts/check-ui-test-harness-blockers @@ -51,8 +51,8 @@ if module_for_count != 0: fail("MODULE_FOR_USAGE_COUNT_UNEXPECTED", actual=module_for_count, expected=0) if module_for_component_count != 0: fail("MODULE_FOR_COMPONENT_USAGE_UNEXPECTED", actual=module_for_component_count, expected=0) -if direct_qunit_import_count != 105: - fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=105) +if direct_qunit_import_count != 106: + fail("DIRECT_QUNIT_IMPORT_COUNT_UNEXPECTED", actual=direct_qunit_import_count, expected=106) volatile_computed_count = 0 for path in Path("app").rglob("*.js"): diff --git a/tests/integration/components/form-resources-test.js b/tests/integration/components/form-resources-test.js index 8ad151f517..6da5eb0887 100644 --- a/tests/integration/components/form-resources-test.js +++ b/tests/integration/components/form-resources-test.js @@ -74,6 +74,23 @@ module('Integration | Component | hardware resources', function(hooks) { assert.ok(unit.getBoundingClientRect().width < input.getBoundingClientRect().width, 'the unit is compact and the value gets useful space'); assert.ok(find('.resource-field-host select[id$="-host"]'), 'host inventory is prominent in the hardware section'); assert.ok(find('.resource-advanced-toggle[aria-expanded="false"]'), 'rare settings remain discoverable without overwhelming the common path'); + await click('.resource-advanced-toggle'); + let pids = find('[data-hardware="pids"]'); + let init = find('[data-hardware="init"]'); + let pidsRect = pids.getBoundingClientRect(); + let initRect = init.getBoundingClientRect(); + let initFieldRect = init.closest('.resource-init-field').getBoundingClientRect(); + assert.ok(initRect.left >= initFieldRect.left, + 'the init checkbox stays inside its own grid column instead of protruding into the previous control'); + if ( initRect.top < pidsRect.bottom && initRect.bottom > pidsRect.top ) { + assert.ok(initRect.left - pidsRect.right >= 16, + 'same-row PID and init controls retain a visible grid gap'); + } else { + assert.ok(initRect.top >= pidsRect.bottom, 'responsive rows remain vertically separated'); + } + assert.equal(getComputedStyle(init).position, 'static', 'the checkbox does not use the legacy negative-offset layout'); + await click(init); + assert.true(this.instance.get('runInit'), 'the aligned control still writes the launch configuration'); }); test('switching primary and sidekick resets drafts and advanced maps without rewriting either config', async function(assert) { diff --git a/tests/unit/components/new-container-hardware-payload-test.js b/tests/unit/components/new-container-hardware-payload-test.js new file mode 100644 index 0000000000..a3a3070547 --- /dev/null +++ b/tests/unit/components/new-container-hardware-payload-test.js @@ -0,0 +1,120 @@ +import { A } from '@ember/array'; +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; + +import NewContainerComponent from 'ui/components/new-container/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | new container hardware payload'); + +function hardwareLaunchConfig() { + return EmberObject.create({ + labels: {}, + ports: A(), + secrets: A(), + shmSize: 2147483648, + ipcMode: 'private', + runtime: 'nvidia', + runInit: true, + pidsLimit: 512, + cpuQuota: 200000, + cpuPeriod: 100000, + groupAdd: ['993'], + tmpfs: {'/run': 'rw,noexec,nosuid,size=64m'}, + sysctls: {'net.core.somaxconn': '1024'}, + ulimits: [{name: 'memlock', soft: -1, hard: -1}], + devices: ['/dev/dri/renderD128:/dev/dri/renderD128:rw'], + deviceRequests: [{driver: 'nvidia', count: 1, capabilities: [['gpu']]}], + requestedHostId: '1h1', + }); +} + +function createComponent(service, launchConfig) { + let component; + + run(() => { + component = createOwned(NewContainerComponent, { + renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), + launchConfig, + service, + primaryResource: service, + primaryService: service, + isService: true, + }, 'component'); + }); + + return component; +} + +function hardwareSnapshot(config) { + return { + shmSize: config.get('shmSize'), + ipcMode: config.get('ipcMode'), + runtime: config.get('runtime'), + runInit: config.get('runInit'), + pidsLimit: config.get('pidsLimit'), + cpuQuota: config.get('cpuQuota'), + cpuPeriod: config.get('cpuPeriod'), + groupAdd: config.get('groupAdd'), + tmpfs: config.get('tmpfs'), + sysctls: config.get('sysctls'), + ulimits: config.get('ulimits'), + devices: config.get('devices'), + deviceRequests: config.get('deviceRequests'), + requestedHostId: config.get('requestedHostId'), + }; +} + +test('service creation retains every resource and hardware launch field', async function(assert) { + let launchConfig = hardwareLaunchConfig(); + let saved; + let service = EmberObject.create({ + launchConfig, + secondaryLaunchConfigs: A(), + save() { + saved = hardwareSnapshot(this.get('launchConfig')); + return Promise.resolve(this); + }, + }); + let component = createComponent(service, launchConfig); + + await component.doSave(); + + assert.deepEqual(saved, hardwareSnapshot(launchConfig), 'create payload keeps all configured fields'); + destroyOwned(component); +}); + +test('service upgrade sends every resource and hardware field in the upgrade strategy', async function(assert) { + let launchConfig = hardwareLaunchConfig(); + let action; + let payload; + let service = EmberObject.create({ + launchConfig, + secondaryLaunchConfigs: A(), + save() { return Promise.resolve(this); }, + waitForAction(name) { + assert.equal(name, 'upgrade', 'waits for the upgrade action'); + return Promise.resolve(); + }, + doAction(name, value) { + action = name; + payload = value; + return Promise.resolve(); + }, + }); + let component = createComponent(service, launchConfig); + + run(() => component.setProperties({ + isUpgrade: true, + upgradeOptions: {batchSize: 1, intervalMillis: 2000, startFirst: false}, + })); + await component.doSave(); + + assert.equal(action, 'upgrade'); + assert.deepEqual(hardwareSnapshot(payload.inServiceStrategy.launchConfig), hardwareSnapshot(launchConfig), + 'upgrade payload keeps all configured fields'); + destroyOwned(component); +});