mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 23:57:58 +09:00
MinIO's images no longer pull, so object storage is now the Garage template. Attach storage creates a bucket named for the app and a key that can read and write only that bucket, keeps the secret in an internal vault, and wires S3_* and AWS_* env through fromService and fromSecret. Detach revokes the key and removes the env but keeps the bucket and its objects. A docker leg proves the scope against a real Garage.
167 lines
7.1 KiB
Python
167 lines
7.1 KiB
Python
"""Attach storage against a REAL Garage (plan 86 §C2).
|
|
|
|
The bundled Garage template is rendered with the panel's own variable and
|
|
file rules and started with its managed override. Attaching gives an app a
|
|
bucket and a scoped key; a separate project that has only the env the
|
|
attach wrote can write and read its own bucket, is refused on another
|
|
bucket, and after a detach the same key is refused everywhere.
|
|
|
|
Gated with the other docker legs (pulls images):
|
|
|
|
SERVERKIT_DOCKER_BUILDS=1 pytest tests -m docker_builds
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app import db
|
|
from app.services.app_attachment_service import AppAttachmentService
|
|
from app.services.compose_env_service import ComposeEnvService
|
|
from app.services.env_service import EnvService
|
|
from app.services.garage_admin import GarageAdmin, GarageError
|
|
from app.services.template_service import TemplateService
|
|
from tests.factories import make_application
|
|
|
|
|
|
def _docker_ready() -> bool:
|
|
if shutil.which('docker') is None:
|
|
return False
|
|
try:
|
|
return subprocess.run(['docker', 'info'], capture_output=True,
|
|
timeout=20).returncode == 0
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
pytestmark = [
|
|
pytest.mark.docker_builds,
|
|
pytest.mark.skipif(os.environ.get('SERVERKIT_DOCKER_BUILDS') != '1',
|
|
reason='docker-builds leg; opt in with SERVERKIT_DOCKER_BUILDS=1'),
|
|
pytest.mark.skipif(not _docker_ready(), reason='docker daemon not reachable'),
|
|
]
|
|
|
|
PROBE = r'''
|
|
import os, sys, boto3, botocore
|
|
cfg = dict(endpoint_url=os.environ["S3_ENDPOINT"], region_name=os.environ["S3_REGION"],
|
|
aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"],
|
|
aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"])
|
|
s3 = boto3.client("s3", **cfg)
|
|
def attempt(fn):
|
|
try:
|
|
fn(); return "ok"
|
|
except botocore.exceptions.ClientError as e:
|
|
return e.response["Error"]["Code"]
|
|
own = os.environ["S3_BUCKET"]
|
|
print("own-put", attempt(lambda: s3.put_object(Bucket=own, Key="k", Body=b"v")))
|
|
print("own-get", attempt(lambda: s3.get_object(Bucket=own, Key="k")["Body"].read()))
|
|
print("other-put", attempt(lambda: s3.put_object(Bucket=sys.argv[1], Key="k", Body=b"v")))
|
|
'''
|
|
|
|
|
|
def _compose(project, root, *files, args, check=True, timeout=300):
|
|
argv = ['docker', 'compose', '-p', project]
|
|
for name in files:
|
|
argv += ['-f', os.path.join(root, name)]
|
|
proc = subprocess.run(argv + args, capture_output=True, text=True,
|
|
timeout=timeout, cwd=root)
|
|
if check:
|
|
assert proc.returncode == 0, f'{argv + args}\n{proc.stdout}\n{proc.stderr}'
|
|
return proc
|
|
|
|
|
|
def _results(stdout):
|
|
return dict(line.split(' ', 1) for line in stdout.splitlines()
|
|
if line.split(' ', 1)[0] in ('own-put', 'own-get', 'other-put'))
|
|
|
|
|
|
def test_attach_storage_scopes_a_key_to_one_bucket(app, tmp_path, monkeypatch):
|
|
suffix = uuid.uuid4().hex[:8]
|
|
store_name, web_name = f'skc-store-{suffix}', f'skc-web-{suffix}'
|
|
installed = {}
|
|
monkeypatch.setattr(TemplateService, 'get_config', classmethod(
|
|
lambda cls: {'repos': [], 'installed': installed}))
|
|
|
|
template = TemplateService.get_template('garage')['template']
|
|
variables = {'APP_NAME': store_name, 'API_PORT': '0', 'BIND_ADDRESS': '127.0.0.1'}
|
|
for var in template['variables']:
|
|
if var['name'] not in variables:
|
|
variables[var['name']] = TemplateService.generate_value(var)
|
|
store_root = tmp_path / store_name
|
|
store_root.mkdir()
|
|
rendered = TemplateService._render_compose_and_files(template, variables, str(store_root))
|
|
assert rendered['success'], rendered
|
|
compose = yaml.safe_load(rendered['compose_content'])
|
|
for svc in compose['services'].values():
|
|
svc.pop('ports', None) # reachability comes from the network alone
|
|
(store_root / 'docker-compose.yml').write_text(yaml.safe_dump(compose))
|
|
for f in rendered['files']:
|
|
with open(f['path'], 'w', newline='\n') as fh:
|
|
fh.write(f['content'])
|
|
(store_root / '.serverkit-template.json').write_text(json.dumps(
|
|
{'template_id': 'garage', 'variables': variables}))
|
|
store = make_application(db, name=store_name, root_path=str(store_root))
|
|
installed[str(store.id)] = {'template_id': 'garage'}
|
|
|
|
web_root = tmp_path / web_name
|
|
web_root.mkdir()
|
|
(web_root / 'probe.py').write_text(PROBE)
|
|
web = make_application(db, name=web_name, root_path=str(web_root))
|
|
|
|
base, override = 'docker-compose.yml', ComposeEnvService.OVERRIDE_NAME
|
|
other_bucket = f'other-{suffix}'
|
|
try:
|
|
assert ComposeEnvService.refresh_for_project(str(store_root))
|
|
_compose(store_name, str(store_root), base, override, args=['up', '-d'])
|
|
admin = GarageAdmin(store_name)
|
|
deadline = time.time() + 60
|
|
while True:
|
|
try:
|
|
admin.call('GetClusterStatus')
|
|
break
|
|
except GarageError:
|
|
assert time.time() < deadline, 'garage never answered'
|
|
time.sleep(1)
|
|
|
|
row, created = AppAttachmentService.attach_storage(web, store)
|
|
assert created
|
|
again, created_again = AppAttachmentService.attach_storage(web, store)
|
|
assert again.id == row.id and not created_again
|
|
admin.ensure_bucket(other_bucket)
|
|
|
|
(web_root / 'docker-compose.yml').write_text(yaml.safe_dump({'services': {'probe': {
|
|
'image': 'python:3.12-alpine',
|
|
'volumes': ['./probe.py:/probe.py:ro'],
|
|
'command': ['sh', '-c', 'pip install -q --disable-pip-version-check boto3 '
|
|
f'>/dev/null 2>&1 && python /probe.py {other_bucket}'],
|
|
}}}))
|
|
assert ComposeEnvService.refresh_for_project(str(web_root))
|
|
first = _compose(web_name, str(web_root), base, override, args=['run', '--rm', 'probe'])
|
|
assert _results(first.stdout) == {
|
|
'own-put': 'ok', 'own-get': 'ok', 'other-put': 'AccessDenied'}, first.stdout
|
|
|
|
# Detach revokes the key: replay the same credentials explicitly.
|
|
creds = EnvService.get_effective_env(web.id)
|
|
assert AppAttachmentService.detach(row) is None
|
|
assert 'S3_BUCKET' not in EnvService.get_effective_env(web.id)
|
|
replay = ['run', '--rm']
|
|
for key in ('S3_ENDPOINT', 'S3_REGION', 'S3_ACCESS_KEY_ID',
|
|
'S3_SECRET_ACCESS_KEY', 'S3_BUCKET'):
|
|
replay += ['-e', f'{key}={creds[key]}']
|
|
# Keep the network so only the revoked key can explain a refusal.
|
|
(web_root / 'net.yml').write_text(yaml.safe_dump({
|
|
'services': {'probe': {'networks': ['default', 'serverkit-services']}},
|
|
'networks': {'serverkit-services': {'external': True}}}))
|
|
after = _compose(web_name, str(web_root), base, 'net.yml', args=replay + ['probe'])
|
|
assert _results(after.stdout)['own-put'] in ('InvalidAccessKeyId', 'AccessDenied'), after.stdout
|
|
finally:
|
|
_compose(web_name, str(web_root), base, check=False,
|
|
args=['down', '-v', '--remove-orphans'])
|
|
_compose(store_name, str(store_root), base, override, check=False,
|
|
args=['down', '-v', '--remove-orphans'])
|