PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.29
ووسلام – همگام سازی ووکامرس و باسلام v1.10.29
1.10.29 1.10.28 1.1.26 1.10.27 1.10.24 1.10.25 1.10.23 1.10.22 1.10.21 1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 All 62 releases
sync-basalam / tests / integration / webservers / run.py

run.py in ووسلام – همگام سازی ووکامرس و باسلام 1.10.29, at tests/integration/webservers/run.py

325 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 #!/usr/bin/env python3
2 """Real HTTP workers on disposable Apache, Nginx/FPM, Caddy/FPM and OLS."""
3 import argparse
4 import concurrent.futures
5 import json
6 import os
7 from pathlib import Path
8 import secrets
9 import subprocess
10 import time
11 import urllib.error
12 import urllib.parse
13 import urllib.request
14
15 HERE = Path(__file__).resolve().parent
16 PROTECTED = """# BEGIN SyncBasalam Async
17 <IfModule LiteSpeed>
18 <IfModule mod_rewrite.c>
19 RewriteEngine On
20 RewriteCond %{REQUEST_URI} /wp-admin/admin-ajax\\.php$
21 RewriteCond %{QUERY_STRING} (^|&)action=sync_basalam_run_jobs_async(&|$)
22 RewriteRule .* - [E=noabort:1]
23 </IfModule>
24 </IfModule>
25 # END SyncBasalam Async
26 """
27 SERVERS = {
28 'apache': (8141, 'apache', ['apache'], 'apache2handler'),
29 'nginx': (8142, 'fpm', ['fpm', 'nginx'], 'fpm-fcgi'),
30 'caddy': (8143, 'caddy-fpm', ['caddy-fpm', 'caddy'], 'fpm-fcgi'),
31 'ols': (8144, 'ols', ['ols'], 'litespeed'),
32 }
33
34
35 class Matrix:
36 def __init__(self, args):
37 self.args = args
38 self.site = args.site_dir.resolve()
39 self.output = args.results.resolve()
40 self.output.mkdir(parents=True, exist_ok=True)
41 self.token = secrets.token_hex(32)
42 self.env = dict(os.environ, MATRIX_TOKEN=self.token, MATRIX_SITE_DIR=str(self.site))
43 self.command = ['docker', 'compose', '-f', str(HERE / 'compose.yml')]
44 self.report = {'success': False, 'servers': [], 'source_commit': self.git_head()}
45 self.assertions = 0
46
47 def git_head(self):
48 return subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=HERE, text=True).strip()
49
50 def compose(self, *args, check=True):
51 result = subprocess.run(self.command + list(args), env=self.env, text=True, capture_output=True)
52 if check and result.returncode:
53 raise RuntimeError(result.stdout + result.stderr)
54 return result
55
56 def http(self, fields, control=True, timeout=15):
57 body = urllib.parse.urlencode(fields).encode()
58 headers = {'X-Matrix-Token': self.token} if control else {}
59 url = self.url if control else self.url + '?action=sync_basalam_run_jobs_async'
60 request = urllib.request.Request(url, data=body, headers=headers)
61 try:
62 with urllib.request.urlopen(request, timeout=timeout) as response:
63 return response.status, json.loads(response.read())
64 except urllib.error.HTTPError as error:
65 raw = error.read()
66 try:
67 data = json.loads(raw)
68 except json.JSONDecodeError:
69 data = {'body': raw.decode(errors='replace')[:1000]}
70 return error.code, data
71
72 def control(self, op='state', **fields):
73 status, data = self.http(dict(action='sync_basalam_matrix_control', op=op, **fields))
74 if status != 200 or not data.get('success'):
75 raise RuntimeError(f'Control {op}: {status} {data}')
76 self.last_state = data['data']
77 return data['data']
78
79 def worker(self, nonce=None):
80 if nonce is None:
81 nonce = self.control()['nonce']
82 try:
83 return self.http(dict(action='sync_basalam_run_jobs_async', nonce=nonce), False, 90)
84 except (OSError, ValueError) as error:
85 return 0, {'error': str(error)}
86
87 def wait(self, predicate, seconds=20):
88 deadline = time.monotonic() + seconds
89 last = None
90 while time.monotonic() < deadline:
91 last = self.control()
92 if predicate(last):
93 return last
94 time.sleep(.15)
95 raise RuntimeError(f'Timed out waiting for worker: {last}')
96
97 def check(self, condition, description):
98 if not condition:
99 raise AssertionError(description)
100 self.assertions += 1
101
102 def completed(self, key):
103 state = self.wait(lambda s: s['events'].get(key, {}).get('returns') == 1 and s['lock_free'])
104 self.check(not state['jobs'], f'{key}: success must delete the queue record')
105 self.check(state['events'][key]['starts'] == 1, f'{key}: job executed exactly once')
106 return state
107
108 def ready(self):
109 deadline = time.monotonic() + 45
110 while time.monotonic() < deadline:
111 try:
112 return self.control()
113 except (OSError, ValueError, RuntimeError):
114 time.sleep(.3)
115 raise RuntimeError(f'HTTP server did not become ready: {self.url}')
116
117 def run_server(self, name):
118 port, backend, services, expected_sapi = SERVERS[name]
119 self.url = f'http://127.0.0.1:{port}/wp-admin/admin-ajax.php'
120 self.compose('up', '-d', *services)
121 state = self.ready()
122 self.check(state['sapi'] == expected_sapi, f'{name}: actual web SAPI must be {expected_sapi}')
123 result = {'server': name, 'runtime': {k: state[k] for k in ['sapi', 'php', 'server']}, 'scenarios': []}
124 start_assertions = self.assertions
125 self.report['servers'].append(result)
126 scenario = lambda text: (result['scenarios'].append(text), print(f'{name}: PASS {text}', flush=True))
127
128 self.control('reset')
129 status, _ = self.worker('invalid-matrix-nonce')
130 self.check(status == 403, f'{name}: invalid nonce must be rejected')
131 status, body = self.worker()
132 self.check(status == 200 and body['data']['processed'] == 0, 'Valid empty worker must return zero')
133 scenario('real HTTP nonce rejection, acceptance and empty batch')
134
135 self.control('reset')
136 self.control('enqueue', key='success')
137 status, body = self.worker()
138 self.check(status == 200 and body['data']['processed'] == 1, 'Worker must execute one job')
139 state = self.completed('success')
140 self.check(state['notice'] is None, 'Healthy runtime must not alert')
141 self.check(state['health']['last_returned_protected'] is (True if name == 'ols' else None), 'Protection must match the real runtime')
142 self.check(state['autoload'] in ['off', 'no'], 'Health metadata must not autoload')
143 result['healthy_evidence'] = state
144 scenario('actual queue success, runtime protection, cleanup and non-autoloaded health')
145
146 self.control('reset')
147 self.control('enqueue', key='rejected', mode='reject')
148 self.worker()
149 state = self.control()
150 self.check(state['jobs'][0]['status'] == 'failed' and '422' in state['jobs'][0]['error_message'], 'API rejection must be preserved')
151 self.check(state['notice'] is None, 'API rejection must not produce a hosting alert')
152 scenario('explicit API 422 remains separate from host interruption')
153
154 self.control('reset')
155 began = time.monotonic()
156 self.control('enqueue', key='disconnected', seconds=3, dispatch=1)
157 dispatch_seconds = time.monotonic() - began
158 state = self.completed('disconnected')
159 self.check(dispatch_seconds < 2.5, 'Nonblocking caller must return before the three-second job completes')
160 self.check(state['events']['disconnected']['returned_at'] - state['events']['disconnected']['started_at'] >= 2.9, 'Background job must finish after client disconnection')
161 self.check(state['notice'] is None, 'Completed background request must not alert')
162 result['disconnect_evidence'] = {'caller_seconds': round(dispatch_seconds, 3), 'worker': state['events']['disconnected']}
163 scenario('normal nonblocking WordPress loopback survives client disconnection')
164
165 self.control('reset')
166 job = self.control('enqueue', key='concurrent', seconds=3)['id']
167 with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
168 futures = [pool.submit(self.worker) for _ in range(4)]
169 self.wait(lambda s: not s['lock_free'] and s['events'].get('concurrent', {}).get('starts') == 1)
170 self.control('age', id=job)
171 html = self.control('notice')['html']
172 self.check('اجرای ه�
173 گا�
174 ‌سازی نی�
175 ه‌کاره' not in html, 'Slow live worker must not be called interrupted')
176 responses = [f.result() for f in futures]
177 self.check(all(status == 200 for status, _ in responses), 'All concurrent requests must return HTTP 200')
178 self.check(sorted(body['data']['processed'] for _, body in responses) == [0, 0, 0, 1], 'Only one runner may execute the job')
179 self.completed('concurrent')
180 scenario('four real HTTP workers, real advisory lock, no slow-worker false alarm')
181
182 self.control('reset')
183 job = self.control('enqueue', key='killed', seconds=60)['id']
184 with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
185 future = pool.submit(self.worker)
186 state = self.wait(lambda s: not s['lock_free'] and 'killed' in s['events'])
187 pid = int(state['events']['killed']['pid'])
188 self.check(pid > 1, 'Kill must target a PHP worker, never container init')
189 self.compose('exec', '-T', backend, 'sh', '-c', 'kill -9 "$1"', 'matrix-kill', str(pid))
190 future.result()
191 self.wait(lambda s: s['lock_free'])
192 self.control('age', id=job)
193 html = self.control('notice')['html']
194 state = self.control()
195 self.check(state['notice']['class'] == 'notice-error' and state['notice']['job_id'] == job, 'A later HTTP admin request must detect the dead worker')
196 self.check(str(job) in html and 'role="alert"' in html, 'Admin notice must identify the job')
197 self.check(('noabort' in html) == (name == 'ols'), 'Hosting advice must match actual runtime')
198 self.check(self.control('notice', role='anonymous')['html'] == '', 'Anonymous callers cannot see the admin notice')
199 result['kill_evidence'] = {'pid': pid, 'health': state['health'], 'notice': state['notice']}
200 scenario('SIGKILL of actual HTTP PHP worker, independent detection and admin permissions')
201
202 self.compose('restart', backend)
203 self.ready()
204 self.check(self.control()['notice']['job_id'] == job, 'Interruption evidence must persist across server restart')
205 self.control('retry', id=job)
206 self.worker()
207 state = self.control()
208 self.check(not state['jobs'] and state['notice'] is None, 'Normal stale recovery must complete the job and clear warning')
209 recovered = state['events']['killed']
210 attempts = [entry['attempts'] for entry in recovered['history']]
211 # A webserver may replay a failed upstream request. Each real execution
212 # must be a distinct queue attempt; only the recovered attempt returns.
213 self.check(recovered['starts'] >= 2 and recovered['returns'] == 1 and attempts == list(range(recovered['starts'])), 'Every recovery start must be a distinct queue attempt, with exactly one return')
214 result['recovery_evidence'] = recovered
215 scenario('restart persistence, real stale recovery and warning clearance')
216
217 self.control('reset')
218 self.control('lease')
219 self.control('enqueue', key='orphaned-lease', dispatch=1)
220 self.completed('orphaned-lease')
221 scenario('legacy dispatch lock without expiration cannot block automatic execution')
222
223 self.control('reset')
224 job = self.control('enqueue', key='fatal', mode='fatal')['id']
225 self.worker()
226 state = self.wait(lambda s: s['lock_free'])
227 self.check(state['jobs'][0]['status'] == 'processing', 'Fatal PHP error must leave observable interrupted job')
228 self.control('age', id=job)
229 self.control('notice')
230 self.check(self.control()['notice']['job_id'] == job, 'Fatal PHP error must be detected on a later admin request')
231 self.control('retry', id=job)
232 self.worker()
233 self.check(self.control()['notice'] is None, 'Successful retry after fatal must clear interruption')
234 scenario('actual PHP fatal, later detection and successful recovery')
235
236 if name in ['nginx', 'caddy']:
237 self.control('reset')
238 self.compose('exec', '-T', backend, 'sh', '-c', "printf '%s\\n' '[www]' 'request_terminate_timeout = 4s' > /usr/local/etc/php-fpm.d/zz-matrix-timeout.conf")
239 self.compose('restart', backend)
240 self.ready()
241 job = self.control('enqueue', key='fpm-timeout', seconds=10)['id']
242 status, _ = self.worker()
243 self.check(status in [0, 502, 503, 504], 'FPM timeout must interrupt HTTP execution')
244 state = self.wait(lambda s: s['lock_free'])
245 self.check(state['events']['fpm-timeout']['returns'] == 0, 'FPM must actually terminate the worker before return')
246 self.control('age', id=job)
247 self.control('notice')
248 self.check(self.control()['notice']['job_id'] == job, 'Resource timeout must produce an interruption alert')
249 self.compose('exec', '-T', backend, 'rm', '/usr/local/etc/php-fpm.d/zz-matrix-timeout.conf')
250 self.compose('restart', backend)
251 self.ready()
252 self.control('retry', id=job)
253 self.worker()
254 self.check(self.control()['notice'] is None, 'Recovery after FPM timeout must clear alert')
255 scenario('real PHP-FPM request timeout, interruption alert and recovery')
256
257 if name == 'ols':
258 self.control('reset')
259 (self.site / '.htaccess').write_text('')
260 self.compose('restart', 'ols')
261 self.ready()
262 self.control('enqueue', key='unprotected')
263 self.worker()
264 state = self.completed('unprotected')
265 self.check(state['notice']['class'] == 'notice-warning', 'Actual LSAPI without rule must report unverified protection')
266 self.check(state['health']['last_returned_protected'] is False, 'Missing actual noabort flag must be recorded')
267 result['unprotected_evidence'] = state
268 scenario('actual LSAPI without htaccess protection warns even after fast completion')
269 (self.site / '.htaccess').write_text(PROTECTED)
270 self.compose('restart', 'ols')
271 self.ready()
272 self.control('enqueue', key='protection-restored', seconds=3, dispatch=1)
273 state = self.completed('protection-restored')
274 self.check(state['notice'] is None and state['health']['last_returned_protected'] is True, 'Restoring actual scoped rule must clear warning after real execution')
275 scenario('restored scoped htaccess rule is observed in LSAPI and clears warning')
276
277 self.control('reset')
278 result['assertions'] = self.assertions - start_assertions
279 result['success'] = True
280 self.compose('stop', *services)
281
282 def run(self):
283 self.check((self.site / 'wp-load.php').is_file(), 'Supply a disposable WordPress core')
284 self.check((self.site / 'wp-content/plugins/woocommerce/woocommerce.php').is_file(), 'Supply WooCommerce in the disposable site')
285 if (self.site / 'wp-config.php').exists() and not (
286 (self.site / '.sync-basalam-matrix').is_file() and (self.site / 'wp-config.php').stat().st_size == 0
287 ):
288 raise RuntimeError('Refusing a directory containing wp-config.php; use an unconfigured disposable copy.')
289 (self.site / '.sync-basalam-matrix').write_text('Disposable sync-basalam HTTP worker test site\n')
290 (self.site / 'wp-content/mu-plugins').mkdir(parents=True, exist_ok=True)
291 (self.site / 'wp-config.php').touch()
292 (self.site / 'wp-content/mu-plugins/matrix.php').touch()
293 (self.site / 'wp-content/plugins/sync-basalam').mkdir(exist_ok=True)
294 (self.site / '.htaccess').write_text(PROTECTED)
295 try:
296 self.compose('up', '-d', '--wait', 'db', 'apache')
297 self.compose('exec', '-T', 'apache', 'php', '/matrix/install.php')
298 self.compose('stop', 'apache')
299 for name in self.args.servers:
300 self.run_server(name)
301 self.report['success'] = True
302 except BaseException as error:
303 self.report['error'] = str(error)
304 self.report['last_state'] = getattr(self, 'last_state', None)
305 raise
306 finally:
307 self.report['assertions'] = self.assertions
308 self.output.joinpath('results.json').write_text(json.dumps(self.report, indent=2, ensure_ascii=False))
309 self.output.joinpath('containers.log').write_text(self.compose('logs', '--no-color', check=False).stdout)
310 images = subprocess.run(['docker', 'image', 'inspect', 'wordpress:php8.2-apache', 'wordpress:php8.2-fpm', 'nginx:1.29-alpine', 'caddy:2-alpine', 'litespeedtech/openlitespeed:1.9.3-lsphp83'], text=True, capture_output=True)
311 if images.returncode == 0:
312 self.output.joinpath('images.json').write_text(json.dumps([{k: image[k] for k in ['Id', 'RepoDigests', 'Architecture']} for image in json.loads(images.stdout)], indent=2))
313 if not self.args.keep:
314 self.compose('down', '-v', check=False)
315 print(f'PASS {len(self.report["servers"])} webservers, {self.assertions} assertions', flush=True)
316
317
318 if __name__ == '__main__':
319 parser = argparse.ArgumentParser(description=__doc__)
320 parser.add_argument('--site-dir', type=Path, required=True)
321 parser.add_argument('--results', type=Path, required=True)
322 parser.add_argument('--servers', nargs='+', choices=SERVERS, default=list(SERVERS))
323 parser.add_argument('--keep', action='store_true', help='Keep disposable containers after the run for debugging')
324 Matrix(parser.parse_args()).run()
325