Further fixes to the Kubernetes config provider, and a new set of proper unit tests
This commit is contained in:
parent
babb7bb803
commit
2ae69dc651
8 changed files with 181 additions and 107 deletions
|
@ -291,7 +291,7 @@ class SuperUserConfigFile(ApiResource):
|
||||||
if not uploaded_file:
|
if not uploaded_file:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
config_provider.save_volume_file(filename, uploaded_file)
|
config_provider.save_volume_file(uploaded_file, filename)
|
||||||
return {
|
return {
|
||||||
'status': True
|
'status': True
|
||||||
}
|
}
|
||||||
|
|
|
@ -927,7 +927,7 @@ class SuperUserCustomCertificate(ApiResource):
|
||||||
|
|
||||||
logger.debug('Saving custom certificate %s', certpath)
|
logger.debug('Saving custom certificate %s', certpath)
|
||||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||||
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
config_provider.save_volume_file(uploaded_file, cert_full_path)
|
||||||
logger.debug('Saved custom certificate %s', certpath)
|
logger.debug('Saved custom certificate %s', certpath)
|
||||||
|
|
||||||
# Validate the certificate.
|
# Validate the certificate.
|
||||||
|
|
|
@ -39,14 +39,14 @@ class BaseFileProvider(BaseProvider):
|
||||||
def volume_exists(self):
|
def volume_exists(self):
|
||||||
return os.path.exists(self.config_volume)
|
return os.path.exists(self.config_volume)
|
||||||
|
|
||||||
def volume_file_exists(self, filename):
|
def volume_file_exists(self, relative_file_path):
|
||||||
return os.path.exists(os.path.join(self.config_volume, filename))
|
return os.path.exists(os.path.join(self.config_volume, relative_file_path))
|
||||||
|
|
||||||
def get_volume_file(self, filename, mode='r'):
|
def get_volume_file(self, relative_file_path, mode='r'):
|
||||||
return open(os.path.join(self.config_volume, filename), mode=mode)
|
return open(os.path.join(self.config_volume, relative_file_path), mode=mode)
|
||||||
|
|
||||||
def get_volume_path(self, directory, filename):
|
def get_volume_path(self, directory, relative_file_path):
|
||||||
return os.path.join(directory, filename)
|
return os.path.join(directory, relative_file_path)
|
||||||
|
|
||||||
def list_volume_directory(self, path):
|
def list_volume_directory(self, path):
|
||||||
dirpath = os.path.join(self.config_volume, path)
|
dirpath = os.path.join(self.config_volume, path)
|
||||||
|
|
|
@ -89,20 +89,17 @@ class BaseProvider(object):
|
||||||
""" Returns whether the config override volume exists. """
|
""" Returns whether the config override volume exists. """
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def volume_file_exists(self, filename):
|
def volume_file_exists(self, relative_file_path):
|
||||||
""" Returns whether the file with the given name exists under the config override volume. """
|
""" Returns whether the file with the given relative path exists under the config override
|
||||||
|
volume. """
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_volume_file(self, filename, mode='r'):
|
def get_volume_file(self, relative_file_path, mode='r'):
|
||||||
""" Returns a Python file referring to the given name under the config override volume. """
|
""" Returns a Python file referring to the given path under the config override volume. """
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write_volume_file(self, filename, contents):
|
def remove_volume_file(self, relative_file_path):
|
||||||
""" Writes the given contents to the config override volumne, with the given filename. """
|
""" Removes the config override volume file with the given path. """
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def remove_volume_file(self, filename):
|
|
||||||
""" Removes the config override volume file with the given filename. """
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def list_volume_directory(self, path):
|
def list_volume_directory(self, path):
|
||||||
|
@ -111,9 +108,9 @@ class BaseProvider(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def save_volume_file(self, filename, flask_file):
|
def save_volume_file(self, flask_file, relative_file_path):
|
||||||
""" Saves the given flask file to the config override volume, with the given
|
""" Saves the given flask file to the config override volume, with the given
|
||||||
filename.
|
relative path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
@ -124,5 +121,5 @@ class BaseProvider(object):
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_volume_path(self, directory, filename):
|
def get_volume_path(self, directory, filename):
|
||||||
""" Helper for constructing file paths, which may differ between providers. For example,
|
""" Helper for constructing relative file paths, which may differ between providers.
|
||||||
kubernetes can't have subfolders in configmaps """
|
For example, kubernetes can't have subfolders in configmaps """
|
||||||
|
|
|
@ -30,24 +30,12 @@ class FileConfigProvider(BaseFileProvider):
|
||||||
def save_config(self, config_obj):
|
def save_config(self, config_obj):
|
||||||
export_yaml(config_obj, self.yaml_path)
|
export_yaml(config_obj, self.yaml_path)
|
||||||
|
|
||||||
def write_volume_file(self, filename, contents):
|
def remove_volume_file(self, relative_file_path):
|
||||||
filepath = os.path.join(self.config_volume, filename)
|
filepath = os.path.join(self.config_volume, relative_file_path)
|
||||||
_ensure_parent_dir(filepath)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(filepath, mode='w') as f:
|
|
||||||
f.write(contents)
|
|
||||||
except IOError as ioe:
|
|
||||||
raise CannotWriteConfigException(str(ioe))
|
|
||||||
|
|
||||||
return filepath
|
|
||||||
|
|
||||||
def remove_volume_file(self, filename):
|
|
||||||
filepath = os.path.join(self.config_volume, filename)
|
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
|
|
||||||
def save_volume_file(self, filename, flask_file):
|
def save_volume_file(self, flask_file, relative_file_path):
|
||||||
filepath = os.path.join(self.config_volume, filename)
|
filepath = os.path.join(self.config_volume, relative_file_path)
|
||||||
_ensure_parent_dir(filepath)
|
_ensure_parent_dir(filepath)
|
||||||
|
|
||||||
# Write the file.
|
# Write the file.
|
||||||
|
|
|
@ -4,6 +4,7 @@ import json
|
||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from cStringIO import StringIO
|
||||||
from requests import Request, Session
|
from requests import Request, Session
|
||||||
|
|
||||||
from util.config.provider.baseprovider import CannotWriteConfigException, get_yaml
|
from util.config.provider.baseprovider import CannotWriteConfigException, get_yaml
|
||||||
|
@ -25,16 +26,21 @@ QE_CONFIG_SECRET = os.environ.get('QE_K8S_CONFIG_SECRET', 'quay-enterprise-confi
|
||||||
class KubernetesConfigProvider(BaseFileProvider):
|
class KubernetesConfigProvider(BaseFileProvider):
|
||||||
""" Implementation of the config provider that reads and writes configuration
|
""" Implementation of the config provider that reads and writes configuration
|
||||||
data from a Kubernetes Secret. """
|
data from a Kubernetes Secret. """
|
||||||
def __init__(self, config_volume, yaml_filename, py_filename):
|
def __init__(self, config_volume, yaml_filename, py_filename, api_host=None,
|
||||||
|
service_account_token_path=None):
|
||||||
super(KubernetesConfigProvider, self).__init__(config_volume, yaml_filename, py_filename)
|
super(KubernetesConfigProvider, self).__init__(config_volume, yaml_filename, py_filename)
|
||||||
|
service_account_token_path = service_account_token_path or SERVICE_ACCOUNT_TOKEN_PATH
|
||||||
|
api_host = api_host or KUBERNETES_API_HOST
|
||||||
|
|
||||||
# Load the service account token from the local store.
|
# Load the service account token from the local store.
|
||||||
if not os.path.exists(SERVICE_ACCOUNT_TOKEN_PATH):
|
if not os.path.exists(service_account_token_path):
|
||||||
raise Exception('Cannot load Kubernetes service account token')
|
raise Exception('Cannot load Kubernetes service account token')
|
||||||
|
|
||||||
with open(SERVICE_ACCOUNT_TOKEN_PATH, 'r') as f:
|
with open(service_account_token_path, 'r') as f:
|
||||||
self._service_token = f.read()
|
self._service_token = f.read()
|
||||||
|
|
||||||
|
self._api_host = api_host
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider_id(self):
|
def provider_id(self):
|
||||||
return 'k8s'
|
return 'k8s'
|
||||||
|
@ -44,13 +50,16 @@ class KubernetesConfigProvider(BaseFileProvider):
|
||||||
# in Kubernetes secrets.
|
# in Kubernetes secrets.
|
||||||
return "_".join([directory.rstrip('/'), filename])
|
return "_".join([directory.rstrip('/'), filename])
|
||||||
|
|
||||||
def volume_file_exists(self, filename):
|
def volume_file_exists(self, relative_file_path):
|
||||||
|
if '/' in relative_file_path:
|
||||||
|
raise Exception('Expected path from get_volume_path, but found slashes')
|
||||||
|
|
||||||
# NOTE: Overridden because we don't have subdirectories, which aren't supported
|
# NOTE: Overridden because we don't have subdirectories, which aren't supported
|
||||||
# in Kubernetes secrets.
|
# in Kubernetes secrets.
|
||||||
secret = self._lookup_secret()
|
secret = self._lookup_secret()
|
||||||
if not secret or not secret.get('data'):
|
if not secret or not secret.get('data'):
|
||||||
return False
|
return False
|
||||||
return filename in secret['data']
|
return relative_file_path in secret['data']
|
||||||
|
|
||||||
def list_volume_directory(self, path):
|
def list_volume_directory(self, path):
|
||||||
# NOTE: Overridden because we don't have subdirectories, which aren't supported
|
# NOTE: Overridden because we don't have subdirectories, which aren't supported
|
||||||
|
@ -69,22 +78,24 @@ class KubernetesConfigProvider(BaseFileProvider):
|
||||||
def save_config(self, config_obj):
|
def save_config(self, config_obj):
|
||||||
self._update_secret_file(self.yaml_filename, get_yaml(config_obj))
|
self._update_secret_file(self.yaml_filename, get_yaml(config_obj))
|
||||||
|
|
||||||
def write_volume_file(self, filename, contents):
|
def remove_volume_file(self, relative_file_path):
|
||||||
try:
|
try:
|
||||||
self._update_secret_file(filename, contents)
|
self._update_secret_file(relative_file_path, None)
|
||||||
except IOError as ioe:
|
except IOError as ioe:
|
||||||
raise CannotWriteConfigException(str(ioe))
|
raise CannotWriteConfigException(str(ioe))
|
||||||
|
|
||||||
def remove_volume_file(self, filename):
|
def save_volume_file(self, flask_file, relative_file_path):
|
||||||
|
# Write the file to a temp location.
|
||||||
|
buf = StringIO()
|
||||||
try:
|
try:
|
||||||
self._update_secret_file(filename, None)
|
try:
|
||||||
|
flask_file.save(buf)
|
||||||
except IOError as ioe:
|
except IOError as ioe:
|
||||||
raise CannotWriteConfigException(str(ioe))
|
raise CannotWriteConfigException(str(ioe))
|
||||||
|
|
||||||
def save_volume_file(self, filename, flask_file):
|
self._update_secret_file(relative_file_path, buf.getvalue())
|
||||||
filepath = super(KubernetesConfigProvider, self).save_volume_file(filename, flask_file)
|
finally:
|
||||||
with open(filepath, 'r') as f:
|
buf.close()
|
||||||
self.write_volume_file(filename, f.read())
|
|
||||||
|
|
||||||
def _assert_success(self, response):
|
def _assert_success(self, response):
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
@ -92,7 +103,10 @@ class KubernetesConfigProvider(BaseFileProvider):
|
||||||
response.text)
|
response.text)
|
||||||
raise CannotWriteConfigException('Kubernetes API call failed: %s' % response.text)
|
raise CannotWriteConfigException('Kubernetes API call failed: %s' % response.text)
|
||||||
|
|
||||||
def _update_secret_file(self, filename, value=None):
|
def _update_secret_file(self, relative_file_path, value=None):
|
||||||
|
if '/' in relative_file_path:
|
||||||
|
raise Exception('Expected path from get_volume_path, but found slashes')
|
||||||
|
|
||||||
# Check first that the namespace for Quay Enterprise exists. If it does not, report that
|
# Check first that the namespace for Quay Enterprise exists. If it does not, report that
|
||||||
# as an error, as it seems to be a common issue.
|
# as an error, as it seems to be a common issue.
|
||||||
namespace_url = 'namespaces/%s' % (QE_NAMESPACE)
|
namespace_url = 'namespaces/%s' % (QE_NAMESPACE)
|
||||||
|
@ -119,9 +133,9 @@ class KubernetesConfigProvider(BaseFileProvider):
|
||||||
secret['data'] = secret.get('data', {})
|
secret['data'] = secret.get('data', {})
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
secret['data'][filename] = base64.b64encode(value)
|
secret['data'][relative_file_path] = base64.b64encode(value)
|
||||||
else:
|
else:
|
||||||
secret['data'].pop(filename)
|
secret['data'].pop(relative_file_path)
|
||||||
|
|
||||||
self._assert_success(self._execute_k8s_api('PUT', secret_url, secret))
|
self._assert_success(self._execute_k8s_api('PUT', secret_url, secret))
|
||||||
|
|
||||||
|
@ -164,7 +178,7 @@ class KubernetesConfigProvider(BaseFileProvider):
|
||||||
|
|
||||||
data = json.dumps(data) if data else None
|
data = json.dumps(data) if data else None
|
||||||
session = Session()
|
session = Session()
|
||||||
url = 'https://%s/api/v1/%s' % (KUBERNETES_API_HOST, relative_url)
|
url = 'https://%s/api/v1/%s' % (self._api_host, relative_url)
|
||||||
|
|
||||||
request = Request(method, url, data=data, headers=headers)
|
request = Request(method, url, data=data, headers=headers)
|
||||||
return session.send(request.prepare(), verify=False, timeout=2)
|
return session.send(request.prepare(), verify=False, timeout=2)
|
||||||
|
|
|
@ -1,60 +1,138 @@
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mock import Mock
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from collections import namedtuple
|
||||||
|
from httmock import urlmatch, HTTMock
|
||||||
|
|
||||||
from util.config.provider import KubernetesConfigProvider
|
from util.config.provider import KubernetesConfigProvider
|
||||||
|
|
||||||
from test.fixtures import *
|
def normalize_path(path):
|
||||||
|
return path.replace('/', '_')
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_kubernetes_api(tmpdir_factory, files=None):
|
||||||
|
hostname = 'kubapi'
|
||||||
|
service_account_token_path = str(tmpdir_factory.mktemp("k8s").join("serviceaccount"))
|
||||||
|
auth_header = str(uuid.uuid4())
|
||||||
|
|
||||||
|
with open(service_account_token_path, 'w') as f:
|
||||||
|
f.write(auth_header)
|
||||||
|
|
||||||
|
global secret
|
||||||
|
secret = {
|
||||||
|
'data': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def write_file(config_dir, filepath, value):
|
||||||
|
normalized_path = normalize_path(filepath)
|
||||||
|
absolute_path = str(config_dir.join(normalized_path))
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(absolute_path))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with open(absolute_path, 'w') as f:
|
||||||
|
f.write(value)
|
||||||
|
|
||||||
|
config_dir = tmpdir_factory.mktemp("config")
|
||||||
|
if files:
|
||||||
|
for filepath, value in files.iteritems():
|
||||||
|
normalized_path = normalize_path(filepath)
|
||||||
|
write_file(config_dir, filepath, value)
|
||||||
|
secret['data'][normalized_path] = base64.b64encode(value)
|
||||||
|
|
||||||
|
@urlmatch(netloc=hostname,
|
||||||
|
path='/api/v1/namespaces/quay-enterprise/secrets/quay-enterprise-config-secret$',
|
||||||
|
method='get')
|
||||||
|
def get_secret(_, __):
|
||||||
|
return {'status_code': 200, 'content': json.dumps(secret)}
|
||||||
|
|
||||||
|
@urlmatch(netloc=hostname,
|
||||||
|
path='/api/v1/namespaces/quay-enterprise/secrets/quay-enterprise-config-secret$',
|
||||||
|
method='put')
|
||||||
|
def put_secret(_, request):
|
||||||
|
updated_secret = json.loads(request.body)
|
||||||
|
for filepath, value in updated_secret['data'].iteritems():
|
||||||
|
if filepath not in secret['data']:
|
||||||
|
# Add
|
||||||
|
write_file(config_dir, filepath, base64.b64decode(value))
|
||||||
|
|
||||||
|
for filepath in secret['data']:
|
||||||
|
if filepath not in updated_secret['data']:
|
||||||
|
# Remove.
|
||||||
|
normalized_path = normalize_path(filepath)
|
||||||
|
os.remove(str(config_dir.join(normalized_path)))
|
||||||
|
|
||||||
|
secret['data'] = updated_secret['data']
|
||||||
|
return {'status_code': 200, 'content': json.dumps(secret)}
|
||||||
|
|
||||||
|
@urlmatch(netloc=hostname, path='/api/v1/namespaces/quay-enterprise$')
|
||||||
|
def get_namespace(_, __):
|
||||||
|
return {'status_code': 200, 'content': json.dumps({})}
|
||||||
|
|
||||||
|
@urlmatch(netloc=hostname)
|
||||||
|
def catch_all(url, _):
|
||||||
|
print url
|
||||||
|
return {'status_code': 404, 'content': '{}'}
|
||||||
|
|
||||||
|
with HTTMock(get_secret, put_secret, get_namespace, catch_all):
|
||||||
|
provider = KubernetesConfigProvider(str(config_dir), 'config.yaml', 'config.py',
|
||||||
|
api_host=hostname,
|
||||||
|
service_account_token_path=service_account_token_path)
|
||||||
|
|
||||||
|
# Validate all the files.
|
||||||
|
for filepath, value in files.iteritems():
|
||||||
|
normalized_path = normalize_path(filepath)
|
||||||
|
assert provider.volume_file_exists(normalized_path)
|
||||||
|
with provider.get_volume_file(normalized_path) as f:
|
||||||
|
assert f.read() == value
|
||||||
|
|
||||||
|
yield provider
|
||||||
|
|
||||||
|
|
||||||
class TestKubernetesConfigProvider(KubernetesConfigProvider):
|
def test_basic_config(tmpdir_factory):
|
||||||
def __init__(self):
|
basic_files = {
|
||||||
self.config_volume = ''
|
'config.yaml': 'FOO: bar',
|
||||||
self.yaml_filename = 'yaml_filename'
|
}
|
||||||
self.py_filename = None
|
|
||||||
|
|
||||||
self.yaml_path = os.path.join(self.config_volume, self.yaml_filename)
|
with fake_kubernetes_api(tmpdir_factory, files=basic_files) as provider:
|
||||||
|
assert provider.config_exists()
|
||||||
self._service_token = 'service_token'
|
assert provider.get_config() is not None
|
||||||
self._execute_k8s_api = Mock()
|
assert provider.get_config()['FOO'] == 'bar'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('directory,filename,expected', [
|
@pytest.mark.parametrize('filepath', [
|
||||||
("directory", "file", "directory_file"),
|
'foo',
|
||||||
("directory/dir", "file", "directory/dir_file"),
|
'foo/meh',
|
||||||
("directory/dir/", "file", "directory/dir_file"),
|
'foo/bar/baz',
|
||||||
("directory", "file/test", "directory_file/test"),
|
|
||||||
])
|
])
|
||||||
def test_get_volume_path(directory, filename, expected):
|
def test_remove_file(filepath, tmpdir_factory):
|
||||||
provider = TestKubernetesConfigProvider()
|
basic_files = {
|
||||||
assert expected == provider.get_volume_path(directory, filename)
|
filepath: 'foo',
|
||||||
|
}
|
||||||
|
|
||||||
|
with fake_kubernetes_api(tmpdir_factory, files=basic_files) as provider:
|
||||||
|
normalized_path = normalize_path(filepath)
|
||||||
|
assert provider.volume_file_exists(normalized_path)
|
||||||
|
provider.remove_volume_file(normalized_path)
|
||||||
|
assert not provider.volume_file_exists(normalized_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('response,expected', [
|
class TestFlaskFile(object):
|
||||||
(Mock(text="{\"data\": {\"license\":\"test\"}}", status_code=200), {"data": {"license":"test"}}),
|
def save(self, buf):
|
||||||
(Mock(text="{\"data\": {\"license\":\"test\"}}", status_code=404), None),
|
buf.write('hello world!')
|
||||||
])
|
|
||||||
def test_lookup_secret(response, expected):
|
|
||||||
provider = TestKubernetesConfigProvider()
|
|
||||||
provider._execute_k8s_api.return_value = response
|
|
||||||
assert expected == provider._lookup_secret()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('response,key,expected', [
|
def test_save_file(tmpdir_factory):
|
||||||
(Mock(text="{\"data\": {\"license\":\"test\"}}", status_code=200), "license", True),
|
basic_files = {}
|
||||||
(Mock(text="{\"data\": {\"license\":\"test\"}}", status_code=200), "config.yaml", False),
|
|
||||||
(Mock(text="", status_code=404), "license", False),
|
|
||||||
])
|
|
||||||
def test_volume_file_exists(response, key, expected):
|
|
||||||
provider = TestKubernetesConfigProvider()
|
|
||||||
provider._execute_k8s_api.return_value = response
|
|
||||||
assert expected == provider.volume_file_exists(key)
|
|
||||||
|
|
||||||
|
with fake_kubernetes_api(tmpdir_factory, files=basic_files) as provider:
|
||||||
@pytest.mark.parametrize('response,expected', [
|
assert not provider.volume_file_exists('testfile')
|
||||||
(Mock(text="{\"data\": {\"extra_license\":\"test\"}}", status_code=200), ["license"]),
|
flask_file = TestFlaskFile()
|
||||||
(Mock(text="", status_code=404), []),
|
provider.save_volume_file(flask_file, 'testfile')
|
||||||
])
|
assert provider.volume_file_exists('testfile')
|
||||||
def test_list_volume_directory(response, expected):
|
|
||||||
provider = TestKubernetesConfigProvider()
|
|
||||||
provider._execute_k8s_api.return_value = response
|
|
||||||
assert expected == provider.list_volume_directory("extra")
|
|
||||||
|
|
|
@ -45,12 +45,9 @@ class TestConfigProvider(BaseProvider):
|
||||||
|
|
||||||
return filename in self.files
|
return filename in self.files
|
||||||
|
|
||||||
def save_volume_file(self, filename, flask_file):
|
def save_volume_file(self, flask_file, filename):
|
||||||
self.files[filename] = flask_file.read()
|
self.files[filename] = flask_file.read()
|
||||||
|
|
||||||
def write_volume_file(self, filename, contents):
|
|
||||||
self.files[filename] = contents
|
|
||||||
|
|
||||||
def get_volume_file(self, filename, mode='r'):
|
def get_volume_file(self, filename, mode='r'):
|
||||||
if filename in REAL_FILES:
|
if filename in REAL_FILES:
|
||||||
return open(filename, mode=mode)
|
return open(filename, mode=mode)
|
||||||
|
|
Reference in a new issue