This commit is contained in:
kiri committed 2026-09-22 21:44:35 +02:00
1 parent f164d8b76e
commit 5748b3409b
60 files changed
+1346 -590

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# Company environment
This capability owns the company-root tools, human workbench environment and
persistent read-only company mounts. Garden packaging and company certificates
remain in `../yookr-dev/`. Requests, queries, analysis, `uv.lock`, content and
runtime state stay in `~/yookr/workbench`.
Home Manager installs `devenv.nix` and `devenv.yaml` in `~/yookr/workbench`
(human credentials), selecting the system's exact nixpkgs store path. The work
profile also installs the tools for use at the company root without activation. The devenv CLI
uses its own upstream dependency closure; this avoids rebuilding its dependencies
against an unsupported pin. Its version is locked by the repository flake.
## Activation and trust
After reviewing and activating the configuration on each workstation:
1. Remove the obsolete `~/yookr/.envrc` and `~/yookr/workbench/.envrc`. Direnv is
no longer installed or hooked. Product/personal `.envrc` files remain yours to
migrate; this pass does not convert them.
2. Run `devenv allow` in `~/yookr/workbench` only. Open a new terminal
for the native Zsh hook. Reload/file watching uses upstream defaults.
3. `devenv.lock` and `.devenv/` are local generated state, not new configuration
owners. Do not commit them into the Nix repository. Retain product locks.
4. The human workbench attempts company, live and test resolution independently.
`workbench-reload` retries; `workbench-reload --tools-only` clears the variables
owned by this loader. Existing processes/editors keep their original environment.
For a clean, credential-free check use the flake's tools-only `#workbench` shell
from a clean parent environment. For native devenv without credential loading,
set `WORKBENCH_TOOLS_ONLY=1` before `devenv shell`. Neither method removes arbitrary
credentials already inherited from its parent; they are not isolation mechanisms.
The company root intentionally has no generated devenv environment pending review:
upstream's hook does nothing while another devenv shell is active, so an ancestor
shell would prevent automatic workbench entry and interfere with project environments.
The recommendation is installed tools at the company root, native activation at the
workbench, and product-owned environments below `projects/`.
## Credential provisioning (user-run only)
The encrypted company document belongs at `modules/secrets/company.yaml` in this
repository. It is deliberately absent until you migrate it. Reuse the existing
machine age identities; never copy private keys into Git. On each workstation,
obtain its public recipient with `age-keygen -y /var/lib/sops/keys.txt`.
On the machine holding the old credentials, run from this repository:
```console
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" python3 modules/capabilities/workbench/migrate-credentials.py \
"$HOME/.config/yookr/credentials.yaml" modules/secrets/company.yaml \
--age 'POLARIS_PUBLIC_AGE_RECIPIENT,ZENITH_PUBLIC_AGE_RECIPIENT'
```
The helper decrypts only in memory, preserves the old file, and refuses to overwrite
an existing destination. The new document contains root-level company login and
live database URLs for SecretSpec, plus the existing `leafcloud` map for mounts.
MySQL certificate verification behavior is preserved. Test credentials are not
copied into the new document. Review the encrypted result before a local commit;
pushing remains a separate decision. Other machines receive ciphertext through the
existing repository. Edit it with `sops edit modules/secrets/company.yaml` using the
machine's normal `SOPS_AGE_KEY_FILE=/var/lib/sops/keys.txt`.
### Test cluster: mapping and login still pending
No cluster query is needed to build this repository. Before enabling test loading,
settle the explicit test context and existing application Secret/namespace/key
mappings. The loader expects a `~/.config/workbench/test-secrets.toml` manifest with
profile `test`, using only explicit `k8s+secret://SECRET@NAMESPACE` providers and
`ref.item` for existing keys. Required exports are:
- `YOOKR_MYSQL_TEST_DATABASE_URL`, `YOOKR_MONGODB_TEST_DATABASE_URL`
- `RABBITMQADMIN_USERNAME`, `RABBITMQADMIN_PASSWORD`
If applications store URL components separately, the manifest/composition must be
adapted after confirming their keys; do not invent field names or change URL escaping.
This manifest is a provisioning boundary pending review, not a second global secret store.
Test loading also requires an explicitly prepared, noninteractive kubeconfig at
`$XDG_RUNTIME_DIR/workbench/test-kubeconfig`. No generic login helper is provided:
the actual context and exec plugin must be inspected first, then its native or
provider-specific cached login flow selected. Do not reimplement Kubernetes' exec
credential protocol for an unknown plugin. Until both runtime files exist, test
reports unavailable. Shell entry never invokes authentication or opens a login popup.
SecretSpec exports are captured in memory with stdin closed and a timeout. Errors
report only the unavailable source, not provider output or secret values. Sources
load independently; unavailable sources clear their old variables. Test never falls
back to SOPS, and live never depends on Kubernetes. Native devenv's all-at-once secret
resolution is intentionally not used: it would couple their failure behavior.
## Mounts
`company-obs.service` and `company-sharepoint.service` are user services on both
workstations. Mounts live at `~/yookr/workbench/{obs,sharepoint}`. They are read-only,
use `--vfs-cache-mode off`, and retry failures every minute. No background content
mirror remains. The existing local SharePoint mirror is preserved, not deleted.
Small memory buffers and filesystem metadata are still used.
OBS uses the company's encrypted `leafcloud` map and machine age key. SharePoint
uses the existing native rclone remotes `company-sharepoint` and
`company-sharepoint-projecten` in `~/.config/rclone/rclone.conf`; configure/login to
these on each workstation with rclone. Tokens remain application-owned outside Git.
Before first activation, unmount any old workbench-entry OBS mount. Do not empty or
replace nonempty mount directories: move any authored content deliberately first.
```console
systemctl --user restart company-obs company-sharepoint
systemctl --user status company-obs company-sharepoint
```
Do not invoke the old workspace `just mount-obs` helper once systemd owns the mount.
The old helper/recipes can be removed after the new mounts are accepted in use.
Shell exit does not unmount company files. All processes running as your user can
read the mounts; these services do not isolate agents.
@@ -0,0 +1,35 @@
{
pkgs,
garden ? import ../../yookr-dev/_support/packages.nix { inherit pkgs; },
}:
{
packages = [
garden.gardenctl
garden.gardenlogin
]
++ (with pkgs; [
k9s
kubectl
kubelogin-oidc
jq
just
python314
rclone
sops
secretspec
util-linux
uv
]);
env = {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.stdenv.cc.cc.lib
pkgs.zlib
];
UV_PYTHON = "${pkgs.python314}/bin/python";
UV_PYTHON_DOWNLOADS = "never";
RABBITMQADMIN_TARGET_HOST = "test.rabbitmq.yookr.org";
RABBITMQADMIN_TARGET_PORT = "443";
RABBITMQADMIN_USE_TLS = "true";
RABBITMQADMIN_NON_INTERACTIVE_MODE = "true";
};
}
+132
View File
@@ -0,0 +1,132 @@
{ inputs, ... }:
{
flake.modules.homeManager.workbench =
{
config,
lib,
pkgs,
...
}:
let
home = config.home.homeDirectory;
root = "${home}/yookr/workbench";
companySecrets = "${home}/.config/nixos/modules/secrets/company.yaml";
environmentInputs = builtins.toJSON {
inputs = {
nixpkgs.url = "path:${inputs.nixpkgs}";
git-hooks.url = "path:${inputs.devenv.inputs.git-hooks}";
};
};
secretsManifest = (pkgs.formats.toml { }).generate "workbench-secrets.toml" {
project = {
name = "workbench";
revision = "1";
};
providers.company = "sops://${companySecrets}?age_key_file=/var/lib/sops/keys.txt";
profiles =
lib.mapAttrs
(
_: names:
lib.genAttrs names (name: {
description = name;
ref.item = name;
providers = [ "company" ];
})
)
{
company = [
"email"
"password"
];
live = [
"YOOKR_MYSQL_LIVE_DATABASE_URL"
"YOOKR_MONGODB_LIVE_DATABASE_URL"
];
};
};
mountObs = pkgs.writeShellApplication {
name = "mount-company-obs";
runtimeInputs = [
pkgs.python3
pkgs.rclone
pkgs.sops
];
text = ''
export PATH=/run/wrappers/bin:"$PATH"
exec python3 ${./mount-obs.py} ${lib.escapeShellArg companySecrets} ${lib.escapeShellArg "${root}/obs"}
'';
};
mountSharepoint = pkgs.writeShellApplication {
name = "mount-company-sharepoint";
runtimeInputs = [
pkgs.rclone
pkgs.coreutils
];
text = ''
export PATH=/run/wrappers/bin:"$PATH"
mkdir -p ${lib.escapeShellArg "${root}/sharepoint"}
export RCLONE_CONFIG_SHAREPOINT_TYPE=combine
export RCLONE_CONFIG_SHAREPOINT_UPSTREAMS='Algemeen=company-sharepoint: Projecten=company-sharepoint-projecten:'
exec rclone mount sharepoint: ${lib.escapeShellArg "${root}/sharepoint"} \
--config ${lib.escapeShellArg "${config.xdg.configHome}/rclone/rclone.conf"} \
--read-only --vfs-cache-mode off --buffer-size 8M
'';
};
mountService = name: command: {
Unit = {
Description = "Read-only company ${name} mount";
After = [ "network-online.target" ];
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
ExecStart = lib.getExe command;
ExecStop = "/run/wrappers/bin/fusermount3 -u ${root}/${name}";
Environment = "SOPS_AGE_KEY_FILE=/var/lib/sops/keys.txt";
UMask = "0077";
Restart = "on-failure";
RestartSec = 60;
};
Install.WantedBy = [ "default.target" ];
};
in
{
# devenv already installs its bundled SecretSpec CLI in the workstation profile.
home.packages =
lib.remove pkgs.secretspec (import ./_support/tools.nix { inherit pkgs; }).packages;
programs.zsh.initContent = lib.mkAfter ''
workbench-reload() {
if [[ "''${DEVENV_ROOT:-}" != ${lib.escapeShellArg root} ]]; then
print -u2 'Enter the workbench devenv shell before loading credentials.'
return 1
fi
local exports
exports="$(${pkgs.python3}/bin/python3 ${./load-secrets.py} ${secretsManifest} "$@")" || return
eval "$exports"
}
'';
home.file."yookr/workbench/devenv.yaml".text = environmentInputs;
home.file."yookr/workbench/devenv.nix".text = ''
{ pkgs, ... }:
let tools = import ${./_support/tools.nix} {
inherit pkgs;
garden = import ${../yookr-dev/_support/packages.nix} { inherit pkgs; };
};
in {
inherit (tools) packages env;
enterShell = ${lib.strings.escapeNixString ''
if [ "''${WORKBENCH_TOOLS_ONLY:-0}" = 1 ]; then
eval "$(python3 ${./load-secrets.py} ${secretsManifest} --tools-only)"
else
eval "$(python3 ${./load-secrets.py} ${secretsManifest})"
fi
''};
}
'';
systemd.user.services = {
company-obs = mountService "obs" mountObs;
company-sharepoint = mountService "sharepoint" mountSharepoint;
};
};
}
@@ -0,0 +1,11 @@
{
perSystem =
{ pkgs, ... }:
let
tools = import ./_support/tools.nix { inherit pkgs; };
in
{
# A credential-free build target for the same tools used by native devenv.
devShells.workbench = pkgs.mkShell (tools.env // { inherit (tools) packages; });
};
}
@@ -0,0 +1,64 @@
"""Emit shell exports for independent sources; never print provider errors or values to stderr."""
import argparse
import concurrent.futures
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('manifest', type=Path)
parser.add_argument('--tools-only', action='store_true')
args = parser.parse_args()
groups = {
'company': ['email', 'password'],
'live': ['YOOKR_MYSQL_LIVE_DATABASE_URL', 'YOOKR_MONGODB_LIVE_DATABASE_URL'],
'test': ['YOOKR_MYSQL_TEST_DATABASE_URL', 'YOOKR_MONGODB_TEST_DATABASE_URL',
'RABBITMQADMIN_USERNAME', 'RABBITMQADMIN_PASSWORD'],
}
def load(group):
env = os.environ.copy()
# Explicit manifests/profiles/providers must not inherit a user's override.
for name in ('SECRETSPEC_PROVIDER', 'SECRETSPEC_PROFILE', 'SECRETSPEC_SCOPE'):
env.pop(name, None)
if group == 'test':
# A dedicated, explicitly provisioned test kubeconfig is mandatory.
kubeconfig = Path(os.environ.get('XDG_RUNTIME_DIR', '/nonexistent')) / 'workbench/test-kubeconfig'
manifest = Path.home() / '.config/workbench/test-secrets.toml'
if not kubeconfig.is_file() or not manifest.is_file():
return group, None
env['KUBECONFIG'] = str(kubeconfig)
else:
manifest = args.manifest
try:
result = subprocess.run(
['secretspec', '--file', str(manifest), 'export', '--profile', group, '--format', 'json'],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
timeout=20, check=True, text=True)
values = json.loads(result.stdout)
if not all(isinstance(values.get(key), str) and values[key] for key in groups[group]):
return group, None
return group, {key: values[key] for key in groups[group]}
except (OSError, ValueError, subprocess.SubprocessError):
return group, None
# Clear inherited target values before attempting a reload, including on failure.
for keys in groups.values():
for key in keys:
print('unset ' + key)
if args.tools_only:
for group in groups:
print('export WORKBENCH_' + group.upper() + '_STATUS=disabled')
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
for group, values in executor.map(load, groups):
status = 'ready' if values is not None else 'unavailable'
print('export WORKBENCH_' + group.upper() + '_STATUS=' + status)
if values:
for key, value in values.items():
print('export ' + key + '=' + shlex.quote(value))
print(f'workbench: {group} {status}', file=sys.stderr)
print('Retry after provisioning/login: workbench-reload. Clean tools: workbench-reload --tools-only.', file=sys.stderr)
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""User-run migration: decrypt in memory, reshape, encrypt for both machine keys."""
import argparse
import json
import os
from pathlib import Path
import subprocess
from urllib.parse import quote
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('destination', type=Path)
parser.add_argument('--age', required=True, help='Comma-separated public age recipients for both machines')
args = parser.parse_args()
if args.destination.exists():
parser.error('Destination exists; refusing to overwrite it')
def url(scheme, database):
authority = quote(database['username'], safe='') + ':' + quote(database['password'], safe='')
authority += '@' + database['host']
if database.get('port'):
authority += ':' + str(database['port'])
return scheme + '://' + authority
try:
old = json.loads(subprocess.check_output(
['sops', 'decrypt', '--output-type', 'json', str(args.source)], stderr=subprocess.DEVNULL))
new = {
'email': old['company_login']['email'],
'password': old['company_login']['password'],
'YOOKR_MYSQL_LIVE_DATABASE_URL': url('mariadb', old['databases']['mysql']['live']) + '?ssl-verify-server-cert=0',
'YOOKR_MONGODB_LIVE_DATABASE_URL': url('mongodb+srv', old['databases']['mongodb']['live']),
'leafcloud': old['leafcloud'],
}
encrypted = subprocess.check_output(
['sops', 'encrypt', '--input-type', 'json', '--output-type', 'yaml',
'--age', args.age, '/dev/stdin'], input=json.dumps(new).encode(), stderr=subprocess.DEVNULL)
os.umask(0o077)
with args.destination.open('xb') as output:
output.write(encrypted)
except (subprocess.CalledProcessError, KeyError, ValueError, OSError):
raise SystemExit('Migration failed; no plaintext was written. Check source schema, keys and recipients.')
print('Encrypted company document created. Original credentials were preserved.')
@@ -0,0 +1,35 @@
"""Foreground read-only OBS mount; systemd owns lifecycle and retries."""
import json
import os
from pathlib import Path
import re
import subprocess
import sys
try:
projects = json.loads(subprocess.check_output(
['sops', 'decrypt', '--extract', '["leafcloud"]', '--output-type', 'json', sys.argv[1]],
stderr=subprocess.DEVNULL))
if not projects:
raise ValueError('No projects')
env = os.environ.copy()
upstreams = []
for index, (project, credentials) in enumerate(projects.items()):
if not re.fullmatch(r'[A-Za-z0-9_-]+', project):
raise ValueError('Invalid project name')
remote = f'obs_project_{index}'
options = dict(type='s3', provider='Other', region='europe-nl-ams1',
endpoint='https://leafcloud.store', force_path_style='true',
access_key_id=credentials['access_key_id'],
secret_access_key=credentials['secret_access_key'])
env.update({f'RCLONE_CONFIG_{remote.upper()}_{key.upper()}': str(value)
for key, value in options.items()})
upstreams.append(f'{project}={remote}:')
env['RCLONE_CONFIG_OBS_TYPE'] = 'combine'
env['RCLONE_CONFIG_OBS_UPSTREAMS'] = ' '.join(upstreams)
mount = Path(sys.argv[2])
mount.mkdir(parents=True, exist_ok=True)
os.execvpe('rclone', ['rclone', 'mount', 'obs:', str(mount), '--config', '/dev/null',
'--read-only', '--vfs-cache-mode', 'off', '--buffer-size', '8M'], env)
except (subprocess.CalledProcessError, KeyError, ValueError, OSError):
raise SystemExit('OBS mount unavailable: check company SOPS provisioning and the machine age key.')