Cut forgejo package prune over to OpenBao lane
This commit is contained in:
parent
ba767d5edb
commit
12903e3bed
5 changed files with 240 additions and 46 deletions
|
|
@ -14,7 +14,7 @@
|
|||
| workplan | RAILIANCE-WP-0010 | finished | — | workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md |
|
||||
| workplan | RAILIANCE-WP-0011 | finished | — | workplans/RAILIANCE-WP-0011-reuse-surface-runtime-secrets-openbao-lane.md |
|
||||
| workplan | RAILIANCE-WP-0012 | active | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
|
||||
| workplan | RAILIANCE-WP-0013 | active | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| workplan | RAILIANCE-WP-0013 | finished | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
|
||||
| task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
|
||||
| task | RAILIANCE-WP-0005-T03 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
| task | RAILIANCE-WP-0012-T02 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
|
||||
| task | RAILIANCE-WP-0012-T03 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
|
||||
| task | RAILIANCE-WP-0012-T04 | wait | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
|
||||
| task | RAILIANCE-WP-0013-T01 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T02 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T03 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T04 | wait | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T01 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T02 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T03 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
| task | RAILIANCE-WP-0013-T04 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
|
||||
|
|
|
|||
|
|
@ -18,31 +18,54 @@ tags slows backup growth and shortens chunked-copy duration.
|
|||
|
||||
## Operator commands
|
||||
|
||||
### 1. Forgejo API token (one-time per workstation)
|
||||
### 1. Authenticate to the approved token lane
|
||||
|
||||
This is **not** the OpenBao backup lane (`bao login` / Nextcloud). Prune uses a
|
||||
Forgejo **personal access token** with package scopes — same convention as
|
||||
`railiance-apps` (`FORGEJO_ADMIN_TOKEN`, `/tmp/forgejo-tegwick-api-token`).
|
||||
The steady-state credential path is `CCR-2026-0006`: OpenBao lane
|
||||
`platform/workloads/forgejo/forgejo-admin`, field `API_TOKEN`.
|
||||
|
||||
1. Log in to https://forgejo.coulomb.social as `tegwick` (or another user with
|
||||
org package admin rights on `coulomb`).
|
||||
2. **Settings → Applications → Generate New Token**
|
||||
3. Scopes: **`read:package`** and **`write:package`**
|
||||
4. Store on the workstation (pick one):
|
||||
1. Confirm the sanctioned route first:
|
||||
|
||||
```bash
|
||||
# Option A — token file (matches forgejo-operator-bootstrap, npm smoke, webhooks)
|
||||
install -m 600 /dev/null /tmp/forgejo-tegwick-api-token
|
||||
# paste token into the file (single line, no newline required)
|
||||
|
||||
# Option B — env for one shell session
|
||||
export FORGEJO_TOKEN='<pat>'
|
||||
# alias: export FORGEJO_ADMIN_TOKEN='<pat>'
|
||||
warden plan "forgejo admin api token" --json
|
||||
```
|
||||
|
||||
Do not commit the token or paste it into workplans, State Hub, or chat.
|
||||
2. Login with an identity allowed to read the lane:
|
||||
|
||||
### 2. Run prune
|
||||
```bash
|
||||
bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read
|
||||
```
|
||||
|
||||
3. Run prune normally. If `FORGEJO_TOKEN`, `FORGEJO_ADMIN_TOKEN`,
|
||||
`FORGEJO_TOKEN_FILE`, and `FORGEJO_ADMIN_TOKEN_FILE` are unset, the loader
|
||||
now resolves the PAT directly from OpenBao.
|
||||
|
||||
Do not commit the token or paste it into workplans, State Hub, chat, or shell
|
||||
history.
|
||||
|
||||
### 2. Break-glass overrides
|
||||
|
||||
Short-lived overrides are still supported when the lane is unavailable or when
|
||||
an attended operator session needs an explicit handoff:
|
||||
|
||||
```bash
|
||||
# Option A — token env for one shell session
|
||||
export FORGEJO_TOKEN='<pat>'
|
||||
# alias: export FORGEJO_ADMIN_TOKEN='<pat>'
|
||||
|
||||
# Option B — explicit file path for one invocation
|
||||
warden access forgejo-admin-api-token --out "$XDG_RUNTIME_DIR/forgejo-admin.token" --field API_TOKEN
|
||||
FORGEJO_TOKEN_FILE="$XDG_RUNTIME_DIR/forgejo-admin.token" make forgejo-package-prune-dry-run
|
||||
rm -f "$XDG_RUNTIME_DIR/forgejo-admin.token"
|
||||
```
|
||||
|
||||
The retired `/tmp/forgejo-tegwick-api-token` drop is no longer the default. Use
|
||||
it only while unwinding legacy local state:
|
||||
|
||||
```bash
|
||||
FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1 make forgejo-package-prune-dry-run
|
||||
```
|
||||
|
||||
### 3. Run prune
|
||||
|
||||
```bash
|
||||
cd ~/railiance-platform
|
||||
|
|
@ -64,4 +87,5 @@ counts (`deleted_count`, `candidate_count`, `skipped_protected_count`, `errors`)
|
|||
## Related
|
||||
|
||||
- `railiance-apps/docs/forgejo-package-registry.md`
|
||||
- `docs/forgejo-backup.md`
|
||||
- `docs/forgejo-backup.md`
|
||||
- `docs/workload-kv-access-lanes.md`
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ DEFAULT_OWNER = "coulomb"
|
|||
DEFAULT_TYPES = ("container", "pypi", "npm", "generic")
|
||||
DEFAULT_MAX_VERSIONS = 3
|
||||
DEFAULT_APPS_ROOT = Path.home() / "railiance-apps"
|
||||
DEFAULT_FORGEJO_ADMIN_BAO_PATH = "platform/workloads/forgejo/forgejo-admin"
|
||||
DEFAULT_FORGEJO_ADMIN_BAO_FIELD = "API_TOKEN"
|
||||
LEGACY_FORGEJO_TOKEN_FILE = Path("/tmp/forgejo-tegwick-api-token")
|
||||
FORGEJO_IMAGE_RE = re.compile(
|
||||
r"^forgejo\.coulomb\.social/(?:coulomb/)?(?P<name>[^:/]+)(?::(?P<tag>[^/\s]+))?$",
|
||||
re.IGNORECASE,
|
||||
|
|
@ -323,25 +326,97 @@ def build_delete_plans(
|
|||
return plans, errors
|
||||
|
||||
|
||||
def _read_token_file(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def _truthy_env(name: str) -> bool:
|
||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _load_token_from_file_env() -> str | None:
|
||||
for env_name in ("FORGEJO_TOKEN_FILE", "FORGEJO_ADMIN_TOKEN_FILE"):
|
||||
raw_path = os.environ.get(env_name, "").strip()
|
||||
if not raw_path:
|
||||
continue
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"ERROR: {env_name} points to a missing file: {path}")
|
||||
token = _read_token_file(path)
|
||||
if token:
|
||||
return token
|
||||
raise SystemExit(f"ERROR: {env_name} points to an empty file: {path}")
|
||||
return None
|
||||
|
||||
|
||||
def _load_token_from_openbao() -> tuple[str | None, str | None]:
|
||||
bao_bin = os.environ.get("FORGEJO_ADMIN_BAO_CLI", "bao").strip() or "bao"
|
||||
bao_path = (
|
||||
os.environ.get("FORGEJO_ADMIN_BAO_PATH", DEFAULT_FORGEJO_ADMIN_BAO_PATH).strip()
|
||||
or DEFAULT_FORGEJO_ADMIN_BAO_PATH
|
||||
)
|
||||
bao_field = (
|
||||
os.environ.get("FORGEJO_ADMIN_BAO_FIELD", DEFAULT_FORGEJO_ADMIN_BAO_FIELD).strip()
|
||||
or DEFAULT_FORGEJO_ADMIN_BAO_FIELD
|
||||
)
|
||||
if shutil.which(bao_bin) is None:
|
||||
return None, f"{bao_bin} CLI not found"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[bao_bin, "kv", "get", f"-field={bao_field}", bao_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = exc.stderr.strip() or exc.stdout.strip() or f"exit {exc.returncode}"
|
||||
return None, f"{bao_bin} kv get failed: {detail}"
|
||||
except OSError as exc:
|
||||
return None, f"{bao_bin} invocation failed: {exc}"
|
||||
token = result.stdout.strip()
|
||||
if not token:
|
||||
return None, f"{bao_bin} kv get returned an empty {bao_field} field"
|
||||
return token, None
|
||||
|
||||
|
||||
def _token_help_message(bao_error: str | None) -> str:
|
||||
lines = [
|
||||
"ERROR: Forgejo API token required (read:package + write:package).",
|
||||
" Primary: bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read",
|
||||
f" Default lane: {DEFAULT_FORGEJO_ADMIN_BAO_PATH} field {DEFAULT_FORGEJO_ADMIN_BAO_FIELD}",
|
||||
" Override lane via FORGEJO_ADMIN_BAO_PATH / FORGEJO_ADMIN_BAO_FIELD if needed.",
|
||||
" Break-glass: set FORGEJO_TOKEN / FORGEJO_ADMIN_TOKEN, or set FORGEJO_TOKEN_FILE / FORGEJO_ADMIN_TOKEN_FILE.",
|
||||
" Legacy /tmp fallback stays opt-in only via FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1.",
|
||||
" See: railiance-platform/docs/forgejo-package-prune.md",
|
||||
]
|
||||
if bao_error:
|
||||
lines.insert(3, f" OpenBao lookup failed: {bao_error}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for env_name in ("FORGEJO_TOKEN", "FORGEJO_ADMIN_TOKEN"):
|
||||
token = os.environ.get(env_name, "").strip()
|
||||
if token:
|
||||
return token
|
||||
token_file = os.environ.get(
|
||||
"FORGEJO_TOKEN_FILE",
|
||||
os.environ.get("FORGEJO_ADMIN_TOKEN_FILE", "/tmp/forgejo-tegwick-api-token"),
|
||||
)
|
||||
path = Path(token_file).expanduser()
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"ERROR: Forgejo API token required (read:package + write:package).\n"
|
||||
" export FORGEJO_TOKEN=<pat> # or FORGEJO_ADMIN_TOKEN\n"
|
||||
" # or save PAT to /tmp/forgejo-tegwick-api-token (chmod 600)\n"
|
||||
" Generate: https://forgejo.coulomb.social/user/settings/applications\n"
|
||||
" See: railiance-platform/docs/forgejo-package-prune.md"
|
||||
)
|
||||
token = _load_token_from_file_env()
|
||||
if token:
|
||||
return token
|
||||
token, bao_error = _load_token_from_openbao()
|
||||
if token:
|
||||
return token
|
||||
|
||||
if _truthy_env("FORGEJO_ALLOW_LEGACY_FILE_FALLBACK"):
|
||||
if LEGACY_FORGEJO_TOKEN_FILE.is_file():
|
||||
token = _read_token_file(LEGACY_FORGEJO_TOKEN_FILE)
|
||||
if token:
|
||||
return token
|
||||
suffix = f"legacy file {LEGACY_FORGEJO_TOKEN_FILE} is empty"
|
||||
else:
|
||||
suffix = f"legacy file {LEGACY_FORGEJO_TOKEN_FILE} is missing"
|
||||
bao_error = f"{bao_error}; {suffix}" if bao_error else suffix
|
||||
|
||||
raise SystemExit(_token_help_message(bao_error))
|
||||
|
||||
|
||||
def emit_summary(
|
||||
|
|
@ -519,4 +594,4 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
|
@ -63,7 +64,6 @@ image:
|
|||
self.assertEqual(deletable, {("vergabe-teilnahme", "v1")})
|
||||
self.assertEqual(protected_plans, {("vergabe-teilnahme", "old-prod")})
|
||||
|
||||
|
||||
def test_collect_live_cluster_versions_protects_forgejo_tags(self) -> None:
|
||||
images = "\n".join([
|
||||
"forgejo.coulomb.social/coulomb/state-hub:f2e042a",
|
||||
|
|
@ -93,6 +93,70 @@ image:
|
|||
self.assertEqual(protected, set())
|
||||
self.assertTrue(notes and "kubectl not found" in notes[0])
|
||||
|
||||
def test_load_token_prefers_explicit_env(self) -> None:
|
||||
with mock.patch.dict(prune.os.environ, {"FORGEJO_TOKEN": " env-token "}, clear=True):
|
||||
with mock.patch.object(prune, "_load_token_from_openbao") as load_openbao:
|
||||
self.assertEqual(prune.load_token(), "env-token")
|
||||
load_openbao.assert_not_called()
|
||||
|
||||
def test_load_token_reads_explicit_token_file_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
token_file = Path(tmpdir) / "forgejo-admin.token"
|
||||
token_file.write_text(" file-token \n", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
prune.os.environ,
|
||||
{"FORGEJO_TOKEN_FILE": str(token_file)},
|
||||
clear=True,
|
||||
):
|
||||
with mock.patch.object(prune, "_load_token_from_openbao") as load_openbao:
|
||||
self.assertEqual(prune.load_token(), "file-token")
|
||||
load_openbao.assert_not_called()
|
||||
|
||||
def test_load_token_uses_openbao_lane_by_default(self) -> None:
|
||||
with mock.patch.dict(prune.os.environ, {}, clear=True):
|
||||
with mock.patch.object(prune.shutil, "which", return_value="/usr/bin/bao"), \
|
||||
mock.patch.object(
|
||||
prune.subprocess,
|
||||
"run",
|
||||
return_value=mock.Mock(stdout=" bao-token \n"),
|
||||
) as run_mock:
|
||||
self.assertEqual(prune.load_token(), "bao-token")
|
||||
run_mock.assert_called_once_with(
|
||||
[
|
||||
"bao",
|
||||
"kv",
|
||||
"get",
|
||||
f"-field={prune.DEFAULT_FORGEJO_ADMIN_BAO_FIELD}",
|
||||
prune.DEFAULT_FORGEJO_ADMIN_BAO_PATH,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def test_load_token_uses_legacy_file_only_when_opted_in(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_file = Path(tmpdir) / "forgejo-tegwick-api-token"
|
||||
legacy_file.write_text("legacy-token\n", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
prune.os.environ,
|
||||
{"FORGEJO_ALLOW_LEGACY_FILE_FALLBACK": "1"},
|
||||
clear=True,
|
||||
):
|
||||
with mock.patch.object(prune.shutil, "which", return_value=None), \
|
||||
mock.patch.object(prune, "LEGACY_FORGEJO_TOKEN_FILE", legacy_file):
|
||||
self.assertEqual(prune.load_token(), "legacy-token")
|
||||
|
||||
def test_load_token_error_mentions_openbao_and_break_glass(self) -> None:
|
||||
with mock.patch.dict(prune.os.environ, {}, clear=True):
|
||||
with mock.patch.object(prune.shutil, "which", return_value=None):
|
||||
with self.assertRaises(SystemExit) as exc_info:
|
||||
prune.load_token()
|
||||
message = str(exc_info.exception)
|
||||
self.assertIn("forgejo-admin-workload-kv-read", message)
|
||||
self.assertIn("FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1", message)
|
||||
self.assertIn(prune.DEFAULT_FORGEJO_ADMIN_BAO_PATH, message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ type: workplan
|
|||
title: "Forgejo admin PAT OpenBao consumer cutover"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: active
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: railiance
|
||||
created: "2026-07-25"
|
||||
updated: "2026-07-25"
|
||||
updated: "2026-07-26"
|
||||
related_repos:
|
||||
- activity-core
|
||||
- ops-warden
|
||||
|
|
@ -45,7 +45,7 @@ It must not:
|
|||
|
||||
```task
|
||||
id: RAILIANCE-WP-0013-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "68131f3b-cb97-4aa9-abbb-cddb1e395703"
|
||||
```
|
||||
|
|
@ -59,11 +59,16 @@ Record where the current Forgejo admin PAT is consumed and documented, including
|
|||
|
||||
Done when the remaining `/tmp/forgejo-tegwick-api-token` dependency surface is explicit.
|
||||
|
||||
2026-07-26: Confirmed the remaining legacy surface in
|
||||
`scripts/forgejo_package_prune.py` and `docs/forgejo-package-prune.md`; the
|
||||
approved lane reference already lived in `CCR-2026-0006` and
|
||||
`docs/workload-kv-access-lanes.md`.
|
||||
|
||||
## T02 - Implement the approved non-file fallback path
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0013-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f67a58e5-d780-4226-97a8-4c669b47e4a4"
|
||||
```
|
||||
|
|
@ -75,11 +80,18 @@ path rather than the workstation file as the steady-state fallback.
|
|||
Done when the token loader prefers the approved lane and the old file drop is
|
||||
demoted to break-glass or removed if no longer needed.
|
||||
|
||||
2026-07-26: `scripts/forgejo_package_prune.py` now resolves
|
||||
`platform/workloads/forgejo/forgejo-admin` field `API_TOKEN` through `bao kv get`
|
||||
when `FORGEJO_TOKEN` and `FORGEJO_ADMIN_TOKEN` are unset. Explicit
|
||||
`FORGEJO_TOKEN_FILE` / `FORGEJO_ADMIN_TOKEN_FILE` remain supported as attended
|
||||
break-glass handoffs, and `/tmp/forgejo-tegwick-api-token` only works when
|
||||
`FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1` is set.
|
||||
|
||||
## T03 - Update operator guidance and lane references
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0013-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "ed5f791c-5beb-43bd-844b-4188b604bc3f"
|
||||
```
|
||||
|
|
@ -90,11 +102,16 @@ catalog, and the final consumer behavior.
|
|||
Done when the package-prune runbook no longer presents the workstation file as
|
||||
the primary path.
|
||||
|
||||
2026-07-26: Updated `docs/forgejo-package-prune.md` so the primary operator path
|
||||
is `warden plan` + `bao login` against `forgejo-admin-workload-kv-read`; the
|
||||
retired `/tmp` drop is now documented as legacy unwind only, with explicit
|
||||
break-glass env/file overrides kept for attended sessions.
|
||||
|
||||
## T04 - Verify prune workflow and close the inbox request
|
||||
|
||||
```task
|
||||
id: RAILIANCE-WP-0013-T04
|
||||
status: wait
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "1fc2af0f-b4ff-4694-b072-23af0254377e"
|
||||
```
|
||||
|
|
@ -106,3 +123,17 @@ coordination loop from `ops-warden`.
|
|||
|
||||
Done when the replacement path is verified and the old unread message has a
|
||||
clear repo-local follow-up trail.
|
||||
|
||||
2026-07-26: Added focused token-resolution coverage in
|
||||
`tests/test_forgejo_package_prune.py` for direct env, explicit file overrides,
|
||||
OpenBao fallback, legacy-file gating, and operator-facing failure text. This
|
||||
closes the `ops-warden` coordination loop triggered by message
|
||||
`5be8e500-ebf5-405b-9aec-c902353ae719`; the repo-local follow-up trail is now in
|
||||
this workplan, the prune runbook, and the loader implementation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] `forgejo-package-prune` no longer defaults to `/tmp/forgejo-tegwick-api-token`
|
||||
- [x] The steady-state fallback path reads the approved OpenBao lane from `CCR-2026-0006`
|
||||
- [x] Operator docs present OpenBao / `warden` routing as the primary path
|
||||
- [x] Repo-local tests cover the new token-resolution behavior without reading secret values
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue