附录:随书实验完整代码
以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。
lab/README.md
# MySQL 恢复实验
需要 linux/amd64、Python 3.12+、Docker Engine、可下载固定官方 MySQL 镜像,约 2 GB 可用内存与 3 GB 磁盘。工具RPM固定为x86_64,ARM原生环境未验证;本书正式证据来自 Ubuntu 24.04 GitHub Actions,不把不同平台结果混为一谈。
```sh
python3 lab/run.py --evidence evidence/runtime.json
```
程序创建带随机前缀的私有 bridge network 和四个实例,端口不发布到宿主机,使用教学专用口令,完成后只删除本次记录的容器(含匿名卷)及网络。不会执行 Docker prune。遇异常不跳过测试,返回非零,证据 status 为 FAIL;进程被强杀则需按本次随机前缀手工清理其资源,不能清理其他项目。
10个场景必须全部 PASS:GTID复制建立、写入期间的一致快照、干净基线恢复、事务边界PITR、缺失日志不可达到目标、误删同样复制、应用暂停再追平、未隔离拒绝提升、隔离后手工提升并观测已确认事务丢失、返回旧主断网且只读。
业务写入由ledger_app执行,仅具有ledger.*的SELECT/INSERT/UPDATE/DELETE,没有SUPER或CONNECTION_ADMIN。提升显式关闭super_read_only和read_only并检查0/0,再用该普通业务账号提交与查询。角色管理、备份和核对使用root。
事务业务不变量:账户余额=100000+分录delta之和;订单与分录逐行一一对应;分录金额等于订单金额相反数。恢复目标还比较全部有序订单、余额和分录总和的SHA-256,不能仅用行数或线程状态判定成功。
RTO仅计本次docker stop发起到新主成功提交并查询确认的手工流程,排除故障发现、路由、DNS与客户端重试。RPO报告实际缺失事务ID和数量;观察窗口是停止复制后相邻客户端观测间隔,不是线上RPO保证。
使用的逻辑备份参数为 `--single-transaction --source-data=2 --set-gtid-purged=OFF --databases ledger`,实验只针对InnoDB事务表且没有并发DDL。恢复目的不是直接加入原复制拓扑,不能照搬此GTID选项到生产副本初始化。回放前FLUSH封口并复制真实日志到任务私有归档,记录字节与hash,停止源实例后用无网络工具容器只读挂载归档。缺失日志负例临时移走实际必需文件并验证基线未变化。回放使用同一实际binlog文件中的开始位置与误删事务之前的提交结束位置,生产跨文件日志需逐文件验证连续性。
每次运行均重新拉起干净实例,固定镜像digest,报告实际VERSION和变量。不包含自动选主、代理改路由、物理备份、跨地域容灾、故障磁盘持久性或生产密钥管理。
lab/run.py
#!/usr/bin/env python3
"""Real, isolated MySQL recovery exercises. Requires Docker; never skips tests."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import tempfile
import time
import urllib.request
IMAGE = 'mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364'
PASSWORD = 'recovery-lab-only'
CLIENT_URL = 'https://repo.mysql.com/yum/mysql-8.4-community/el/9/x86_64/mysql-community-client-8.4.11-1.el9.x86_64.rpm'
CLIENT_SHA256 = '2f1e8317c557363dd1554b29a9f6d69403802dc623fe72d9995f20f542771bb1'
def call(args, data=None, check=True, timeout=180):
p = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
if check and p.returncode:
raise RuntimeError(f'{args[0:3]} failed ({p.returncode}): {p.stderr.decode(errors="replace")[-3000:]}')
return p
class Lab:
def __init__(self, output):
self.prefix = 'mysql-recovery-' + secrets.token_hex(6)
self.network = self.prefix + '-net'
self.names = []
self.tmp = tempfile.TemporaryDirectory(prefix=self.prefix)
self.report = {'status': 'RUNNING', 'image': IMAGE, 'scenarios': [], 'scope':
'Isolated Linux Docker bridge; teaching data; manual failover; no production routing, automatic HA or cross-region claim.'}
self.report['source_sha'] = os.environ.get('GITHUB_SHA', 'local-unattributed-run')
self.output = Path(output)
self.output.parent.mkdir(parents=True, exist_ok=True)
def sql(self, name, query, check=True):
return self.sql_result(name, query, check=check).stdout.decode().strip()
def sql_result(self, name, query, check=True, user='root'):
return call(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-' + name,
'mysql', '-u' + user, '--batch', '--raw', '--skip-column-names', '-e', query],
check=check)
def business(self, name, query):
return self.sql_result(name, query, user='ledger_app').stdout.decode().strip()
def readonly_rejected(self, name, query):
result = self.sql_result(name, query, check=False, user='ledger_app')
error = result.stderr.decode(errors='replace')
assert result.returncode != 0 and 'ERROR 1290' in error and 'read-only' in error, error
return {'user': 'ledger_app', 'exit': result.returncode, 'mysql_error': error.strip()}
def prepare_tools(self):
context = Path(self.tmp.name) / 'tools'
context.mkdir()
rpm = context / 'client.rpm'
with urllib.request.urlopen(CLIENT_URL, timeout=90) as response:
rpm.write_bytes(response.read())
assert hashlib.sha256(rpm.read_bytes()).hexdigest() == CLIENT_SHA256
dockerfile = Path(__file__).with_name('tools.Dockerfile')
(context / 'Dockerfile').write_bytes(dockerfile.read_bytes())
call(['docker', 'build', '-t', self.prefix + '-tools', str(context)], timeout=240)
version = call(['docker', 'run', '--rm', '--network', 'none', self.prefix + '-tools',
'mysqlbinlog', '--version']).stdout.decode().strip()
assert '8.4.11' in version
self.report['binlog_tool'] = {'url': CLIENT_URL, 'rpm_sha256': CLIENT_SHA256, 'version': version,
'architecture': 'linux/amd64'}
def archived_binlog(self, directory, filename, startpos, stoppos, check=True):
return call(['docker', 'run', '--rm', '--network', 'none', '--mount',
f'type=bind,source={directory},target=/archive,readonly', self.prefix + '-tools',
'mysqlbinlog', '--start-position=' + str(startpos), '--stop-position=' + str(stoppos),
'/archive/' + filename], check=check)
def stop_source(self, stage):
call(['docker', 'stop', '--time', '10', self.prefix + '-source'])
state = json.loads(call(['docker', 'inspect', '--format', '{{json .State}}',
self.prefix + '-source']).stdout)
assert state['Running'] is False and state['OOMKilled'] is False
self.report.setdefault('stop_observations', []).append({'stage': stage,
'running': state['Running'], 'exit_code': state['ExitCode'],
'finished_at': state['FinishedAt'], 'grace_seconds': 10})
def feed(self, name, data):
return call(['docker', 'exec', '-i', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-' + name,
'mysql', '-uroot', '--binary-mode'], data=data)
def check(self, name, **values):
self.report['scenarios'].append({'name': name, 'status': 'PASS', **values})
print('PASS', name, flush=True)
def start(self, name, server_id):
full = self.prefix + '-' + name
call(['docker', 'run', '-d', '--name', full, '--network', self.network,
'--network-alias', name, '--label', 'aias.mysql-recovery=' + self.prefix,
'-e', 'MYSQL_ROOT_PASSWORD=' + PASSWORD, '-e', 'MYSQL_ROOT_HOST=%', IMAGE,
'--server-id=' + str(server_id), '--log-bin=mysql-bin', '--gtid-mode=ON',
'--enforce-gtid-consistency=ON', '--binlog-row-image=FULL',
'--innodb-buffer-pool-size=64M', '--performance-schema=OFF'])
self.names.append(name)
for _ in range(100):
try:
if self.sql(name, 'SELECT 1') == '1':
return
except RuntimeError:
pass
time.sleep(1)
raise RuntimeError('MySQL readiness timed out: ' + name)
def state(self, name):
rows = self.sql(name, 'SELECT id,amount,tag FROM ledger.orders ORDER BY id;').splitlines()
account = int(self.sql(name, 'SELECT balance FROM ledger.accounts WHERE id=1'))
delta = int(self.sql(name, 'SELECT COALESCE(SUM(delta),0) FROM ledger.entries'))
entry_count = int(self.sql(name, 'SELECT COUNT(*) FROM ledger.entries'))
orphan = int(self.sql(name, 'SELECT COUNT(*) FROM ledger.entries e LEFT JOIN ledger.orders o ON e.order_id=o.id WHERE o.id IS NULL OR e.delta<>-o.amount'))
assert account == 100000 + delta, (name, account, delta)
assert entry_count == len(rows) and orphan == 0, (name, entry_count, rows, orphan)
canonical = '\n'.join(rows) + f'\nbalance={account}\ndelta={delta}'
return {'orders': len(rows), 'balance': account, 'entry_sum': delta,
'sha256': hashlib.sha256(canonical.encode()).hexdigest(), 'rows': rows}
@staticmethod
def order(ident, tag, amount=1):
assert re.fullmatch('[a-z0-9-]+', tag)
return (f"START TRANSACTION; INSERT INTO ledger.orders VALUES({ident},{amount},'{tag}'); "
f"INSERT INTO ledger.entries VALUES({ident},{ident},-{amount}); "
f"UPDATE ledger.accounts SET balance=balance-{amount} WHERE id=1; COMMIT;")
def dump(self):
p = call(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, self.prefix + '-source',
'mysqldump', '-uroot', '--single-transaction', '--source-data=2',
'--set-gtid-purged=OFF', '--routines', '--triggers', '--events', '--hex-blob',
'--databases', 'ledger'])
return p.stdout
def wait_replica(self):
gtid = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
result = self.sql('replica', f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gtid}',30)")
assert result == '0', self.sql('replica', 'SHOW REPLICA STATUS\\G')
assert self.state('source') == self.state('replica')
return gtid
def promote(self):
running = call(['docker', 'inspect', '--format', '{{.State.Running}}', self.prefix + '-source']).stdout.strip()
if running != b'false':
raise RuntimeError('FENCE_REQUIRED: old primary remains running; promotion refused')
self.sql('replica', 'STOP REPLICA; RESET REPLICA ALL; SET GLOBAL super_read_only=OFF; SET GLOBAL read_only=OFF;')
assert self.sql('replica', 'SELECT @@GLOBAL.read_only,@@GLOBAL.super_read_only') == '0\t0'
def run(self):
self.prepare_tools()
call(['docker', 'network', 'create', '--label', 'aias.mysql-recovery=' + self.prefix, self.network])
for i, name in enumerate(['source', 'replica', 'restore', 'pitr'], 11):
self.start(name, i)
self.report['mysql_version'] = self.sql('source', 'SELECT VERSION()')
self.report['server_variables'] = self.sql('source', 'SELECT @@gtid_mode,@@enforce_gtid_consistency,@@binlog_format,@@binlog_row_image,@@sync_binlog,@@innodb_flush_log_at_trx_commit')
self.report['image_inspect'] = json.loads(call(['docker', 'image', 'inspect', IMAGE, '--format', '{{json .RepoDigests}}']).stdout)
self.sql('source', "CREATE USER 'repl'@'%' IDENTIFIED BY 'replica-lab-only'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';")
self.sql('source', f"CREATE USER 'ledger_app'@'%' IDENTIFIED BY '{PASSWORD}'; GRANT SELECT,INSERT,UPDATE,DELETE ON ledger.* TO 'ledger_app'@'%';")
grants = self.sql('source', "SHOW GRANTS FOR 'ledger_app'@'%'")
assert 'SUPER' not in grants and 'CONNECTION_ADMIN' not in grants and 'ALL PRIVILEGES' not in grants
self.report['business_identity'] = {'user': 'ledger_app', 'grants': grants,
'scope': 'ledger.* DML; no administrative privileges'}
self.sql('replica', "CHANGE REPLICATION SOURCE TO SOURCE_HOST='source',SOURCE_USER='repl',SOURCE_PASSWORD='replica-lab-only',SOURCE_AUTO_POSITION=1,SOURCE_CONNECT_RETRY=1,GET_SOURCE_PUBLIC_KEY=1; START REPLICA; SET GLOBAL super_read_only=ON;")
self.sql('source', '''CREATE DATABASE ledger; USE ledger;
CREATE TABLE accounts(id INT PRIMARY KEY,balance BIGINT NOT NULL) ENGINE=InnoDB;
CREATE TABLE orders(id INT PRIMARY KEY,amount INT NOT NULL,tag VARCHAR(40) NOT NULL) ENGINE=InnoDB;
CREATE TABLE entries(id INT PRIMARY KEY,order_id INT UNIQUE NOT NULL,delta INT NOT NULL) ENGINE=InnoDB;
CREATE TABLE padding(id INT PRIMARY KEY,payload LONGTEXT NOT NULL) ENGINE=InnoDB;
INSERT INTO accounts VALUES(1,100000);''')
self.business('source', self.order(1, 'opening-order', 100))
# Slow enough for real dump/commit overlap, but padding is never a business invariant.
self.sql('source', "SET SESSION cte_max_recursion_depth=3000; INSERT INTO ledger.padding WITH RECURSIVE seq AS (SELECT 1 n UNION ALL SELECT n+1 FROM seq WHERE n<2000) SELECT n,REPEAT('x',8192) FROM seq;")
self.wait_replica()
self.check('replication-bootstrap', source_equals_replica=True)
writer_sql = ''.join(self.order(i, 'backup-concurrent') + ' DO SLEEP(0.08);' for i in range(2, 122))
writer = subprocess.Popen(['docker', 'exec', '-i', '-e', 'MYSQL_PWD=' + PASSWORD,
self.prefix + '-source', 'mysql', '-uledger_app'], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
writer.stdin.write(writer_sql.encode()); writer.stdin.close(); writer.stdin = None
deadline = time.monotonic() + 15
while int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders')) < 2:
assert time.monotonic() < deadline
time.sleep(.03)
before = int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders'))
dump_started = time.monotonic()
snapshot = self.dump()
dump_seconds = time.monotonic() - dump_started
after = int(self.sql('source', 'SELECT COUNT(*) FROM ledger.orders'))
writer_out, writer_err = writer.communicate(timeout=40)
assert writer.returncode == 0, writer_err.decode()
source_final = self.state('source')
assert after > before, ('No observed commits during actual dump', before, after)
self.feed('restore', snapshot)
restored = self.state('restore')
assert before <= restored['orders'] <= after < source_final['orders'], (before, restored, after)
self.check('consistent-snapshot-under-writes', source_count_before=before,
source_count_after=after, restored=restored, final_source_orders=source_final['orders'],
dump_seconds=round(dump_seconds, 4), dump_sha256=hashlib.sha256(snapshot).hexdigest())
# A second quiet baseline makes the exact recovery target independently unambiguous.
self.sql('source', 'FLUSH BINARY LOGS')
baseline = self.dump()
pos = re.search(rb"SOURCE_LOG_FILE='([^']+)', SOURCE_LOG_POS=(\d+)", baseline)
assert pos, 'No source coordinates in dump'
filename, startpos = pos[1].decode(), int(pos[2])
self.feed('pitr', baseline)
assert self.state('pitr') == source_final
self.check('clean-baseline-restore', baseline=source_final, binlog=filename, start_position=startpos)
self.business('source', self.order(2001, 'keep-before-delete', 17))
self.business('source', self.order(2002, 'keep-second', 23))
target = self.state('source')
status = self.sql('source', 'SHOW BINARY LOG STATUS').split('\t')
assert status[0] == filename
stoppos = int(status[1])
target_gtid = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
# This unwanted transaction is valid SQL and replicates: replication is not a backup.
self.business('source', 'START TRANSACTION; DELETE FROM ledger.entries; DELETE FROM ledger.orders; UPDATE ledger.accounts SET balance=100000 WHERE id=1; COMMIT;')
deleted = self.state('source')
assert deleted['orders'] == 0
self.business('source', self.order(3001, 'after-delete', 7))
self.sql('source', 'FLUSH BINARY LOGS')
archive = Path(self.tmp.name) / 'archive'
archive.mkdir()
archived_file = archive / filename
call(['docker', 'cp', self.prefix + '-source:/var/lib/mysql/' + filename, str(archived_file)])
self.report['binlog_archive'] = {'file': filename, 'bytes': archived_file.stat().st_size,
'sha256': hashlib.sha256(archived_file.read_bytes()).hexdigest(),
'sealed_by_flush': True, 'replay_mount_readonly': True, 'replay_network': 'none'}
self.stop_source('offline-archive-replay')
# Remove the actual required archive: the failure must not mutate the clean baseline.
held = archived_file.with_suffix('.held')
archived_file.rename(held)
missing = self.archived_binlog(archive, filename, startpos, stoppos, check=False)
assert missing.returncode != 0
assert self.state('pitr') == source_final and source_final['sha256'] != target['sha256']
self.check('missing-log-cannot-reach-target', mysqlbinlog_exit=missing.returncode,
required_file=filename, baseline_unchanged=True, snapshot_differs_from_target=True)
held.rename(archived_file)
replay = self.archived_binlog(archive, filename, startpos, stoppos).stdout
self.feed('pitr', replay)
assert self.state('pitr') == target
assert not self.sql('pitr', 'SELECT id FROM ledger.orders WHERE id=3001')
self.check('transaction-boundary-pitr', start_position=startpos, stop_position=stoppos,
binlog=filename, expected=target, actual=self.state('pitr'), excluded=['delete-transaction','after-delete'],
target_source_gtid=target_gtid, replay_sha256=hashlib.sha256(replay).hexdigest(),
source_running_during_replay=False, archive=self.report['binlog_archive'])
call(['docker', 'start', self.prefix + '-source'])
for _ in range(60):
try:
if self.sql('source', 'SELECT 1') == '1':
break
except RuntimeError:
time.sleep(1)
else:
raise RuntimeError('Source did not restart after offline archive replay')
# Restart receiver explicitly; do not let its default reconnect backoff
# become an undocumented 60-second wait in a 30-second assertion.
self.sql('replica', 'STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;')
self.wait_replica()
assert self.state('replica')['orders'] == 1 # it received the accidental deletion too
self.check('accidental-delete-replicates', replica_orders=1, recovered_pitr_orders=target['orders'])
self.sql('replica', 'STOP REPLICA SQL_THREAD')
applied = self.state('replica')
self.business('source', self.order(4001, 'delayed-apply', 11))
wanted = self.sql('source', 'SELECT @@GLOBAL.gtid_executed')
assert self.state('replica') == applied
assert self.sql('replica', f"SELECT GTID_SUBSET('{wanted}',@@GLOBAL.gtid_executed)") == '0'
self.sql('replica', 'START REPLICA SQL_THREAD')
self.wait_replica()
self.check('pause-and-catch-up', during_pause=applied, after_resume=self.state('replica'))
self.sql('replica', 'STOP REPLICA')
failover_prefix = self.state('replica')
prefix_time = time.monotonic()
self.business('source', self.order(5001, 'acknowledged-but-not-replicated', 13))
source_ack_time = time.monotonic()
source_before_failure = self.state('source')
assert source_before_failure['orders'] == failover_prefix['orders'] + 1
assert not self.sql('replica', 'SELECT id FROM ledger.orders WHERE id=5001')
try:
self.promote()
except RuntimeError as error:
assert 'FENCE_REQUIRED' in str(error)
else:
raise AssertionError('Unsafe promotion was accepted')
denied = self.readonly_rejected('replica', self.order(6000, 'must-reject'))
assert self.state('replica') == failover_prefix
assert self.sql('replica', 'SELECT @@GLOBAL.super_read_only') == '1'
self.check('unfenced-promotion-refused', old_primary_running=True, replica_remains_read_only=True,
actual_write_rejection=denied)
failure_start = time.monotonic()
self.stop_source('failover-fence')
self.promote()
self.business('replica', self.order(6001, 'new-primary-only', 19))
promoted = self.state('replica')
assert not self.sql('replica', 'SELECT id FROM ledger.orders WHERE id=5001')
assert self.business('replica', 'SELECT tag FROM ledger.orders WHERE id=6001') == 'new-primary-only'
assert promoted['orders'] == failover_prefix['orders'] + 1
expected_rows = failover_prefix['rows'] + ['6001\t19\tnew-primary-only']
assert promoted['rows'] == expected_rows
assert promoted['balance'] == failover_prefix['balance'] - 19
new_primary_ready = time.monotonic()
self.check('fenced-manual-failover-with-observed-loss', source_before_failure=source_before_failure,
surviving_prefix=failover_prefix, promoted=promoted, lost_acknowledged_order_ids=[5001],
rpo_lost_transactions=1, observed_window_seconds=round(source_ack_time-prefix_time, 4),
manual_rto_seconds=round(new_primary_ready-failure_start, 4),
business_user='ledger_app', read_only=self.sql('replica', 'SELECT @@GLOBAL.read_only'),
super_read_only=self.sql('replica', 'SELECT @@GLOBAL.super_read_only'),
rto_boundary='docker-stop invocation to verified new-primary business commit; excludes detection/routing/client retry')
# A returning old primary stays disconnected; fence its writes before any possible rejoin.
call(['docker', 'network', 'disconnect', self.network, self.prefix + '-source'])
call(['docker', 'start', self.prefix + '-source'])
for _ in range(60):
try:
self.sql('source', 'SET GLOBAL super_read_only=ON')
break
except RuntimeError:
time.sleep(1)
else:
raise RuntimeError('Returning source did not start')
denied = self.readonly_rejected('source', self.order(7001, 'old-primary-reject'))
assert not self.sql('source', 'SELECT id FROM ledger.orders WHERE id=7001')
assert not self.sql('source', 'SELECT id FROM ledger.orders WHERE id=6001')
networks = json.loads(call(['docker', 'inspect', '--format', '{{json .NetworkSettings.Networks}}', self.prefix + '-source']).stdout)
assert not networks
self.check('returning-old-primary-quarantined', networks=networks,
super_read_only=self.sql('source', 'SELECT @@GLOBAL.super_read_only'),
divergent_old_state=True, automatically_rejoined=False, actual_write_rejection=denied)
self.report['status'] = 'PASS'
self.report['scenario_count'] = len(self.report['scenarios'])
assert self.report['scenario_count'] == 10
def close(self):
self.output.write_text(json.dumps(self.report, ensure_ascii=False, indent=2) + '\n')
for name in self.names:
call(['docker', 'rm', '-f', '-v', self.prefix + '-' + name], check=False)
call(['docker', 'network', 'rm', self.network], check=False)
call(['docker', 'image', 'rm', self.prefix + '-tools'], check=False)
self.tmp.cleanup()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--evidence', default='evidence/runtime.json')
args = parser.parse_args()
lab = Lab(args.evidence)
try:
lab.run()
except BaseException as error:
lab.report['status'] = 'FAIL'
lab.report['error'] = str(error)
for name in lab.names:
logs = call(['docker', 'logs', '--tail', '40', lab.prefix + '-' + name], check=False)
print(name, logs.stderr.decode(errors='replace')[-5000:])
raise
finally:
lab.close()
if __name__ == '__main__':
main()lab/tools.Dockerfile
FROM mysql@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364
# Only extraction uses cpio. The actual mysqlbinlog comes from an exact Oracle
# client RPM whose SHA-256 is checked by run.py before this build.
COPY client.rpm /tmp/client.rpm
RUN microdnf install -y cpio && \
cd /tmp && rpm2cpio client.rpm | cpio -id ./usr/bin/mysqlbinlog && \
cp /tmp/usr/bin/mysqlbinlog /usr/local/bin/mysqlbinlog && \
rm -rf /tmp/client.rpm /tmp/usr && microdnf clean all