Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff408cc07 | ||
|
|
1988a68e67 | ||
|
|
c9e13ce143 | ||
|
|
f6fd002981 | ||
|
|
b6eb14b13f | ||
|
|
8e401b80a0 | ||
|
|
cc0a88ed9f | ||
|
|
ce73552b59 | ||
|
|
1b0b119a09 | ||
|
|
7fe6335583 |
@@ -18,7 +18,7 @@ app:
|
||||
network: archy-net
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256Mi
|
||||
memory_limit: 256m
|
||||
disk_limit: 128Mi
|
||||
security:
|
||||
capabilities: []
|
||||
|
||||
@@ -685,23 +685,100 @@ impl Runtime for Podman {
|
||||
Ok(())
|
||||
}
|
||||
async fn healthy(&self, id: &str) -> Result<bool> {
|
||||
// Missing healthcheck is not fabricated health. Running-only containers
|
||||
// can pass runtime readiness; healthchecked members must report healthy.
|
||||
for _ in 0..30 {
|
||||
wait_for_container_health(|| async {
|
||||
let raw = Self::command(&["inspect", id]).await?;
|
||||
let rows: Vec<serde_json::Value> = serde_json::from_str(&raw)?;
|
||||
let row = rows.first().context("Missing updated container")?;
|
||||
if row.pointer("/State/Status").and_then(|v| v.as_str()) != Some("running") {
|
||||
return Ok(false);
|
||||
rows.into_iter().next().context("Missing updated container")
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// A healthcheck may not run until its interval elapses. The former 30 samples
|
||||
// could reject a service immediately before its first successful scheduled check.
|
||||
const HEALTH_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
const HEALTH_POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
fn health_wait_budget(row: &serde_json::Value) -> std::time::Duration {
|
||||
let config = row.pointer("/Config/Healthcheck");
|
||||
let positive = |field: &str, default: u64| {
|
||||
config
|
||||
.and_then(|v| v.get(field))
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(default) as u128
|
||||
};
|
||||
// Zero/missing interval and timeout use conservative 30-second defaults;
|
||||
// zero/missing retries uses three. Never let malformed/huge metadata extend
|
||||
// the global ceiling, and never extend the deadline on subsequent polls.
|
||||
let interval = positive("Interval", 30_000_000_000);
|
||||
let timeout = positive("Timeout", 30_000_000_000);
|
||||
let retries = positive("Retries", 3);
|
||||
let start = config
|
||||
.and_then(|v| v.get("StartPeriod"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u128;
|
||||
let nanos = start
|
||||
.saturating_add(retries.saturating_mul(interval.saturating_add(timeout)))
|
||||
.saturating_add(HEALTH_POLL.as_nanos())
|
||||
.clamp(30_000_000_000, HEALTH_MAX_WAIT.as_nanos());
|
||||
std::time::Duration::from_nanos(nanos as u64)
|
||||
}
|
||||
|
||||
fn container_health(row: &serde_json::Value) -> Option<bool> {
|
||||
if row.pointer("/State/Status").and_then(|v| v.as_str()) != Some("running") {
|
||||
return Some(false);
|
||||
}
|
||||
match row.pointer("/State/Health/Status").and_then(|v| v.as_str()) {
|
||||
Some("healthy") => Some(true),
|
||||
Some("unhealthy") => Some(false),
|
||||
None | Some("") => {
|
||||
let configured = match row.pointer("/Config/Healthcheck") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
Some(config) => match config.get("Test").and_then(|v| v.as_array()) {
|
||||
Some(test) => {
|
||||
!test.is_empty() && test.first().and_then(|v| v.as_str()) != Some("NONE")
|
||||
}
|
||||
None => true, // malformed/incomplete check config is not proof of no check
|
||||
},
|
||||
};
|
||||
if configured {
|
||||
None
|
||||
} else {
|
||||
Some(true)
|
||||
}
|
||||
match row.pointer("/State/Health/Status").and_then(|v| v.as_str()) {
|
||||
None | Some("") | Some("healthy") => return Ok(true),
|
||||
Some("unhealthy") => return Ok(false),
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(false)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_container_health<F, Fut>(mut inspect: F) -> Result<bool>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<serde_json::Value>>,
|
||||
{
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut row = match tokio::time::timeout_at(started + HEALTH_MAX_WAIT, inspect()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
let deadline = started + health_wait_budget(&row);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(healthy) = container_health(&row) {
|
||||
return Ok(healthy);
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep_until((now + HEALTH_POLL).min(deadline)).await;
|
||||
row = match tokio::time::timeout_at(deadline, inspect()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,3 +1213,128 @@ mod tests {
|
||||
assert!(Guard::acquire(root.path()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod health_readiness_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
fn checked(status: &str) -> serde_json::Value {
|
||||
json!({"State":{"Status":"running","Health":{"Status":status}},
|
||||
"Config":{"Healthcheck":{"Test":["CMD","probe"],"Interval":30_000_000_000u64,
|
||||
"Timeout":5_000_000_000u64,"Retries":5}}})
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn scheduled_health_at_31_seconds_is_not_rejected_at_30() {
|
||||
let started = tokio::time::Instant::now();
|
||||
let result = wait_for_container_health(|| async {
|
||||
Ok(checked(if started.elapsed() >= Duration::from_secs(31) {
|
||||
"healthy"
|
||||
} else {
|
||||
"starting"
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(31));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn unhealthy_and_exited_fail_immediately() {
|
||||
for row in [checked("unhealthy"), json!({"State":{"Status":"exited"}})] {
|
||||
let started = tokio::time::Instant::now();
|
||||
assert!(
|
||||
!wait_for_container_health(|| std::future::ready(Ok(row.clone())))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(started.elapsed(), Duration::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn starting_is_bounded_by_initial_config_even_if_later_config_grows() {
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut first = true;
|
||||
assert!(!wait_for_container_health(|| {
|
||||
let mut row = checked("starting");
|
||||
if first {
|
||||
first = false;
|
||||
} else {
|
||||
row["Config"]["Healthcheck"]["StartPeriod"] = json!(u64::MAX);
|
||||
}
|
||||
std::future::ready(Ok(row))
|
||||
})
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(176));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn blocked_initial_inspect_cannot_exceed_global_ceiling() {
|
||||
let started = tokio::time::Instant::now();
|
||||
assert!(!wait_for_container_health(|| std::future::pending())
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(started.elapsed(), HEALTH_MAX_WAIT);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn blocked_later_inspect_cannot_exceed_initial_deadline() {
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut calls = 0;
|
||||
assert!(!wait_for_container_health(|| {
|
||||
calls += 1;
|
||||
let call = calls;
|
||||
async move {
|
||||
if call == 1 {
|
||||
Ok(checked("starting"))
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(176));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn missing_status_is_not_success_for_configured_healthcheck() {
|
||||
let mut row = checked("starting");
|
||||
row["State"].as_object_mut().unwrap().remove("Health");
|
||||
let started = tokio::time::Instant::now();
|
||||
assert!(
|
||||
!wait_for_container_health(|| std::future::ready(Ok(row.clone())))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(started.elapsed(), Duration::from_secs(176));
|
||||
assert!(wait_for_container_health(|| std::future::ready(Ok(
|
||||
json!({"State":{"Status":"running"}})
|
||||
)))
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_budget_defaults_start_period_and_extreme_values_are_bounded() {
|
||||
assert_eq!(
|
||||
health_wait_budget(&checked("starting")),
|
||||
Duration::from_secs(176)
|
||||
);
|
||||
let mut row = checked("starting");
|
||||
row["Config"]["Healthcheck"]["StartPeriod"] = json!(20_000_000_000u64);
|
||||
assert_eq!(health_wait_budget(&row), Duration::from_secs(196));
|
||||
for value in [json!(0), json!("bad"), json!(-1)] {
|
||||
row["Config"]["Healthcheck"] =
|
||||
json!({"Interval":value,"Timeout":value,"Retries":value});
|
||||
assert_eq!(health_wait_budget(&row), Duration::from_secs(181));
|
||||
}
|
||||
row["Config"]["Healthcheck"] = json!({"StartPeriod":u64::MAX,"Interval":u64::MAX,"Timeout":u64::MAX,"Retries":u64::MAX});
|
||||
assert_eq!(health_wait_budget(&row), HEALTH_MAX_WAIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import http.client
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -128,31 +130,66 @@ def screen_tips(npub, lightning_address):
|
||||
name, host = lightning_address.split('@')
|
||||
if not name or not host or any(char in host for char in '/?#:@'):
|
||||
raise ValueError('invalid Lightning address')
|
||||
endpoint = assert_public_https(f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}')
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
raise ValueError('LNURL redirect is not permitted for screen metadata')
|
||||
|
||||
with build_opener(NoRedirect).open(Request(endpoint, headers={'User-Agent': 'JustWorks-Screen/1'}), timeout=4) as response:
|
||||
raw = response.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('LNURL metadata too large')
|
||||
pay = json.loads(raw)
|
||||
endpoint = f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}'
|
||||
pay = fetch_lnurl_json(endpoint, timeout=4)
|
||||
provider = pay.get('nostrPubkey')
|
||||
if pay.get('tag') != 'payRequest' or pay.get('allowsNostr') is not True or not isinstance(provider, str) or len(provider) != 64 or any(c not in '0123456789abcdef' for c in provider):
|
||||
raise ValueError('No verified Nostr receipt source')
|
||||
return helper({'action': 'screen-tips', 'npub': npub, 'provider': provider, 'relays': PAYMENT_RELAYS})['tips']
|
||||
|
||||
|
||||
def assert_public_https(url):
|
||||
def resolve_public_https(url):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
|
||||
raise ValueError("Lightning provider returned an unsafe URL")
|
||||
addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM)
|
||||
if not addresses or any(not ipaddress.ip_address(item[4][0]).is_global for item in addresses):
|
||||
raise ValueError("Lightning provider must use a public host")
|
||||
return url
|
||||
return parsed, addresses
|
||||
|
||||
|
||||
class PinnedLnurlConnection(http.client.HTTPSConnection):
|
||||
def __init__(self, parsed, addresses, timeout):
|
||||
# Direct sockets intentionally ignore ambient HTTP(S)_PROXY settings.
|
||||
super().__init__(parsed.hostname, parsed.port or 443, timeout=timeout,
|
||||
context=ssl.create_default_context())
|
||||
self.addresses = addresses
|
||||
|
||||
def connect(self):
|
||||
last_error = None
|
||||
for family, kind, protocol, _canonical, address in self.addresses:
|
||||
connection = socket.socket(family, kind, protocol)
|
||||
try:
|
||||
connection.settimeout(self.timeout)
|
||||
# Numeric sockaddr from the validated lookup: no second DNS lookup.
|
||||
connection.connect(address)
|
||||
self.sock = self._context.wrap_socket(connection, server_hostname=self.host)
|
||||
return
|
||||
except OSError as error:
|
||||
connection.close()
|
||||
last_error = error
|
||||
raise last_error or OSError("Lightning provider connection unavailable")
|
||||
|
||||
|
||||
def fetch_lnurl_json(url, timeout=10):
|
||||
parsed, addresses = resolve_public_https(url)
|
||||
connection = PinnedLnurlConnection(parsed, addresses, timeout)
|
||||
try:
|
||||
target = parsed.path or "/"
|
||||
if parsed.query:
|
||||
target += "?" + parsed.query
|
||||
connection.request("GET", target, headers={"User-Agent": "JustWorks-Business/0.1"})
|
||||
response = connection.getresponse()
|
||||
if 300 <= response.status < 400:
|
||||
raise ValueError("Lightning provider redirects are not permitted")
|
||||
if response.status >= 400:
|
||||
raise HTTPError(url, response.status, response.reason, response.headers, None)
|
||||
raw = response.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError("Lightning provider response too large")
|
||||
return json.loads(raw)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def lightning_invoice(lightning_address, amount_msat, comment=""):
|
||||
@@ -161,14 +198,14 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
|
||||
name, host = lightning_address.rsplit("@", 1)
|
||||
if not name or not host or any(char in host for char in "/?#"):
|
||||
raise ValueError("Merchant Lightning address is invalid")
|
||||
endpoint = assert_public_https(f"https://{host}/.well-known/lnurlp/{name}")
|
||||
pay = fetch_json(endpoint)
|
||||
endpoint = f"https://{host}/.well-known/lnurlp/{quote(name, safe="")}"
|
||||
pay = fetch_lnurl_json(endpoint)
|
||||
if pay.get("tag") != "payRequest" or not pay.get("callback"):
|
||||
raise ValueError("Lightning address does not support payments")
|
||||
minimum, maximum = int(pay.get("minSendable", 0)), int(pay.get("maxSendable", 0))
|
||||
if amount_msat < minimum or (maximum and amount_msat > maximum):
|
||||
raise ValueError(f"Amount must be between {max(1, minimum // 1000)} and {maximum // 1000} sats")
|
||||
callback = assert_public_https(str(pay["callback"]))
|
||||
callback = str(pay["callback"])
|
||||
# Some LNURL providers reject otherwise valid NIP-57 requests when their
|
||||
# signed content contains a literal percent sign. Keep the human meaning
|
||||
# while using a provider-safe comment for both the zap and callback.
|
||||
@@ -186,7 +223,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
|
||||
params["comment"] = safe_comment[:int(pay["commentAllowed"])]
|
||||
separator = "&" if "?" in callback else "?"
|
||||
try:
|
||||
invoice = fetch_json(f"{callback}{separator}{urlencode(params)}")
|
||||
invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(params)}")
|
||||
except HTTPError:
|
||||
if not proof:
|
||||
raise
|
||||
@@ -195,7 +232,7 @@ def lightning_invoice(lightning_address, amount_msat, comment=""):
|
||||
# the result non-verifiable instead of blocking the customer.
|
||||
proof = None
|
||||
fallback = {key: value for key, value in params.items() if key not in {"nostr", "lnurl"}}
|
||||
invoice = fetch_json(f"{callback}{separator}{urlencode(fallback)}")
|
||||
invoice = fetch_lnurl_json(f"{callback}{separator}{urlencode(fallback)}")
|
||||
if invoice.get("status") == "ERROR" or not invoice.get("pr"):
|
||||
raise ValueError(invoice.get("reason", "Lightning invoice unavailable"))
|
||||
return {"bolt11": invoice["pr"], "zap_pubkey": proof["pubkey"] if proof else None,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""No-network checks using real HTTP request/response parsing over fake sockets."""
|
||||
import io
|
||||
import socket
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.error import HTTPError
|
||||
import server
|
||||
|
||||
PUBLIC = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('93.184.216.34', 443))]
|
||||
PRIVATE = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('127.0.0.1', 443))]
|
||||
|
||||
class FixtureSocket:
|
||||
def __init__(self, body=b'{}', redirect=None, status=302):
|
||||
headers = f'Location: {redirect}\r\n' if redirect else ''
|
||||
self.response = (f'HTTP/1.1 {status if redirect else 200} Fixture\r\n{headers}Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode() + body
|
||||
self.connected, self.sent, self.closed, self.timeout = [], b'', False, None
|
||||
def settimeout(self, value): self.timeout = value
|
||||
def connect(self, address): self.connected.append(address)
|
||||
def sendall(self, body): self.sent += body
|
||||
def makefile(self, *args): return io.BytesIO(self.response)
|
||||
def close(self): self.closed = True
|
||||
|
||||
class LnurlTransportTests(unittest.TestCase):
|
||||
def transport(self, fixture, url='https://provider.example/lnurl', timeout=10):
|
||||
context = Mock()
|
||||
context.wrap_socket.return_value = fixture
|
||||
with patch('server.socket.getaddrinfo', side_effect=[PUBLIC, PRIVATE]) as lookup, patch('server.socket.socket', return_value=fixture), patch('server.ssl.create_default_context', return_value=context), patch.dict('os.environ', {'HTTPS_PROXY':'http://127.0.0.1:9999'}):
|
||||
try: return server.fetch_lnurl_json(url, timeout=timeout)
|
||||
finally:
|
||||
self.assertEqual(lookup.call_count, 1)
|
||||
self.assertEqual(fixture.connected, [('93.184.216.34', 443)])
|
||||
context.wrap_socket.assert_called_once_with(fixture, server_hostname='provider.example')
|
||||
self.assertIn(b'Host: provider.example\r\n', fixture.sent)
|
||||
self.assertTrue(fixture.closed)
|
||||
def test_redirects_never_connect_to_another_destination(self):
|
||||
for destination in ['http://127.0.0.1/admin', 'https://10.0.0.1/private', 'https://other.example/callback']:
|
||||
for status in [301, 302, 303, 307, 308]:
|
||||
with self.subTest(destination=destination, status=status), self.assertRaisesRegex(ValueError, 'redirects are not permitted'):
|
||||
self.transport(FixtureSocket(redirect=destination, status=status))
|
||||
def test_dns_rebinding_cannot_trigger_second_lookup_and_tls_keeps_hostname(self):
|
||||
fixture = FixtureSocket(body=b'{"tag":"payRequest"}')
|
||||
self.assertEqual(self.transport(fixture), {'tag':'payRequest'})
|
||||
self.assertEqual(fixture.timeout, 10)
|
||||
def test_response_cap_precedes_json_parsing(self):
|
||||
with self.assertRaisesRegex(ValueError, 'response too large'): self.transport(FixtureSocket(body=b' ' * 65537))
|
||||
def test_private_initial_resolution_never_opens_socket(self):
|
||||
with patch('server.socket.getaddrinfo', return_value=PRIVATE), patch('server.socket.socket') as connect:
|
||||
with self.assertRaisesRegex(ValueError, 'public host'): server.fetch_lnurl_json('https://provider.example/lnurl')
|
||||
connect.assert_not_called()
|
||||
def test_non_https_and_userinfo_never_resolve(self):
|
||||
for url in ['http://provider.example', 'https://owner:secret@provider.example', 'https://provider.example/#fragment']:
|
||||
with self.subTest(url=url), patch('server.socket.getaddrinfo') as lookup:
|
||||
with self.assertRaises(ValueError): server.fetch_lnurl_json(url)
|
||||
lookup.assert_not_called()
|
||||
def test_screen_metadata_retains_four_second_timeout_and_shared_transport(self):
|
||||
with patch('server.fetch_lnurl_json', return_value={'tag':'payRequest','allowsNostr':True,'nostrPubkey':'a'*64}) as fetch, patch('server.helper', return_value={'tips':[]}):
|
||||
self.assertEqual(server.screen_tips('fixture-npub','merchant@provider.example'), [])
|
||||
fetch.assert_called_once_with('https://provider.example/.well-known/lnurlp/merchant', timeout=4)
|
||||
fixture=FixtureSocket();self.transport(fixture,timeout=4);self.assertEqual(fixture.timeout,4)
|
||||
def test_metadata_and_invoice_use_restricted_transport(self):
|
||||
pay={'tag':'payRequest','callback':'https://provider.example/invoice','minSendable':1,'maxSendable':5000}
|
||||
with patch('server.fetch_lnurl_json', side_effect=[pay,{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted:
|
||||
result=server.lightning_invoice('merchant@provider.example',1000)
|
||||
self.assertEqual(result['bolt11'],'fixture-invoice');self.assertEqual(fetch.call_count,2);unrestricted.assert_not_called()
|
||||
def test_zap_fallback_uses_restricted_transport(self):
|
||||
pay={'tag':'payRequest','callback':'https://provider.example/invoice','allowsNostr':True,'nostrPubkey':'a'*64}
|
||||
with patch('server.helper',return_value={'event':{},'pubkey':'b'*64,'lnurl':'fixture-lnurl'}), patch('server.fetch_lnurl_json',side_effect=[pay,HTTPError('',400,'fixture',{},None),{'pr':'fixture-invoice'}]) as fetch, patch('server.fetch_json') as unrestricted:
|
||||
result=server.lightning_invoice('merchant@provider.example',1000)
|
||||
self.assertFalse(result['verifiable']);self.assertEqual(fetch.call_count,3);unrestricted.assert_not_called()
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -37,8 +37,8 @@ consent. No node wallet key is sent to hosted Core.
|
||||
|
||||
Ingest into the next normal deployment
|
||||
1. Review/merge once on ngit, then mirror the exact accepted commits to Gitea.
|
||||
The explicitly requested Gitea PR is a cross-linked review mirror; do not
|
||||
independently squash or merge it to produce a different history.
|
||||
PRs belong on ngit only. Gitea mirrors accepted code; do not open a duplicate
|
||||
PR or independently squash/merge to produce a different history.
|
||||
2. Build the current combined platform source. Do not deploy an old demo backend
|
||||
or replace newer work with the binary recorded below. Include the Just Works
|
||||
FIPS port mapping and existing native-import RPCs.
|
||||
|
||||
@@ -1558,3 +1558,43 @@ announcements remain unrecovered from the sources checked. This releases the
|
||||
all-project-discovery publication hold, not a claim that recovery passed. Track
|
||||
recovery separately and retain the limitation in release notes. Other artifact,
|
||||
upgrade, security and publication checks remain required.
|
||||
|
||||
## 2026-10-08: final IndeeHub update restored at health-check boundary
|
||||
|
||||
Native operation `851483a2` automatically restored during target startup, before
|
||||
the final frontend was started. This is **not successful final delivery**.
|
||||
The MinIO target started at 18:24:31.489 UTC. Its immediate probe was still
|
||||
`starting`; MinIO reported API readiness at 18:24:32, and the next scheduled
|
||||
health check reported `healthy` at 18:25:02.851. The updater's previous 30 samples,
|
||||
one second apart with no final sample after sleeping, could expire before that
|
||||
healthy result. The actual health configuration uses a 30-second interval,
|
||||
five-second timeout and five retries. PostgreSQL, Redis and MinIO were the only
|
||||
target members started; relay, API, worker and final frontend were not started.
|
||||
|
||||
Independent post-restoration verification passed: all 31 running containers,
|
||||
24 unrelated runtime identities preserved, the exact current 110-migration
|
||||
history and original database commitments preserved, and restoration-image
|
||||
proof matched. Retain the operation journal and backup history; do not treat a
|
||||
restored transaction as a successful update or erase the first failed attempt.
|
||||
|
||||
The shared native provider (`b77…`) is deployed. The final frontend image
|
||||
(`8db…`) remains pending; paired cached-account-A to chosen-identity-B acceptance
|
||||
has not run. Prior clean native login success does not prove this reported
|
||||
cached-account transition is fixed on the deployed frontend.
|
||||
|
||||
Source correction `7fe63355` replaces the sample-count deadline with a bounded
|
||||
monotonic readiness deadline derived once from the first inspected health
|
||||
configuration. It includes the configured start period, intervals, timeouts and
|
||||
retries, with a 30-second minimum and five-minute ceiling; every inspect is
|
||||
bounded too. `starting` never qualifies as healthy, configured-but-missing health
|
||||
status stays pending, and unhealthy or exited containers fail immediately.
|
||||
Running-only readiness remains allowed only for containers without a configured
|
||||
health check. The isolated runner checkout bind is separately committed as
|
||||
`1b0b119a` and preserves all existing isolation properties.
|
||||
|
||||
Validation at this checkpoint: source formatting and independent source review
|
||||
passed; seven deterministic paused-time regressions are compiling through the
|
||||
isolated runner. The full isolated suite, production backend build, another
|
||||
explicit native update, post-update runtime/data proofs, and paired browser
|
||||
acceptance remain pending. No wallet, payment, personal-media or profile changes
|
||||
are part of this readiness correction.
|
||||
|
||||
@@ -42,6 +42,7 @@ PY
|
||||
unit="archy-isolated-tests-$(date +%s)-$$"
|
||||
sudo -n systemd-run --unit="$unit" --wait --pipe --collect \
|
||||
--property="WorkingDirectory=$REPO/core" \
|
||||
--property="BindReadOnlyPaths=$REPO" \
|
||||
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
|
||||
--property=ProtectSystem=strict --property=ProtectHome=read-only \
|
||||
--property=NoNewPrivileges=yes \
|
||||
|
||||
Reference in New Issue
Block a user