github-actions-ingester 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_actions_ingester/__init__.py +9 -0
- github_actions_ingester/__main__.py +330 -0
- github_actions_ingester/app_manifest.py +97 -0
- github_actions_ingester/collector.py +338 -0
- github_actions_ingester/config.py +290 -0
- github_actions_ingester/github.py +585 -0
- github_actions_ingester/metrics.py +184 -0
- github_actions_ingester/migrations/0001_initial.sql +140 -0
- github_actions_ingester/ratelimit.py +39 -0
- github_actions_ingester/server.py +115 -0
- github_actions_ingester/store.py +580 -0
- github_actions_ingester/workflow_schedule.py +83 -0
- github_actions_ingester-0.1.0.dist-info/METADATA +522 -0
- github_actions_ingester-0.1.0.dist-info/RECORD +17 -0
- github_actions_ingester-0.1.0.dist-info/WHEEL +4 -0
- github_actions_ingester-0.1.0.dist-info/entry_points.txt +2 -0
- github_actions_ingester-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""GitHub Actions ingester.
|
|
2
|
+
|
|
3
|
+
Pulls repositories, workflows, workflow runs and jobs from the GitHub REST
|
|
4
|
+
API into PostgreSQL on a fixed interval, keeps the schema up to date on
|
|
5
|
+
its own, and exposes Prometheus metrics about the ingestion itself and
|
|
6
|
+
about the liveness of scheduled workflows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Entry point — CLI with a handful of subcommands.
|
|
2
|
+
|
|
3
|
+
github-actions-ingester run forever (default)
|
|
4
|
+
github-actions-ingester run --once one cycle, then exit
|
|
5
|
+
github-actions-ingester migrate bootstrap/upgrade the schema only
|
|
6
|
+
github-actions-ingester check validate config, GitHub auth, DB
|
|
7
|
+
github-actions-ingester app-manifest print the GitHub App manifest
|
|
8
|
+
github-actions-ingester app-convert X exchange a manifest code for creds
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from types import FrameType
|
|
21
|
+
|
|
22
|
+
import structlog
|
|
23
|
+
from pydantic import ValidationError
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .app_manifest import convert_code, manifest_form_url, manifest_json, write_private_key
|
|
27
|
+
from .collector import Collector
|
|
28
|
+
from .config import Settings, load_settings
|
|
29
|
+
from .github import AppAuth, GitHubAPIError, GitHubClient, TokenAuth
|
|
30
|
+
from .metrics import Metrics
|
|
31
|
+
from .ratelimit import RateLimiter
|
|
32
|
+
from .server import MetricsServer
|
|
33
|
+
from .store import Store
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _setup_logging(level: str, fmt: str) -> None:
|
|
37
|
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
|
38
|
+
logging.basicConfig(level=log_level, format="%(message)s", stream=sys.stderr)
|
|
39
|
+
# httpx logs one INFO line per request; the client has its own metrics
|
|
40
|
+
# and structured events for that, so keep the library quiet unless
|
|
41
|
+
# debugging.
|
|
42
|
+
for name in ("httpx", "httpcore"):
|
|
43
|
+
logging.getLogger(name).setLevel(max(log_level, logging.WARNING))
|
|
44
|
+
processors: list[structlog.types.Processor] = [
|
|
45
|
+
structlog.contextvars.merge_contextvars,
|
|
46
|
+
structlog.processors.add_log_level,
|
|
47
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
48
|
+
]
|
|
49
|
+
if fmt == "json":
|
|
50
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
51
|
+
else:
|
|
52
|
+
processors.append(structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()))
|
|
53
|
+
structlog.configure(
|
|
54
|
+
processors=processors,
|
|
55
|
+
wrapper_class=structlog.make_filtering_bound_logger(log_level),
|
|
56
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
57
|
+
# Not cached: a reconfigured stream (tests, supervisors that swap
|
|
58
|
+
# stderr) must be picked up; the volume here is far too low to matter.
|
|
59
|
+
cache_logger_on_first_use=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_client(settings: Settings, metrics: Metrics | None = None) -> GitHubClient:
|
|
64
|
+
auth: TokenAuth | AppAuth
|
|
65
|
+
if settings.uses_app():
|
|
66
|
+
orgs = settings.org_list()
|
|
67
|
+
auth = AppAuth(
|
|
68
|
+
app_id=settings.github_app_id,
|
|
69
|
+
private_key_pem=settings.app_private_key_pem(),
|
|
70
|
+
installation_id=settings.github_app_installation_id,
|
|
71
|
+
preferred_owner=orgs[0] if orgs else "",
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
auth = TokenAuth(settings.github_token)
|
|
75
|
+
|
|
76
|
+
def on_request(status: int) -> None:
|
|
77
|
+
if metrics is not None:
|
|
78
|
+
metrics.github_requests_total.labels(status=str(status)).inc()
|
|
79
|
+
|
|
80
|
+
return GitHubClient(
|
|
81
|
+
auth=auth,
|
|
82
|
+
base_url=settings.github_api_base,
|
|
83
|
+
timeout=settings.api_timeout_seconds,
|
|
84
|
+
limiter=RateLimiter(settings.api_rate_limit_rps),
|
|
85
|
+
min_remaining=settings.api_min_remaining,
|
|
86
|
+
max_retries=settings.api_max_retries,
|
|
87
|
+
on_request=on_request,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load(log_to_console: bool = False) -> Settings | None:
|
|
92
|
+
try:
|
|
93
|
+
settings = load_settings()
|
|
94
|
+
except ValidationError as exc:
|
|
95
|
+
sys.stderr.write(f"configuration error:\n{exc}\n")
|
|
96
|
+
return None
|
|
97
|
+
_setup_logging(settings.log_level, "console" if log_to_console else settings.log_format)
|
|
98
|
+
return settings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Subcommands
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_run(once: bool) -> int:
|
|
107
|
+
settings = _load()
|
|
108
|
+
if settings is None:
|
|
109
|
+
return 2
|
|
110
|
+
log = structlog.get_logger("github_actions_ingester")
|
|
111
|
+
log.info(
|
|
112
|
+
"ingester.start",
|
|
113
|
+
version=__version__,
|
|
114
|
+
auth="app" if settings.uses_app() else "token",
|
|
115
|
+
orgs=settings.org_list(),
|
|
116
|
+
repos=settings.repo_list(),
|
|
117
|
+
poll_interval=settings.poll_interval_seconds,
|
|
118
|
+
backfill_days=settings.backfill_days,
|
|
119
|
+
listen=f"{settings.listen_host}:{settings.listen_port}",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
metrics = Metrics()
|
|
123
|
+
metrics.build_info.info({"version": __version__, "python": sys.version.split()[0]})
|
|
124
|
+
metrics.up.set(0)
|
|
125
|
+
metrics.ready.set(0)
|
|
126
|
+
|
|
127
|
+
store = Store(
|
|
128
|
+
settings.database_url,
|
|
129
|
+
settings.database_schema,
|
|
130
|
+
settings.database_connect_timeout_seconds,
|
|
131
|
+
)
|
|
132
|
+
ready = threading.Event()
|
|
133
|
+
server = MetricsServer(
|
|
134
|
+
settings.listen_host, settings.listen_port, metrics.registry, ready.is_set
|
|
135
|
+
)
|
|
136
|
+
server.start()
|
|
137
|
+
|
|
138
|
+
stop = threading.Event()
|
|
139
|
+
|
|
140
|
+
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
|
141
|
+
log.info("ingester.signal", signal=signal.Signals(signum).name)
|
|
142
|
+
stop.set()
|
|
143
|
+
|
|
144
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
145
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
146
|
+
|
|
147
|
+
exit_code = 0
|
|
148
|
+
client: GitHubClient | None = None
|
|
149
|
+
try:
|
|
150
|
+
# Bootstrap the schema before anything else; retry while the
|
|
151
|
+
# database is not there yet (rollouts, fresh clusters).
|
|
152
|
+
while not stop.is_set():
|
|
153
|
+
try:
|
|
154
|
+
store.migrate()
|
|
155
|
+
break
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
log.error("store.bootstrap_failed", error=str(exc), retry_in=15)
|
|
158
|
+
metrics.errors_total.labels(stage="bootstrap").inc()
|
|
159
|
+
if once:
|
|
160
|
+
return 1
|
|
161
|
+
stop.wait(15)
|
|
162
|
+
if stop.is_set():
|
|
163
|
+
return 0
|
|
164
|
+
client = _build_client(settings, metrics)
|
|
165
|
+
collector = Collector(client, store, metrics, settings)
|
|
166
|
+
if once:
|
|
167
|
+
result = collector.run_cycle()
|
|
168
|
+
ready.set()
|
|
169
|
+
exit_code = 0 if result != "error" else 1
|
|
170
|
+
else:
|
|
171
|
+
first = threading.Thread(
|
|
172
|
+
target=lambda: collector.run_forever(stop), name="ingest", daemon=True
|
|
173
|
+
)
|
|
174
|
+
first.start()
|
|
175
|
+
# Readiness flips after the first cycle, whatever its outcome:
|
|
176
|
+
# the schema is up and metrics are meaningful from here on.
|
|
177
|
+
while not stop.is_set() and collector.cycles == 0:
|
|
178
|
+
stop.wait(1)
|
|
179
|
+
ready.set()
|
|
180
|
+
while not stop.is_set():
|
|
181
|
+
stop.wait(1)
|
|
182
|
+
first.join(timeout=30)
|
|
183
|
+
finally:
|
|
184
|
+
server.stop()
|
|
185
|
+
if client is not None:
|
|
186
|
+
client.close()
|
|
187
|
+
store.close()
|
|
188
|
+
log.info("ingester.stopped")
|
|
189
|
+
return exit_code
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_migrate() -> int:
|
|
193
|
+
settings = _load(log_to_console=True)
|
|
194
|
+
if settings is None:
|
|
195
|
+
return 2
|
|
196
|
+
store = Store(
|
|
197
|
+
settings.database_url,
|
|
198
|
+
settings.database_schema,
|
|
199
|
+
settings.database_connect_timeout_seconds,
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
report = store.migrate()
|
|
203
|
+
finally:
|
|
204
|
+
store.close()
|
|
205
|
+
print(
|
|
206
|
+
f"schema {settings.database_schema}: version {report.current_version}, "
|
|
207
|
+
f"applied now: {report.applied or 'nothing'}"
|
|
208
|
+
)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_check() -> int:
|
|
213
|
+
settings = _load(log_to_console=True)
|
|
214
|
+
if settings is None:
|
|
215
|
+
return 2
|
|
216
|
+
ok = True
|
|
217
|
+
store = Store(
|
|
218
|
+
settings.database_url,
|
|
219
|
+
settings.database_schema,
|
|
220
|
+
settings.database_connect_timeout_seconds,
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
version = store.schema_version()
|
|
224
|
+
print(f"database: ok (schema {settings.database_schema} version {version})")
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
print(f"database: FAILED ({exc})")
|
|
227
|
+
ok = False
|
|
228
|
+
finally:
|
|
229
|
+
store.close()
|
|
230
|
+
|
|
231
|
+
client = _build_client(settings)
|
|
232
|
+
try:
|
|
233
|
+
if settings.uses_app():
|
|
234
|
+
repos = list(client.list_installation_repositories())
|
|
235
|
+
print(f"github: ok (App, installation sees {len(repos)} repositories)")
|
|
236
|
+
else:
|
|
237
|
+
for org in settings.org_list():
|
|
238
|
+
n = sum(1 for _ in client.list_org_repositories(org))
|
|
239
|
+
print(f"github: ok (token, org {org}: {n} repositories)")
|
|
240
|
+
for full_name in settings.repo_list():
|
|
241
|
+
client.get_repository(full_name)
|
|
242
|
+
print(f"github: ok (token, repo {full_name})")
|
|
243
|
+
rl = client.rate_limit
|
|
244
|
+
print(f"rate limit: {rl.remaining}/{rl.limit}")
|
|
245
|
+
except GitHubAPIError as exc:
|
|
246
|
+
print(f"github: FAILED ({exc})")
|
|
247
|
+
ok = False
|
|
248
|
+
finally:
|
|
249
|
+
client.close()
|
|
250
|
+
return 0 if ok else 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_app_manifest(org: str, redirect_url: str, name: str) -> int:
|
|
254
|
+
print(manifest_json(name=name, redirect_url=redirect_url))
|
|
255
|
+
sys.stderr.write(f"\nPOST this manifest (form field `manifest`) to {manifest_form_url(org)}\n")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_app_convert(code: str, key_file: str, api_base: str) -> int:
|
|
260
|
+
try:
|
|
261
|
+
body = convert_code(code, api_base)
|
|
262
|
+
except RuntimeError as exc:
|
|
263
|
+
sys.stderr.write(f"{exc}\n")
|
|
264
|
+
return 1
|
|
265
|
+
pem = str(body.get("pem", ""))
|
|
266
|
+
target = Path(key_file)
|
|
267
|
+
write_private_key(pem, target)
|
|
268
|
+
summary = {
|
|
269
|
+
"app_id": body.get("id"),
|
|
270
|
+
"client_id": body.get("client_id"),
|
|
271
|
+
"slug": body.get("slug"),
|
|
272
|
+
"html_url": body.get("html_url"),
|
|
273
|
+
"private_key_file": str(target),
|
|
274
|
+
}
|
|
275
|
+
print(json.dumps(summary, indent=2))
|
|
276
|
+
sys.stderr.write(
|
|
277
|
+
"\nNext: install the App on your organization "
|
|
278
|
+
f"({body.get('html_url')}/installations/new), then set\n"
|
|
279
|
+
f" GHA_GITHUB_APP_ID={body.get('id')}\n"
|
|
280
|
+
f" GHA_GITHUB_APP_PRIVATE_KEY_FILE={target}\n"
|
|
281
|
+
)
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
prog="github-actions-ingester",
|
|
291
|
+
description="Ingest GitHub Actions runs and jobs into PostgreSQL.",
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
294
|
+
sub = parser.add_subparsers(dest="command")
|
|
295
|
+
|
|
296
|
+
run = sub.add_parser("run", help="run the ingester (default)")
|
|
297
|
+
run.add_argument("--once", action="store_true", help="run a single cycle and exit")
|
|
298
|
+
|
|
299
|
+
sub.add_parser("migrate", help="bootstrap or upgrade the database schema and exit")
|
|
300
|
+
sub.add_parser("check", help="validate configuration, GitHub credentials and database")
|
|
301
|
+
|
|
302
|
+
man = sub.add_parser("app-manifest", help="print the GitHub App manifest JSON")
|
|
303
|
+
man.add_argument("--org", default="", help="organization that will own the App")
|
|
304
|
+
man.add_argument("--redirect-url", default="", help="where GitHub sends the code")
|
|
305
|
+
man.add_argument("--name", default="github-actions-ingester")
|
|
306
|
+
|
|
307
|
+
conv = sub.add_parser("app-convert", help="exchange a manifest code for App credentials")
|
|
308
|
+
conv.add_argument("code")
|
|
309
|
+
conv.add_argument("--key-file", default="github-app.pem")
|
|
310
|
+
conv.add_argument("--api-base", default="https://api.github.com")
|
|
311
|
+
return parser
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(argv: list[str] | None = None) -> int:
|
|
315
|
+
args = build_parser().parse_args(argv)
|
|
316
|
+
if args.command in (None, "run"):
|
|
317
|
+
return cmd_run(once=bool(getattr(args, "once", False)))
|
|
318
|
+
if args.command == "migrate":
|
|
319
|
+
return cmd_migrate()
|
|
320
|
+
if args.command == "check":
|
|
321
|
+
return cmd_check()
|
|
322
|
+
if args.command == "app-manifest":
|
|
323
|
+
return cmd_app_manifest(args.org, args.redirect_url, args.name)
|
|
324
|
+
if args.command == "app-convert":
|
|
325
|
+
return cmd_app_convert(args.code, args.key_file, args.api_base)
|
|
326
|
+
return 2
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
sys.exit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""GitHub App Manifest flow helpers.
|
|
2
|
+
|
|
3
|
+
Lets every operator create *their own* GitHub App, inside their own
|
|
4
|
+
organization, with exactly the permissions the ingester needs and no
|
|
5
|
+
private key ever leaving their hands:
|
|
6
|
+
|
|
7
|
+
1. ``github-actions-ingester app-manifest`` prints the manifest JSON
|
|
8
|
+
(also embedded in ``examples/github-app/create-app.html``, a static
|
|
9
|
+
page that POSTs it to GitHub);
|
|
10
|
+
2. GitHub creates the App and redirects back with a one-hour ``code``;
|
|
11
|
+
3. ``github-actions-ingester app-convert <code>`` exchanges it for the
|
|
12
|
+
App ID and the private key (``POST /app-manifests/{code}/conversions``).
|
|
13
|
+
|
|
14
|
+
Reference: https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .github import API_VERSION
|
|
26
|
+
|
|
27
|
+
PROJECT_URL = "https://github.com/danielgines/github-actions-ingester"
|
|
28
|
+
|
|
29
|
+
# Least privilege for everything the collector reads. `contents: read`
|
|
30
|
+
# only serves the workflow-file read behind GHA_SYNC_SCHEDULES.
|
|
31
|
+
DEFAULT_PERMISSIONS: dict[str, str] = {
|
|
32
|
+
"actions": "read",
|
|
33
|
+
"metadata": "read",
|
|
34
|
+
"contents": "read",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_manifest(
|
|
39
|
+
name: str = "github-actions-ingester",
|
|
40
|
+
redirect_url: str = "",
|
|
41
|
+
public: bool = False,
|
|
42
|
+
description: str = "Ingests GitHub Actions runs and jobs into PostgreSQL for Grafana.",
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
manifest: dict[str, Any] = {
|
|
45
|
+
"name": name,
|
|
46
|
+
"url": PROJECT_URL,
|
|
47
|
+
"description": description,
|
|
48
|
+
"public": public,
|
|
49
|
+
"default_permissions": DEFAULT_PERMISSIONS,
|
|
50
|
+
"default_events": [],
|
|
51
|
+
"hook_attributes": {"active": False},
|
|
52
|
+
}
|
|
53
|
+
if redirect_url:
|
|
54
|
+
manifest["redirect_url"] = redirect_url
|
|
55
|
+
return manifest
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def manifest_form_url(org: str = "") -> str:
|
|
59
|
+
"""Where the manifest must be POSTed (org-owned or user-owned App)."""
|
|
60
|
+
if org:
|
|
61
|
+
return f"https://github.com/organizations/{org}/settings/apps/new"
|
|
62
|
+
return "https://github.com/settings/apps/new"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def convert_code(
|
|
66
|
+
code: str, api_base: str = "https://api.github.com", timeout: float = 30.0
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
"""Exchange the temporary ``code`` for the App credentials.
|
|
69
|
+
|
|
70
|
+
The code is the credential: no token is sent. Returns the API body
|
|
71
|
+
(``id``, ``slug``, ``client_id``, ``pem``, ``html_url`` ...).
|
|
72
|
+
"""
|
|
73
|
+
resp = httpx.post(
|
|
74
|
+
f"{api_base.rstrip('/')}/app-manifests/{code}/conversions",
|
|
75
|
+
headers={
|
|
76
|
+
"Accept": "application/vnd.github+json",
|
|
77
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
78
|
+
"User-Agent": "github-actions-ingester",
|
|
79
|
+
},
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
)
|
|
82
|
+
if resp.status_code != 201:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"conversion failed: HTTP {resp.status_code} {resp.text[:300]} "
|
|
85
|
+
"(the code expires one hour after the App is created)"
|
|
86
|
+
)
|
|
87
|
+
body: dict[str, Any] = resp.json()
|
|
88
|
+
return body
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_private_key(pem: str, path: Path) -> None:
|
|
92
|
+
path.write_text(pem, encoding="utf-8")
|
|
93
|
+
path.chmod(0o600)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def manifest_json(**kwargs: Any) -> str:
|
|
97
|
+
return json.dumps(build_manifest(**kwargs), indent=2)
|