blockmachine 0.2.1__tar.gz → 0.3.2__tar.gz
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.
- {blockmachine-0.2.1 → blockmachine-0.3.2}/PKG-INFO +1 -1
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/PKG-INFO +1 -1
- {blockmachine-0.2.1 → blockmachine-0.3.2}/commands/miner.py +333 -23
- {blockmachine-0.2.1 → blockmachine-0.3.2}/pyproject.toml +1 -1
- {blockmachine-0.2.1 → blockmachine-0.3.2}/settings.py +1 -1
- {blockmachine-0.2.1 → blockmachine-0.3.2}/__init__.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/SOURCES.txt +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/dependency_links.txt +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/entry_points.txt +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/requires.txt +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/blockmachine.egg-info/top_level.txt +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/client.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/commands/__init__.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/commands/auth.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/commands/validator.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/config.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/main.py +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/setup.cfg +0 -0
- {blockmachine-0.2.1 → blockmachine-0.3.2}/utils.py +0 -0
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
"""Miner commands: node registration, secrets, and pricing"""
|
|
2
2
|
|
|
3
3
|
import getpass
|
|
4
|
+
import hashlib
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
4
10
|
import time
|
|
5
|
-
from typing import Optional
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
from urllib.parse import urlparse
|
|
6
13
|
|
|
14
|
+
import httpx
|
|
7
15
|
import typer
|
|
8
16
|
from rich.console import Console
|
|
9
17
|
from rich.table import Table
|
|
@@ -22,6 +30,7 @@ app.add_typer(secret_app, name="secret")
|
|
|
22
30
|
app.add_typer(price_app, name="price")
|
|
23
31
|
|
|
24
32
|
console = Console()
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
25
34
|
|
|
26
35
|
CHAIN = "tao"
|
|
27
36
|
|
|
@@ -89,6 +98,61 @@ def logout() -> None:
|
|
|
89
98
|
do_logout()
|
|
90
99
|
|
|
91
100
|
|
|
101
|
+
# ── TLS ──────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _test_tls(hostname: str, port: int) -> tuple[bool, Optional[str], str]:
|
|
105
|
+
"""Test TLS handshake. Return (ok, fingerprint, message)."""
|
|
106
|
+
try:
|
|
107
|
+
ctx = ssl.create_default_context()
|
|
108
|
+
ctx.check_hostname = False
|
|
109
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
110
|
+
with socket.create_connection((hostname, port), timeout=10) as sock:
|
|
111
|
+
with ctx.wrap_socket(sock, server_hostname=hostname) as tls:
|
|
112
|
+
der = tls.getpeercert(binary_form=True)
|
|
113
|
+
fp = hashlib.sha256(der).hexdigest() if der else None
|
|
114
|
+
return True, fp, "OK"
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return False, None, str(e)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _bracket_ipv6(hostname: str) -> str:
|
|
120
|
+
"""Wrap bare IPv6 addresses in brackets for use in URLs."""
|
|
121
|
+
try:
|
|
122
|
+
addr = ipaddress.ip_address(hostname)
|
|
123
|
+
if addr.version == 6:
|
|
124
|
+
return f"[{hostname}]"
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass
|
|
127
|
+
return hostname
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_ip_endpoint(endpoint: str) -> bool:
|
|
131
|
+
"""Return True if the endpoint hostname is an IP address (not a domain)."""
|
|
132
|
+
hostname = urlparse(endpoint).hostname
|
|
133
|
+
if not hostname:
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
ipaddress.ip_address(hostname)
|
|
137
|
+
return True
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_cert_fingerprint(endpoint: str) -> Optional[str]:
|
|
143
|
+
"""Fetch the SHA256 fingerprint of the TLS leaf certificate."""
|
|
144
|
+
parsed = urlparse(endpoint)
|
|
145
|
+
hostname = parsed.hostname
|
|
146
|
+
port = parsed.port or 443
|
|
147
|
+
if not hostname:
|
|
148
|
+
return None
|
|
149
|
+
ok, fingerprint, _ = _test_tls(hostname, port)
|
|
150
|
+
if not ok:
|
|
151
|
+
logger.warning("Could not fetch TLS certificate from %s", endpoint)
|
|
152
|
+
return None
|
|
153
|
+
return fingerprint
|
|
154
|
+
|
|
155
|
+
|
|
92
156
|
# ── Node CRUD ────────────────────────────────────────────────────
|
|
93
157
|
|
|
94
158
|
|
|
@@ -114,9 +178,22 @@ def add(
|
|
|
114
178
|
console.print("[red]Secret must be at least 16 characters[/red]")
|
|
115
179
|
raise typer.Exit(1)
|
|
116
180
|
|
|
181
|
+
fp = None
|
|
182
|
+
if _is_ip_endpoint(endpoint):
|
|
183
|
+
fp = _get_cert_fingerprint(endpoint)
|
|
184
|
+
if fp:
|
|
185
|
+
console.print(f"[dim]TLS fingerprint (pinned): {fp}[/dim]")
|
|
186
|
+
else:
|
|
187
|
+
console.print(
|
|
188
|
+
"[yellow]Warning: Could not fetch TLS certificate from endpoint.[/yellow]\n"
|
|
189
|
+
"[yellow]The gateway will not be able to verify this miner's identity.[/yellow]"
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
console.print("[dim]Domain endpoint — using standard CA verification[/dim]")
|
|
193
|
+
|
|
117
194
|
config = load_config()
|
|
118
195
|
with RegistryClient(config) as client:
|
|
119
|
-
node_id = _create_node(client, endpoint, alias)
|
|
196
|
+
node_id = _create_node(client, endpoint, alias, cert_fingerprint=fp)
|
|
120
197
|
_set_node_secret(client, node_id, secret)
|
|
121
198
|
_set_node_price(client, node_id, price)
|
|
122
199
|
|
|
@@ -133,12 +210,17 @@ def _default_alias(endpoint: str) -> str:
|
|
|
133
210
|
return f"tao-{host.replace('.', '-')}"
|
|
134
211
|
|
|
135
212
|
|
|
136
|
-
def _create_node(
|
|
213
|
+
def _create_node(
|
|
214
|
+
client: RegistryClient,
|
|
215
|
+
endpoint: str,
|
|
216
|
+
alias: str,
|
|
217
|
+
cert_fingerprint: Optional[str] = None,
|
|
218
|
+
) -> str:
|
|
137
219
|
"""Register a node and return its ID."""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
220
|
+
payload: dict = {"endpoint": endpoint, "chain": CHAIN, "alias": alias}
|
|
221
|
+
if cert_fingerprint is not None:
|
|
222
|
+
payload["cert_fingerprint"] = cert_fingerprint
|
|
223
|
+
response = client.post("/nodes", json=payload)
|
|
142
224
|
if response.status_code == 201:
|
|
143
225
|
data = response.json()
|
|
144
226
|
console.print(f"[green]Node registered:[/green] {data['id'][:8]}")
|
|
@@ -273,23 +355,100 @@ def show(
|
|
|
273
355
|
node_id = _resolve_node(client, alias)
|
|
274
356
|
response = client.get(f"/nodes/{node_id}")
|
|
275
357
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
358
|
+
if response.status_code == 404:
|
|
359
|
+
console.print(f"[red]Node not found:[/red] {alias}")
|
|
360
|
+
raise typer.Exit(1)
|
|
361
|
+
if response.status_code != 200:
|
|
362
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
363
|
+
raise typer.Exit(1)
|
|
282
364
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
365
|
+
node = response.json()
|
|
366
|
+
console.print(f"[bold]ID:[/bold] {node['id']}")
|
|
367
|
+
console.print(f"[bold]Alias:[/bold] {node.get('alias') or '-'}")
|
|
368
|
+
console.print(f"[bold]Chain:[/bold] {node['chain']}")
|
|
369
|
+
console.print(f"[bold]Status:[/bold] {node['status']}")
|
|
370
|
+
console.print(f"[bold]Endpoint:[/bold] {node['endpoint']}")
|
|
371
|
+
console.print(f"[bold]Created:[/bold] {format_timestamp(node['created_at'])}")
|
|
372
|
+
console.print(
|
|
373
|
+
f"[bold]Last Seen:[/bold] {format_timestamp(node.get('last_seen_at'))}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
m_resp = client.get(f"/nodes/{node_id}/metrics")
|
|
377
|
+
if m_resp.status_code == 200:
|
|
378
|
+
m = m_resp.json()
|
|
379
|
+
if m.get("available"):
|
|
380
|
+
console.print()
|
|
381
|
+
console.print("[bold]Routing Metrics[/bold]")
|
|
382
|
+
_print_metrics(m)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _fmt_success_rate(v: float) -> str:
|
|
386
|
+
pct = v * 100
|
|
387
|
+
color = "green" if pct >= 99 else "yellow" if pct >= 95 else "red"
|
|
388
|
+
return f"[{color}]{pct:.1f}%[/{color}]"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_METRIC_FIELDS: list[tuple[str, str, Callable]] = [
|
|
392
|
+
("request_count", "Requests", lambda v: f"{v:,}"),
|
|
393
|
+
("success_rate", "Success Rate", _fmt_success_rate),
|
|
394
|
+
("p95_ms", "P95 Latency", lambda v: f"{v:.0f} ms"),
|
|
395
|
+
("latency_score", "Latency Score", lambda v: f"{v:.4f}"),
|
|
396
|
+
("routing_score", "Routing Score", lambda v: f"{v:.4f}"),
|
|
397
|
+
("traffic_pct", "Traffic Share", lambda v: f"{v:.1f}%"),
|
|
398
|
+
("reliability", "Reliability", lambda v: f"{v:.4f}"),
|
|
399
|
+
("epochs_good", "Epochs Good", str),
|
|
400
|
+
("bid", "Bid", lambda v: f"${v:.6f}"),
|
|
401
|
+
("cohort_median", "Cohort Median", lambda v: f"${v:.6f}"),
|
|
402
|
+
("price_factor", "Price Factor", lambda v: f"{v:.4f}"),
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _print_metrics(m: dict) -> None:
|
|
407
|
+
"""Print routing metrics from a metrics API response."""
|
|
408
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
409
|
+
table.add_column("Key", style="bold")
|
|
410
|
+
table.add_column("Value")
|
|
411
|
+
|
|
412
|
+
for key, label, fmt in _METRIC_FIELDS:
|
|
413
|
+
val = m.get(key)
|
|
414
|
+
if val is not None:
|
|
415
|
+
table.add_row(label, fmt(val))
|
|
416
|
+
|
|
417
|
+
console.print(table)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@app.command("metrics")
|
|
421
|
+
def metrics(
|
|
422
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Show routing metrics for a miner node."""
|
|
425
|
+
alias = _get_alias(alias)
|
|
426
|
+
config = load_config()
|
|
427
|
+
|
|
428
|
+
with RegistryClient(config) as client:
|
|
429
|
+
node_id = _resolve_node(client, alias)
|
|
430
|
+
response = client.get(f"/nodes/{node_id}/metrics")
|
|
431
|
+
|
|
432
|
+
if response.status_code == 503:
|
|
433
|
+
console.print(
|
|
434
|
+
"[yellow]Metrics not available (scoring not enabled)[/yellow]"
|
|
435
|
+
)
|
|
436
|
+
raise typer.Exit(1)
|
|
437
|
+
if response.status_code != 200:
|
|
438
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
439
|
+
raise typer.Exit(1)
|
|
440
|
+
|
|
441
|
+
m = response.json()
|
|
442
|
+
if not m.get("available"):
|
|
443
|
+
console.print(
|
|
444
|
+
f"[yellow]No metrics available for {alias}[/yellow]\n"
|
|
445
|
+
"[dim]The gateway may not have routed traffic to this"
|
|
446
|
+
" node yet.[/dim]"
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
console.print(f"[bold]Routing Metrics: {alias}[/bold]\n")
|
|
451
|
+
_print_metrics(m)
|
|
293
452
|
|
|
294
453
|
|
|
295
454
|
@app.command("rm")
|
|
@@ -365,6 +524,157 @@ def update(
|
|
|
365
524
|
raise typer.Exit(1)
|
|
366
525
|
|
|
367
526
|
|
|
527
|
+
# ── Test ─────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _test_health(hostname: str) -> tuple[bool, str]:
|
|
531
|
+
"""Test HTTP health endpoint on port 80."""
|
|
532
|
+
try:
|
|
533
|
+
r = httpx.get(
|
|
534
|
+
f"http://{_bracket_ipv6(hostname)}/health",
|
|
535
|
+
timeout=10,
|
|
536
|
+
follow_redirects=True,
|
|
537
|
+
)
|
|
538
|
+
if r.status_code == 200:
|
|
539
|
+
return True, "OK"
|
|
540
|
+
return False, f"HTTP {r.status_code}"
|
|
541
|
+
except Exception as e:
|
|
542
|
+
return False, str(e)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _test_rpc(
|
|
546
|
+
hostname: str, port: int, secret: str
|
|
547
|
+
) -> tuple[bool, Optional[float], str]:
|
|
548
|
+
"""Send system_health RPC over HTTPS. Return (ok, latency_ms, message)."""
|
|
549
|
+
url = f"https://{_bracket_ipv6(hostname)}:{port}"
|
|
550
|
+
payload = {
|
|
551
|
+
"jsonrpc": "2.0",
|
|
552
|
+
"method": "system_health",
|
|
553
|
+
"params": [],
|
|
554
|
+
"id": 1,
|
|
555
|
+
}
|
|
556
|
+
try:
|
|
557
|
+
start = time.monotonic()
|
|
558
|
+
r = httpx.post(
|
|
559
|
+
url,
|
|
560
|
+
json=payload,
|
|
561
|
+
headers={"Authorization": f"Bearer {secret}"},
|
|
562
|
+
timeout=10,
|
|
563
|
+
verify=False,
|
|
564
|
+
)
|
|
565
|
+
latency = (time.monotonic() - start) * 1000
|
|
566
|
+
if r.status_code == 401:
|
|
567
|
+
return False, None, "Authentication failed (secret mismatch)"
|
|
568
|
+
if r.status_code != 200:
|
|
569
|
+
return False, None, f"HTTP {r.status_code}"
|
|
570
|
+
data = r.json()
|
|
571
|
+
if "result" in data:
|
|
572
|
+
result = data["result"]
|
|
573
|
+
peers = result.get("peers", "?")
|
|
574
|
+
syncing = result.get("isSyncing", False)
|
|
575
|
+
status = "syncing" if syncing else "synced"
|
|
576
|
+
return True, latency, f"peers={peers}, {status}"
|
|
577
|
+
error = data.get("error", {}).get("message", json.dumps(data))
|
|
578
|
+
return False, latency, f"RPC error: {error}"
|
|
579
|
+
except Exception as e:
|
|
580
|
+
return False, None, str(e)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _fetch_active_secret(client: RegistryClient, node_id: str) -> Optional[str]:
|
|
584
|
+
"""Fetch the decrypted active secret from the registry."""
|
|
585
|
+
response = client.get(f"/nodes/{node_id}/secret")
|
|
586
|
+
if response.status_code != 200:
|
|
587
|
+
return None
|
|
588
|
+
for s in response.json().get("secrets", []):
|
|
589
|
+
if s.get("state") == "active" and s.get("secret"):
|
|
590
|
+
return s["secret"]
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@app.command("test")
|
|
595
|
+
def test(
|
|
596
|
+
alias: Optional[str] = typer.Argument(None, help="Node alias or ID"),
|
|
597
|
+
endpoint: Optional[str] = typer.Option(
|
|
598
|
+
None, "--endpoint", "-e", help="Endpoint URL (skip registry lookup)"
|
|
599
|
+
),
|
|
600
|
+
secret: Optional[str] = typer.Option(
|
|
601
|
+
None, "--secret", help="Bearer token (overrides registry)"
|
|
602
|
+
),
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Test connectivity to a miner node (TLS, health, auth, RPC).
|
|
605
|
+
|
|
606
|
+
For registered nodes, fetches the secret from the registry
|
|
607
|
+
automatically. Use --endpoint and --secret for pre-registration
|
|
608
|
+
testing.
|
|
609
|
+
"""
|
|
610
|
+
if not endpoint:
|
|
611
|
+
alias = _get_alias(alias)
|
|
612
|
+
config = load_config()
|
|
613
|
+
with RegistryClient(config) as client:
|
|
614
|
+
node_id = _resolve_node(client, alias)
|
|
615
|
+
response = client.get(f"/nodes/{node_id}")
|
|
616
|
+
if response.status_code != 200:
|
|
617
|
+
console.print(f"[red]Error:[/red] {response.status_code}")
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
node = response.json()
|
|
620
|
+
endpoint = node["endpoint"]
|
|
621
|
+
if not secret:
|
|
622
|
+
secret = _fetch_active_secret(client, node_id)
|
|
623
|
+
console.print(f"[bold]Alias:[/bold] {alias}")
|
|
624
|
+
|
|
625
|
+
parsed = urlparse(endpoint)
|
|
626
|
+
hostname = parsed.hostname
|
|
627
|
+
port = parsed.port or 443
|
|
628
|
+
|
|
629
|
+
if not hostname:
|
|
630
|
+
console.print(f"[red]Cannot parse endpoint:[/red] {endpoint}")
|
|
631
|
+
raise typer.Exit(1)
|
|
632
|
+
|
|
633
|
+
console.print(f"[bold]Endpoint:[/bold] {endpoint}\n")
|
|
634
|
+
|
|
635
|
+
all_ok = True
|
|
636
|
+
|
|
637
|
+
# 1. TLS
|
|
638
|
+
console.print("TLS handshake... ", end="")
|
|
639
|
+
tls_ok, fingerprint, tls_msg = _test_tls(hostname, port)
|
|
640
|
+
if tls_ok:
|
|
641
|
+
is_ip = _is_ip_endpoint(endpoint)
|
|
642
|
+
mode = "pinned" if is_ip else "CA-verified"
|
|
643
|
+
fp_short = fingerprint[:16] + "..." if fingerprint else "none"
|
|
644
|
+
console.print(f"[green]OK[/green] ({mode}, {fp_short})")
|
|
645
|
+
else:
|
|
646
|
+
console.print(f"[red]FAIL[/red] {tls_msg}")
|
|
647
|
+
all_ok = False
|
|
648
|
+
|
|
649
|
+
# 2. Health
|
|
650
|
+
console.print("Health check... ", end="")
|
|
651
|
+
health_ok, health_msg = _test_health(hostname)
|
|
652
|
+
if health_ok:
|
|
653
|
+
console.print("[green]OK[/green]")
|
|
654
|
+
else:
|
|
655
|
+
console.print(f"[red]FAIL[/red] {health_msg}")
|
|
656
|
+
all_ok = False
|
|
657
|
+
|
|
658
|
+
# 3. RPC with auth (only if secret provided)
|
|
659
|
+
if secret:
|
|
660
|
+
console.print("RPC query... ", end="")
|
|
661
|
+
rpc_ok, latency, rpc_msg = _test_rpc(hostname, port, secret)
|
|
662
|
+
if rpc_ok:
|
|
663
|
+
console.print(f"[green]OK[/green] ({rpc_msg}, {latency:.0f}ms)")
|
|
664
|
+
else:
|
|
665
|
+
console.print(f"[red]FAIL[/red] {rpc_msg}")
|
|
666
|
+
all_ok = False
|
|
667
|
+
else:
|
|
668
|
+
console.print("[dim]Pass --secret to also test auth and RPC[/dim]")
|
|
669
|
+
|
|
670
|
+
console.print()
|
|
671
|
+
if all_ok:
|
|
672
|
+
console.print("[green]All checks passed[/green]")
|
|
673
|
+
else:
|
|
674
|
+
console.print("[red]Some checks failed[/red]")
|
|
675
|
+
raise typer.Exit(1)
|
|
676
|
+
|
|
677
|
+
|
|
368
678
|
# ── Snapshots ────────────────────────────────────────────────────
|
|
369
679
|
|
|
370
680
|
|
|
@@ -20,7 +20,7 @@ MAINNET = NetworkConfig(
|
|
|
20
20
|
|
|
21
21
|
TESTNET = NetworkConfig(
|
|
22
22
|
auth_url="https://test-auth.taostats.io",
|
|
23
|
-
api_url="
|
|
23
|
+
api_url="https://testnet-registry.blockmachine.io",
|
|
24
24
|
netuid=417,
|
|
25
25
|
)
|
|
26
26
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|