ci(gitea): verify container images with isolated smoke tests
Development CI / check-and-package (push) Successful in 9m10s
Development CI / docker-smoke (push) Successful in 16s

This commit is contained in:
eezd committed 2026-10-27 16:02:50 +08:00
1 parent d99cdd75b6
commit 27e854159d
4 files changed
+180 -2

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Verify a container image with disposable networking, storage and notes."""
import argparse
import contextlib
import json
from pathlib import Path
import secrets
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('image')
parser.add_argument('--client-container', help='CI job container to connect to the test network')
args = parser.parse_args()
version = Path('VERSION').read_text().strip()
name = 'rawmark-ci-' + secrets.token_hex(8)
def docker(*command):
return subprocess.check_output(['docker', *command], text=True).strip()
config = json.loads(docker('image', 'inspect', args.image))[0]['Config']
assert config['User'].split(':')[0] not in ('', '0', 'root'), 'Image must run as non-root'
assert config['Healthcheck']['Test'] == ['CMD', '/rawmark', 'healthcheck']
assert docker('run', '--rm', '--network', 'none', args.image, '--version') == f'RawMark {version}'
with contextlib.ExitStack() as cleanup:
docker('network', 'create', '--internal', name)
cleanup.callback(docker, 'network', 'rm', name)
docker('volume', 'create', name)
cleanup.callback(docker, 'volume', 'rm', name)
if args.client_container:
docker('network', 'connect', name, args.client_container)
cleanup.callback(docker, 'network', 'disconnect', name, args.client_container)
create = ('create', '--name', name, '--network', name,
'--mount', f'type=volume,source={name},target=/data',
'--cpus', '1', '--memory', '512m', '--health-interval', '1s',
'--health-start-period', '1s', args.image)
docker(*create)
cleanup.callback(docker, 'rm', '--force', '--volumes', name)
docker('start', name)
info = json.loads(docker('inspect', name))[0]
host = name if args.client_container else info['NetworkSettings']['Networks'][name]['IPAddress']
origin = f'http://{host}:8080'
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(path, *, method='GET', body=None, token=None, status=200, raw=False):
headers = {'Accept-Language': 'en'}
if token:
headers['Authorization'] = 'Bearer ' + token
if body is not None and not isinstance(body, bytes):
headers['Content-Type'] = 'application/json'
body = json.dumps(body).encode()
req = urllib.request.Request(origin + path, data=body, headers=headers, method=method)
try:
response = opener.open(req, timeout=5)
except urllib.error.HTTPError as error:
response = error
with response:
assert response.status == status, (path, response.status, response.read())
if raw:
return response.read()
result = json.load(response)
assert (result['code'] == 0) == (status < 400), result
return result['data']
def wait_healthy():
for _ in range(60):
state = json.loads(docker('inspect', name))[0]['State']
if state['Status'] != 'running' or state['Health']['Status'] == 'unhealthy':
raise RuntimeError(f'Container failed: {state["Status"]}, {state["Health"]["Status"]}')
if state['Health']['Status'] == 'healthy':
docker('exec', name, '/rawmark', 'healthcheck')
request('/api/v1/health')
return
time.sleep(1)
raise TimeoutError('Container did not become healthy')
try:
wait_healthy()
assert b'<html' in request('/', raw=True).lower(), 'Embedded frontend is missing'
assert request('/api/v1/auth/state')['setUp'] is False
request('/api/v1/tree', status=401)
token = request('/api/v1/auth/setup', method='POST',
body={'password': secrets.token_urlsafe(24)})['token']
assert token.startswith('rms_')
entries = request('/api/v1/tree', token=token)['entries']
assert len([e for e in entries if not e['dir'] and e['path'].endswith('.md')]) == 7
for note in Path('deploy/data/vault').rglob('*.md'):
path = urllib.parse.quote(note.relative_to('deploy/data/vault').as_posix())
assert request('/api/v1/files/' + path, token=token, raw=True) == note.read_bytes()
note = '/api/v1/files/' + urllib.parse.quote('中文 folder/笔记.md')
moved = '/api/v1/files/' + urllib.parse.quote('移动 folder/新笔记.md')
content = '# Container note\n\n中文 content.\n'.encode()
request(note, method='PUT', body=content, token=token)
content += b'Updated.\n'
request(note, method='PUT', body=content, token=token)
request('/api/v1/move', method='POST', token=token,
body={'from': '中文 folder/笔记.md', 'to': '移动 folder/新笔记.md'})
request(note, token=token, status=404)
item = request(moved, method='DELETE', token=token)
request(moved, token=token, status=404)
assert item['id'] in [e['id'] for e in request('/api/v1/trash', token=token)['items']]
request('/api/v1/trash/' + item['id'] + '/restore', method='POST', token=token)
assert request(moved, token=token, raw=True) == content
for replace in (False, True):
if replace:
docker('rm', '--force', '--volumes', name)
docker(*create)
docker('start', name)
if not args.client_container:
info = json.loads(docker('inspect', name))[0]
origin = f'http://{info["NetworkSettings"]["Networks"][name]["IPAddress"]}:8080'
else:
docker('restart', name)
wait_healthy()
assert request('/api/v1/auth/state')['setUp'] is True
request('/api/v1/auth/me', token=token)
assert request(moved, token=token, raw=True) == content
assert request('/api/v1/auth/logout', method='POST', token=token, status=204, raw=True) == b''
request('/api/v1/auth/me', token=token, status=401)
print(f'RawMark {version}: container non-root user, frontend, health, authentication, examples, '
'overwrite, Unicode paths, move, recycle bin, restart/replacement persistence and logout passed')
except BaseException:
subprocess.run(['docker', 'logs', name], check=False)
raise