feat: add tuwunel/matrix support with OpenSearch ML post-apply hooks
- Add matrix to MANAGED_NS and tuwunel to restart/build targets
- Add post-apply hooks for matrix namespace:
- _patch_tuwunel_oauth2_redirect: reads client_id from hydra-maester
Secret and patches OAuth2Client redirectUris dynamically
- _inject_opensearch_model_id: reads model_id from ingest pipeline
and writes to ConfigMap for tuwunel deployment env var injection
- Add post-apply hook for data namespace:
- _ensure_opensearch_ml: idempotently registers/deploys all-mpnet-base-v2
(768-dim) model, creates ingest + hybrid search pipelines
- Add tuwunel secrets to OpenBao seed (OIDC, TURN, registration token)
- Refactor secret seeding to only write dirty paths (avoid VSO churn)
- Add ACME email fallback from config when not provided via CLI flag
This commit is contained in:
@@ -82,7 +82,8 @@ def main() -> None:
|
|||||||
"docs-frontend", "people-frontend", "people",
|
"docs-frontend", "people-frontend", "people",
|
||||||
"messages", "messages-backend", "messages-frontend",
|
"messages", "messages-backend", "messages-frontend",
|
||||||
"messages-mta-in", "messages-mta-out",
|
"messages-mta-in", "messages-mta-out",
|
||||||
"messages-mpa", "messages-socks-proxy"],
|
"messages-mpa", "messages-socks-proxy",
|
||||||
|
"tuwunel"],
|
||||||
help="What to build")
|
help="What to build")
|
||||||
p_build.add_argument("--push", action="store_true",
|
p_build.add_argument("--push", action="store_true",
|
||||||
help="Push image to registry after building")
|
help="Push image to registry after building")
|
||||||
@@ -104,12 +105,14 @@ def main() -> None:
|
|||||||
p_config = sub.add_parser("config", help="Manage sunbeam configuration")
|
p_config = sub.add_parser("config", help="Manage sunbeam configuration")
|
||||||
config_sub = p_config.add_subparsers(dest="config_action", metavar="action")
|
config_sub = p_config.add_subparsers(dest="config_action", metavar="action")
|
||||||
|
|
||||||
# sunbeam config set --host HOST --infra-dir DIR
|
# sunbeam config set --host HOST --infra-dir DIR --acme-email EMAIL
|
||||||
p_config_set = config_sub.add_parser("set", help="Set configuration values")
|
p_config_set = config_sub.add_parser("set", help="Set configuration values")
|
||||||
p_config_set.add_argument("--host", default="",
|
p_config_set.add_argument("--host", default="",
|
||||||
help="Production SSH host (e.g. user@server.example.com)")
|
help="Production SSH host (e.g. user@server.example.com)")
|
||||||
p_config_set.add_argument("--infra-dir", default="",
|
p_config_set.add_argument("--infra-dir", default="",
|
||||||
help="Infrastructure directory root")
|
help="Infrastructure directory root")
|
||||||
|
p_config_set.add_argument("--acme-email", default="",
|
||||||
|
help="ACME email for Let's Encrypt certificates (e.g. ops@sunbeam.pt)")
|
||||||
|
|
||||||
# sunbeam config get
|
# sunbeam config get
|
||||||
config_sub.add_parser("get", help="Get current configuration")
|
config_sub.add_parser("get", help="Get current configuration")
|
||||||
@@ -249,17 +252,21 @@ def main() -> None:
|
|||||||
p_config.print_help()
|
p_config.print_help()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif action == "set":
|
elif action == "set":
|
||||||
config = SunbeamConfig(
|
config = load_config()
|
||||||
production_host=args.host if args.host else "",
|
if args.host:
|
||||||
infra_directory=args.infra_dir if args.infra_dir else ""
|
config.production_host = args.host
|
||||||
)
|
if args.infra_dir:
|
||||||
|
config.infra_directory = args.infra_dir
|
||||||
|
if args.acme_email:
|
||||||
|
config.acme_email = args.acme_email
|
||||||
save_config(config)
|
save_config(config)
|
||||||
elif action == "get":
|
elif action == "get":
|
||||||
from sunbeam.output import ok
|
from sunbeam.output import ok
|
||||||
config = load_config()
|
config = load_config()
|
||||||
ok(f"Production host: {config.production_host or '(not set)'}")
|
ok(f"Production host: {config.production_host or '(not set)'}")
|
||||||
ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}")
|
ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}")
|
||||||
|
ok(f"ACME email: {config.acme_email or '(not set)'}")
|
||||||
|
|
||||||
# Also show effective production host (from config or env)
|
# Also show effective production host (from config or env)
|
||||||
effective_host = get_production_host()
|
effective_host = get_production_host()
|
||||||
if effective_host:
|
if effective_host:
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from pathlib import Path
|
|||||||
from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, kustomize_build, get_lima_ip, get_domain
|
from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, kustomize_build, get_lima_ip, get_domain
|
||||||
from sunbeam.output import step, ok, warn
|
from sunbeam.output import step, ok, warn
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parents[2] / "infrastructure"
|
from sunbeam.config import get_infra_dir as _get_infra_dir
|
||||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "monitoring", "ory",
|
REPO_ROOT = _get_infra_dir()
|
||||||
"storage", "vault-secrets-operator"]
|
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "monitoring",
|
||||||
|
"ory", "storage", "vault-secrets-operator"]
|
||||||
|
|
||||||
|
|
||||||
def pre_apply_cleanup(namespaces=None):
|
def pre_apply_cleanup(namespaces=None):
|
||||||
@@ -156,6 +157,219 @@ def _filter_by_namespace(manifests: str, namespace: str) -> str:
|
|||||||
return "---\n" + "\n---\n".join(kept) + "\n"
|
return "---\n" + "\n---\n".join(kept) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_tuwunel_oauth2_redirect(domain: str):
|
||||||
|
"""Patch the tuwunel OAuth2Client redirect URI with the actual client_id.
|
||||||
|
|
||||||
|
Hydra-maester generates the client_id when it first reconciles the
|
||||||
|
OAuth2Client CRD, storing it in the oidc-tuwunel Secret. We read that
|
||||||
|
secret and patch the CRD's redirectUris to include the correct callback
|
||||||
|
path that tuwunel will use.
|
||||||
|
"""
|
||||||
|
import base64, json
|
||||||
|
|
||||||
|
client_id_b64 = kube_out("get", "secret", "oidc-tuwunel", "-n", "matrix",
|
||||||
|
"-o=jsonpath={.data.CLIENT_ID}", "--ignore-not-found")
|
||||||
|
if not client_id_b64:
|
||||||
|
warn("oidc-tuwunel secret not yet available — skipping redirect URI patch. "
|
||||||
|
"Re-run 'sunbeam apply matrix' after hydra-maester has reconciled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
client_id = base64.b64decode(client_id_b64).decode()
|
||||||
|
redirect_uri = f"https://messages.{domain}/_matrix/client/unstable/login/sso/callback/{client_id}"
|
||||||
|
|
||||||
|
# Check current redirect URIs to avoid unnecessary patches.
|
||||||
|
current = kube_out("get", "oauth2client", "tuwunel", "-n", "matrix",
|
||||||
|
"-o=jsonpath={.spec.redirectUris[*]}", "--ignore-not-found")
|
||||||
|
if redirect_uri in current.split():
|
||||||
|
return
|
||||||
|
|
||||||
|
patch = json.dumps({"spec": {"redirectUris": [redirect_uri]}})
|
||||||
|
kube("patch", "oauth2client", "tuwunel", "-n", "matrix",
|
||||||
|
"--type=merge", f"-p={patch}", check=False)
|
||||||
|
ok(f"Patched tuwunel OAuth2Client redirect URI.")
|
||||||
|
|
||||||
|
|
||||||
|
def _os_api(path: str, method: str = "GET", data: str | None = None) -> str:
|
||||||
|
"""Call OpenSearch API via kubectl exec. Returns response body."""
|
||||||
|
cmd = ["exec", "deploy/opensearch", "-n", "data", "-c", "opensearch", "--"]
|
||||||
|
curl = ["curl", "-sf", f"http://localhost:9200{path}"]
|
||||||
|
if method != "GET":
|
||||||
|
curl += ["-X", method]
|
||||||
|
if data is not None:
|
||||||
|
curl += ["-H", "Content-Type: application/json", "-d", data]
|
||||||
|
return kube_out(*cmd, *curl)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_opensearch_ml():
|
||||||
|
"""Idempotently configure OpenSearch ML Commons for neural search.
|
||||||
|
|
||||||
|
1. Sets cluster settings to allow ML on data nodes.
|
||||||
|
2. Registers and deploys all-mpnet-base-v2 (pre-trained, 384-dim).
|
||||||
|
3. Creates ingest + search pipelines for hybrid BM25+neural scoring.
|
||||||
|
"""
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
# Check OpenSearch is reachable.
|
||||||
|
if not _os_api("/_cluster/health"):
|
||||||
|
warn("OpenSearch not reachable — skipping ML setup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Ensure ML Commons cluster settings (idempotent PUT).
|
||||||
|
_os_api("/_cluster/settings", "PUT", json.dumps({"persistent": {
|
||||||
|
"plugins.ml_commons.only_run_on_ml_node": False,
|
||||||
|
"plugins.ml_commons.native_memory_threshold": 90,
|
||||||
|
"plugins.ml_commons.model_access_control_enabled": False,
|
||||||
|
"plugins.ml_commons.allow_registering_model_via_url": True,
|
||||||
|
}}))
|
||||||
|
|
||||||
|
# 2. Check if model already registered and deployed.
|
||||||
|
search_resp = _os_api("/_plugins/_ml/models/_search", "POST",
|
||||||
|
'{"query":{"match":{"name":"huggingface/sentence-transformers/all-mpnet-base-v2"}}}')
|
||||||
|
if not search_resp:
|
||||||
|
warn("OpenSearch ML search API failed — skipping ML setup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
resp = json.loads(search_resp)
|
||||||
|
hits = resp.get("hits", {}).get("hits", [])
|
||||||
|
model_id = None
|
||||||
|
|
||||||
|
for hit in hits:
|
||||||
|
state = hit.get("_source", {}).get("model_state", "")
|
||||||
|
if state == "DEPLOYED":
|
||||||
|
model_id = hit["_id"]
|
||||||
|
break
|
||||||
|
elif state in ("REGISTERED", "DEPLOYING"):
|
||||||
|
model_id = hit["_id"]
|
||||||
|
|
||||||
|
if model_id and any(h["_source"].get("model_state") == "DEPLOYED" for h in hits):
|
||||||
|
pass # Already deployed, skip to pipelines.
|
||||||
|
elif model_id:
|
||||||
|
# Registered but not deployed — deploy it.
|
||||||
|
ok("Deploying OpenSearch ML model...")
|
||||||
|
_os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST")
|
||||||
|
for _ in range(30):
|
||||||
|
time.sleep(5)
|
||||||
|
r = _os_api(f"/_plugins/_ml/models/{model_id}")
|
||||||
|
if r and '"DEPLOYED"' in r:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Register from pre-trained hub.
|
||||||
|
ok("Registering OpenSearch ML model (all-mpnet-base-v2)...")
|
||||||
|
reg_resp = _os_api("/_plugins/_ml/models/_register", "POST", json.dumps({
|
||||||
|
"name": "huggingface/sentence-transformers/all-mpnet-base-v2",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"model_format": "TORCH_SCRIPT",
|
||||||
|
}))
|
||||||
|
if not reg_resp:
|
||||||
|
warn("Failed to register ML model — skipping.")
|
||||||
|
return
|
||||||
|
task_id = json.loads(reg_resp).get("task_id", "")
|
||||||
|
if not task_id:
|
||||||
|
warn("No task_id from model registration — skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for registration.
|
||||||
|
ok("Waiting for model registration...")
|
||||||
|
for _ in range(60):
|
||||||
|
time.sleep(10)
|
||||||
|
task_resp = _os_api(f"/_plugins/_ml/tasks/{task_id}")
|
||||||
|
if not task_resp:
|
||||||
|
continue
|
||||||
|
task = json.loads(task_resp)
|
||||||
|
state = task.get("state", "")
|
||||||
|
if state == "COMPLETED":
|
||||||
|
model_id = task.get("model_id", "")
|
||||||
|
break
|
||||||
|
if state == "FAILED":
|
||||||
|
warn(f"ML model registration failed: {task_resp}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not model_id:
|
||||||
|
warn("ML model registration timed out.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deploy.
|
||||||
|
ok("Deploying ML model...")
|
||||||
|
_os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST")
|
||||||
|
for _ in range(30):
|
||||||
|
time.sleep(5)
|
||||||
|
r = _os_api(f"/_plugins/_ml/models/{model_id}")
|
||||||
|
if r and '"DEPLOYED"' in r:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_id:
|
||||||
|
warn("No ML model available — skipping pipeline setup.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Create/update ingest pipeline (PUT is idempotent).
|
||||||
|
_os_api("/_ingest/pipeline/tuwunel_embedding_pipeline", "PUT", json.dumps({
|
||||||
|
"description": "Tuwunel message embedding pipeline",
|
||||||
|
"processors": [{"text_embedding": {
|
||||||
|
"model_id": model_id,
|
||||||
|
"field_map": {"body": "embedding"},
|
||||||
|
}}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
# 4. Create/update search pipeline (PUT is idempotent).
|
||||||
|
_os_api("/_search/pipeline/tuwunel_hybrid_pipeline", "PUT", json.dumps({
|
||||||
|
"description": "Tuwunel hybrid BM25+neural search pipeline",
|
||||||
|
"phase_results_processors": [{"normalization-processor": {
|
||||||
|
"normalization": {"technique": "min_max"},
|
||||||
|
"combination": {"technique": "arithmetic_mean", "parameters": {"weights": [0.3, 0.7]}},
|
||||||
|
}}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
ok(f"OpenSearch ML ready (model: {model_id}).")
|
||||||
|
return model_id
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_opensearch_model_id():
|
||||||
|
"""Read deployed ML model_id from OpenSearch, write to ConfigMap in matrix ns.
|
||||||
|
|
||||||
|
The tuwunel deployment reads TUWUNEL_SEARCH_OPENSEARCH_MODEL_ID from this
|
||||||
|
ConfigMap. Creates or updates the ConfigMap idempotently.
|
||||||
|
|
||||||
|
Reads the model_id from the ingest pipeline (which _ensure_opensearch_ml
|
||||||
|
already configured with the correct model_id).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Read model_id from the ingest pipeline that _ensure_opensearch_ml created.
|
||||||
|
pipe_resp = _os_api("/_ingest/pipeline/tuwunel_embedding_pipeline")
|
||||||
|
if not pipe_resp:
|
||||||
|
warn("OpenSearch ingest pipeline not found — skipping model_id injection. "
|
||||||
|
"Run 'sunbeam apply data' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
pipe = json.loads(pipe_resp)
|
||||||
|
processors = (pipe.get("tuwunel_embedding_pipeline", {})
|
||||||
|
.get("processors", []))
|
||||||
|
model_id = None
|
||||||
|
for proc in processors:
|
||||||
|
model_id = proc.get("text_embedding", {}).get("model_id")
|
||||||
|
if model_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_id:
|
||||||
|
warn("No model_id in ingest pipeline — tuwunel hybrid search will be unavailable.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if ConfigMap already has this value.
|
||||||
|
current = kube_out("get", "configmap", "opensearch-ml-config", "-n", "matrix",
|
||||||
|
"-o=jsonpath={.data.model_id}", "--ignore-not-found")
|
||||||
|
if current == model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
cm = json.dumps({
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "ConfigMap",
|
||||||
|
"metadata": {"name": "opensearch-ml-config", "namespace": "matrix"},
|
||||||
|
"data": {"model_id": model_id},
|
||||||
|
})
|
||||||
|
kube("apply", "--server-side", "-f", "-", input=cm)
|
||||||
|
ok(f"Injected OpenSearch model_id ({model_id}) into matrix/opensearch-ml-config.")
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace: str = ""):
|
def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace: str = ""):
|
||||||
"""Build kustomize overlay for env, substitute domain/email, kubectl apply.
|
"""Build kustomize overlay for env, substitute domain/email, kubectl apply.
|
||||||
|
|
||||||
@@ -163,6 +377,11 @@ def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace:
|
|||||||
cert-manager registers a ValidatingWebhook that must be running before
|
cert-manager registers a ValidatingWebhook that must be running before
|
||||||
ClusterIssuer / Certificate resources can be created.
|
ClusterIssuer / Certificate resources can be created.
|
||||||
"""
|
"""
|
||||||
|
# Fall back to config for ACME email if not provided via CLI flag.
|
||||||
|
if not email:
|
||||||
|
from sunbeam.config import load_config
|
||||||
|
email = load_config().acme_email
|
||||||
|
|
||||||
if env == "production":
|
if env == "production":
|
||||||
if not domain:
|
if not domain:
|
||||||
# Try to discover domain from running cluster
|
# Try to discover domain from running cluster
|
||||||
@@ -207,4 +426,12 @@ def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace:
|
|||||||
kube("apply", "--server-side", "--force-conflicts", "-f", "-", input=manifests2)
|
kube("apply", "--server-side", "--force-conflicts", "-f", "-", input=manifests2)
|
||||||
|
|
||||||
_restart_for_changed_configmaps(before, _snapshot_configmaps())
|
_restart_for_changed_configmaps(before, _snapshot_configmaps())
|
||||||
|
|
||||||
|
# Post-apply hooks for namespaces that need runtime patching.
|
||||||
|
if not namespace or namespace == "matrix":
|
||||||
|
_patch_tuwunel_oauth2_redirect(domain)
|
||||||
|
_inject_opensearch_model_id()
|
||||||
|
if not namespace or namespace == "data":
|
||||||
|
_ensure_opensearch_ml()
|
||||||
|
|
||||||
ok("Applied.")
|
ok("Applied.")
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ from sunbeam.output import step, ok, warn, die
|
|||||||
ADMIN_USERNAME = "estudio-admin"
|
ADMIN_USERNAME = "estudio-admin"
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_fernet_key() -> str:
|
||||||
|
"""Generate a Fernet-compatible key (32 random bytes, URL-safe base64)."""
|
||||||
|
return base64.urlsafe_b64encode(_secrets.token_bytes(32)).decode()
|
||||||
|
|
||||||
|
|
||||||
def _gen_dkim_key_pair() -> tuple[str, str]:
|
def _gen_dkim_key_pair() -> tuple[str, str]:
|
||||||
"""Generate an RSA 2048-bit DKIM key pair using openssl.
|
"""Generate an RSA 2048-bit DKIM key pair using openssl.
|
||||||
|
|
||||||
@@ -133,6 +138,9 @@ def _seed_openbao() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Read-or-generate helper: preserves existing KV values; only generates missing ones.
|
# Read-or-generate helper: preserves existing KV values; only generates missing ones.
|
||||||
|
# Tracks which paths had new values so we only write back when necessary.
|
||||||
|
_dirty_paths: set = set()
|
||||||
|
|
||||||
def get_or_create(path, **fields):
|
def get_or_create(path, **fields):
|
||||||
raw = bao(
|
raw = bao(
|
||||||
f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
||||||
@@ -145,7 +153,12 @@ def _seed_openbao() -> dict:
|
|||||||
pass
|
pass
|
||||||
result = {}
|
result = {}
|
||||||
for key, default_fn in fields.items():
|
for key, default_fn in fields.items():
|
||||||
result[key] = existing.get(key) or default_fn()
|
val = existing.get(key)
|
||||||
|
if val:
|
||||||
|
result[key] = val
|
||||||
|
else:
|
||||||
|
result[key] = default_fn()
|
||||||
|
_dirty_paths.add(path)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def rand():
|
def rand():
|
||||||
@@ -193,7 +206,9 @@ def _seed_openbao() -> dict:
|
|||||||
kratos_admin = get_or_create("kratos-admin",
|
kratos_admin = get_or_create("kratos-admin",
|
||||||
**{"cookie-secret": rand,
|
**{"cookie-secret": rand,
|
||||||
"csrf-cookie-secret": rand,
|
"csrf-cookie-secret": rand,
|
||||||
"admin-identity-ids": lambda: ""})
|
"admin-identity-ids": lambda: "",
|
||||||
|
"s3-access-key": lambda: seaweedfs["access-key"],
|
||||||
|
"s3-secret-key": lambda: seaweedfs["secret-key"]})
|
||||||
|
|
||||||
docs = get_or_create("docs",
|
docs = get_or_create("docs",
|
||||||
**{"django-secret-key": rand,
|
**{"django-secret-key": rand,
|
||||||
@@ -225,15 +240,16 @@ def _seed_openbao() -> dict:
|
|||||||
_dkim_private, _dkim_public = _gen_dkim_key_pair()
|
_dkim_private, _dkim_public = _gen_dkim_key_pair()
|
||||||
|
|
||||||
messages = get_or_create("messages",
|
messages = get_or_create("messages",
|
||||||
**{"django-secret-key": rand,
|
**{"django-secret-key": rand,
|
||||||
"salt-key": rand,
|
"salt-key": rand,
|
||||||
"mda-api-secret": rand,
|
"mda-api-secret": rand,
|
||||||
"dkim-private-key": lambda: _dkim_private,
|
"oidc-refresh-token-key": _gen_fernet_key,
|
||||||
"dkim-public-key": lambda: _dkim_public,
|
"dkim-private-key": lambda: _dkim_private,
|
||||||
"rspamd-password": rand,
|
"dkim-public-key": lambda: _dkim_public,
|
||||||
"socks-proxy-users": lambda: f"sunbeam:{rand()}",
|
"rspamd-password": rand,
|
||||||
"mta-out-smtp-username": lambda: "sunbeam",
|
"socks-proxy-users": lambda: f"sunbeam:{rand()}",
|
||||||
"mta-out-smtp-password": rand})
|
"mta-out-smtp-username": lambda: "sunbeam",
|
||||||
|
"mta-out-smtp-password": rand})
|
||||||
|
|
||||||
collabora = get_or_create("collabora",
|
collabora = get_or_create("collabora",
|
||||||
**{"username": lambda: "admin",
|
**{"username": lambda: "admin",
|
||||||
@@ -262,48 +278,92 @@ def _seed_openbao() -> dict:
|
|||||||
**{"access-key-id": lambda: _scw_config("access-key"),
|
**{"access-key-id": lambda: _scw_config("access-key"),
|
||||||
"secret-access-key": lambda: _scw_config("secret-key")})
|
"secret-access-key": lambda: _scw_config("secret-key")})
|
||||||
|
|
||||||
# Write all secrets to KV (idempotent -- puts same values back)
|
# Only write secrets to OpenBao KV for paths that have new/missing values.
|
||||||
# messages secrets written separately first (multi-field KV, avoids line-length issues)
|
# This avoids unnecessary KV version bumps which trigger VSO re-syncs and
|
||||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
# rollout restarts across the cluster.
|
||||||
f"bao kv put secret/messages"
|
if not _dirty_paths:
|
||||||
f" django-secret-key=\"{messages['django-secret-key']}\""
|
ok("All OpenBao KV secrets already present -- skipping writes.")
|
||||||
f" salt-key=\"{messages['salt-key']}\""
|
else:
|
||||||
f" mda-api-secret=\"{messages['mda-api-secret']}\""
|
ok(f"Writing new secrets to OpenBao KV ({', '.join(sorted(_dirty_paths))})...")
|
||||||
f" rspamd-password=\"{messages['rspamd-password']}\""
|
|
||||||
f" socks-proxy-users=\"{messages['socks-proxy-users']}\""
|
|
||||||
f" mta-out-smtp-username=\"{messages['mta-out-smtp-username']}\""
|
|
||||||
f" mta-out-smtp-password=\"{messages['mta-out-smtp-password']}\""
|
|
||||||
f"'")
|
|
||||||
# DKIM keys stored separately (large PEM values)
|
|
||||||
dkim_priv_b64 = base64.b64encode(messages['dkim-private-key'].encode()).decode()
|
|
||||||
dkim_pub_b64 = base64.b64encode(messages['dkim-public-key'].encode()).decode()
|
|
||||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
|
||||||
f"echo {dkim_priv_b64} | base64 -d > /tmp/dkim_priv.pem && "
|
|
||||||
f"echo {dkim_pub_b64} | base64 -d > /tmp/dkim_pub.pem && "
|
|
||||||
f"bao kv patch secret/messages"
|
|
||||||
f" dkim-private-key=\"$(cat /tmp/dkim_priv.pem)\""
|
|
||||||
f" dkim-public-key=\"$(cat /tmp/dkim_pub.pem)\" && "
|
|
||||||
f"rm /tmp/dkim_priv.pem /tmp/dkim_pub.pem"
|
|
||||||
f"'")
|
|
||||||
|
|
||||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
def _kv_put(path, **kv):
|
||||||
f"bao kv put secret/hydra system-secret=\"{hydra['system-secret']}\" cookie-secret=\"{hydra['cookie-secret']}\" pairwise-salt=\"{hydra['pairwise-salt']}\" && "
|
pairs = " ".join(f'{k}="{v}"' for k, v in kv.items())
|
||||||
f"bao kv put secret/kratos secrets-default=\"{kratos['secrets-default']}\" secrets-cookie=\"{kratos['secrets-cookie']}\" smtp-connection-uri=\"{kratos['smtp-connection-uri']}\" && "
|
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
||||||
f"bao kv put secret/gitea admin-username=\"{gitea['admin-username']}\" admin-password=\"{gitea['admin-password']}\" && "
|
f"bao kv put secret/{path} {pairs}")
|
||||||
f"bao kv put secret/seaweedfs access-key=\"{seaweedfs['access-key']}\" secret-key=\"{seaweedfs['secret-key']}\" && "
|
|
||||||
f"bao kv put secret/hive oidc-client-id=\"{hive['oidc-client-id']}\" oidc-client-secret=\"{hive['oidc-client-secret']}\" && "
|
if "messages" in _dirty_paths:
|
||||||
f"bao kv put secret/livekit api-key=\"{livekit['api-key']}\" api-secret=\"{livekit['api-secret']}\" && "
|
_kv_put("messages",
|
||||||
f"bao kv put secret/people django-secret-key=\"{people['django-secret-key']}\" && "
|
**{"django-secret-key": messages["django-secret-key"],
|
||||||
f"bao kv put secret/login-ui cookie-secret=\"{login_ui['cookie-secret']}\" csrf-cookie-secret=\"{login_ui['csrf-cookie-secret']}\" && "
|
"salt-key": messages["salt-key"],
|
||||||
f"bao kv put secret/kratos-admin cookie-secret=\"{kratos_admin['cookie-secret']}\" csrf-cookie-secret=\"{kratos_admin['csrf-cookie-secret']}\" admin-identity-ids=\"{kratos_admin['admin-identity-ids']}\" && "
|
"mda-api-secret": messages["mda-api-secret"],
|
||||||
f"bao kv put secret/docs django-secret-key=\"{docs['django-secret-key']}\" collaboration-secret=\"{docs['collaboration-secret']}\" && "
|
"oidc-refresh-token-key": messages["oidc-refresh-token-key"],
|
||||||
f"bao kv put secret/meet django-secret-key=\"{meet['django-secret-key']}\" application-jwt-secret-key=\"{meet['application-jwt-secret-key']}\" && "
|
"rspamd-password": messages["rspamd-password"],
|
||||||
f"bao kv put secret/drive django-secret-key=\"{drive['django-secret-key']}\" && "
|
"socks-proxy-users": messages["socks-proxy-users"],
|
||||||
f"bao kv put secret/collabora username=\"{collabora['username']}\" password=\"{collabora['password']}\" && "
|
"mta-out-smtp-username": messages["mta-out-smtp-username"],
|
||||||
f"bao kv put secret/grafana admin-password=\"{grafana['admin-password']}\" && "
|
"mta-out-smtp-password": messages["mta-out-smtp-password"]})
|
||||||
f"bao kv put secret/scaleway-s3 access-key-id=\"{scaleway_s3['access-key-id']}\" secret-access-key=\"{scaleway_s3['secret-access-key']}\" && "
|
# DKIM keys stored separately (large PEM values)
|
||||||
f"bao kv put secret/tuwunel oidc-client-id=\"{tuwunel['oidc-client-id']}\" oidc-client-secret=\"{tuwunel['oidc-client-secret']}\" turn-secret=\"{tuwunel['turn-secret']}\" registration-token=\"{tuwunel['registration-token']}\""
|
dkim_priv_b64 = base64.b64encode(messages['dkim-private-key'].encode()).decode()
|
||||||
f"'")
|
dkim_pub_b64 = base64.b64encode(messages['dkim-public-key'].encode()).decode()
|
||||||
|
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
||||||
|
f"echo {dkim_priv_b64} | base64 -d > /tmp/dkim_priv.pem && "
|
||||||
|
f"echo {dkim_pub_b64} | base64 -d > /tmp/dkim_pub.pem && "
|
||||||
|
f"bao kv patch secret/messages"
|
||||||
|
f" dkim-private-key=\"$(cat /tmp/dkim_priv.pem)\""
|
||||||
|
f" dkim-public-key=\"$(cat /tmp/dkim_pub.pem)\" && "
|
||||||
|
f"rm /tmp/dkim_priv.pem /tmp/dkim_pub.pem"
|
||||||
|
f"'")
|
||||||
|
if "hydra" in _dirty_paths:
|
||||||
|
_kv_put("hydra", **{"system-secret": hydra["system-secret"],
|
||||||
|
"cookie-secret": hydra["cookie-secret"],
|
||||||
|
"pairwise-salt": hydra["pairwise-salt"]})
|
||||||
|
if "kratos" in _dirty_paths:
|
||||||
|
_kv_put("kratos", **{"secrets-default": kratos["secrets-default"],
|
||||||
|
"secrets-cookie": kratos["secrets-cookie"],
|
||||||
|
"smtp-connection-uri": kratos["smtp-connection-uri"]})
|
||||||
|
if "gitea" in _dirty_paths:
|
||||||
|
_kv_put("gitea", **{"admin-username": gitea["admin-username"],
|
||||||
|
"admin-password": gitea["admin-password"]})
|
||||||
|
if "seaweedfs" in _dirty_paths:
|
||||||
|
_kv_put("seaweedfs", **{"access-key": seaweedfs["access-key"],
|
||||||
|
"secret-key": seaweedfs["secret-key"]})
|
||||||
|
if "hive" in _dirty_paths:
|
||||||
|
_kv_put("hive", **{"oidc-client-id": hive["oidc-client-id"],
|
||||||
|
"oidc-client-secret": hive["oidc-client-secret"]})
|
||||||
|
if "livekit" in _dirty_paths:
|
||||||
|
_kv_put("livekit", **{"api-key": livekit["api-key"],
|
||||||
|
"api-secret": livekit["api-secret"]})
|
||||||
|
if "people" in _dirty_paths:
|
||||||
|
_kv_put("people", **{"django-secret-key": people["django-secret-key"]})
|
||||||
|
if "login-ui" in _dirty_paths:
|
||||||
|
_kv_put("login-ui", **{"cookie-secret": login_ui["cookie-secret"],
|
||||||
|
"csrf-cookie-secret": login_ui["csrf-cookie-secret"]})
|
||||||
|
if "kratos-admin" in _dirty_paths:
|
||||||
|
_kv_put("kratos-admin", **{"cookie-secret": kratos_admin["cookie-secret"],
|
||||||
|
"csrf-cookie-secret": kratos_admin["csrf-cookie-secret"],
|
||||||
|
"admin-identity-ids": kratos_admin["admin-identity-ids"],
|
||||||
|
"s3-access-key": kratos_admin["s3-access-key"],
|
||||||
|
"s3-secret-key": kratos_admin["s3-secret-key"]})
|
||||||
|
if "docs" in _dirty_paths:
|
||||||
|
_kv_put("docs", **{"django-secret-key": docs["django-secret-key"],
|
||||||
|
"collaboration-secret": docs["collaboration-secret"]})
|
||||||
|
if "meet" in _dirty_paths:
|
||||||
|
_kv_put("meet", **{"django-secret-key": meet["django-secret-key"],
|
||||||
|
"application-jwt-secret-key": meet["application-jwt-secret-key"]})
|
||||||
|
if "drive" in _dirty_paths:
|
||||||
|
_kv_put("drive", **{"django-secret-key": drive["django-secret-key"]})
|
||||||
|
if "collabora" in _dirty_paths:
|
||||||
|
_kv_put("collabora", **{"username": collabora["username"],
|
||||||
|
"password": collabora["password"]})
|
||||||
|
if "grafana" in _dirty_paths:
|
||||||
|
_kv_put("grafana", **{"admin-password": grafana["admin-password"]})
|
||||||
|
if "scaleway-s3" in _dirty_paths:
|
||||||
|
_kv_put("scaleway-s3", **{"access-key-id": scaleway_s3["access-key-id"],
|
||||||
|
"secret-access-key": scaleway_s3["secret-access-key"]})
|
||||||
|
if "tuwunel" in _dirty_paths:
|
||||||
|
_kv_put("tuwunel", **{"oidc-client-id": tuwunel["oidc-client-id"],
|
||||||
|
"oidc-client-secret": tuwunel["oidc-client-secret"],
|
||||||
|
"turn-secret": tuwunel["turn-secret"],
|
||||||
|
"registration-token": tuwunel["registration-token"]})
|
||||||
|
|
||||||
# Configure Kubernetes auth method so VSO can authenticate with OpenBao
|
# Configure Kubernetes auth method so VSO can authenticate with OpenBao
|
||||||
ok("Configuring Kubernetes auth for VSO...")
|
ok("Configuring Kubernetes auth for VSO...")
|
||||||
@@ -519,7 +579,7 @@ def _seed_kratos_admin_identity(ob_pod: str, root_token: str) -> tuple[str, str]
|
|||||||
ok(f" admin identity exists ({identity_id[:8]}...)")
|
ok(f" admin identity exists ({identity_id[:8]}...)")
|
||||||
else:
|
else:
|
||||||
identity = _kratos_api(base, "/identities", method="POST", body={
|
identity = _kratos_api(base, "/identities", method="POST", body={
|
||||||
"schema_id": "default",
|
"schema_id": "employee",
|
||||||
"traits": {"email": admin_email},
|
"traits": {"email": admin_email},
|
||||||
"state": "active",
|
"state": "active",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from sunbeam.kube import kube, kube_out, parse_target
|
|||||||
from sunbeam.tools import ensure_tool
|
from sunbeam.tools import ensure_tool
|
||||||
from sunbeam.output import step, ok, warn, die
|
from sunbeam.output import step, ok, warn, die
|
||||||
|
|
||||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "ory", "storage",
|
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory",
|
||||||
"vault-secrets-operator"]
|
"storage", "vault-secrets-operator"]
|
||||||
|
|
||||||
SERVICES_TO_RESTART = [
|
SERVICES_TO_RESTART = [
|
||||||
("ory", "hydra"),
|
("ory", "hydra"),
|
||||||
@@ -22,6 +22,7 @@ SERVICES_TO_RESTART = [
|
|||||||
("lasuite", "people-frontend"),
|
("lasuite", "people-frontend"),
|
||||||
("lasuite", "people-celery-worker"),
|
("lasuite", "people-celery-worker"),
|
||||||
("lasuite", "people-celery-beat"),
|
("lasuite", "people-celery-beat"),
|
||||||
|
("matrix", "tuwunel"),
|
||||||
("media", "livekit-server"),
|
("media", "livekit-server"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -186,8 +187,9 @@ def cmd_logs(target: str, follow: bool):
|
|||||||
if not name:
|
if not name:
|
||||||
die("Logs require a service name, e.g. 'ory/kratos'.")
|
die("Logs require a service name, e.g. 'ory/kratos'.")
|
||||||
|
|
||||||
|
_kube_mod.ensure_tunnel()
|
||||||
kubectl = str(ensure_tool("kubectl"))
|
kubectl = str(ensure_tool("kubectl"))
|
||||||
cmd = [kubectl, "--context=sunbeam", "-n", ns, "logs",
|
cmd = [kubectl, _kube_mod.context_arg(), "-n", ns, "logs",
|
||||||
"-l", f"app={name}", "--tail=100"]
|
"-l", f"app={name}", "--tail=100"]
|
||||||
if follow:
|
if follow:
|
||||||
cmd.append("--follow")
|
cmd.append("--follow")
|
||||||
|
|||||||
Reference in New Issue
Block a user