feat(cli): meet build/seed support, production kube tunnel, gitea OIDC bootstrap
- secrets.py: seed secret/meet (django-secret-key, application-jwt-secret-key) - images.py: add sunbeam build meet (meet-backend + meet-frontend from source) - kube.py: production SSH tunnel support, domain discovery from cluster, cmd_bao - gitea.py: configure Hydra as OIDC auth source; mark admin account as private - services.py: minor VSO sync status and services list fixes - users.py: add cmd_user_enable
This commit is contained in:
@@ -4,13 +4,12 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from sunbeam.kube import kube, kube_out
|
from sunbeam.kube import kube, kube_out, context_arg
|
||||||
from sunbeam.output import step, ok, warn
|
from sunbeam.output import step, ok, warn
|
||||||
|
|
||||||
LIMA_VM = "sunbeam"
|
LIMA_VM = "sunbeam"
|
||||||
GITEA_ADMIN_USER = "gitea_admin"
|
GITEA_ADMIN_USER = "gitea_admin"
|
||||||
GITEA_ADMIN_EMAIL = "gitea@local.domain"
|
GITEA_ADMIN_EMAIL = "gitea@local.domain"
|
||||||
K8S_CTX = ["--context=sunbeam"]
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_out(cmd, *, default=""):
|
def _capture_out(cmd, *, default=""):
|
||||||
@@ -26,7 +25,7 @@ def _run(cmd, *, check=True, input=None, capture=False, cwd=None):
|
|||||||
|
|
||||||
def _kube_ok(*args):
|
def _kube_ok(*args):
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["kubectl", *K8S_CTX, *args], capture_output=True
|
["kubectl", context_arg(), *args], capture_output=True
|
||||||
).returncode == 0
|
).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +140,7 @@ def cmd_bootstrap(domain: str = "", gitea_admin_pass: str = ""):
|
|||||||
|
|
||||||
def gitea_exec(*args):
|
def gitea_exec(*args):
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "devtools", "exec", pod, "-c",
|
["kubectl", context_arg(), "-n", "devtools", "exec", pod, "-c",
|
||||||
"gitea", "--"] + list(args),
|
"gitea", "--"] + list(args),
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
@@ -170,6 +169,18 @@ def cmd_bootstrap(domain: str = "", gitea_admin_pass: str = ""):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# Mark admin account as private so it doesn't appear in public listings.
|
||||||
|
r = api("PATCH", f"/admin/users/{GITEA_ADMIN_USER}", {
|
||||||
|
"source_id": 0,
|
||||||
|
"login_name": GITEA_ADMIN_USER,
|
||||||
|
"email": GITEA_ADMIN_EMAIL,
|
||||||
|
"visibility": "private",
|
||||||
|
})
|
||||||
|
if r.get("login") == GITEA_ADMIN_USER:
|
||||||
|
ok(f"Admin '{GITEA_ADMIN_USER}' marked as private.")
|
||||||
|
else:
|
||||||
|
warn(f"Could not set admin visibility: {r}")
|
||||||
|
|
||||||
for org_name, visibility, desc in [
|
for org_name, visibility, desc in [
|
||||||
("studio", "public", "Public source code"),
|
("studio", "public", "Public source code"),
|
||||||
("internal", "private", "Internal tools and services"),
|
("internal", "private", "Internal tools and services"),
|
||||||
@@ -186,5 +197,63 @@ def cmd_bootstrap(domain: str = "", gitea_admin_pass: str = ""):
|
|||||||
else:
|
else:
|
||||||
warn(f"Org '{org_name}': {result.get('message', result)}")
|
warn(f"Org '{org_name}': {result.get('message', result)}")
|
||||||
|
|
||||||
|
# Configure Hydra as the OIDC authentication source.
|
||||||
|
# Source name "Sunbeam" determines the callback URL:
|
||||||
|
# /user/oauth2/Sunbeam/callback (must match oidc-clients.yaml redirectUri)
|
||||||
|
auth_list = gitea_exec("gitea", "admin", "auth", "list")
|
||||||
|
# Parse tab-separated rows: ID\tName\tType\tEnabled
|
||||||
|
existing_id = None
|
||||||
|
exact_ok = False
|
||||||
|
for line in auth_list.stdout.splitlines()[1:]: # skip header
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
src_id, src_name = parts[0].strip(), parts[1].strip()
|
||||||
|
if src_name == "Sunbeam":
|
||||||
|
exact_ok = True
|
||||||
|
break
|
||||||
|
if src_name in ("Sunbeam Auth",) or (src_name.startswith("Sunbeam") and parts[2].strip() == "OAuth2"):
|
||||||
|
existing_id = src_id
|
||||||
|
|
||||||
|
if exact_ok:
|
||||||
|
ok("OIDC auth source 'Sunbeam' already present.")
|
||||||
|
elif existing_id:
|
||||||
|
# Wrong name (e.g. "Sunbeam Auth") — rename in-place to fix callback URL
|
||||||
|
r = gitea_exec("gitea", "admin", "auth", "update-oauth",
|
||||||
|
"--id", existing_id, "--name", "Sunbeam")
|
||||||
|
if r.returncode == 0:
|
||||||
|
ok(f"Renamed OIDC auth source (id={existing_id}) to 'Sunbeam'.")
|
||||||
|
else:
|
||||||
|
warn(f"Rename failed: {r.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
oidc_id_b64 = kube_out("-n", "lasuite", "get", "secret", "oidc-gitea",
|
||||||
|
"-o=jsonpath={.data.CLIENT_ID}")
|
||||||
|
oidc_secret_b64 = kube_out("-n", "lasuite", "get", "secret", "oidc-gitea",
|
||||||
|
"-o=jsonpath={.data.CLIENT_SECRET}")
|
||||||
|
if oidc_id_b64 and oidc_secret_b64:
|
||||||
|
oidc_id = base64.b64decode(oidc_id_b64).decode()
|
||||||
|
oidc_sec = base64.b64decode(oidc_secret_b64).decode()
|
||||||
|
discover_url = (
|
||||||
|
"http://hydra-public.ory.svc.cluster.local:4444"
|
||||||
|
"/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
r = gitea_exec(
|
||||||
|
"gitea", "admin", "auth", "add-oauth",
|
||||||
|
"--name", "Sunbeam",
|
||||||
|
"--provider", "openidConnect",
|
||||||
|
"--key", oidc_id,
|
||||||
|
"--secret", oidc_sec,
|
||||||
|
"--auto-discover-url", discover_url,
|
||||||
|
"--scopes", "openid",
|
||||||
|
"--scopes", "email",
|
||||||
|
"--scopes", "profile",
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
ok("OIDC auth source 'Sunbeam' configured.")
|
||||||
|
else:
|
||||||
|
warn(f"OIDC auth source config failed: {r.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
warn("oidc-gitea secret not found -- OIDC auth source not configured.")
|
||||||
|
|
||||||
ok(f"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from "
|
ok(f"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from "
|
||||||
f"openbao>)")
|
f"openbao>)")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Image mirroring — patch amd64-only images + push to Gitea registry."""
|
"""Image mirroring — patch amd64-only images + push to Gitea registry."""
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -200,6 +201,43 @@ def _run(cmd, *, check=True, input=None, capture=False, cwd=None):
|
|||||||
capture_output=capture, cwd=cwd)
|
capture_output=capture, cwd=cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_and_push(image: str, admin_pass: str):
|
||||||
|
"""Pre-seed a locally-built Docker image into k3s containerd, then push
|
||||||
|
to the Gitea registry via 'ctr images push' inside the Lima VM.
|
||||||
|
|
||||||
|
This avoids 'docker push' entirely — the Lima k3s VM's containerd already
|
||||||
|
trusts the mkcert CA (used for image pulls from Gitea), so ctr push works
|
||||||
|
where docker push would hit a TLS cert verification error on the Mac.
|
||||||
|
"""
|
||||||
|
ok("Pre-seeding image into k3s containerd...")
|
||||||
|
save = subprocess.Popen(["docker", "save", image], stdout=subprocess.PIPE)
|
||||||
|
ctr = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_VM, "--",
|
||||||
|
"sudo", "ctr", "-n", "k8s.io", "images", "import", "-"],
|
||||||
|
stdin=save.stdout,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
save.stdout.close()
|
||||||
|
save.wait()
|
||||||
|
if ctr.returncode != 0:
|
||||||
|
warn(f"containerd import failed:\n{ctr.stderr.decode().strip()}")
|
||||||
|
else:
|
||||||
|
ok("Image pre-seeded.")
|
||||||
|
|
||||||
|
ok("Pushing to Gitea registry (via ctr in Lima VM)...")
|
||||||
|
push = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_VM, "--",
|
||||||
|
"sudo", "ctr", "-n", "k8s.io", "images", "push",
|
||||||
|
"--user", f"{GITEA_ADMIN_USER}:{admin_pass}", image],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if push.returncode != 0:
|
||||||
|
warn(f"ctr push failed (image is pre-seeded; cluster will work without push):\n"
|
||||||
|
f"{push.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
ok(f"Pushed {image}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""):
|
def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""):
|
||||||
"""Patch amd64-only images with an arm64 alias and push to Gitea registry."""
|
"""Patch amd64-only images with an arm64 alias and push to Gitea registry."""
|
||||||
if not domain:
|
if not domain:
|
||||||
@@ -271,21 +309,86 @@ def _trust_registry_in_docker_vm(registry: str):
|
|||||||
ok(f"mkcert CA installed in Docker VM for {registry}.")
|
ok(f"mkcert CA installed in Docker VM for {registry}.")
|
||||||
|
|
||||||
|
|
||||||
def cmd_build(what: str):
|
def cmd_build(what: str, push: bool = False, deploy: bool = False):
|
||||||
"""Build and push an image. Supports 'proxy', 'integration', and 'kratos-admin'."""
|
"""Build an image. Pass push=True to push, deploy=True to also apply + rollout."""
|
||||||
if what == "proxy":
|
if what == "proxy":
|
||||||
_build_proxy()
|
_build_proxy(push=push, deploy=deploy)
|
||||||
elif what == "integration":
|
elif what == "integration":
|
||||||
_build_integration()
|
_build_integration(push=push, deploy=deploy)
|
||||||
elif what == "kratos-admin":
|
elif what == "kratos-admin":
|
||||||
_build_kratos_admin()
|
_build_kratos_admin(push=push, deploy=deploy)
|
||||||
|
elif what == "meet":
|
||||||
|
_build_meet(push=push, deploy=deploy)
|
||||||
|
elif what == "docs-frontend":
|
||||||
|
_build_la_suite_frontend(
|
||||||
|
app="docs-frontend",
|
||||||
|
repo_dir=Path(__file__).resolve().parents[2] / "docs",
|
||||||
|
workspace_rel="src/frontend",
|
||||||
|
app_rel="src/frontend/apps/impress",
|
||||||
|
dockerfile_rel="src/frontend/Dockerfile",
|
||||||
|
image_name="impress-frontend",
|
||||||
|
deployment="docs-frontend",
|
||||||
|
namespace="lasuite",
|
||||||
|
push=push,
|
||||||
|
deploy=deploy,
|
||||||
|
)
|
||||||
|
elif what == "people-frontend":
|
||||||
|
_build_la_suite_frontend(
|
||||||
|
app="people-frontend",
|
||||||
|
repo_dir=Path(__file__).resolve().parents[2] / "people",
|
||||||
|
workspace_rel="src/frontend",
|
||||||
|
app_rel="src/frontend/apps/desk",
|
||||||
|
dockerfile_rel="src/frontend/Dockerfile",
|
||||||
|
image_name="people-frontend",
|
||||||
|
deployment="people-frontend",
|
||||||
|
namespace="lasuite",
|
||||||
|
push=push,
|
||||||
|
deploy=deploy,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
die(f"Unknown build target: {what}")
|
die(f"Unknown build target: {what}")
|
||||||
|
|
||||||
|
|
||||||
def _build_proxy():
|
|
||||||
ip = get_lima_ip()
|
def _seed_image_production(image: str, ssh_host: str, admin_pass: str):
|
||||||
domain = f"{ip}.sslip.io"
|
"""Build linux/amd64 image, pipe into production containerd via SSH, then push to Gitea."""
|
||||||
|
ok("Importing image into production containerd via SSH pipe...")
|
||||||
|
save = subprocess.Popen(["docker", "save", image], stdout=subprocess.PIPE)
|
||||||
|
import_cmd = f"sudo ctr -n k8s.io images import -"
|
||||||
|
ctr = subprocess.run(
|
||||||
|
["ssh", "-p", "2222", "-o", "StrictHostKeyChecking=no", ssh_host, import_cmd],
|
||||||
|
stdin=save.stdout,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
save.stdout.close()
|
||||||
|
save.wait()
|
||||||
|
if ctr.returncode != 0:
|
||||||
|
warn(f"containerd import failed:\n{ctr.stderr.decode().strip()}")
|
||||||
|
return False
|
||||||
|
ok("Image imported into production containerd.")
|
||||||
|
|
||||||
|
ok("Pushing image to Gitea registry (via ctr on production server)...")
|
||||||
|
push = subprocess.run(
|
||||||
|
["ssh", "-p", "2222", "-o", "StrictHostKeyChecking=no", ssh_host,
|
||||||
|
f"sudo ctr -n k8s.io images push --user {GITEA_ADMIN_USER}:{admin_pass} {image}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if push.returncode != 0:
|
||||||
|
warn(f"ctr push failed (image is pre-seeded; cluster will start):\n{push.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
ok(f"Pushed {image} to Gitea registry.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_proxy(push: bool = False, deploy: bool = False):
|
||||||
|
from sunbeam import kube as _kube
|
||||||
|
is_prod = bool(_kube._ssh_host)
|
||||||
|
|
||||||
|
if is_prod:
|
||||||
|
domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt")
|
||||||
|
else:
|
||||||
|
ip = get_lima_ip()
|
||||||
|
domain = f"{ip}.sslip.io"
|
||||||
|
|
||||||
b64 = kube_out("-n", "devtools", "get", "secret",
|
b64 = kube_out("-n", "devtools", "get", "secret",
|
||||||
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
||||||
@@ -302,73 +405,110 @@ def _build_proxy():
|
|||||||
die(f"Proxy source not found at {proxy_dir}")
|
die(f"Proxy source not found at {proxy_dir}")
|
||||||
|
|
||||||
registry = f"src.{domain}"
|
registry = f"src.{domain}"
|
||||||
image = f"{registry}/studio/sunbeam-proxy:latest"
|
image = f"{registry}/studio/proxy:latest"
|
||||||
|
|
||||||
step(f"Building sunbeam-proxy -> {image} ...")
|
step(f"Building sunbeam-proxy -> {image} ...")
|
||||||
|
|
||||||
# Ensure the Lima Docker VM trusts our mkcert CA for this registry.
|
if is_prod:
|
||||||
_trust_registry_in_docker_vm(registry)
|
# Production (x86_64 server): cross-compile on the Mac arm64 host using
|
||||||
|
# x86_64-linux-musl-gcc (brew install filosottile/musl-cross/musl-cross),
|
||||||
# Authenticate Docker with Gitea before the build so --push succeeds.
|
# then package the pre-built static binary into a minimal Docker image.
|
||||||
ok("Logging in to Gitea registry...")
|
# This avoids QEMU x86_64 emulation which crashes rustc (SIGSEGV).
|
||||||
r = subprocess.run(
|
musl_gcc = shutil.which("x86_64-linux-musl-gcc")
|
||||||
["docker", "login", registry,
|
if not musl_gcc:
|
||||||
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
die(
|
||||||
input=admin_pass, text=True, capture_output=True,
|
"x86_64-linux-musl-gcc not found.\n"
|
||||||
)
|
"Install: brew install filosottile/musl-cross/musl-cross"
|
||||||
if r.returncode != 0:
|
)
|
||||||
die(f"docker login failed:\n{r.stderr.strip()}")
|
ok("Cross-compiling sunbeam-proxy for x86_64-musl (native, no QEMU)...")
|
||||||
|
import os as _os
|
||||||
ok("Building image (linux/arm64, push)...")
|
env = dict(_os.environ)
|
||||||
_run(["docker", "buildx", "build",
|
env["CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER"] = musl_gcc
|
||||||
"--platform", "linux/arm64",
|
env["CC_x86_64_unknown_linux_musl"] = musl_gcc
|
||||||
"--push",
|
env["RUSTFLAGS"] = "-C target-feature=+crt-static"
|
||||||
"-t", image,
|
r = subprocess.run(
|
||||||
str(proxy_dir)])
|
["cargo", "build", "--release", "--target", "x86_64-unknown-linux-musl"],
|
||||||
|
cwd=str(proxy_dir),
|
||||||
ok(f"Pushed {image}")
|
env=env,
|
||||||
|
|
||||||
# On single-node clusters, pre-seed the image directly into k3s containerd.
|
|
||||||
# This breaks the circular dependency: when the proxy restarts, Pingora goes
|
|
||||||
# down before the new pod starts, making the Gitea registry (behind Pingora)
|
|
||||||
# unreachable for the image pull. By importing into containerd first,
|
|
||||||
# imagePullPolicy: IfNotPresent means k8s never needs to contact the registry.
|
|
||||||
nodes = kube_out("get", "nodes", "-o=jsonpath={.items[*].metadata.name}").split()
|
|
||||||
if len(nodes) == 1:
|
|
||||||
ok("Single-node cluster: pre-seeding image into k3s containerd...")
|
|
||||||
save = subprocess.Popen(
|
|
||||||
["docker", "save", image],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
)
|
)
|
||||||
ctr = subprocess.run(
|
if r.returncode != 0:
|
||||||
["limactl", "shell", LIMA_VM, "--",
|
die("cargo build failed.")
|
||||||
"sudo", "ctr", "-n", "k8s.io", "images", "import", "-"],
|
binary = proxy_dir / "target" / "x86_64-unknown-linux-musl" / "release" / "sunbeam-proxy"
|
||||||
stdin=save.stdout,
|
|
||||||
capture_output=True,
|
# Download tini static binary for amd64 if not cached
|
||||||
|
import tempfile, urllib.request
|
||||||
|
tmpdir = Path(tempfile.mkdtemp(prefix="proxy-pkg-"))
|
||||||
|
tini_path = tmpdir / "tini"
|
||||||
|
ok("Downloading tini-static-amd64...")
|
||||||
|
urllib.request.urlretrieve(
|
||||||
|
"https://github.com/krallin/tini/releases/download/v0.19.0/tini-static-amd64",
|
||||||
|
str(tini_path),
|
||||||
)
|
)
|
||||||
save.stdout.close()
|
tini_path.chmod(0o755)
|
||||||
save.wait()
|
shutil.copy(str(binary), str(tmpdir / "sunbeam-proxy"))
|
||||||
if ctr.returncode != 0:
|
(tmpdir / "Dockerfile").write_text(
|
||||||
warn(f"containerd import failed (will fall back to registry pull):\n"
|
"FROM cgr.dev/chainguard/static:latest\n"
|
||||||
f"{ctr.stderr.decode().strip()}")
|
"COPY tini /tini\n"
|
||||||
else:
|
"COPY sunbeam-proxy /usr/local/bin/sunbeam-proxy\n"
|
||||||
ok("Image pre-seeded.")
|
"EXPOSE 80 443\n"
|
||||||
|
'ENTRYPOINT ["/tini", "--", "/usr/local/bin/sunbeam-proxy"]\n'
|
||||||
|
)
|
||||||
|
ok("Packaging into Docker image (linux/amd64, pre-built binary)...")
|
||||||
|
_run(["docker", "buildx", "build",
|
||||||
|
"--platform", "linux/amd64",
|
||||||
|
"--provenance=false",
|
||||||
|
"--load",
|
||||||
|
"-t", image,
|
||||||
|
str(tmpdir)])
|
||||||
|
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||||
|
if push:
|
||||||
|
_seed_image_production(image, _kube._ssh_host, admin_pass)
|
||||||
|
else:
|
||||||
|
# Local Lima dev: build linux/arm64 natively.
|
||||||
|
_trust_registry_in_docker_vm(registry)
|
||||||
|
|
||||||
# Apply manifests so the Deployment spec reflects the Gitea image ref.
|
ok("Logging in to Gitea registry...")
|
||||||
from sunbeam.manifests import cmd_apply
|
r = subprocess.run(
|
||||||
cmd_apply()
|
["limactl", "shell", LIMA_DOCKER_VM, "--",
|
||||||
|
"docker", "login", registry,
|
||||||
|
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
||||||
|
input=admin_pass, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(f"docker login failed:\n{r.stderr.strip()}")
|
||||||
|
|
||||||
# Roll the pingora pod.
|
ok("Building image (linux/arm64)...")
|
||||||
ok("Rolling pingora deployment...")
|
_run(["docker", "buildx", "build",
|
||||||
kube("rollout", "restart", "deployment/pingora", "-n", "ingress")
|
"--platform", "linux/arm64",
|
||||||
kube("rollout", "status", "deployment/pingora", "-n", "ingress",
|
"--provenance=false",
|
||||||
"--timeout=120s")
|
"--load",
|
||||||
ok("Pingora redeployed.")
|
"-t", image,
|
||||||
|
str(proxy_dir)])
|
||||||
|
|
||||||
|
if push:
|
||||||
|
ok("Pushing image...")
|
||||||
|
_run(["docker", "push", image])
|
||||||
|
_seed_and_push(image, admin_pass)
|
||||||
|
|
||||||
|
if deploy:
|
||||||
|
from sunbeam.manifests import cmd_apply
|
||||||
|
cmd_apply(env="production" if is_prod else "local", domain=domain)
|
||||||
|
ok("Rolling pingora deployment...")
|
||||||
|
kube("rollout", "restart", "deployment/pingora", "-n", "ingress")
|
||||||
|
kube("rollout", "status", "deployment/pingora", "-n", "ingress",
|
||||||
|
"--timeout=120s")
|
||||||
|
ok("Pingora redeployed.")
|
||||||
|
|
||||||
|
|
||||||
def _build_integration():
|
def _build_integration(push: bool = False, deploy: bool = False):
|
||||||
ip = get_lima_ip()
|
from sunbeam import kube as _kube
|
||||||
domain = f"{ip}.sslip.io"
|
is_prod = bool(_kube._ssh_host)
|
||||||
|
|
||||||
|
if is_prod:
|
||||||
|
domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt")
|
||||||
|
else:
|
||||||
|
ip = get_lima_ip()
|
||||||
|
domain = f"{ip}.sslip.io"
|
||||||
|
|
||||||
b64 = kube_out("-n", "devtools", "get", "secret",
|
b64 = kube_out("-n", "devtools", "get", "secret",
|
||||||
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
||||||
@@ -397,31 +537,21 @@ def _build_integration():
|
|||||||
|
|
||||||
step(f"Building integration -> {image} ...")
|
step(f"Building integration -> {image} ...")
|
||||||
|
|
||||||
_trust_registry_in_docker_vm(registry)
|
platform = "linux/amd64" if is_prod else "linux/arm64"
|
||||||
|
|
||||||
ok("Logging in to Gitea registry...")
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "login", registry,
|
|
||||||
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
|
||||||
input=admin_pass, text=True, capture_output=True,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
die(f"docker login failed:\n{r.stderr.strip()}")
|
|
||||||
|
|
||||||
ok("Building image (linux/arm64, push)...")
|
|
||||||
# --file points to integration-service/Dockerfile; context is sunbeam/ root.
|
# --file points to integration-service/Dockerfile; context is sunbeam/ root.
|
||||||
# Docker resolves .dockerignore relative to the build context root, but since
|
# Copy .dockerignore to context root temporarily if needed.
|
||||||
# --file is outside the context root we provide it explicitly via env or flag.
|
|
||||||
# Workaround: copy .dockerignore to sunbeam/ root temporarily, then remove.
|
|
||||||
root_ignore = sunbeam_dir / ".dockerignore"
|
root_ignore = sunbeam_dir / ".dockerignore"
|
||||||
copied_ignore = False
|
copied_ignore = False
|
||||||
if not root_ignore.exists():
|
if not root_ignore.exists() and dockerignore.exists():
|
||||||
shutil.copy(str(dockerignore), str(root_ignore))
|
shutil.copy(str(dockerignore), str(root_ignore))
|
||||||
copied_ignore = True
|
copied_ignore = True
|
||||||
try:
|
try:
|
||||||
|
ok(f"Building image ({platform})...")
|
||||||
_run(["docker", "buildx", "build",
|
_run(["docker", "buildx", "build",
|
||||||
"--platform", "linux/arm64",
|
"--platform", platform,
|
||||||
"--push",
|
"--provenance=false",
|
||||||
|
"--load",
|
||||||
"-f", str(dockerfile),
|
"-f", str(dockerfile),
|
||||||
"-t", image,
|
"-t", image,
|
||||||
str(sunbeam_dir)])
|
str(sunbeam_dir)])
|
||||||
@@ -429,43 +559,166 @@ def _build_integration():
|
|||||||
if copied_ignore and root_ignore.exists():
|
if copied_ignore and root_ignore.exists():
|
||||||
root_ignore.unlink()
|
root_ignore.unlink()
|
||||||
|
|
||||||
ok(f"Pushed {image}")
|
if push:
|
||||||
|
if is_prod:
|
||||||
# Pre-seed into k3s containerd (same pattern as other custom images).
|
_seed_image_production(image, _kube._ssh_host, admin_pass)
|
||||||
nodes = kube_out("get", "nodes", "-o=jsonpath={.items[*].metadata.name}").split()
|
|
||||||
if len(nodes) == 1:
|
|
||||||
ok("Single-node cluster: pre-seeding image into k3s containerd...")
|
|
||||||
save = subprocess.Popen(
|
|
||||||
["docker", "save", image],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
ctr = subprocess.run(
|
|
||||||
["limactl", "shell", LIMA_VM, "--",
|
|
||||||
"sudo", "ctr", "-n", "k8s.io", "images", "import", "-"],
|
|
||||||
stdin=save.stdout,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
save.stdout.close()
|
|
||||||
save.wait()
|
|
||||||
if ctr.returncode != 0:
|
|
||||||
warn(f"containerd import failed (will fall back to registry pull):\n"
|
|
||||||
f"{ctr.stderr.decode().strip()}")
|
|
||||||
else:
|
else:
|
||||||
ok("Image pre-seeded.")
|
_trust_registry_in_docker_vm(registry)
|
||||||
|
ok("Logging in to Gitea registry...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_DOCKER_VM, "--",
|
||||||
|
"docker", "login", registry,
|
||||||
|
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
||||||
|
input=admin_pass, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(f"docker login failed:\n{r.stderr.strip()}")
|
||||||
|
_seed_and_push(image, admin_pass)
|
||||||
|
|
||||||
from sunbeam.manifests import cmd_apply
|
if deploy:
|
||||||
cmd_apply()
|
from sunbeam.manifests import cmd_apply
|
||||||
|
cmd_apply(env="production" if is_prod else "local", domain=domain)
|
||||||
ok("Rolling integration deployment...")
|
ok("Rolling integration deployment...")
|
||||||
kube("rollout", "restart", "deployment/integration", "-n", "lasuite")
|
kube("rollout", "restart", "deployment/integration", "-n", "lasuite")
|
||||||
kube("rollout", "status", "deployment/integration", "-n", "lasuite",
|
kube("rollout", "status", "deployment/integration", "-n", "lasuite",
|
||||||
"--timeout=120s")
|
"--timeout=120s")
|
||||||
ok("Integration redeployed.")
|
ok("Integration redeployed.")
|
||||||
|
|
||||||
|
|
||||||
def _build_kratos_admin():
|
def _build_kratos_admin(push: bool = False, deploy: bool = False):
|
||||||
ip = get_lima_ip()
|
from sunbeam import kube as _kube
|
||||||
domain = f"{ip}.sslip.io"
|
|
||||||
|
is_prod = bool(_kube._ssh_host)
|
||||||
|
|
||||||
|
b64 = kube_out("-n", "devtools", "get", "secret",
|
||||||
|
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
||||||
|
if not b64:
|
||||||
|
die("gitea-admin-credentials secret not found -- run seed first.")
|
||||||
|
admin_pass = base64.b64decode(b64).decode()
|
||||||
|
|
||||||
|
# kratos-admin source
|
||||||
|
kratos_admin_dir = Path(__file__).resolve().parents[2] / "kratos-admin"
|
||||||
|
if not kratos_admin_dir.is_dir():
|
||||||
|
die(f"kratos-admin source not found at {kratos_admin_dir}")
|
||||||
|
|
||||||
|
if is_prod:
|
||||||
|
domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt")
|
||||||
|
registry = f"src.{domain}"
|
||||||
|
image = f"{registry}/studio/kratos-admin-ui:latest"
|
||||||
|
ssh_host = _kube._ssh_host
|
||||||
|
|
||||||
|
step(f"Building kratos-admin-ui (linux/amd64, native cross-compile) -> {image} ...")
|
||||||
|
|
||||||
|
if not shutil.which("deno"):
|
||||||
|
die("deno not found — install Deno: https://deno.land/")
|
||||||
|
if not shutil.which("npm"):
|
||||||
|
die("npm not found — install Node.js")
|
||||||
|
|
||||||
|
ok("Building UI assets (npm run build)...")
|
||||||
|
_run(["npm", "run", "build"], cwd=str(kratos_admin_dir / "ui"))
|
||||||
|
|
||||||
|
ok("Cross-compiling Deno binary for x86_64-linux-gnu...")
|
||||||
|
_run([
|
||||||
|
"deno", "compile",
|
||||||
|
"--target", "x86_64-unknown-linux-gnu",
|
||||||
|
"--allow-net", "--allow-read", "--allow-env",
|
||||||
|
"--include", "ui/dist",
|
||||||
|
"-o", "kratos-admin-x86_64",
|
||||||
|
"main.ts",
|
||||||
|
], cwd=str(kratos_admin_dir))
|
||||||
|
|
||||||
|
bin_path = kratos_admin_dir / "kratos-admin-x86_64"
|
||||||
|
if not bin_path.exists():
|
||||||
|
die("Deno cross-compilation produced no binary")
|
||||||
|
|
||||||
|
# Build minimal Docker image
|
||||||
|
pkg_dir = Path("/tmp/kratos-admin-pkg")
|
||||||
|
pkg_dir.mkdir(exist_ok=True)
|
||||||
|
import shutil as _sh
|
||||||
|
_sh.copy2(str(bin_path), str(pkg_dir / "kratos-admin"))
|
||||||
|
# Copy ui/dist for serveStatic (binary has it embedded but keep external copy for fallback)
|
||||||
|
(pkg_dir / "dockerfile").write_text(
|
||||||
|
"FROM gcr.io/distroless/cc-debian12:nonroot\n"
|
||||||
|
"WORKDIR /app\n"
|
||||||
|
"COPY kratos-admin ./\n"
|
||||||
|
"EXPOSE 3000\n"
|
||||||
|
'ENTRYPOINT ["/app/kratos-admin"]\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
ok("Building Docker image...")
|
||||||
|
_run([
|
||||||
|
"docker", "buildx", "build",
|
||||||
|
"--platform", "linux/amd64",
|
||||||
|
"--provenance=false",
|
||||||
|
"--load",
|
||||||
|
"-f", str(pkg_dir / "dockerfile"),
|
||||||
|
"-t", image,
|
||||||
|
str(pkg_dir),
|
||||||
|
])
|
||||||
|
|
||||||
|
if push:
|
||||||
|
_seed_image_production(image, ssh_host, admin_pass)
|
||||||
|
|
||||||
|
if deploy:
|
||||||
|
from sunbeam.manifests import cmd_apply
|
||||||
|
cmd_apply(env="production", domain=domain)
|
||||||
|
|
||||||
|
else:
|
||||||
|
ip = get_lima_ip()
|
||||||
|
domain = f"{ip}.sslip.io"
|
||||||
|
registry = f"src.{domain}"
|
||||||
|
image = f"{registry}/studio/kratos-admin-ui:latest"
|
||||||
|
|
||||||
|
if not shutil.which("docker"):
|
||||||
|
die("docker not found -- is the Lima docker VM running?")
|
||||||
|
|
||||||
|
step(f"Building kratos-admin-ui -> {image} ...")
|
||||||
|
|
||||||
|
_trust_registry_in_docker_vm(registry)
|
||||||
|
|
||||||
|
ok("Logging in to Gitea registry...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_DOCKER_VM, "--",
|
||||||
|
"docker", "login", registry,
|
||||||
|
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
||||||
|
input=admin_pass, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(f"docker login failed:\n{r.stderr.strip()}")
|
||||||
|
|
||||||
|
ok("Building image (linux/arm64)...")
|
||||||
|
_run(["docker", "buildx", "build",
|
||||||
|
"--platform", "linux/arm64",
|
||||||
|
"--provenance=false",
|
||||||
|
"--load",
|
||||||
|
"-t", image,
|
||||||
|
str(kratos_admin_dir)])
|
||||||
|
|
||||||
|
if push:
|
||||||
|
_seed_and_push(image, admin_pass)
|
||||||
|
|
||||||
|
if deploy:
|
||||||
|
from sunbeam.manifests import cmd_apply
|
||||||
|
cmd_apply()
|
||||||
|
|
||||||
|
if deploy:
|
||||||
|
ok("Rolling kratos-admin-ui deployment...")
|
||||||
|
kube("rollout", "restart", "deployment/kratos-admin-ui", "-n", "ory")
|
||||||
|
kube("rollout", "status", "deployment/kratos-admin-ui", "-n", "ory",
|
||||||
|
"--timeout=120s")
|
||||||
|
ok("kratos-admin-ui redeployed.")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_meet(push: bool = False, deploy: bool = False):
|
||||||
|
"""Build meet-backend and meet-frontend images from source."""
|
||||||
|
from sunbeam import kube as _kube
|
||||||
|
is_prod = bool(_kube._ssh_host)
|
||||||
|
|
||||||
|
if is_prod:
|
||||||
|
domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt")
|
||||||
|
else:
|
||||||
|
ip = get_lima_ip()
|
||||||
|
domain = f"{ip}.sslip.io"
|
||||||
|
|
||||||
b64 = kube_out("-n", "devtools", "get", "secret",
|
b64 = kube_out("-n", "devtools", "get", "secret",
|
||||||
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
||||||
@@ -476,62 +729,162 @@ def _build_kratos_admin():
|
|||||||
if not shutil.which("docker"):
|
if not shutil.which("docker"):
|
||||||
die("docker not found -- is the Lima docker VM running?")
|
die("docker not found -- is the Lima docker VM running?")
|
||||||
|
|
||||||
# kratos-admin source
|
meet_dir = Path(__file__).resolve().parents[2] / "meet"
|
||||||
kratos_admin_dir = Path(__file__).resolve().parents[2] / "kratos-admin"
|
if not meet_dir.is_dir():
|
||||||
if not kratos_admin_dir.is_dir():
|
die(f"meet source not found at {meet_dir}")
|
||||||
die(f"kratos-admin source not found at {kratos_admin_dir}")
|
|
||||||
|
|
||||||
registry = f"src.{domain}"
|
registry = f"src.{domain}"
|
||||||
image = f"{registry}/studio/kratos-admin-ui:latest"
|
backend_image = f"{registry}/studio/meet-backend:latest"
|
||||||
|
frontend_image = f"{registry}/studio/meet-frontend:latest"
|
||||||
|
platform = "linux/amd64" if is_prod else "linux/arm64"
|
||||||
|
|
||||||
step(f"Building kratos-admin-ui -> {image} ...")
|
if not is_prod:
|
||||||
|
_trust_registry_in_docker_vm(registry)
|
||||||
|
ok("Logging in to Gitea registry...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_DOCKER_VM, "--",
|
||||||
|
"docker", "login", registry,
|
||||||
|
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
||||||
|
input=admin_pass, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(f"docker login failed:\n{r.stderr.strip()}")
|
||||||
|
|
||||||
_trust_registry_in_docker_vm(registry)
|
step(f"Building meet-backend -> {backend_image} ...")
|
||||||
|
ok(f"Building image ({platform}, backend-production target)...")
|
||||||
|
_run(["docker", "buildx", "build",
|
||||||
|
"--platform", platform,
|
||||||
|
"--provenance=false",
|
||||||
|
"--target", "backend-production",
|
||||||
|
"--load",
|
||||||
|
"-t", backend_image,
|
||||||
|
str(meet_dir)])
|
||||||
|
|
||||||
ok("Logging in to Gitea registry...")
|
if push:
|
||||||
r = subprocess.run(
|
if is_prod:
|
||||||
["docker", "login", registry,
|
_seed_image_production(backend_image, _kube._ssh_host, admin_pass)
|
||||||
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
else:
|
||||||
input=admin_pass, text=True, capture_output=True,
|
_seed_and_push(backend_image, admin_pass)
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
die(f"docker login failed:\n{r.stderr.strip()}")
|
|
||||||
|
|
||||||
ok("Building image (linux/arm64, push)...")
|
step(f"Building meet-frontend -> {frontend_image} ...")
|
||||||
|
frontend_dockerfile = meet_dir / "src" / "frontend" / "Dockerfile"
|
||||||
|
if not frontend_dockerfile.exists():
|
||||||
|
die(f"meet frontend Dockerfile not found at {frontend_dockerfile}")
|
||||||
|
|
||||||
|
ok(f"Building image ({platform}, frontend-production target)...")
|
||||||
|
_run(["docker", "buildx", "build",
|
||||||
|
"--platform", platform,
|
||||||
|
"--provenance=false",
|
||||||
|
"--target", "frontend-production",
|
||||||
|
"--build-arg", "VITE_API_BASE_URL=",
|
||||||
|
"--load",
|
||||||
|
"-f", str(frontend_dockerfile),
|
||||||
|
"-t", frontend_image,
|
||||||
|
str(meet_dir)])
|
||||||
|
|
||||||
|
if push:
|
||||||
|
if is_prod:
|
||||||
|
_seed_image_production(frontend_image, _kube._ssh_host, admin_pass)
|
||||||
|
else:
|
||||||
|
_seed_and_push(frontend_image, admin_pass)
|
||||||
|
|
||||||
|
if deploy:
|
||||||
|
from sunbeam.manifests import cmd_apply
|
||||||
|
cmd_apply(env="production" if is_prod else "local", domain=domain)
|
||||||
|
for deployment in ("meet-backend", "meet-celery-worker", "meet-frontend"):
|
||||||
|
ok(f"Rolling {deployment} deployment...")
|
||||||
|
kube("rollout", "restart", f"deployment/{deployment}", "-n", "lasuite")
|
||||||
|
for deployment in ("meet-backend", "meet-celery-worker", "meet-frontend"):
|
||||||
|
kube("rollout", "status", f"deployment/{deployment}", "-n", "lasuite",
|
||||||
|
"--timeout=180s")
|
||||||
|
ok("Meet redeployed.")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_la_suite_frontend(
|
||||||
|
app: str,
|
||||||
|
repo_dir: Path,
|
||||||
|
workspace_rel: str,
|
||||||
|
app_rel: str,
|
||||||
|
dockerfile_rel: str,
|
||||||
|
image_name: str,
|
||||||
|
deployment: str,
|
||||||
|
namespace: str,
|
||||||
|
push: bool = False,
|
||||||
|
deploy: bool = False,
|
||||||
|
):
|
||||||
|
"""Build a La Suite frontend image from source and push to the Gitea registry.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. yarn install in the workspace root — updates yarn.lock for new packages.
|
||||||
|
2. yarn build-theme in the app dir — regenerates cunningham token CSS/TS.
|
||||||
|
3. docker buildx build --target frontend-production → push.
|
||||||
|
4. Pre-seed into k3s containerd.
|
||||||
|
5. sunbeam apply + rollout restart.
|
||||||
|
"""
|
||||||
|
if not shutil.which("yarn"):
|
||||||
|
die("yarn not found on PATH — install Node.js + yarn first (nvm use 22).")
|
||||||
|
if not shutil.which("docker"):
|
||||||
|
die("docker not found — is the Lima docker VM running?")
|
||||||
|
|
||||||
|
ip = get_lima_ip()
|
||||||
|
domain = f"{ip}.sslip.io"
|
||||||
|
|
||||||
|
b64 = kube_out("-n", "devtools", "get", "secret",
|
||||||
|
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
|
||||||
|
if not b64:
|
||||||
|
die("gitea-admin-credentials secret not found — run seed first.")
|
||||||
|
admin_pass = base64.b64decode(b64).decode()
|
||||||
|
|
||||||
|
workspace_dir = repo_dir / workspace_rel
|
||||||
|
app_dir = repo_dir / app_rel
|
||||||
|
dockerfile = repo_dir / dockerfile_rel
|
||||||
|
|
||||||
|
if not repo_dir.is_dir():
|
||||||
|
die(f"{app} source not found at {repo_dir}")
|
||||||
|
if not dockerfile.exists():
|
||||||
|
die(f"Dockerfile not found at {dockerfile}")
|
||||||
|
|
||||||
|
registry = f"src.{domain}"
|
||||||
|
image = f"{registry}/studio/{image_name}:latest"
|
||||||
|
|
||||||
|
step(f"Building {app} -> {image} ...")
|
||||||
|
|
||||||
|
ok("Updating yarn.lock (yarn install in workspace)...")
|
||||||
|
_run(["yarn", "install"], cwd=str(workspace_dir))
|
||||||
|
|
||||||
|
ok("Regenerating cunningham design tokens (yarn build-theme)...")
|
||||||
|
_run(["yarn", "build-theme"], cwd=str(app_dir))
|
||||||
|
|
||||||
|
if push:
|
||||||
|
_trust_registry_in_docker_vm(registry)
|
||||||
|
ok("Logging in to Gitea registry...")
|
||||||
|
r = subprocess.run(
|
||||||
|
["limactl", "shell", LIMA_DOCKER_VM, "--",
|
||||||
|
"docker", "login", registry,
|
||||||
|
"--username", GITEA_ADMIN_USER, "--password-stdin"],
|
||||||
|
input=admin_pass, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die(f"docker login failed:\n{r.stderr.strip()}")
|
||||||
|
|
||||||
|
ok("Building image (linux/arm64, frontend-production target)...")
|
||||||
_run(["docker", "buildx", "build",
|
_run(["docker", "buildx", "build",
|
||||||
"--platform", "linux/arm64",
|
"--platform", "linux/arm64",
|
||||||
"--push",
|
"--provenance=false",
|
||||||
|
"--target", "frontend-production",
|
||||||
|
"--load",
|
||||||
|
"-f", str(dockerfile),
|
||||||
"-t", image,
|
"-t", image,
|
||||||
str(kratos_admin_dir)])
|
str(repo_dir)])
|
||||||
|
|
||||||
ok(f"Pushed {image}")
|
if push:
|
||||||
|
_seed_and_push(image, admin_pass)
|
||||||
|
|
||||||
# Pre-seed into k3s containerd (same pattern as proxy)
|
if deploy:
|
||||||
nodes = kube_out("get", "nodes", "-o=jsonpath={.items[*].metadata.name}").split()
|
from sunbeam.manifests import cmd_apply
|
||||||
if len(nodes) == 1:
|
cmd_apply()
|
||||||
ok("Single-node cluster: pre-seeding image into k3s containerd...")
|
ok(f"Rolling {deployment} deployment...")
|
||||||
save = subprocess.Popen(
|
kube("rollout", "restart", f"deployment/{deployment}", "-n", namespace)
|
||||||
["docker", "save", image],
|
kube("rollout", "status", f"deployment/{deployment}", "-n", namespace,
|
||||||
stdout=subprocess.PIPE,
|
"--timeout=180s")
|
||||||
)
|
ok(f"{deployment} redeployed.")
|
||||||
ctr = subprocess.run(
|
|
||||||
["limactl", "shell", LIMA_VM, "--",
|
|
||||||
"sudo", "ctr", "-n", "k8s.io", "images", "import", "-"],
|
|
||||||
stdin=save.stdout,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
save.stdout.close()
|
|
||||||
save.wait()
|
|
||||||
if ctr.returncode != 0:
|
|
||||||
warn(f"containerd import failed:\n{ctr.stderr.decode().strip()}")
|
|
||||||
else:
|
|
||||||
ok("Image pre-seeded.")
|
|
||||||
|
|
||||||
from sunbeam.manifests import cmd_apply
|
|
||||||
cmd_apply()
|
|
||||||
|
|
||||||
ok("Rolling kratos-admin-ui deployment...")
|
|
||||||
kube("rollout", "restart", "deployment/kratos-admin-ui", "-n", "ory")
|
|
||||||
kube("rollout", "status", "deployment/kratos-admin-ui", "-n", "ory",
|
|
||||||
"--timeout=120s")
|
|
||||||
ok("kratos-admin-ui redeployed.")
|
|
||||||
|
|||||||
106
sunbeam/kube.py
106
sunbeam/kube.py
@@ -1,9 +1,56 @@
|
|||||||
"""Kubernetes interface — kubectl/kustomize wrappers, domain substitution, target parsing."""
|
"""Kubernetes interface — kubectl/kustomize wrappers, domain substitution, target parsing."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sunbeam.tools import run_tool, CACHE_DIR
|
from sunbeam.tools import run_tool, CACHE_DIR
|
||||||
from sunbeam.output import die
|
from sunbeam.output import die, ok
|
||||||
|
|
||||||
|
# Active kubectl context. Set once at startup via set_context().
|
||||||
|
# Defaults to "sunbeam" (Lima VM) for local dev.
|
||||||
|
_context: str = "sunbeam"
|
||||||
|
|
||||||
|
# SSH host for production tunnel. Set alongside context for production env.
|
||||||
|
_ssh_host: str = ""
|
||||||
|
_tunnel_proc: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_context(ctx: str, ssh_host: str = "") -> None:
|
||||||
|
global _context, _ssh_host
|
||||||
|
_context = ctx
|
||||||
|
_ssh_host = ssh_host
|
||||||
|
|
||||||
|
|
||||||
|
def context_arg() -> str:
|
||||||
|
"""Return '--context=<active>' for use in subprocess command lists."""
|
||||||
|
return f"--context={_context}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tunnel() -> None:
|
||||||
|
"""Open SSH tunnel to localhost:16443 → remote:6443 for production if needed."""
|
||||||
|
global _tunnel_proc
|
||||||
|
if not _ssh_host:
|
||||||
|
return
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", 16443), timeout=0.5):
|
||||||
|
return # already open
|
||||||
|
except (ConnectionRefusedError, TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
ok(f"Opening SSH tunnel to {_ssh_host}...")
|
||||||
|
_tunnel_proc = subprocess.Popen(
|
||||||
|
["ssh", "-p", "2222", "-L", "16443:127.0.0.1:6443", "-N", "-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=no", _ssh_host],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", 16443), timeout=0.5):
|
||||||
|
return
|
||||||
|
except (ConnectionRefusedError, TimeoutError, OSError):
|
||||||
|
time.sleep(0.5)
|
||||||
|
die(f"SSH tunnel to {_ssh_host} did not open in time")
|
||||||
|
|
||||||
|
|
||||||
def parse_target(s: str | None) -> tuple[str | None, str | None]:
|
def parse_target(s: str | None) -> tuple[str | None, str | None]:
|
||||||
@@ -42,23 +89,26 @@ def get_lima_ip() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def kube(*args, input=None, check=True) -> subprocess.CompletedProcess:
|
def kube(*args, input=None, check=True) -> subprocess.CompletedProcess:
|
||||||
"""Run kubectl with --context=sunbeam."""
|
"""Run kubectl against the active context, opening SSH tunnel if needed."""
|
||||||
|
ensure_tunnel()
|
||||||
text = not isinstance(input, bytes)
|
text = not isinstance(input, bytes)
|
||||||
return run_tool("kubectl", "--context=sunbeam", *args,
|
return run_tool("kubectl", context_arg(), *args,
|
||||||
input=input, text=text, check=check,
|
input=input, text=text, check=check,
|
||||||
capture_output=False)
|
capture_output=False)
|
||||||
|
|
||||||
|
|
||||||
def kube_out(*args) -> str:
|
def kube_out(*args) -> str:
|
||||||
"""Run kubectl and return stdout (empty string on failure)."""
|
"""Run kubectl and return stdout (empty string on failure)."""
|
||||||
r = run_tool("kubectl", "--context=sunbeam", *args,
|
ensure_tunnel()
|
||||||
|
r = run_tool("kubectl", context_arg(), *args,
|
||||||
capture_output=True, text=True, check=False)
|
capture_output=True, text=True, check=False)
|
||||||
return r.stdout.strip() if r.returncode == 0 else ""
|
return r.stdout.strip() if r.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
def kube_ok(*args) -> bool:
|
def kube_ok(*args) -> bool:
|
||||||
"""Return True if kubectl command exits 0."""
|
"""Return True if kubectl command exits 0."""
|
||||||
r = run_tool("kubectl", "--context=sunbeam", *args,
|
ensure_tunnel()
|
||||||
|
r = run_tool("kubectl", context_arg(), *args,
|
||||||
capture_output=True, check=False)
|
capture_output=True, check=False)
|
||||||
return r.returncode == 0
|
return r.returncode == 0
|
||||||
|
|
||||||
@@ -95,7 +145,7 @@ def create_secret(ns: str, name: str, **literals) -> None:
|
|||||||
|
|
||||||
def kube_exec(ns: str, pod: str, *cmd: str, container: str | None = None) -> tuple[int, str]:
|
def kube_exec(ns: str, pod: str, *cmd: str, container: str | None = None) -> tuple[int, str]:
|
||||||
"""Run a command inside a pod. Returns (returncode, stdout)."""
|
"""Run a command inside a pod. Returns (returncode, stdout)."""
|
||||||
args = ["kubectl", "--context=sunbeam", "exec", "-n", ns, pod]
|
args = ["kubectl", context_arg(), "exec", "-n", ns, pod]
|
||||||
if container:
|
if container:
|
||||||
args += ["-c", container]
|
args += ["-c", container]
|
||||||
args += ["--", *cmd]
|
args += ["--", *cmd]
|
||||||
@@ -106,22 +156,42 @@ def kube_exec(ns: str, pod: str, *cmd: str, container: str | None = None) -> tup
|
|||||||
def get_domain() -> str:
|
def get_domain() -> str:
|
||||||
"""Discover the active domain from cluster state.
|
"""Discover the active domain from cluster state.
|
||||||
|
|
||||||
Reads a known substituted configmap value; falls back to the Lima VM IP.
|
Tries multiple reliable anchors; falls back to the Lima VM IP for local dev.
|
||||||
"""
|
"""
|
||||||
raw = kube_out("get", "configmap", "lasuite-oidc-provider", "-n", "lasuite",
|
import base64
|
||||||
"-o=jsonpath={.data.OIDC_OP_JWKS_ENDPOINT}")
|
|
||||||
if raw and "https://auth." in raw:
|
# 1. Gitea inline-config secret: server section contains DOMAIN=src.<domain>
|
||||||
# e.g. "https://auth.192.168.105.2.sslip.io/.well-known/jwks.json"
|
# Works in both local and production because DOMAIN_SUFFIX is substituted
|
||||||
return raw.split("https://auth.")[1].split("/")[0]
|
# into gitea-values.yaml at apply time.
|
||||||
|
raw = kube_out("get", "secret", "gitea-inline-config", "-n", "devtools",
|
||||||
|
"-o=jsonpath={.data.server}", "--ignore-not-found")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
server_ini = base64.b64decode(raw).decode()
|
||||||
|
for line in server_ini.splitlines():
|
||||||
|
if line.startswith("DOMAIN=src."):
|
||||||
|
# e.g. "DOMAIN=src.sunbeam.pt"
|
||||||
|
return line.split("DOMAIN=src.", 1)[1].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Fallback: lasuite-oidc-provider configmap (works if La Suite is deployed)
|
||||||
|
raw2 = kube_out("get", "configmap", "lasuite-oidc-provider", "-n", "lasuite",
|
||||||
|
"-o=jsonpath={.data.OIDC_OP_JWKS_ENDPOINT}", "--ignore-not-found")
|
||||||
|
if raw2 and "https://auth." in raw2:
|
||||||
|
return raw2.split("https://auth.")[1].split("/")[0]
|
||||||
|
|
||||||
|
# 3. Local dev fallback
|
||||||
ip = get_lima_ip()
|
ip = get_lima_ip()
|
||||||
return f"{ip}.sslip.io"
|
return f"{ip}.sslip.io"
|
||||||
|
|
||||||
|
|
||||||
def cmd_k8s(kubectl_args: list[str]) -> int:
|
def cmd_k8s(kubectl_args: list[str]) -> int:
|
||||||
"""Transparent kubectl --context=sunbeam passthrough. Returns kubectl's exit code."""
|
"""Transparent kubectl passthrough for the active context."""
|
||||||
|
ensure_tunnel()
|
||||||
from sunbeam.tools import ensure_tool
|
from sunbeam.tools import ensure_tool
|
||||||
bin_path = ensure_tool("kubectl")
|
bin_path = ensure_tool("kubectl")
|
||||||
r = subprocess.run([str(bin_path), "--context=sunbeam", *kubectl_args])
|
r = subprocess.run([str(bin_path), context_arg(), *kubectl_args])
|
||||||
return r.returncode
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
@@ -149,19 +219,21 @@ def cmd_bao(bao_args: list[str]) -> int:
|
|||||||
|
|
||||||
cmd_str = "VAULT_TOKEN=" + root_token + " bao " + " ".join(bao_args)
|
cmd_str = "VAULT_TOKEN=" + root_token + " bao " + " ".join(bao_args)
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["kubectl", "--context=sunbeam", "-n", "data", "exec", ob_pod,
|
["kubectl", context_arg(), "-n", "data", "exec", ob_pod,
|
||||||
"-c", "openbao", "--", "sh", "-c", cmd_str]
|
"-c", "openbao", "--", "sh", "-c", cmd_str]
|
||||||
)
|
)
|
||||||
return r.returncode
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
def kustomize_build(overlay: Path, domain: str) -> str:
|
def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
|
||||||
"""Run kustomize build --enable-helm and apply domain substitution."""
|
"""Run kustomize build --enable-helm and apply domain/email substitution."""
|
||||||
r = run_tool(
|
r = run_tool(
|
||||||
"kustomize", "build", "--enable-helm", str(overlay),
|
"kustomize", "build", "--enable-helm", str(overlay),
|
||||||
capture_output=True, text=True, check=True,
|
capture_output=True, text=True, check=True,
|
||||||
)
|
)
|
||||||
text = r.stdout
|
text = r.stdout
|
||||||
text = domain_replace(text, domain)
|
text = domain_replace(text, domain)
|
||||||
|
if email:
|
||||||
|
text = text.replace("ACME_EMAIL", email)
|
||||||
text = text.replace("\n annotations: null", "")
|
text = text.replace("\n annotations: null", "")
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import urllib.request
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, ensure_ns, create_secret, get_domain
|
from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, ensure_ns, create_secret, get_domain, context_arg
|
||||||
from sunbeam.output import step, ok, warn, die
|
from sunbeam.output import step, ok, warn, die
|
||||||
|
|
||||||
ADMIN_USERNAME = "estudio-admin"
|
ADMIN_USERNAME = "estudio-admin"
|
||||||
@@ -21,7 +21,6 @@ PG_USERS = [
|
|||||||
"docs", "meet", "drive", "messages", "conversations",
|
"docs", "meet", "drive", "messages", "conversations",
|
||||||
"people", "find",
|
"people", "find",
|
||||||
]
|
]
|
||||||
K8S_CTX = ["--context=sunbeam"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -49,7 +48,7 @@ def _seed_openbao() -> dict:
|
|||||||
|
|
||||||
def bao(cmd):
|
def bao(cmd):
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "data", "exec", ob_pod, "-c", "openbao",
|
["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao",
|
||||||
"--", "sh", "-c", cmd],
|
"--", "sh", "-c", cmd],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
@@ -174,6 +173,27 @@ def _seed_openbao() -> dict:
|
|||||||
**{"django-secret-key": rand,
|
**{"django-secret-key": rand,
|
||||||
"collaboration-secret": rand})
|
"collaboration-secret": rand})
|
||||||
|
|
||||||
|
meet = get_or_create("meet",
|
||||||
|
**{"django-secret-key": rand,
|
||||||
|
"application-jwt-secret-key": rand})
|
||||||
|
|
||||||
|
# Scaleway S3 credentials for CNPG barman backups.
|
||||||
|
# Read from `scw config` at seed time; falls back to empty string (operator must fill in).
|
||||||
|
def _scw_config(key):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["scw", "config", "get", key],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
return r.stdout.strip() if r.returncode == 0 else ""
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
grafana = get_or_create("grafana",
|
||||||
|
**{"admin-password": rand})
|
||||||
|
|
||||||
|
scaleway_s3 = get_or_create("scaleway-s3",
|
||||||
|
**{"access-key-id": lambda: _scw_config("access-key"),
|
||||||
|
"secret-access-key": lambda: _scw_config("secret-key")})
|
||||||
|
|
||||||
# Write all secrets to KV (idempotent -- puts same values back)
|
# Write all secrets to KV (idempotent -- puts same values back)
|
||||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
||||||
f"bao kv put secret/hydra system-secret=\"{hydra['system-secret']}\" cookie-secret=\"{hydra['cookie-secret']}\" pairwise-salt=\"{hydra['pairwise-salt']}\" && "
|
f"bao kv put secret/hydra system-secret=\"{hydra['system-secret']}\" cookie-secret=\"{hydra['cookie-secret']}\" pairwise-salt=\"{hydra['pairwise-salt']}\" && "
|
||||||
@@ -185,7 +205,10 @@ def _seed_openbao() -> dict:
|
|||||||
f"bao kv put secret/people django-secret-key=\"{people['django-secret-key']}\" && "
|
f"bao kv put secret/people django-secret-key=\"{people['django-secret-key']}\" && "
|
||||||
f"bao kv put secret/login-ui cookie-secret=\"{login_ui['cookie-secret']}\" csrf-cookie-secret=\"{login_ui['csrf-cookie-secret']}\" && "
|
f"bao kv put secret/login-ui cookie-secret=\"{login_ui['cookie-secret']}\" csrf-cookie-secret=\"{login_ui['csrf-cookie-secret']}\" && "
|
||||||
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']}\" && "
|
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']}\" && "
|
||||||
f"bao kv put secret/docs django-secret-key=\"{docs['django-secret-key']}\" collaboration-secret=\"{docs['collaboration-secret']}\""
|
f"bao kv put secret/docs django-secret-key=\"{docs['django-secret-key']}\" collaboration-secret=\"{docs['collaboration-secret']}\" && "
|
||||||
|
f"bao kv put secret/meet django-secret-key=\"{meet['django-secret-key']}\" application-jwt-secret-key=\"{meet['application-jwt-secret-key']}\" && "
|
||||||
|
f"bao kv put secret/grafana admin-password=\"{grafana['admin-password']}\" && "
|
||||||
|
f"bao kv put secret/scaleway-s3 access-key-id=\"{scaleway_s3['access-key-id']}\" secret-access-key=\"{scaleway_s3['secret-access-key']}\""
|
||||||
f"'")
|
f"'")
|
||||||
|
|
||||||
# Configure Kubernetes auth method so VSO can authenticate with OpenBao
|
# Configure Kubernetes auth method so VSO can authenticate with OpenBao
|
||||||
@@ -208,7 +231,7 @@ def _seed_openbao() -> dict:
|
|||||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
||||||
f"bao write auth/kubernetes/role/vso "
|
f"bao write auth/kubernetes/role/vso "
|
||||||
f"bound_service_account_names=default "
|
f"bound_service_account_names=default "
|
||||||
f"bound_service_account_namespaces=ory,devtools,storage,lasuite,media "
|
f"bound_service_account_namespaces=ory,devtools,storage,lasuite,media,data,monitoring "
|
||||||
f"policies=vso-reader "
|
f"policies=vso-reader "
|
||||||
f"ttl=1h")
|
f"ttl=1h")
|
||||||
|
|
||||||
@@ -253,7 +276,7 @@ def _configure_db_engine(ob_pod, root_token, pg_user, pg_pass):
|
|||||||
|
|
||||||
def bao(cmd, check=True):
|
def bao(cmd, check=True):
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "data", "exec", ob_pod, "-c", "openbao",
|
["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao",
|
||||||
"--", "sh", "-c", cmd],
|
"--", "sh", "-c", cmd],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
@@ -276,7 +299,7 @@ def _configure_db_engine(ob_pod, root_token, pg_user, pg_pass):
|
|||||||
|
|
||||||
def psql(sql):
|
def psql(sql):
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "data", "exec", cnpg_pod, "-c", "postgres",
|
["kubectl", context_arg(), "-n", "data", "exec", cnpg_pod, "-c", "postgres",
|
||||||
"--", "psql", "-U", "postgres", "-c", sql],
|
"--", "psql", "-U", "postgres", "-c", sql],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
@@ -351,7 +374,7 @@ def _configure_db_engine(ob_pod, root_token, pg_user, pg_pass):
|
|||||||
def _kratos_admin_pf(local_port=14434):
|
def _kratos_admin_pf(local_port=14434):
|
||||||
"""Port-forward directly to the Kratos admin API."""
|
"""Port-forward directly to the Kratos admin API."""
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["kubectl", *K8S_CTX, "-n", "ory", "port-forward",
|
["kubectl", context_arg(), "-n", "ory", "port-forward",
|
||||||
"svc/kratos-admin", f"{local_port}:80"],
|
"svc/kratos-admin", f"{local_port}:80"],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -424,7 +447,7 @@ def _seed_kratos_admin_identity(ob_pod: str, root_token: str) -> tuple[str, str]
|
|||||||
|
|
||||||
def _bao(cmd):
|
def _bao(cmd):
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "data", "exec", ob_pod, "-c", "openbao",
|
["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao",
|
||||||
"--", "sh", "-c", cmd],
|
"--", "sh", "-c", cmd],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
@@ -498,9 +521,11 @@ def cmd_seed() -> dict:
|
|||||||
f"CREATE DATABASE {db} OWNER {user};", check=False)
|
f"CREATE DATABASE {db} OWNER {user};", check=False)
|
||||||
|
|
||||||
# Read CNPG superuser credentials and configure database secrets engine.
|
# Read CNPG superuser credentials and configure database secrets engine.
|
||||||
pg_user_b64 = kube_out("-n", "data", "get", "secret", "postgres-superuser",
|
# CNPG creates secret named "{cluster}-app" (not "{cluster}-superuser")
|
||||||
|
# when owner is specified without an explicit secret field.
|
||||||
|
pg_user_b64 = kube_out("-n", "data", "get", "secret", "postgres-app",
|
||||||
"-o=jsonpath={.data.username}")
|
"-o=jsonpath={.data.username}")
|
||||||
pg_pass_b64 = kube_out("-n", "data", "get", "secret", "postgres-superuser",
|
pg_pass_b64 = kube_out("-n", "data", "get", "secret", "postgres-app",
|
||||||
"-o=jsonpath={.data.password}")
|
"-o=jsonpath={.data.password}")
|
||||||
pg_user = base64.b64decode(pg_user_b64).decode() if pg_user_b64 else "postgres"
|
pg_user = base64.b64decode(pg_user_b64).decode() if pg_user_b64 else "postgres"
|
||||||
pg_pass = base64.b64decode(pg_pass_b64).decode() if pg_pass_b64 else ""
|
pg_pass = base64.b64decode(pg_pass_b64).decode() if pg_pass_b64 else ""
|
||||||
@@ -555,6 +580,7 @@ def cmd_seed() -> dict:
|
|||||||
DJANGO_SECRET_KEY=django_secret)
|
DJANGO_SECRET_KEY=django_secret)
|
||||||
|
|
||||||
ensure_ns("media")
|
ensure_ns("media")
|
||||||
|
ensure_ns("monitoring")
|
||||||
|
|
||||||
# Ensure the Kratos admin identity exists and ADMIN_IDENTITY_IDS is set.
|
# Ensure the Kratos admin identity exists and ADMIN_IDENTITY_IDS is set.
|
||||||
# This runs after all other secrets are in place (Kratos must be up).
|
# This runs after all other secrets are in place (Kratos must be up).
|
||||||
@@ -606,7 +632,7 @@ def cmd_verify():
|
|||||||
|
|
||||||
def bao(cmd, *, check=True):
|
def bao(cmd, *, check=True):
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["kubectl", *K8S_CTX, "-n", "data", "exec", ob_pod, "-c", "openbao",
|
["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao",
|
||||||
"--", "sh", "-c", cmd],
|
"--", "sh", "-c", cmd],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sunbeam.kube as _kube_mod
|
||||||
from sunbeam.kube import kube, kube_out, parse_target
|
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
|
||||||
@@ -24,7 +25,10 @@ SERVICES_TO_RESTART = [
|
|||||||
("media", "livekit-server"),
|
("media", "livekit-server"),
|
||||||
]
|
]
|
||||||
|
|
||||||
K8S_CTX = ["--context=sunbeam"]
|
|
||||||
|
def _k8s_ctx():
|
||||||
|
"""Return the kubectl --context flag matching the active environment."""
|
||||||
|
return [_kube_mod.context_arg()]
|
||||||
|
|
||||||
|
|
||||||
def _capture_out(cmd, *, default=""):
|
def _capture_out(cmd, *, default=""):
|
||||||
@@ -43,7 +47,7 @@ def _vso_sync_status():
|
|||||||
|
|
||||||
# VaultStaticSecrets: synced when secretMAC is populated
|
# VaultStaticSecrets: synced when secretMAC is populated
|
||||||
vss_raw = _capture_out([
|
vss_raw = _capture_out([
|
||||||
"kubectl", *K8S_CTX, "get", "vaultstaticsecret", "-A", "--no-headers",
|
"kubectl", *_k8s_ctx(), "get", "vaultstaticsecret", "-A", "--no-headers",
|
||||||
"-o=custom-columns="
|
"-o=custom-columns="
|
||||||
"NS:.metadata.namespace,NAME:.metadata.name,MAC:.status.secretMAC",
|
"NS:.metadata.namespace,NAME:.metadata.name,MAC:.status.secretMAC",
|
||||||
])
|
])
|
||||||
@@ -65,7 +69,7 @@ def _vso_sync_status():
|
|||||||
|
|
||||||
# VaultDynamicSecrets: synced when lastRenewalTime is non-zero
|
# VaultDynamicSecrets: synced when lastRenewalTime is non-zero
|
||||||
vds_raw = _capture_out([
|
vds_raw = _capture_out([
|
||||||
"kubectl", *K8S_CTX, "get", "vaultdynamicsecret", "-A", "--no-headers",
|
"kubectl", *_k8s_ctx(), "get", "vaultdynamicsecret", "-A", "--no-headers",
|
||||||
"-o=custom-columns="
|
"-o=custom-columns="
|
||||||
"NS:.metadata.namespace,NAME:.metadata.name,RENEWED:.status.lastRenewalTime",
|
"NS:.metadata.namespace,NAME:.metadata.name,RENEWED:.status.lastRenewalTime",
|
||||||
])
|
])
|
||||||
@@ -101,7 +105,7 @@ def cmd_status(target: str | None):
|
|||||||
if target is None:
|
if target is None:
|
||||||
# All pods across managed namespaces
|
# All pods across managed namespaces
|
||||||
raw = _capture_out([
|
raw = _capture_out([
|
||||||
"kubectl", *K8S_CTX,
|
"kubectl", *_k8s_ctx(),
|
||||||
"get", "pods",
|
"get", "pods",
|
||||||
"--field-selector=metadata.namespace!= kube-system",
|
"--field-selector=metadata.namespace!= kube-system",
|
||||||
"-A", "--no-headers",
|
"-A", "--no-headers",
|
||||||
@@ -120,7 +124,7 @@ def cmd_status(target: str | None):
|
|||||||
if name:
|
if name:
|
||||||
# Specific service: namespace/service
|
# Specific service: namespace/service
|
||||||
raw = _capture_out([
|
raw = _capture_out([
|
||||||
"kubectl", *K8S_CTX,
|
"kubectl", *_k8s_ctx(),
|
||||||
"get", "pods", "-n", ns, "-l", f"app={name}", "--no-headers",
|
"get", "pods", "-n", ns, "-l", f"app={name}", "--no-headers",
|
||||||
])
|
])
|
||||||
pods = []
|
pods = []
|
||||||
@@ -133,7 +137,7 @@ def cmd_status(target: str | None):
|
|||||||
else:
|
else:
|
||||||
# Namespace only
|
# Namespace only
|
||||||
raw = _capture_out([
|
raw = _capture_out([
|
||||||
"kubectl", *K8S_CTX,
|
"kubectl", *_k8s_ctx(),
|
||||||
"get", "pods", "-n", ns, "--no-headers",
|
"get", "pods", "-n", ns, "--no-headers",
|
||||||
])
|
])
|
||||||
pods = []
|
pods = []
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import urllib.request
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import sunbeam.kube as _kube_mod
|
||||||
from sunbeam.output import step, ok, warn, die, table
|
from sunbeam.output import step, ok, warn, die, table
|
||||||
|
|
||||||
K8S_CTX = ["--context=sunbeam"]
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80):
|
def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80):
|
||||||
"""Port-forward directly to the Kratos admin HTTP API and yield the local URL."""
|
"""Port-forward directly to the Kratos admin HTTP API and yield the local URL."""
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["kubectl", *K8S_CTX, "-n", ns, "port-forward",
|
["kubectl", _kube_mod.context_arg(), "-n", ns, "port-forward",
|
||||||
f"svc/{svc}", f"{local_port}:{remote_port}"],
|
f"svc/{svc}", f"{local_port}:{remote_port}"],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
@@ -166,6 +165,27 @@ def cmd_user_disable(target):
|
|||||||
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE — currently 1h.")
|
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE — currently 1h.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_user_set_password(target, password):
|
||||||
|
"""Set (or reset) the password credential for an identity."""
|
||||||
|
step(f"Setting password for: {target}")
|
||||||
|
with _port_forward() as base:
|
||||||
|
identity = _find_identity(base, target)
|
||||||
|
iid = identity["id"]
|
||||||
|
_api(base, f"/identities/{iid}", method="PUT", body={
|
||||||
|
"schema_id": identity["schema_id"],
|
||||||
|
"traits": identity["traits"],
|
||||||
|
"state": identity.get("state", "active"),
|
||||||
|
"metadata_public": identity.get("metadata_public"),
|
||||||
|
"metadata_admin": identity.get("metadata_admin"),
|
||||||
|
"credentials": {
|
||||||
|
"password": {
|
||||||
|
"config": {"password": password},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ok(f"Password set for {iid[:8]}...")
|
||||||
|
|
||||||
|
|
||||||
def cmd_user_enable(target):
|
def cmd_user_enable(target):
|
||||||
"""Re-enable a previously disabled identity."""
|
"""Re-enable a previously disabled identity."""
|
||||||
step(f"Enabling identity: {target}")
|
step(f"Enabling identity: {target}")
|
||||||
|
|||||||
Reference in New Issue
Block a user