jw-pkg/src/python/jw/pkg/cmds/secrets/lib/DistroContext.py
Jan Lindemann f08f658d33
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
lib.ec.secret_store: Add and use framework
"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>
2026-10-17 12:52:20 +02:00

170 lines
6.1 KiB
Python

from __future__ import annotations
import re
import sys
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING
from ....lib.ec.secret_store.Base import Base as SecretStore
from ....lib.ec.secret_store.KvFile import KvFile
from ....lib.ec.secret_store.layout import (
DEFAULT_PREFIX,
FT_SECRET,
FT_SECRET_KEY,
SECRET_SUFFIX,
host_prefix,
)
from ....lib.FileContext import FileContext
from ....lib.log import DEBUG, NOTICE, WARNING, log
from .FilesContext import FilesContext
if TYPE_CHECKING:
from collections.abc import Collection
from ....lib.Distro import Distro
from .base import Attrs
class DistroContext(FilesContext):
def __init__(self, distro: Distro) -> None:
super().__init__(distro.ctx, distro = distro)
self.__distro = distro
async def match_files(self, pkg_names: Collection[str], pattern: str) -> list[str]:
ret: list[str] = []
for pkg_name in pkg_names:
for path in await self.__distro.pkg_files(pkg_name):
if re.match(pattern, path):
ret.append(path)
return ret
async def list_template_files(self, pkg_names: Collection[str]) -> list[str]:
if not pkg_names:
pkg_names = [p.name for p in await self.__distro.select()]
return await self.match_files(pkg_names, pattern = r'.*\.jw-tmpl$')
async def list_secret_paths(
self, pkg_names: Collection[str], ignore_missing: bool = False
) -> list[str]:
ret = []
for tmpl in await self.list_template_files(pkg_names):
path = str(Path(tmpl).with_suffix(SECRET_SUFFIX))
if ignore_missing and not await self.ctx.file_exists(path):
continue
ret.append(path)
return ret
async def list_compilation_targets(
self, pkg_names: Collection[str], ignore_missing: bool = False
) -> list[str]:
ret = []
for tmpl in await self.list_template_files(pkg_names):
path = tmpl.removesuffix('.jw-tmpl')
if ignore_missing and not await self.ctx.file_exists(path):
continue
ret.append(path)
return ret
async def remove_compilation_targets(self, pkg_names: Collection[str]) -> list[str]:
ret: list[str] = []
for path in await self.list_compilation_targets(pkg_names):
try:
await self.ctx.stat(path)
log(NOTICE, f'Removing {path}')
await self.ctx.unlink(path)
ret.append(path)
except FileNotFoundError:
log(DEBUG, f"Compilation target {path} doesn't exist (ignored)")
continue
return ret
async def compile_template_files(
self, pkg_names: Collection[str], default_attrs: Attrs
) -> list[str]:
ret: list[str] = []
missing = 0
for target in await self.list_compilation_targets(pkg_names):
if await self.compile_template_file(target, default_attrs):
ret.append(target)
else:
missing += 1
if missing > 0:
from ....lib.util import pretty_cmd
cmdline = pretty_cmd(sys.argv)
log(
WARNING,
(
f'{missing} missing secrets found. You might want to add them and '
f'run sudo {cmdline} again'
),
)
return ret
async def install(
self,
src_uri: str,
pkg_names: Collection[str],
only_missing: bool = False,
verbose: bool = False,
) -> None:
if only_missing:
raise NotImplementedError('--only-missing is not yet implemented')
fc = FileContext.create(src_uri, chroot = True)
await fc.open()
try:
hostname = self.ctx.uri.hostname
if not hostname:
raise Exception('Have no hostname to find secrets in the store')
if isinstance(fc, SecretStore):
if not fc.whole:
raise Exception(
f'src {src_uri} must name the whole store, not a '
f'rooted subtree'
)
src: FileContext = fc
# -- Store candidates: the host tree, then the default
# tree
prefixes: tuple[str, ...] = (host_prefix(hostname), DEFAULT_PREFIX)
else:
# -- A file tree in the on-host layout: wrap it in the kvfile store
src = KvFile(fc, distro = self.__distro)
prefixes = ('', )
# -- The store and the kvfile source are alike addressed
# by the on-host file name
suffix = SECRET_SUFFIX
for entry in await self.list_compilation_targets(pkg_names):
# -- A present entry, even an empty one, wins over the
# fallback
entry_rel = f'{entry.lstrip("/")}{suffix}'
addr = entry_rel
keys: list[str] | None = None
for pfx in prefixes:
addr = f'{pfx}/{entry_rel}' if pfx else entry_rel
try:
keys = list(await src.readdir(addr))
break
except FileNotFoundError:
continue
if keys is None:
log(NOTICE, f'not extracted: {entry}')
continue
log(NOTICE, f'extracted: {entry}')
pairs: list[tuple[str, bytes]] = []
for key in keys:
val = await src.get(f'{addr}/{key}')
pairs.append((key, val.stdout))
dst = self.store
with suppress(FileNotFoundError):
await dst.unlink(entry_rel)
await dst.mkdir(entry_rel, ftype = FT_SECRET)
for key, content in pairs:
await dst.put(f'{entry_rel}/{key}', content, ftype = FT_SECRET_KEY)
finally:
await fc.close()