lib.ec.secret_store: Add and use framework
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m48s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 5m12s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m35s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m49s
CI / Packaging test (push) Successful in 0s
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m48s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 5m12s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m35s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m49s
CI / Packaging test (push) Successful in 0s
"jw-pkg secrets install" copies secret material from a GPG-encrypted tar
archive to dotenv .jw-secret files on a target host. This commit refactors
the underlying code and adds hooks for the inverse copy direction, and for
more secret sources and targets.
The peers between which secrets are copied are represented by the new
abstract secret_store.Base class, and the existing tar blob and host
storages, inheriting from that class, are TarGpg and KvFile. Moreover,
secret_store.Base is inheriting from FileContext, so all of jw-pkg's
file-like operations are available as hooks.
Secrets inside instances of two file contexts are addressable as
(targpg|kvfile)://target.host.com/path/to/file.conf.jw-secret. The on-host
KvFile will look either locally or via SSH for that file, and TarGpg will
look inside a tar archive specified in the JW_SECRETS_TARGPG_TAR
environment variable.
Uris with these schemas can be passed to FileContext.create(), which will
return a chrooted file context of the correct type.
KvFile.stat("/path/to/file.conf.jw-secret").ftype is FT_SECRET which
inherits from FT_COMPOUND - which means that readdir() works on such
animals. Not entirely sure if that's a good idea, but it feels pretty
generic. Let's see where that takes us.
DistroContext.install() after this refactor reads its source through the
store interface and writes the landing through the KvFile store.
Both secret stores are exercised through the existing unit test of jw-pkg
secrets install, and unit tests for both stores are also added by this
commit.
Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
parent
e02f724808
commit
f08f658d33
7 changed files with 1835 additions and 169 deletions
|
|
@ -0,0 +1,7 @@
|
|||
TOPDIR = ../../../../../../../../../
|
||||
|
||||
include $(TOPDIR)/make/proj.mk
|
||||
include $(JWBDIR)/make/py-run.mk
|
||||
|
||||
all:
|
||||
test: run
|
||||
384
test/unit/python/jw/pkg/lib/ec/secret_store/KvFile/test.py
Normal file
384
test/unit/python/jw/pkg/lib/ec/secret_store/KvFile/test.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import asyncio
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
from jw.pkg.lib.distros.suse.Distro import Distro as SuseDistro
|
||||
from jw.pkg.lib.ec.Local import Local
|
||||
from jw.pkg.lib.ec.secret_store.KvFile import KvFile
|
||||
from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
|
||||
from jw.pkg.lib.file_types import FT_DIR
|
||||
from jw.pkg.lib.FileContext import FileContext
|
||||
from jw.pkg.lib.Package import Package
|
||||
|
||||
async def run_tests() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = os.path.join(tmp, 'etc', 'foo.conf')
|
||||
os.makedirs(os.path.dirname(target))
|
||||
with open(target + '.jw-secret', 'w') as f:
|
||||
f.write(
|
||||
'# a comment\n\n key1 = value1 \nkey2=value2\n'
|
||||
'=novalue\nkey3=\n'
|
||||
)
|
||||
|
||||
# FileContext.create() resolves the kvfile scheme, keeping
|
||||
# the URI as the store's identity
|
||||
created = FileContext.create('kvfile://')
|
||||
assert isinstance(created, KvFile)
|
||||
assert created.uri.scheme == 'kvfile'
|
||||
|
||||
store = KvFile(Local())
|
||||
assert set(await store.readdir(target)) == {'key1', 'key2', 'key3'}
|
||||
assert (await store.get(f'{target}/key1')).stdout == b'value1'
|
||||
assert (await store.get(f'{target}/key3')).stdout == b''
|
||||
|
||||
# The entry is gettable as a whole: the serialized file
|
||||
assert (await store.get(target)).stdout == \
|
||||
b'# a comment\n\n key1 = value1 \nkey2=value2\n=novalue\nkey3=\n'
|
||||
|
||||
# -- stat() answers the node's type, the metadata of the
|
||||
# host file behind it; a key has no file of its own, and
|
||||
# keeps the size 0
|
||||
blob = os.path.join(tmp, 'etc', 'bar.pem')
|
||||
with open(blob + '.jw-secret-file', 'w') as f:
|
||||
f.write('a blob\n')
|
||||
st = await store.stat(target)
|
||||
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
|
||||
assert st.is_gettable
|
||||
assert st.size == os.path.getsize(target + '.jw-secret')
|
||||
st = await store.stat(f'{target}/key1')
|
||||
assert st.ftype is FT_SECRET_KEY and stat.S_ISREG(st.mode)
|
||||
assert st.is_gettable
|
||||
assert st.size == 0
|
||||
st = await store.stat(blob)
|
||||
assert st.ftype is FT_SECRET_FILE and stat.S_ISREG(st.mode)
|
||||
assert st.is_gettable
|
||||
assert st.size == os.path.getsize(blob + '.jw-secret-file')
|
||||
st = await store.stat(os.path.dirname(target))
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
assert not st.is_gettable
|
||||
st = await store.stat('')
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
assert not st.is_gettable
|
||||
try:
|
||||
await store.stat(f'{target}/missing')
|
||||
raise AssertionError('expected FileNotFoundError')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# -- file_exists() works on the store, being built on stat()
|
||||
assert await store.file_exists(target)
|
||||
assert not await store.file_exists(f'{target}/missing')
|
||||
|
||||
# A missing key and a missing entry raise FileNotFoundError
|
||||
for path in (f'{target}/missing', os.path.join(tmp, 'etc', 'missing.conf')):
|
||||
try:
|
||||
await store.get(path)
|
||||
raise AssertionError(f'expected FileNotFoundError for {path}')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# put writes a key into the entry file, merging with the
|
||||
# existing keys
|
||||
await store.put(f'{target}/key4', b'value4', ftype = FT_SECRET_KEY)
|
||||
assert (await store.get(f'{target}/key4')).stdout == b'value4'
|
||||
assert (await store.get(f'{target}/key1')).stdout == b'value1'
|
||||
# -- a second put overwrites the entry file, which is mode 0400
|
||||
await store.put(f'{target}/key1', b'value1-new', ftype = FT_SECRET_KEY)
|
||||
assert (await store.get(f'{target}/key1')).stdout == b'value1-new'
|
||||
assert stat.S_IMODE(os.stat(target + '.jw-secret').st_mode) == 0o400
|
||||
new_entry = os.path.join(tmp, 'etc', 'new.conf')
|
||||
# -- a key put does not create: below a missing entry file
|
||||
# it fails with EROFS; mkdir() is the materialization
|
||||
try:
|
||||
await store.put(f'{new_entry}/k', b'v', ftype = FT_SECRET_KEY)
|
||||
raise AssertionError('expected EROFS')
|
||||
except OSError as e:
|
||||
assert e.errno == errno.EROFS
|
||||
await store.mkdir(new_entry, ftype = FT_SECRET)
|
||||
await store.put(f'{new_entry}/k', b'v', ftype = FT_SECRET_KEY)
|
||||
with open(new_entry + '.jw-secret') as f:
|
||||
assert f.read() == 'k=v\n'
|
||||
|
||||
# -- a typed put replaces the entry: the serialized content
|
||||
# is written over the entry file, mode 0400
|
||||
await store.put(target, b'new1=n1\nnew2=n2\n', ftype = FT_SECRET)
|
||||
assert set(await store.readdir(target)) == {'new1', 'new2'}
|
||||
assert (await store.get(target)).stdout == b'new1=n1\nnew2=n2\n'
|
||||
with open(target + '.jw-secret') as f:
|
||||
assert f.read() == 'new1=n1\nnew2=n2\n'
|
||||
assert stat.S_IMODE(os.stat(target + '.jw-secret').st_mode) == 0o400
|
||||
|
||||
# -- a put that changes nothing does not rewrite the file
|
||||
mtime = os.stat(target + '.jw-secret').st_mtime_ns
|
||||
await store.put(target, b'new1=n1\nnew2=n2\n', ftype = FT_SECRET)
|
||||
assert os.stat(target + '.jw-secret').st_mtime_ns == mtime
|
||||
|
||||
# -- An untyped put is refused: the store content types are
|
||||
# entry, key, and blob
|
||||
try:
|
||||
await store.put('HOSTNAME', b'x')
|
||||
raise AssertionError('expected ValueError')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# -- put: a blob below a branch lands as a .jw-secret-file at
|
||||
# its config path, mode 0400
|
||||
pem = os.path.join(tmp, 'etc', 'cert.pem.jw-secret-file')
|
||||
await store.put(
|
||||
os.path.join(tmp, 'etc', 'cert.pem'),
|
||||
b'-----BEGIN CERT-----\n',
|
||||
ftype = FT_SECRET_FILE,
|
||||
)
|
||||
with open(pem) as f:
|
||||
assert f.read() == '-----BEGIN CERT-----\n'
|
||||
assert stat.S_IMODE(os.stat(pem).st_mode) == 0o400
|
||||
|
||||
# -- mkdir: the type says which node to build, the default
|
||||
# a branch; a branch is a real directory, an entry an
|
||||
# empty entry file, mode 0400
|
||||
await store.mkdir(os.path.join(tmp, 'etc'))
|
||||
assert not os.path.exists(os.path.join(tmp, 'etc.jw-secret'))
|
||||
baz = os.path.join(tmp, 'etc', 'baz')
|
||||
await store.mkdir(baz)
|
||||
assert os.path.isdir(baz)
|
||||
bar = os.path.join(tmp, 'etc', 'bar.conf')
|
||||
await store.mkdir(bar, ftype = FT_SECRET)
|
||||
assert os.path.isfile(bar + '.jw-secret')
|
||||
assert open(bar + '.jw-secret').read() == ''
|
||||
assert stat.S_IMODE(os.stat(bar + '.jw-secret').st_mode) == 0o400
|
||||
|
||||
# -- mkdir and put fail with EROFS below a missing directory
|
||||
for fn in (
|
||||
lambda: store.mkdir(os.path.join(tmp, 'no', 'such', 'entry')),
|
||||
lambda: store.put(
|
||||
os.path.join(tmp, 'no', 'such', 'entry', 'key'),
|
||||
b'x',
|
||||
ftype = FT_SECRET_KEY, ),
|
||||
):
|
||||
try:
|
||||
await fn()
|
||||
raise AssertionError('expected EROFS')
|
||||
except OSError as e:
|
||||
assert e.errno == errno.EROFS
|
||||
|
||||
# -- put forwards owner and group to the host context: an
|
||||
# unknown group fails the put
|
||||
try:
|
||||
await store.put(
|
||||
f'{target}/key7', b'v7', ftype = FT_SECRET_KEY, group = 'no-such-group'
|
||||
)
|
||||
except AssertionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
assert 'no-such-group' in str(e)
|
||||
else:
|
||||
raise AssertionError('expected the put to fail')
|
||||
|
||||
# unlink drops the file the address names, and a missing
|
||||
# file raises
|
||||
await store.unlink(target + '.jw-secret')
|
||||
assert not os.path.exists(target + '.jw-secret')
|
||||
try:
|
||||
await store.unlink(target + '.jw-secret')
|
||||
raise AssertionError('expected FileNotFoundError')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# -- Branch listing: the config paths below a branch, the
|
||||
# root included, come from the host's package set, the
|
||||
# templates of the installed packages, presence-checked
|
||||
# on the host. A fake distro supplies the list
|
||||
class FakeDistro(SuseDistro):
|
||||
|
||||
def __init__(self, files: list[str]) -> None:
|
||||
super().__init__(Local(), id = 'suse')
|
||||
self.files = files
|
||||
self.queries = 0
|
||||
|
||||
@override
|
||||
async def _pkg_files(self, names: list[str] | None) -> list[str]:
|
||||
self.queries += 1
|
||||
return self.files
|
||||
|
||||
tree = os.path.join(tmp, 'tree')
|
||||
os.makedirs(os.path.join(tree, 'etc'))
|
||||
with open(os.path.join(tree, 'etc', 'foo.conf.jw-secret'), 'w') as f:
|
||||
f.write('k1=v1\nk2=v2\n')
|
||||
with open(os.path.join(tree, 'etc', 'cert.pem.jw-secret-file'), 'w') as f:
|
||||
f.write('-----BEGIN CERT-----\n')
|
||||
with open(os.path.join(tree, 'etc', 'other.conf.jw-secret'), 'w') as f:
|
||||
f.write('o1=v1\n')
|
||||
# -- A template without a secret file is not a config path;
|
||||
# a package file that is not a template is not one
|
||||
# either
|
||||
fake = FakeDistro(
|
||||
[
|
||||
f'{tree}/etc/foo.conf.jw-tmpl',
|
||||
f'{tree}/etc/cert.pem.jw-tmpl',
|
||||
f'{tree}/etc/ghost.conf.jw-tmpl',
|
||||
f'{tree}/etc/other.conf',
|
||||
]
|
||||
)
|
||||
# -- The address space is rooted at the host root; the
|
||||
# package file list reports host-absolute paths, so the
|
||||
# fixture tree sits at its path below the root
|
||||
bstore = KvFile(Local(), distro = fake)
|
||||
base = tree.lstrip('/')
|
||||
# -- is_dir answers from the host state, without consulting
|
||||
# the package set
|
||||
assert await bstore.is_dir('')
|
||||
assert await bstore.is_dir(base)
|
||||
assert await bstore.is_dir(f'{base}/etc/foo.conf')
|
||||
assert not await bstore.is_dir(f'{base}/etc/cert.pem')
|
||||
assert fake.queries == 0
|
||||
# -- is_gettable answers from the node's type: an entry,
|
||||
# a key, a blob; not a branch, the root included
|
||||
assert await bstore.is_gettable(f'{base}/etc/foo.conf')
|
||||
assert await bstore.is_gettable(f'{base}/etc/foo.conf/k1')
|
||||
assert await bstore.is_gettable(f'{base}/etc/cert.pem')
|
||||
assert not await bstore.is_gettable(f'{base}/etc')
|
||||
assert not await bstore.is_gettable('')
|
||||
assert fake.queries == 0
|
||||
# -- Listing consults it, once: each branch lists the secret
|
||||
# files below it, under their on-host file names,
|
||||
# presence-checked
|
||||
assert set(await bstore.readdir('')) == {'tmp'}
|
||||
assert fake.queries == 1
|
||||
assert set(await bstore.readdir(f'{base}/etc')) == \
|
||||
{'cert.pem.jw-secret-file', 'foo.conf.jw-secret'}
|
||||
assert fake.queries == 1
|
||||
# -- An entry still lists its keys, without the package set
|
||||
assert set(await bstore.readdir(f'{base}/etc/foo.conf')) == {'k1', 'k2'}
|
||||
assert fake.queries == 1
|
||||
# -- A branch without config paths raises FileNotFoundError
|
||||
try:
|
||||
await bstore.readdir('no/such')
|
||||
raise AssertionError('expected FileNotFoundError')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# -- A real directory with no secret children lists empty
|
||||
os.makedirs(os.path.join(tree, 'var', 'empty'))
|
||||
assert set(await bstore.readdir(f'{base}/var/empty')) == set()
|
||||
# -- The blob reads back through the store
|
||||
assert (await bstore.get(f'{base}/etc/cert.pem')).stdout == \
|
||||
b'-----BEGIN CERT-----\n'
|
||||
|
||||
# -- The entry's config path is also a deployed file on the
|
||||
# host, as on a real host: a key below it has no host
|
||||
# path, and is_dir() must not stat below the file
|
||||
with open(os.path.join(tree, 'etc', 'foo.conf'), 'w') as f:
|
||||
f.write('deployed\n')
|
||||
assert not (await bstore.is_dir(f'{base}/etc/foo.conf/k1'))
|
||||
assert (await bstore.get(f'{base}/etc/foo.conf/k1')).stdout == b'v1'
|
||||
|
||||
# -- A URI may root the store at a subtree of the host tree:
|
||||
# create() keeps the URI as the store's identity, and its
|
||||
# path is composed into every address. The inner context
|
||||
# is host-rooted, so the on-host files are reached
|
||||
# without a double path
|
||||
created = FileContext.create(f'kvfile://{tree}/etc')
|
||||
assert created.uri.path == f'{tree}/etc'
|
||||
sstore = KvFile(Local(), uri = f'kvfile://{tree}/etc', distro = fake)
|
||||
await sstore.open()
|
||||
assert set(await sstore.readdir('')) == \
|
||||
{'cert.pem.jw-secret-file', 'foo.conf.jw-secret'}
|
||||
# -- An entry lists its keys, suffixed and unsuffixed alike
|
||||
assert set(await sstore.readdir('foo.conf.jw-secret')) == {'k1', 'k2'}
|
||||
assert set(await sstore.readdir('foo.conf')) == {'k1', 'k2'}
|
||||
assert (await sstore.get('foo.conf/k1')).stdout == b'v1'
|
||||
assert (await sstore.get('cert.pem')).stdout == \
|
||||
b'-----BEGIN CERT-----\n'
|
||||
# -- A suffixed address names the on-host file of its config
|
||||
# path, the suffix picking the kind
|
||||
assert (await sstore.get('foo.conf.jw-secret')).stdout == \
|
||||
b'k1=v1\nk2=v2\n'
|
||||
assert (await sstore.get('cert.pem.jw-secret-file')).stdout == \
|
||||
b'-----BEGIN CERT-----\n'
|
||||
st = await sstore.stat('foo.conf.jw-secret')
|
||||
assert st.ftype is FT_SECRET
|
||||
st = await sstore.stat('foo.conf.jw-secret/k1')
|
||||
assert st.ftype is FT_SECRET_KEY
|
||||
st = await sstore.stat('cert.pem.jw-secret-file')
|
||||
assert st.ftype is FT_SECRET_FILE
|
||||
# -- A blob address refuses to list
|
||||
try:
|
||||
await sstore.readdir('cert.pem.jw-secret-file')
|
||||
raise AssertionError('expected NotADirectoryError')
|
||||
except NotADirectoryError:
|
||||
pass
|
||||
# -- A write through the subtree root lands in the subtree
|
||||
await sstore.mkdir('sub')
|
||||
await sstore.mkdir('sub/conf', ftype = FT_SECRET)
|
||||
await sstore.put('sub/conf/k', b'v', ftype = FT_SECRET_KEY)
|
||||
with open(os.path.join(tree, 'etc', 'sub', 'conf.jw-secret')) as f:
|
||||
assert f.read() == 'k=v\n'
|
||||
await sstore.unlink('sub/conf.jw-secret')
|
||||
assert not os.path.exists(os.path.join(tree, 'etc', 'sub', 'conf.jw-secret'))
|
||||
await sstore.close()
|
||||
|
||||
# -- The autodetected distro applies the app's documented
|
||||
# filter fallback: JW_DEFAULT_PKG_FILTER reaches
|
||||
# Distro.instantiate() as the default package filter
|
||||
from unittest import mock
|
||||
|
||||
from jw.pkg.lib.Distro import Distro
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def __instantiate(*args: Any, **kwargs: Any) -> FakeDistro:
|
||||
captured['kwargs'] = kwargs
|
||||
return fake
|
||||
|
||||
os.environ['JW_DEFAULT_PKG_FILTER'] = 'url=~jw.dev'
|
||||
try:
|
||||
with mock.patch.object(Distro,
|
||||
'instantiate',
|
||||
new = classmethod(__instantiate)):
|
||||
astore = KvFile(Local())
|
||||
assert set(await astore.readdir('')) == {'tmp'}
|
||||
flt = captured['kwargs'].get('default_pkg_filter')
|
||||
assert flt is not None
|
||||
assert flt.match(Package('fixture', url = 'https://jw.dev/fixture.rpm'))
|
||||
assert not flt.match(
|
||||
Package('other', url = 'https://elsewhere/other.rpm')
|
||||
)
|
||||
finally:
|
||||
del os.environ['JW_DEFAULT_PKG_FILTER']
|
||||
|
||||
# -- The lifecycle propagates to the host context: an
|
||||
# open() pin on the store keeps the host's transport
|
||||
# open for its duration, and the run()s within it share
|
||||
# it
|
||||
class CountingLocal(Local):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.opens = 0
|
||||
self.closes = 0
|
||||
|
||||
@override
|
||||
async def _open(self) -> None:
|
||||
self.opens += 1
|
||||
await super()._open()
|
||||
|
||||
@override
|
||||
async def _close(self) -> None:
|
||||
self.closes += 1
|
||||
await super()._close()
|
||||
|
||||
cl = CountingLocal()
|
||||
pstore = KvFile(cl)
|
||||
await pstore.open()
|
||||
assert cl.opens == 1
|
||||
await cl.run(['true'])
|
||||
await cl.run(['true'])
|
||||
assert cl.opens == 1
|
||||
assert cl.closes == 0
|
||||
await pstore.close()
|
||||
assert cl.closes == 1
|
||||
|
||||
asyncio.run(run_tests())
|
||||
print('All secrets kvfile store tests passed')
|
||||
4
test/unit/python/jw/pkg/lib/ec/secret_store/Makefile
Normal file
4
test/unit/python/jw/pkg/lib/ec/secret_store/Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
TOPDIR = ../../../../../../../../
|
||||
|
||||
include $(TOPDIR)/make/proj.mk
|
||||
include $(JWBDIR)/make/dirs.mk
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
TOPDIR = ../../../../../../../../../
|
||||
|
||||
include $(TOPDIR)/make/proj.mk
|
||||
include $(JWBDIR)/make/py-run.mk
|
||||
|
||||
all:
|
||||
test: run
|
||||
235
test/unit/python/jw/pkg/lib/ec/secret_store/TarGpg/test.py
Normal file
235
test/unit/python/jw/pkg/lib/ec/secret_store/TarGpg/test.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
from jw.pkg.lib.ec.secret_store.layout import FT_SECRET, FT_SECRET_FILE, FT_SECRET_KEY
|
||||
from jw.pkg.lib.ec.secret_store.TarGpg import TarGpg
|
||||
from jw.pkg.lib.file_types import FT_DIR
|
||||
from jw.pkg.lib.FileContext import FileContext
|
||||
from jw.pkg.lib.util import copy
|
||||
|
||||
def make_tar(path: str, entries: dict[str, str]) -> None:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj = buf, mode = 'w') as tf:
|
||||
for name, text in entries.items():
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size = len(data)
|
||||
info.mode = 0o400
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
with open(path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
async def run_tests() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# -- A throw-away GPG home with a key for the test
|
||||
gpghome = os.path.join(tmp, 'gnupg')
|
||||
os.makedirs(gpghome, mode = 0o700)
|
||||
env = dict(os.environ, GNUPGHOME = gpghome)
|
||||
subprocess.run(
|
||||
[
|
||||
'gpg',
|
||||
'--batch',
|
||||
'--pinentry-mode',
|
||||
'loopback',
|
||||
'--passphrase',
|
||||
'',
|
||||
'--quick-gen-key',
|
||||
'test@example.com',
|
||||
],
|
||||
check = True,
|
||||
capture_output = True,
|
||||
env = env,
|
||||
)
|
||||
|
||||
# -- A store in the canonical layout, GPG-encrypted tarball
|
||||
tar_path = os.path.join(tmp, 'secrets.tar')
|
||||
make_tar(
|
||||
tar_path,
|
||||
{
|
||||
'default/etc/a.jw-secret': 'a=1\n',
|
||||
'default/etc/d.jw-secret': 'd=4\n',
|
||||
'com/example/host/etc/a.jw-secret': 'a=2\n',
|
||||
'com/example/host/etc/b.jw-secret': 'b=3\n',
|
||||
# Non-key-value content is stored as a .jw-secret-file
|
||||
# and addressable as a blob
|
||||
'com/example/host/etc/c.pem.jw-secret-file': \
|
||||
'-----BEGIN CERT-----\n',
|
||||
# A member without a content suffix is store
|
||||
# content too: non-key-value content is a blob
|
||||
'com/example/host/etc/README.md': 'not key-value content\n',
|
||||
}
|
||||
)
|
||||
enc_path = tar_path + '.gpg'
|
||||
subprocess.run(
|
||||
[
|
||||
'gpg',
|
||||
'--batch',
|
||||
'--yes',
|
||||
'--quiet',
|
||||
'--no-tty',
|
||||
'--trust-model',
|
||||
'always',
|
||||
'--recipient',
|
||||
'test@example.com',
|
||||
'--encrypt',
|
||||
'--output',
|
||||
enc_path,
|
||||
tar_path,
|
||||
],
|
||||
check = True,
|
||||
capture_output = True,
|
||||
env = env,
|
||||
)
|
||||
|
||||
# The store decrypts in a local exec context, which inherits
|
||||
# the test's GNUPGHOME; the tarball location is deployment
|
||||
# information, passed via the environment
|
||||
os.environ['GNUPGHOME'] = gpghome
|
||||
os.environ['JW_SECRETS_TARGPG_TAR'] = 'local://' + enc_path
|
||||
|
||||
# -- The whole store: strict, no fallback
|
||||
store = FileContext.create('targpg://')
|
||||
await store.open()
|
||||
assert isinstance(store, TarGpg)
|
||||
assert set(await store.readdir('')) == {'default', 'com'}
|
||||
# -- The names as stored: the store does not strip or filter
|
||||
# by member name, a suffixed name being the address of
|
||||
# its node
|
||||
assert set(await store.readdir('com/example/host/etc')) == \
|
||||
{'a.jw-secret', 'b.jw-secret', 'c.pem.jw-secret-file',
|
||||
'README.md'}
|
||||
assert (await store.get('default/etc/a.jw-secret/a')).stdout == b'1'
|
||||
assert (await store.get('com/example/host/etc/a.jw-secret/a')).stdout == \
|
||||
b'2'
|
||||
assert (await store.get('com/example/host/etc/b.jw-secret/b')).stdout == \
|
||||
b'3'
|
||||
|
||||
# The entry is gettable as a whole: the member's content
|
||||
assert (await store.get('default/etc/a.jw-secret')).stdout == b'a=1\n'
|
||||
|
||||
# A blob is a file: get() returns its bytes, readdir() refuses
|
||||
pem = 'com/example/host/etc/c.pem.jw-secret-file'
|
||||
assert (await store.get(pem)).stdout == b'-----BEGIN CERT-----\n'
|
||||
try:
|
||||
await store.readdir(pem)
|
||||
raise AssertionError('expected NotADirectoryError')
|
||||
except NotADirectoryError:
|
||||
pass
|
||||
|
||||
# A member without a content suffix is store content too: a
|
||||
# non-key-value member is a blob, listed, statable, gettable
|
||||
assert 'README.md' in set(await store.readdir('com/example/host/etc'))
|
||||
st = await store.stat('com/example/host/etc/README.md')
|
||||
assert st.ftype is FT_SECRET_FILE
|
||||
assert (await store.get('com/example/host/etc/README.md')).stdout == \
|
||||
b'not key-value content\n'
|
||||
|
||||
# A missing entry and a missing key raise FileNotFoundError
|
||||
for path in ('default/etc/missing', 'default/etc/a.jw-secret/missing'):
|
||||
try:
|
||||
await store.get(path)
|
||||
raise AssertionError(f'expected FileNotFoundError for {path}')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# -- stat() answers the node's type, the metadata of the
|
||||
# member behind it; a key has no member of its own, and a
|
||||
# branch none at all, so they keep the size 0
|
||||
st = await store.stat('')
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
st = await store.stat('default/etc/a.jw-secret')
|
||||
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
|
||||
assert st.size == len((await store.get('default/etc/a.jw-secret')).stdout)
|
||||
st = await store.stat('default/etc/a.jw-secret/a')
|
||||
assert st.ftype is FT_SECRET_KEY and stat.S_ISREG(st.mode)
|
||||
assert st.size == 0
|
||||
st = await store.stat('com/example/host/etc/c.pem.jw-secret-file')
|
||||
assert st.ftype is FT_SECRET_FILE and stat.S_ISREG(st.mode)
|
||||
assert st.size == len(
|
||||
(await store.get('com/example/host/etc/c.pem.jw-secret-file')).stdout
|
||||
)
|
||||
st = await store.stat('default/etc')
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
assert st.size == 0
|
||||
# -- is_dir answers the node's listability: an entry lists
|
||||
# its keys, a branch its children; a key is a leaf
|
||||
assert await store.is_dir('default/etc/a.jw-secret')
|
||||
assert await store.is_dir('default/etc')
|
||||
assert not await store.is_dir('default/etc/a.jw-secret/a')
|
||||
try:
|
||||
await store.stat('default/etc/missing')
|
||||
raise AssertionError('expected FileNotFoundError')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# -- file_exists() works on the store, being built on stat()
|
||||
assert await store.file_exists('default/etc/a.jw-secret')
|
||||
assert not await store.file_exists('default/etc/missing')
|
||||
await store.close()
|
||||
|
||||
# -- A host-rooted store applies the default fallback
|
||||
store = FileContext.create('targpg://host.example.com')
|
||||
assert isinstance(store, TarGpg)
|
||||
await store.open()
|
||||
assert (await store.get('etc/a.jw-secret/a')).stdout == b'2'
|
||||
# A directory listing is the union with the default tree: d
|
||||
# exists only under default/, but is listable as it is
|
||||
# gettable
|
||||
assert set(await store.readdir('etc')) == \
|
||||
{'a.jw-secret', 'b.jw-secret', 'c.pem.jw-secret-file',
|
||||
'd.jw-secret', 'README.md'}
|
||||
# A miss below the host is retried below the default tree
|
||||
assert (await store.get('etc/d.jw-secret/d')).stdout == b'4'
|
||||
# A key listing stays host-wins-wholesale
|
||||
assert set(await store.readdir('etc/d.jw-secret')) == {'d'}
|
||||
# -- stat() distinguishes an entry from a branch and the root
|
||||
st = await store.stat('etc/a.jw-secret')
|
||||
assert st.ftype is FT_SECRET and stat.S_ISREG(st.mode)
|
||||
st = await store.stat('etc')
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
st = await store.stat('')
|
||||
assert st.ftype is FT_DIR and stat.S_ISDIR(st.mode)
|
||||
await store.close()
|
||||
|
||||
# -- A store as a copy source: an entry lands as a single
|
||||
# file with its serialized content, a branch as a
|
||||
# directory, and a blob as a file; the default-only entry
|
||||
# d lands from the union listing
|
||||
host = os.path.join(tmp, 'host-tree')
|
||||
os.makedirs(host)
|
||||
ret = await copy('targpg://host.example.com', host, recursive = True)
|
||||
assert isinstance(ret, list)
|
||||
assert set(ret) == {
|
||||
f'{host}/etc/a.jw-secret',
|
||||
f'{host}/etc/b.jw-secret',
|
||||
f'{host}/etc/c.pem.jw-secret-file',
|
||||
f'{host}/etc/d.jw-secret',
|
||||
f'{host}/etc/README.md',
|
||||
}
|
||||
with open(os.path.join(host, 'etc', 'a.jw-secret')) as f:
|
||||
assert f.read() == 'a=2\n'
|
||||
assert not os.path.exists(os.path.join(host, 'etc', 'a', 'a'))
|
||||
with open(os.path.join(host, 'etc', 'c.pem.jw-secret-file')) as f:
|
||||
assert f.read() == '-----BEGIN CERT-----\n'
|
||||
# -- A single object that is an entry: the serialized whole
|
||||
# lands at the destination
|
||||
dst = os.path.join(tmp, 'copied', 'entry')
|
||||
os.makedirs(os.path.dirname(dst))
|
||||
ret = await copy('targpg://host.example.com/etc/a.jw-secret', dst)
|
||||
assert ret == dst
|
||||
with open(dst) as f:
|
||||
assert f.read() == 'a=2\n'
|
||||
|
||||
def main() -> None:
|
||||
if not shutil.which('gpg'):
|
||||
print('Skipping secrets targpg store tests (gpg required)')
|
||||
return
|
||||
asyncio.run(run_tests())
|
||||
print('All secrets targpg store tests passed')
|
||||
|
||||
main()
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
TOPDIR = ../../../../../../../../..
|
||||
|
||||
include $(TOPDIR)/make/proj.mk
|
||||
include $(JWBDIR)/make/py-run.mk
|
||||
|
||||
all:
|
||||
test: run
|
||||
41
test/unit/python/jw/pkg/lib/ec/secret_store/layout/test.py
Normal file
41
test/unit/python/jw/pkg/lib/ec/secret_store/layout/test.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from jw.pkg.lib.ec.secret_store.layout import (
|
||||
BLOB_SUFFIX,
|
||||
DEFAULT_PREFIX,
|
||||
FT_SECRET,
|
||||
FT_SECRET_FILE,
|
||||
FT_SECRET_KEY,
|
||||
SECRET_SUFFIX,
|
||||
content_name,
|
||||
host_prefix,
|
||||
)
|
||||
from jw.pkg.lib.file_types import FT_COMPOUND, FT_DIR, FT_REG
|
||||
|
||||
# The layout constants are the store contract
|
||||
assert DEFAULT_PREFIX == 'default'
|
||||
assert SECRET_SUFFIX == '.jw-secret'
|
||||
assert BLOB_SUFFIX == '.jw-secret-file'
|
||||
|
||||
# content_name strips the content suffix, and names without a
|
||||
# content suffix are not store content
|
||||
assert content_name('etc/a.jw-secret') == 'etc/a'
|
||||
assert content_name('etc/c.pem.jw-secret-file') == 'etc/c.pem'
|
||||
assert content_name('etc/README.md') is None
|
||||
|
||||
# An FQDN maps to its reversed, dot-split host root
|
||||
assert host_prefix('host.example.com') == 'com/example/host'
|
||||
assert host_prefix('a.b.c.d') == 'd/c/b/a'
|
||||
|
||||
# A single-label hostname maps to itself
|
||||
assert host_prefix('localhost') == 'localhost'
|
||||
|
||||
# The store content types are registered with the file types: an
|
||||
# entry is a kind of compound, a key and a blob a kind of file
|
||||
assert FT_SECRET.is_subtype(FT_COMPOUND)
|
||||
assert FT_SECRET.is_subtype(FT_REG)
|
||||
assert not FT_SECRET.is_subtype(FT_DIR)
|
||||
assert FT_SECRET_KEY.is_subtype(FT_REG)
|
||||
assert not FT_SECRET_KEY.is_subtype(FT_DIR)
|
||||
assert FT_SECRET_FILE.is_subtype(FT_REG)
|
||||
assert not FT_SECRET_FILE.is_subtype(FT_DIR)
|
||||
|
||||
print('All secrets layout tests passed')
|
||||
Loading…
Reference in a new issue