Files

152 lines
6.2 KiB
Python

"""Which steps run, in what order, and the bookkeeping around them: fresh install, resume and update are one rule.
Each step has a key: a fingerprint of everything its output depends on, computed before anything runs (a step's key
includes the keys of the steps it reads from, so a change upstream reaches every step below it). A step is skipped
when totk-install.json records it with the same key and its output still matches the record (Step.current, cheap).
Otherwise it runs. A transient step (its output lives in the temp folder and only the next step reads it) runs only
when the next step runs. `always` steps (check, cleanup) run every time.
Update mode is the same rule with a newer release: the extraction's key depends only on the expected hashes, so a
release that changes the lifter or the runtime keeps romfs/ and exefs/ and rebuilds from lift on. Before keeping the
output of a step recorded by another release (or with --verify), the check step calls Step.verify (expensive: the full
RomFS hash); a mismatch makes the step run again.
A failed or cancelled run leaves the finished steps recorded; the next run starts at the first step that is not.
"""
import datetime
import traceback
from . import state as statefile
class InstallError(Exception):
"""A failure with a message id (installer/text/en.json) and its parameters."""
def __init__(self, id, **params):
super().__init__(id)
self.id, self.params = id, params
class Step:
id = ''
transient = False
always = False
def key(self, ctx):
"""Fingerprint of the step's inputs; may read ctx.keys of earlier steps."""
return ''
def current(self, ctx, record):
"""True when the recorded output is still in place (cheap checks only)."""
return True
def verify(self, ctx, record):
"""True when the recorded output passes the full check (hashes); called before keeping another release's
output."""
return True
def run(self, ctx):
"""Do the work. Returns fields to record with the step."""
return {}
class Context:
def __init__(self, layout, dest, temp, reporter, options, state=None, verify=False):
self.layout, self.dest, self.temp, self.reporter, self.options = layout, dest, temp, reporter, options
self.state = state or statefile.empty(layout.release)
self.verify = verify
self.mode = 'install' if state is None else 'resume' if state.get('release') == layout.release else 'update'
self.keys, self.runs, self.reasons, self.steps, self.forced = {}, {}, {}, [], set()
def record(self, step_id):
return self.state['steps'].get(step_id)
def will_run(self, step_id):
return self.runs.get(step_id, False)
def save(self):
self.dest.mkdir(parents=True, exist_ok=True)
statefile.save(self.dest, self.state)
def plan(self, forced=()):
"""Fill keys (once), runs and reasons. forced: step ids that must run."""
if not self.keys:
for s in self.steps:
self.keys[s.id] = s.key(self)
for s in self.steps:
rec = self.record(s.id)
if s.always or s.id in forced:
self.runs[s.id] = True
else:
self.runs[s.id] = not (rec and rec.get('key') == self.keys[s.id] and s.current(self, rec))
self.reasons[s.id] = '' if self.runs[s.id] else 'current'
for i in range(len(self.steps) - 2, -1, -1):
s, after = self.steps[i], self.steps[i + 1]
if s.transient and self.runs[s.id] and not self.runs[after.id]:
self.runs[s.id], self.reasons[s.id] = False, 'not_needed'
self.forced = set(forced)
def verify_kept(self, progress_step):
"""Full check of every kept step output that another release recorded (or of all with --verify). A step that
fails it is planned to run. Returns the ids that failed."""
failed = []
for s in self.steps:
rec = self.record(s.id)
if self.runs[s.id] or self.reasons[s.id] != 'current' or s.always:
continue
if not (self.verify or rec.get('release') != self.layout.release):
continue
if not s.verify(self, rec):
failed.append(s.id)
if failed:
for f in failed:
self.reporter.warning('state.output_changed', step=f)
self.plan(self.forced | set(failed))
return failed
def now_utc():
return datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
def run_steps(steps, ctx):
"""Run the plan. Returns 'ok', 'failed' or 'cancelled'; events and the log say why."""
rep = ctx.reporter
ctx.steps = steps
current = None
try:
ctx.plan()
rep.emit('plan', run=[s.id for s in steps if ctx.runs[s.id]],
skip={s.id: ctx.reasons[s.id] for s in steps if not ctx.runs[s.id]})
for s in steps:
if not ctx.runs[s.id]:
rep.step(s.id, 'skipped', reason=ctx.reasons[s.id])
continue
current = s.id
rep.step(s.id, 'started')
t0 = rep.elapsed()
fields = s.run(ctx) or {}
seconds = round(rep.elapsed() - t0, 1)
if not s.always:
ctx.state['steps'][s.id] = {'key': ctx.keys[s.id], 'release': ctx.layout.release,
'finished': now_utc(), 'seconds': seconds, **fields}
ctx.save() # the first save, after the check, creates the install folder
rep.step(s.id, 'done', seconds=seconds)
current = None
ctx.state['release'] = ctx.layout.release
ctx.save()
return 'ok'
except InstallError as e:
rep.error(e.id, e.params, step=current)
return 'failed'
except statefile.StateError as e:
rep.error(e.id, {'path': str(e.path)}, step=current)
return 'failed'
except KeyboardInterrupt:
rep.log(f'cancelled during {current}')
return 'cancelled'
except Exception as e:
rep.log(traceback.format_exc())
rep.error('internal', {'exception': type(e).__name__, 'detail': str(e)}, step=current)
return 'failed'