updates
This commit is contained in:
1 parent
f164d8b76e
commit
5748b3409b
60 files changed
+1346
-590
No files matched your search
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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.')
|
||||
Reference in new issue
Block a user