batch delete: report an already-absent object as such

A delete run whose DELETE answered 6A88/6A82 is still a success (the
object is gone, the goal is met) but it is no longer reported as a plain
"OK": the attempt result is 'already_absent', the counts gain that
bucket ("Deleted: N - Already absent: M"), the verdict reads "Already
absent" and the notice says the card was already absent.  A reinstall's
tolerated pre-delete keeps the install outcome (the install is the
point).

1024 Python + 1063 frontend; simple-v471.
This commit is contained in:
2026-10-23 21:23:55 +03:00
parent fb65644328
commit 110dc1f1ad
8 changed files with 108 additions and 10 deletions
+9
View File
@@ -22,6 +22,15 @@ every change carries a descriptive commit message.
Start click (browser autoplay policy); reduced-motion users get the static card-above- Start click (browser autoplay policy); reduced-motion users get the static card-above-
the-slot picture. the-slot picture.
### Changed
- A **batch delete that finds the object already gone** is still a success — the goal is met
and the run continues — but it is no longer reported as a plain "OK": the attempt's verdict
reads **Already absent**, the delete run's counts separate real deletions from no-ops
(`Deleted: N · Already absent: M`), and the notice says the card was already absent. The
counts gain an `already_absent` bucket (the attempt result is `already_absent`); a
reinstall's tolerated pre-delete is *not* affected — its outcome is still the install.
### Fixed ### Fixed
- The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI - The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -324,7 +324,7 @@
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click &mdash; the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) &mdash; a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr> <tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click &mdash; the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) &mdash; a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card &mdash; the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word &mdash; the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports &ldquo;already processed&rdquo; &mdash; restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card &mdash; the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word &mdash; the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports &ldquo;already processed&rdquo; &mdash; restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package &mdash; no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package &mdash; no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as <strong>Already absent</strong> and counted separately from real deletions &mdash; it is still a success (the goal is met) and the run continues; any other failure is reported with the card's status word.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' &mdash; a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' &mdash; a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr> <tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
+10 -3
View File
@@ -10321,7 +10321,9 @@ function ramBatchModalRender(st, okLabel, actLabel) {
document.getElementById('ram-op-bar').style.width = pct + '%'; document.getElementById('ram-op-bar').style.width = pct + '%';
// the counts and the last finished card's verdict // the counts and the last finished card's verdict
const c = st.counts || {}; const c = st.counts || {};
let info = okLabel + ': ' + (c.installed || 0) + ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0) let info = okLabel + ': ' + (c.installed || 0);
if (c.already_absent) info += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
info += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0); + ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
if (st.attempts_total) info += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts'); if (st.attempts_total) info += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
if (st.notice) info += '\n' + st.notice; if (st.notice) info += '\n' + st.notice;
@@ -10544,8 +10546,9 @@ function ramBatchRender() {
const counts = document.getElementById('ram-batch-counts'); const counts = document.getElementById('ram-batch-counts');
if (counts) { if (counts) {
const c = st.counts || {}; const c = st.counts || {};
let text = okLabel + ': ' + (c.installed || 0) let text = okLabel + ': ' + (c.installed || 0);
+ ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0) if (c.already_absent) text += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
text += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0); + ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
if (st.attempts_total) text += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts'); if (st.attempts_total) text += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
if (active && _ramBatchProgress && _ramBatchProgress.active && _ramBatchProgress.name) { if (active && _ramBatchProgress && _ramBatchProgress.active && _ramBatchProgress.name) {
@@ -10581,7 +10584,10 @@ function ramBatchAttemptsHtml(attempts) {
for (let i = attempts.length - 1; i >= 0; i--) { for (let i = attempts.length - 1; i >= 0; i--) {
const a = attempts[i]; const a = attempts[i];
const when = a.ts ? new Date(a.ts * 1000).toLocaleTimeString() : ''; const when = a.ts ? new Date(a.ts * 1000).toLocaleTimeString() : '';
// A delete that found the object already gone is a success, but not a
// deletion: say so instead of a plain OK (v3.36.0).
const verdict = a.result === 'installed' ? t('OK') const verdict = a.result === 'installed' ? t('OK')
: a.result === 'already_absent' ? t('Already absent')
: (a.result === 'failed' ? t('Failed') : t('Skipped')); : (a.result === 'failed' ? t('Failed') : t('Skipped'));
const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400' const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400'
: (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'); : (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400');
@@ -26904,6 +26910,7 @@ const LANG_RU = {
'Stopping after the current card...': 'Остановка после текущей карты…', 'Stopping after the current card...': 'Остановка после текущей карты…',
'Insert next card. Waiting for a card...': 'Вставьте следующую карту. Ожидание карты…', 'Insert next card. Waiting for a card...': 'Вставьте следующую карту. Ожидание карты…',
'Insert next card': 'Вставьте следующую карту', 'Insert next card': 'Вставьте следующую карту',
'Already absent': 'Уже отсутствует',
'Sound on': 'Звук включён', 'Sound on': 'Звук включён',
'Sound off': 'Звук выключен', 'Sound off': 'Звук выключен',
'Installed': 'Установлено', 'Installed': 'Установлено',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v470'; const CACHE = 'simple-v471';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+26
View File
@@ -735,3 +735,29 @@ test('the waiting cue is wired into the poll render, the Start gesture and the C
assert.match(css, /\.ram-insert-glow/); assert.match(css, /\.ram-insert-glow/);
assert.match(css, /prefers-reduced-motion: reduce/); assert.match(css, /prefers-reduced-motion: reduce/);
}); });
test('an already-absent delete is reported as such, not as a deletion (v3.36.0)', () => {
clearEls();
setPanelEls();
setModalEls();
app.resetBatchModal();
app.setLast({ active: false, phase: 'finished', kind: 'delete',
attempts: [{
iccid: '8970119000000115026', preset: 'Alfa', result: 'already_absent',
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true }],
steps_total: 1, duration_ms: 1200,
}],
counts: { installed: 2, already_absent: 1, failed: 0, skipped: 0 },
attempts_total: 3, notice: 'Card 8970119000000115026 was already absent.' });
app.ramBatchRender();
// the counts separate a real deletion from a no-op
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
assert.match(els['ram-batch-counts'].textContent, /Already absent: 1/);
// the attempt's verdict says it - not a plain OK - and keeps the step line
assert.match(els['ram-batch-attempts'].innerHTML, /Already absent/);
assert.match(els['ram-batch-attempts'].innerHTML, /6A88 \(not found\)/);
// the modal carries the same counts
app.ramBatchModalOpen();
assert.match(els['ram-op-batch-info'].textContent, /Deleted: 2/);
assert.match(els['ram-op-batch-info'].textContent, /Already absent: 1/);
});
+15 -3
View File
@@ -2914,7 +2914,7 @@ def _ram_batch_new_state(plan):
return {'active': True, 'phase': 'waiting_card', 'stop': False, return {'active': True, 'phase': 'waiting_card', 'stop': False,
'started': time.time(), 'stopped': None, 'plan': plan, 'started': time.time(), 'stopped': None, 'plan': plan,
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0, 'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
'counts': {'installed': 0, 'failed': 0, 'skipped': 0}, 'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'current': None, 'notice': ''} 'current': None, 'notice': ''}
@@ -2939,6 +2939,8 @@ def _ram_batch_record(state, rec):
verb = {'delete': 'deleted', verb = {'delete': 'deleted',
'reinstall-app': 'reinstalled'}.get(kind, 'installed') 'reinstall-app': 'reinstalled'}.get(kind, 'installed')
state['notice'] = 'Card %s %s.' % (who, verb) state['notice'] = 'Card %s %s.' % (who, verb)
elif result == 'already_absent':
state['notice'] = 'Card %s was already absent.' % who
elif result == 'failed': elif result == 'failed':
step = rec.get('failed_step') or '' step = rec.get('failed_step') or ''
sw = rec.get('por_sw') or rec.get('sw') or '' sw = rec.get('por_sw') or rec.get('sw') or ''
@@ -2947,8 +2949,9 @@ def _ram_batch_record(state, rec):
else: else:
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '') state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
detail = rec.get('reason') or rec.get('failed_step') or '' detail = rec.get('reason') or rec.get('failed_step') or ''
label = {'already_absent': 'already absent'}.get(result, result)
sys.stderr.write('RAM-BATCH: %s%s%s\n' % ( sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else '')) label, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
def _ram_batch_failure_fields(result): def _ram_batch_failure_fields(result):
@@ -3044,6 +3047,15 @@ def _ram_batch_handle_card(state, server):
'module_aid': result.get('module_aid'), 'module_aid': result.get('module_aid'),
'nv_delta': result.get('nv_delta')} 'nv_delta': result.get('nv_delta')}
rec.update(_ram_batch_steps_fields(result)) rec.update(_ram_batch_steps_fields(result))
# A delete run whose DELETE answered 'not found' is a success (the
# object is gone, the goal is met) but not a deletion: report it as
# "already absent" and count it separately (v3.36.0). Only the delete
# kind: a reinstall's tolerated pre-delete is not the outcome.
if (state.get('plan') or {}).get('kind') == 'delete' and any(
s.get('delete_not_found') for s in (result.get('steps') or [])):
outcome = 'already_absent'
rec['result'] = outcome
rec['not_found'] = True
elif result is not None: elif result is not None:
outcome = 'failed' outcome = 'failed'
rec = {'iccid': iccid, 'preset': name, 'result': outcome, rec = {'iccid': iccid, 'preset': name, 'result': outcome,
@@ -3178,7 +3190,7 @@ def _ram_batch_status():
if _RAM_BATCH is None: if _RAM_BATCH is None:
return {'active': False, 'phase': 'idle', 'attempts': [], return {'active': False, 'phase': 'idle', 'attempts': [],
'attempts_total': 0, 'current': None, 'notice': '', 'attempts_total': 0, 'current': None, 'notice': '',
'counts': {'installed': 0, 'failed': 0, 'skipped': 0}, 'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'started': None, 'stopped': None, 'progress': None, 'started': None, 'stopped': None, 'progress': None,
'kind': None} 'kind': None}
return {'active': bool(_RAM_BATCH.get('active')), return {'active': bool(_RAM_BATCH.get('active')),
+45 -1
View File
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'}) 'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped', srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
'reason': 'no preset matches this ICCID'}) 'reason': 'no preset matches this ICCID'})
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1}) self.assertEqual(st['counts'], {'installed': 1, 'already_absent': 0,
'failed': 1, 'skipped': 1})
self.assertEqual(st['attempts_total'], 3) self.assertEqual(st['attempts_total'], 3)
# the notice names the last attempt's outcome # the notice names the last attempt's outcome
self.assertIn('skipped: no preset matches this ICCID', st['notice']) self.assertIn('skipped: no preset matches this ICCID', st['notice'])
@@ -230,6 +231,49 @@ class BatchCardTests(unittest.TestCase):
self.assertEqual(seen['current']['iccid'], '8970119000004600098') self.assertEqual(seen['current']['iccid'], '8970119000004600098')
self.assertIn('installed', st['notice']) # ...and set for the new outcome self.assertIn('installed', st['notice']) # ...and set for the new outcome
def test_a_delete_that_finds_nothing_is_reported_already_absent(self):
"""A delete run whose DELETE answered 6A88 is a success (the object is
gone, the goal is met) but not a deletion: it is counted in its own
bucket and the notice says so (v3.36.0)."""
st = srv._ram_batch_new_state(_plan('delete'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True, 'final_cntr': '0000000002',
'steps': [{'name': 'DELETE (application)', 'sw': '9000',
'por_status': 'por_ok', 'por_sw': '6A88',
'delete_not_found': True}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'already_absent')
self.assertTrue(rec['not_found'])
self.assertEqual(st['counts']['already_absent'], 1)
self.assertEqual(st['counts']['installed'], 0)
self.assertIn('already absent', st['notice'])
self.assertEqual(st['seen']['8970119000004600098'], 'already_absent')
def test_a_reinstall_with_a_tolerated_pre_delete_is_still_an_install(self):
"""The reinstall's pre-delete may tolerate 'not found', but the
outcome is the INSTALL, not 'already absent'."""
st = srv._ram_batch_new_state(_plan('reinstall-app'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True,
'steps': [{'name': 'DELETE (application)', 'por_sw': '6A88',
'delete_not_found': True},
{'name': 'INSTALL [for install]', 'por_sw': '9000'}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
self.assertEqual(st['attempts'][0]['result'], 'installed')
self.assertEqual(st['counts']['installed'], 1)
self.assertEqual(st['counts']['already_absent'], 0)
def test_a_card_already_processed_is_reported_not_reinstalled(self): def test_a_card_already_processed_is_reported_not_reinstalled(self):
st = srv._ram_batch_new_state(_plan()) st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa') preset = _preset('8970119000004600098', 'Alfa')