This commit is contained in:
2026-10-19 16:40:42 -04:00
parent 321f7d8c00
commit d038fe3bab
10 changed files with 411 additions and 46 deletions
+150 -21
View File
@@ -5,10 +5,13 @@ Deploy the Wikideck production container to an existing Kubernetes cluster using
volume claim, a dedicated ServiceAccount, a ClusterIP Service, and a NetworkPolicy. volume claim, a dedicated ServiceAccount, a ClusterIP Service, and a NetworkPolicy.
An HTTPS Ingress is optional and disabled by default. An HTTPS Ingress is optional and disabled by default.
**Wikideck is a prototype, not a hardened public service.** This role does not add **Wikideck is an experimental RandomX testnet, not a production-ready public
application authentication, distributed consensus, a database, or wallet recovery. mainnet.** The updated application has independent validating nodes, fork choice,
Use test wallets and restrict access. Mining happens in the browser; the server native NFTs, and encrypted wallet backup/recovery. It supplies no public bootnodes
coordinates the chain and verifies proofs. or deployed public network. Use test wallets and no valuable assets. This role
deploys a **validator**, never a miner: mining is an explicitly started native CLI
process outside the browser. Private keys, backups and passwords must stay off
validator pods and out of Ansible variables.
## Requirements ## Requirements
@@ -24,6 +27,10 @@ coordinates the chain and verifies proofs.
- **A published image built from Wikideck's production Dockerfile.** There is no - **A published image built from Wikideck's production Dockerfile.** There is no
assumed official registry image. Set `wikideck_image` to your actual tagged or assumed official registry image. Set `wikideck_image` to your actual tagged or
digest-pinned image. The role does not build/push images or install a cluster. digest-pinned image. The role does not build/push images or install a cluster.
Rebuild from the current source: the image must include Node 24, libstdc++, and
the native RandomX executable, with `RANDOMX_BINARY` set by the image. An old
browser-mining image is incompatible. Build and verify for your worker node
architecture; do not assume a locally built image is multi-architecture.
- For ingress: an ingress controller, DNS, and a trusted TLS Secret in the app - For ingress: an ingress controller, DNS, and a trusted TLS Secret in the app
namespace. Alternatively, configure cert-manager annotations and an existing namespace. Alternatively, configure cert-manager annotations and an existing
issuer. The role does not install cert-manager or create an issuer. issuer. The role does not install cert-manager or create an issuer.
@@ -37,8 +44,8 @@ environment. Windows controllers must run Ansible through WSL or Linux container
Install this checkout into your playbook's role search path as `roles/wikideck`, Install this checkout into your playbook's role search path as `roles/wikideck`,
or install it using an Ansible Galaxy Git role requirement named `wikideck`. or install it using an Ansible Galaxy Git role requirement named `wikideck`.
[examples/playbook.yml](examples/playbook.yml) shows an HTTPS deployment with [examples/playbook.yml](examples/playbook.yml) shows an HTTPS deployment with
ingress-nginx and cert-manager. Replace the image, hostname, class, issuer, and ingress-nginx and cert-manager. Replace the image, hostname, peer URLs, class,
controller namespace with real values before running it. issuer, and controller namespace with real values before running it.
```yaml ```yaml
- name: Deploy a cluster-internal Wikideck instance - name: Deploy a cluster-internal Wikideck instance
@@ -67,6 +74,64 @@ for browser wallet cryptography. Configure HTTP-to-HTTPS redirect at your ingres
controller; the example includes the ingress-nginx annotation. Ingress creation controller; the example includes the ingress-nginx annotation. Ingress creation
does not wait for DNS, certificate issuance, or an externally reachable endpoint. does not wait for DNS, certificate issuance, or an externally reachable endpoint.
### Connect validators
Set `wikideck_peers` to a list of up to 16 compatible peer HTTPS origins, such as
`https://peer-a.example.org`. The role joins this list into `WIKIDECK_PEERS`.
An empty list deliberately starts an **isolated validator**. There is no automatic
discovery, bootnode list, or automatic reciprocal configuration: configure the
other operators' nodes too. All nodes must run the same network/genesis/consensus.
Peer RPC uses `/p2p/*` on the **same port 3001** as the UI/API; no separate TCP/UDP
P2P Service or host networking is required. The existing `/` ingress path forwards
those routes. Keep peer JSON responses uncompressed and do not redirect HTTPS
peer requests: the client rejects redirects, non-JSON and compressed responses.
An interactive login gateway on `/p2p/*` will break the current unauthenticated
peer protocol; use deliberate source restrictions/abuse controls instead.
Public peers must use HTTPS and public DNS/IP addresses. Wikideck validates every
connection's DNS answers and pins the connection to that validated result. Never
disable TLS verification to work around a certificate or split-horizon DNS error.
The default policy allows public TCP 443 only when peers are configured. For
HTTPS peer origins on another port, add that port to
`wikideck_network_policy_https_ports`, or supply narrowly scoped explicit egress
rules. Browser Wikimedia image fetches are client-side; validators no longer
fetch random Wikipedia articles.
For an explicitly private testnet only, set `wikideck_allow_private_peers: true`
and provide `wikideck_network_policy_peer_egress` rules with destination selectors
and numeric TCP ports. This opt-in permits **both private addresses and HTTP** in
the application; it does not automatically open network policy. For example:
```yaml
wikideck_peers:
- http://other-node.games.svc.cluster.local:80
wikideck_allow_private_peers: true
wikideck_network_policy_https_to: []
wikideck_network_policy_peer_egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: games
podSelector:
matchLabels:
app.kubernetes.io/instance: other-node
ports:
- protocol: TCP
port: 3001
```
The receiving node must separately allow the sender through its ingress policy.
Service DNAT handling depends on the CNI; the example selects the destination pod
and its target port 3001 rather than opening all destinations on Service port 80.
Do not enable private-peer exceptions for public peer configuration.
Deploy multiple validators as **separate role instances with different names and
PVCs** (and separate hostnames if using ingress), not by scaling one Deployment.
Sharing a machine/cluster/storage failure domain does not provide independent
operator security. Empty local chains can synchronize from configured peers;
never seed a second running node by sharing its writable PVC.
## Variables ## Variables
All defaults are in [defaults/main.yml](defaults/main.yml). All defaults are in [defaults/main.yml](defaults/main.yml).
@@ -78,10 +143,12 @@ All defaults are in [defaults/main.yml](defaults/main.yml).
| `wikideck_namespace` | `wikideck` | Namespace for all namespaced resources. | | `wikideck_namespace` | `wikideck` | Namespace for all namespaced resources. |
| `wikideck_create_namespace` | `true` | Create the namespace; disable for a pre-provisioned namespace. | | `wikideck_create_namespace` | `true` | Create the namespace; disable for a pre-provisioned namespace. |
| `wikideck_kubeconfig` / `wikideck_kube_context` | `null` | Override controller authentication/context. API TLS verification remains enabled. | | `wikideck_kubeconfig` / `wikideck_kube_context` | `null` | Override controller authentication/context. API TLS verification remains enabled. |
| `wikideck_wait` / `wikideck_wait_timeout` | `true` / `300` | Wait for Deployment readiness, timeout in seconds. | | `wikideck_wait` / `wikideck_wait_timeout` | `true` / `900` | Wait for Deployment readiness, timeout in seconds. |
| `wikideck_startup_probe_failure_threshold` | `180` | Failed probes at 5-second intervals before startup restart; default budget 900 seconds. |
| `wikideck_image_pull_policy` | `IfNotPresent` | `Always`, `IfNotPresent`, or `Never`. | | `wikideck_image_pull_policy` | `IfNotPresent` | `Always`, `IfNotPresent`, or `Never`. |
| `wikideck_image_pull_secrets` | `[]` | Names of existing registry Secrets in this namespace. | | `wikideck_image_pull_secrets` | `[]` | Names of existing registry Secrets in this namespace. |
| `wikideck_offline` | `false` | Starter articles without live Wikipedia requests. | | `wikideck_peers` | `[]` | Up to 16 static peer origins; empty means isolated. |
| `wikideck_allow_private_peers` | `false` | Explicit test-only private/HTTP peer permission. |
| `wikideck_public_origin` | empty | Additional allowed HTTPS browser origin; required with ingress. | | `wikideck_public_origin` | empty | Additional allowed HTTPS browser origin; required with ingress. |
| `wikideck_ingress_enabled` | `false` | Create TLS ingress; disabling removes the role's named ingress. | | `wikideck_ingress_enabled` | `false` | Create TLS ingress; disabling removes the role's named ingress. |
| `wikideck_ingress_host` | empty | DNS hostname, without scheme, port, or path. | | `wikideck_ingress_host` | empty | DNS hostname, without scheme, port, or path. |
@@ -92,12 +159,19 @@ All defaults are in [defaults/main.yml](defaults/main.yml).
| `wikideck_storage_size` | `1Gi` | Requested PVC capacity; resizing requires StorageClass support. | | `wikideck_storage_size` | `1Gi` | Requested PVC capacity; resizing requires StorageClass support. |
| `wikideck_storage_class` | `null` | `null`: cluster default; empty string: no class; otherwise a class name. | | `wikideck_storage_class` | `null` | `null`: cluster default; empty string: no class; otherwise a class name. |
| `wikideck_storage_access_modes` | `[ReadWriteOnce]` | `ReadWriteOnce` or `ReadWriteOncePod`; never `ReadWriteMany`. | | `wikideck_storage_access_modes` | `[ReadWriteOnce]` | `ReadWriteOnce` or `ReadWriteOncePod`; never `ReadWriteMany`. |
| `wikideck_resources` | see defaults | CPU/memory requests `100m`/`128Mi`, limits `1`/`512Mi`. | | `wikideck_resources` | see defaults | CPU/memory requests `250m`/`512Mi`, limits `1`/`1Gi`; small-validator starting point, not a capacity guarantee. |
| `wikideck_node_selector` / `wikideck_tolerations` / `wikideck_affinity` | `{}` / `[]` / `{}` | Pod placement settings. | | `wikideck_node_selector` / `wikideck_tolerations` / `wikideck_affinity` | `{}` / `[]` / `{}` | Pod placement settings. |
| `wikideck_network_policy_enabled` | `true` | Manage network isolation; disabling deletes this role's policy. | | `wikideck_network_policy_enabled` | `true` | Manage network isolation; disabling deletes this role's policy. |
| `wikideck_network_policy_ingress_from` | same-namespace pods | List of NetworkPolicy peers allowed to reach TCP 3001; empty list denies all ingress. | | `wikideck_network_policy_ingress_from` | same-namespace pods | List of NetworkPolicy peers allowed to reach TCP 3001; empty list denies all ingress. |
| `wikideck_network_policy_dns_to` | `kube-system` namespace | DNS peers allowed on UDP/TCP 53; adapt for custom/NodeLocal DNS. | | `wikideck_network_policy_dns_to` | `kube-system` namespace | DNS peers allowed on UDP/TCP 53; adapt for custom/NodeLocal DNS. |
| `wikideck_network_policy_https_to` | public IPv4/IPv6 ranges, private/link-local excluded | TCP 443 egress peers; omitted in offline mode. | | `wikideck_network_policy_https_to` | public IPv4/IPv6 ranges, private/link-local excluded | Public peer destinations; omitted when there are no configured peers. |
| `wikideck_network_policy_https_ports` | `[443]` | Public peer TCP ports; extend for custom HTTPS origins. |
| `wikideck_network_policy_peer_egress` | `[]` | Additional explicit `to` + numeric TCP `ports` rules, emitted only with configured peers. |
`wikideck_offline` / `WIKIDECK_OFFLINE` have been removed. The current catalog is
fixed and genesis-bound, not a live Wikipedia feed. Remove the old role variable;
it no longer controls networking. The image's packaged `RANDOMX_BINARY` and default
entrypoint are deliberately preserved; no production fake-PoW fallback is added.
## Storage and safe updates ## Storage and safe updates
@@ -108,7 +182,11 @@ All defaults are in [defaults/main.yml](defaults/main.yml).
`ReadWriteOncePod` if supported by your CSI driver. Do not force-delete an old `ReadWriteOncePod` if supported by your CSI driver. Do not force-delete an old
pod on a partitioned node until it has been fenced; Recreate is not a pod on a partitioned node until it has been fenced; Recreate is not a
distributed storage lock. distributed storage lock.
- `/app/data` contains the chain and signed transfer journal. The process runs - `/app/data/testnet/ledger.json` stores the block DAG and pending transfers;
`/app/data/testnet-node.lock` exclusively locks the entire data directory.
The old demo chain is separate, not imported, and has **no migration** to the
new testnet. Back it up first; preferably use a fresh claim for the new network.
The process runs
as UID/GID 1000 with `fsGroup: 1000`. Your storage driver must honor ownership as UID/GID 1000 with `fsGroup: 1000`. Your storage driver must honor ownership
settings, or the existing volume must already be writable by this identity. settings, or the existing volume must already be writable by this identity.
No privileged/root ownership-fixing init container is used. No privileged/root ownership-fixing init container is used.
@@ -121,9 +199,37 @@ All defaults are in [defaults/main.yml](defaults/main.yml).
backup or use your CSI provider's documented snapshot procedure. Encrypt disks backup or use your CSI provider's documented snapshot procedure. Encrypt disks
and backups, define retention, and test restores. Deleting a namespace or PVC and backups, define retention, and test restores. Deleting a namespace or PVC
can still destroy data depending on the underlying reclaim policy. can still destroy data depending on the underlying reclaim policy.
- Chain verification runs at startup. For large chains, review resources, - Full native RandomX revalidation runs before the server listens. Increase
startup probe budget (300 seconds), and readiness wait timeout. JSON storage `wikideck_startup_probe_failure_threshold` and `wikideck_wait_timeout` together
is unbounded; this role cannot solve Wikideck's persistence scalability limits. for your measured replay time, leaving scheduling/image-pull margin. A healthy
empty node does not exercise the native cache or prove peer synchronization.
- The app bounds the ledger to 128 MiB and 10,000 total DAG blocks, with 256
pending transfers. Complete NFT states are retained per DAG node, so RAM may
grow quadratically despite disk bounds. Whole-ledger rewrites and full replay
are **not suitable for a long-lived public chain**; a larger PVC does not fix
those application limits.
### Crash recovery and resource sizing
The application removes its lock on graceful shutdown but deliberately does
**not** automatically recover a stale lock after an OOM kill, forced termination,
node loss, or interruption during startup replay. Kubernetes restarts can remain
in CrashLoopBackOff until an operator intervenes. This role never deletes the lock
in an init container or as an automatic "repair".
To recover, first stop the Deployment and verify every former owner is gone;
fence an unreachable old node before mounting the claim elsewhere. Take a backup,
then remove only the confirmed stale lock using an approved maintenance pod or
storage procedure. Preserve the ledger, restore the Deployment to one replica,
and inspect replay and peer status. Do not automatically remove locks based on
their PID: PIDs are reused and isolated between containers.
Each validator uses native **light-mode RandomX**, requiring a 256 MiB cache plus
scratchpad, Node heap, ledger states, and process overhead. The new 1 GiB limit
matches the updated upstream Compose starting budget, but must be measured against
your ledger, peer load and replay. Watch OOM events, CPU throttling, replay time,
and block propagation. Fast miners need **over 2.3 GiB per process** and separate
capacity/security planning; this role never launches one or mounts its wallet.
## Security and networking ## Security and networking
@@ -135,14 +241,14 @@ temporary directory are writable. All three HTTP probes target `/healthz` on por
period accommodates the application's 25-second shutdown deadline. period accommodates the application's 25-second shutdown deadline.
NetworkPolicy defaults allow same-namespace clients, cluster DNS in `kube-system`, NetworkPolicy defaults allow same-namespace clients, cluster DNS in `kube-system`,
and public HTTPS for Wikipedia. With ingress, explicitly allow the ingress and public HTTPS only when peer origins are configured. With ingress, allow the ingress
controller's namespace/pods as shown in the example. Peer entries are additive controller's namespace/pods as shown in the example. Peer entries are additive
OR alternatives: put a namespaceSelector and podSelector in the **same entry** to OR alternatives: put a namespaceSelector and podSelector in the **same entry** to
restrict pods within that namespace. Empty peer lists deny that traffic; empty restrict pods within that namespace. Empty peer lists deny that traffic; empty
peer objects are rejected to avoid accidentally allowing every source/destination. peer objects are rejected to avoid accidentally allowing every source/destination.
Standard NetworkPolicy cannot allow Wikipedia by hostname; the default public Standard NetworkPolicy cannot allow peer origins by hostname; public
TCP 443 egress is broader than a domain allowlist. Use CNI FQDN policies or an TCP 443 egress is broader than the app's fixed peer allowlist. Narrow CIDRs or use CNI FQDN policies or an
egress gateway if required. Policies are additive with any other policies in the egress gateway if required. Policies are additive with any other policies in the
namespace and have no effect without an enforcing CNI. NodeLocal DNS, host-network namespace and have no effect without an enforcing CNI. NodeLocal DNS, host-network
ingress controllers, service meshes, and private proxies may need peer changes. ingress controllers, service meshes, and private proxies may need peer changes.
@@ -155,16 +261,28 @@ API certificate verification. The role does not embed secrets or implement its
own cryptography; image signature verification and encrypted storage are cluster own cryptography; image signature verification and encrypted storage are cluster
operator responsibilities. Scan and rebuild the application image regularly. operator responsibilities. Scan and rebuild the application image regularly.
Native RandomX uses ordinary memory allocations and secure W^X JIT; the current
app does not request huge pages, host devices, executable files in `/tmp`, or
privileged capabilities. Keep the existing hardening. Verify actual hashing on
your worker architecture and runtime security profile; HTTP health alone cannot
detect every native engine failure. Do not grant privileged mode or disable
seccomp as an untested performance workaround.
Wikideck does not currently configure Express `trust proxy`. Clients behind one Wikideck does not currently configure Express `trust proxy`. Clients behind one
proxy can therefore share an IP-based application rate limit. Review the proxy can therefore share an IP-based application rate limit. Review the
application before public exposure; do not try to fix this by blindly trusting application before public exposure; do not try to fix this by blindly trusting
forwarded headers. The role adds no authentication, WAF, or abuse protection. forwarded headers. The current app limits `/api` and `/p2p` together; default
forwarded-header behavior can also produce express-rate-limit validation errors
in logs. The role adds no authentication, WAF, or abuse protection. Eclipse,
majority-hashpower, spam/DoS and reorganization risks remain application/protocol
issues; deployment hardening does not make a public mainnet safe.
## Validation ## Validation
[tests/render.yml](tests/render.yml) runs entirely offline: it validates inputs, [tests/render.yml](tests/render.yml) runs entirely offline: it validates inputs,
renders/parses every template across default, ingress, existing-claim, storage renders/parses every template across default, ingress, existing-claim, storage
class, offline, deny-all, and digest-image variants, and checks wiring/security class, isolated/public/private peers, custom HTTPS ports, deny-all, longer startup,
and digest-image variants, and checks wiring/security
invariants. It also checks that unsafe/invalid settings are rejected. Run it with invariants. It also checks that unsafe/invalid settings are rejected. Run it with
`ansible-playbook -i tests/inventory tests/render.yml`. `ansible-playbook -i tests/inventory tests/render.yml`.
@@ -173,8 +291,19 @@ checkout. Syntax-check it with `ansible-playbook -i tests/inventory tests/test.y
To deploy to a disposable test namespace, run it with To deploy to a disposable test namespace, run it with
`ansible-playbook -i tests/inventory tests/test.yml -e wikideck_image=YOUR_IMAGE -e wikideck_namespace=wikideck-test`. `ansible-playbook -i tests/inventory tests/test.yml -e wikideck_image=YOUR_IMAGE -e wikideck_namespace=wikideck-test`.
Run it a second time to verify idempotence. Check Pod readiness, `/healthz`, PVC Run it a second time to verify idempotence. Check Pod readiness, `/healthz`, PVC
retention across updates, DNS/HTTPS egress, TLS, and browser mining/transfer flows retention across updates, DNS/peer egress, TLS, and wallet/transfer flows on your
on your chosen cluster. Offline tests do not prove CSI/CNI/controller behavior. chosen cluster. `/healthz` is only process health, not sync readiness. Inspect
`/api/peers` for connected peers and `/p2p/status` for matching network, chainId and
tip across independently deployed nodes. `/api/peers` exposes configuration;
apply your access policy deliberately. Offline tests do not prove CSI/CNI/controller
behavior or consensus safety. Mining a real block is a separate explicit-consent
test; it must not be part of routine deployment or health probes.
[tests/runtime-smoke.mjs](tests/runtime-smoke.mjs) is a disposable image-level
check: it runs the native known-vector self-test (hashing, **not mining**), checks
node API health, and verifies graceful lock cleanup and restart durability. Mount
it read-only and invoke Node inside the current image with disposable writable
data and no network. It does not use or modify a real chain or wallet.
Ansible check mode (`--check --diff`) is supported by the Kubernetes module, but Ansible check mode (`--check --diff`) is supported by the Kubernetes module, but
still needs API access; creating a new namespace in check mode may require a still needs API access; creating a new namespace in check mode may require a
+17 -6
View File
@@ -10,12 +10,17 @@ wikideck_create_namespace: true
wikideck_kubeconfig: null wikideck_kubeconfig: null
wikideck_kube_context: null wikideck_kube_context: null
wikideck_wait: true wikideck_wait: true
wikideck_wait_timeout: 300 wikideck_wait_timeout: 900
# Full native RandomX ledger revalidation happens before HTTP starts.
wikideck_startup_probe_failure_threshold: 180
wikideck_image_pull_policy: IfNotPresent wikideck_image_pull_policy: IfNotPresent
# Existing registry Secret names in the application namespace. # Existing registry Secret names in the application namespace.
wikideck_image_pull_secrets: [] wikideck_image_pull_secrets: []
wikideck_offline: false # Static peer origins, at most 16. Empty means an isolated validator, not a network.
wikideck_peers: []
# Test/private networks only; permits private addresses AND HTTP in the app.
wikideck_allow_private_peers: false
# Exact browser origin, without a trailing slash. Required when using ingress. # Exact browser origin, without a trailing slash. Required when using ingress.
wikideck_public_origin: "" wikideck_public_origin: ""
wikideck_ingress_enabled: false wikideck_ingress_enabled: false
@@ -35,11 +40,11 @@ wikideck_storage_access_modes:
wikideck_resources: wikideck_resources:
requests: requests:
cpu: 100m cpu: 250m
memory: 128Mi memory: 512Mi
limits: limits:
cpu: "1" cpu: "1"
memory: 512Mi memory: 1Gi
wikideck_node_selector: {} wikideck_node_selector: {}
wikideck_tolerations: [] wikideck_tolerations: []
wikideck_affinity: {} wikideck_affinity: {}
@@ -54,7 +59,13 @@ wikideck_network_policy_dns_to:
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
kubernetes.io/metadata.name: kube-system kubernetes.io/metadata.name: kube-system
# Live article discovery needs HTTPS. Standard NetworkPolicy cannot select FQDNs. # Public peer HTTPS egress, emitted only when wikideck_peers is nonempty.
# Standard NetworkPolicy cannot select FQDNs; narrow these CIDRs when practical.
wikideck_network_policy_https_ports:
- 443
# Explicit additional NetworkPolicy egress rules for private/custom-port peers.
# Each rule must include nonempty 'to' peers and TCP 'ports'. No rules by default.
wikideck_network_policy_peer_egress: []
wikideck_network_policy_https_to: wikideck_network_policy_https_to:
- ipBlock: - ipBlock:
cidr: 0.0.0.0/0 cidr: 0.0.0.0/0
+4
View File
@@ -8,6 +8,10 @@
vars: vars:
# Replace with your published, scanned image; preferably use @sha256:... # Replace with your published, scanned image; preferably use @sha256:...
wikideck_image: registry.example.com/games/wikideck:1.0.0 wikideck_image: registry.example.com/games/wikideck:1.0.0
# Replace with real compatible testnet peers. No bootnodes are supplied.
wikideck_peers:
- https://peer-a.example.org
- https://peer-b.example.org
wikideck_ingress_enabled: true wikideck_ingress_enabled: true
wikideck_ingress_host: wikideck.example.com wikideck_ingress_host: wikideck.example.com
wikideck_public_origin: https://wikideck.example.com wikideck_public_origin: https://wikideck.example.com
+54 -2
View File
@@ -11,6 +11,7 @@
- wikideck_namespace | length <= 63 - wikideck_namespace | length <= 63
- wikideck_image_pull_policy in ['Always', 'IfNotPresent', 'Never'] - wikideck_image_pull_policy in ['Always', 'IfNotPresent', 'Never']
- wikideck_wait_timeout | string is match('^[1-9][0-9]*$') - wikideck_wait_timeout | string is match('^[1-9][0-9]*$')
- wikideck_startup_probe_failure_threshold is integer and wikideck_startup_probe_failure_threshold > 0
- wikideck_resources is mapping - wikideck_resources is mapping
- wikideck_resources.requests is defined and wikideck_resources.requests is mapping - wikideck_resources.requests is defined and wikideck_resources.requests is mapping
- wikideck_resources.limits is defined and wikideck_resources.limits is mapping - wikideck_resources.limits is defined and wikideck_resources.limits is mapping
@@ -29,7 +30,7 @@
- wikideck_public_origin | length == 0 or wikideck_public_origin is match('^https://[a-z0-9.-]+(:[0-9]+)?$') - wikideck_public_origin | length == 0 or wikideck_public_origin is match('^https://[a-z0-9.-]+(:[0-9]+)?$')
- wikideck_ingress_enabled is boolean - wikideck_ingress_enabled is boolean
- wikideck_network_policy_enabled is boolean - wikideck_network_policy_enabled is boolean
- wikideck_offline is boolean - wikideck_allow_private_peers is boolean
- wikideck_create_namespace is boolean - wikideck_create_namespace is boolean
- wikideck_wait is boolean - wikideck_wait is boolean
fail_msg: >- fail_msg: >-
@@ -64,6 +65,56 @@
- wikideck_network_policy_ingress_from - wikideck_network_policy_ingress_from
- wikideck_network_policy_dns_to - wikideck_network_policy_dns_to
- wikideck_network_policy_https_to - wikideck_network_policy_https_to
- wikideck_network_policy_https_ports
- wikideck_network_policy_peer_egress
- wikideck_peers
- name: Validate the static peer allowlist
ansible.builtin.assert:
that:
- wikideck_peers | length <= 16
fail_msg: Wikideck supports at most 16 configured peer origins.
- name: Validate peer origins without credentials or paths
ansible.builtin.assert:
that:
- item is string
- item | length <= 2048
- item is match('^https?://([a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?|\[[0-9a-fA-F:]+\])(:[0-9]{1,5})?$')
- wikideck_allow_private_peers or item is match('^https://')
fail_msg: >-
Peers must be HTTPS origins without credentials, paths, commas, queries or
fragments. Private/HTTP test peers require wikideck_allow_private_peers=true.
Wikideck additionally validates ports and public DNS/IP addresses at runtime.
loop: "{{ wikideck_peers }}"
- name: Validate public peer egress ports
ansible.builtin.assert:
that:
- item is integer and item > 0 and item <= 65535
fail_msg: HTTPS egress ports must be integers in the range 1..65535.
loop: "{{ wikideck_network_policy_https_ports }}"
- name: Validate explicit peer egress rules
ansible.builtin.assert:
that:
- item is mapping
- item.to is defined and item.to is sequence and item.to is not string and item.to is not mapping
- item.to | length > 0
- item.ports is defined and item.ports is sequence and item.ports is not string and item.ports is not mapping
- item.ports | length > 0
fail_msg: Peer egress rules require explicit nonempty to and ports lists.
loop: "{{ wikideck_network_policy_peer_egress }}"
- name: Validate explicit peer egress TCP ports
ansible.builtin.assert:
that:
- item is mapping
- item.protocol | default('TCP') == 'TCP'
- item.port is defined and item.port is integer and item.port > 0 and item.port <= 65535
- item.endPort is not defined
fail_msg: Peer egress ports must be individual numeric TCP ports in the range 1..65535.
loop: "{{ wikideck_network_policy_peer_egress | map(attribute='ports') | flatten }}"
- name: Require explicit network policy peers - name: Require explicit network policy peers
ansible.builtin.assert: ansible.builtin.assert:
@@ -73,5 +124,6 @@
fail_msg: Use an explicit podSelector, namespaceSelector, or ipBlock rather than an empty peer. fail_msg: Use an explicit podSelector, namespaceSelector, or ipBlock rather than an empty peer.
loop: >- loop: >-
{{ wikideck_network_policy_ingress_from + wikideck_network_policy_dns_to {{ wikideck_network_policy_ingress_from + wikideck_network_policy_dns_to
+ wikideck_network_policy_https_to }} + wikideck_network_policy_https_to
+ (wikideck_network_policy_peer_egress | map(attribute='to') | flatten) }}
when: wikideck_network_policy_enabled | bool when: wikideck_network_policy_enabled | bool
+6 -4
View File
@@ -9,7 +9,7 @@ metadata:
app.kubernetes.io/instance: {{ wikideck_name | to_json }} app.kubernetes.io/instance: {{ wikideck_name | to_json }}
app.kubernetes.io/managed-by: ansible app.kubernetes.io/managed-by: ansible
spec: spec:
# Never expose replicas as a variable: chain.json has no multi-writer locking. # One validator per ledger/PVC. Other validators need separate role instances.
replicas: 1 replicas: 1
strategy: strategy:
type: Recreate type: Recreate
@@ -67,8 +67,10 @@ spec:
value: "3001" value: "3001"
- name: WIKIDECK_DATA_DIR - name: WIKIDECK_DATA_DIR
value: /app/data value: /app/data
- name: WIKIDECK_OFFLINE - name: WIKIDECK_PEERS
value: {{ ('1' if wikideck_offline else '0') | to_json }} value: {{ (wikideck_peers | join(',')) | to_json }}
- name: WIKIDECK_ALLOW_PRIVATE_PEERS
value: {{ ('1' if wikideck_allow_private_peers else '0') | to_json }}
{% if wikideck_public_origin | length > 0 %} {% if wikideck_public_origin | length > 0 %}
- name: PUBLIC_ORIGIN - name: PUBLIC_ORIGIN
value: {{ wikideck_public_origin | to_json }} value: {{ wikideck_public_origin | to_json }}
@@ -80,7 +82,7 @@ spec:
port: http port: http
periodSeconds: 5 periodSeconds: 5
timeoutSeconds: 5 timeoutSeconds: 5
failureThreshold: 60 failureThreshold: {{ wikideck_startup_probe_failure_threshold | int }}
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
+11 -3
View File
@@ -23,7 +23,8 @@ spec:
{% else %} {% else %}
ingress: [] ingress: []
{% endif %} {% endif %}
{% if wikideck_network_policy_dns_to | length == 0 and (wikideck_offline or wikideck_network_policy_https_to | length == 0) %} {% set public_egress = wikideck_peers | length > 0 and wikideck_network_policy_https_to | length > 0 and wikideck_network_policy_https_ports | length > 0 %}
{% if wikideck_network_policy_dns_to | length == 0 and not public_egress and (wikideck_peers | length == 0 or wikideck_network_policy_peer_egress | length == 0) %}
egress: [] egress: []
{% else %} {% else %}
egress: egress:
@@ -35,10 +36,17 @@ spec:
- protocol: TCP - protocol: TCP
port: 53 port: 53
{% endif %} {% endif %}
{% if not wikideck_offline and wikideck_network_policy_https_to | length > 0 %} {% if public_egress %}
- to: {{ wikideck_network_policy_https_to | to_json }} - to: {{ wikideck_network_policy_https_to | to_json }}
ports: ports:
{% for port in wikideck_network_policy_https_ports %}
- protocol: TCP - protocol: TCP
port: 443 port: {{ port | int }}
{% endfor %}
{% endif %}
{% if wikideck_peers | length > 0 %}
{% for rule in wikideck_network_policy_peer_egress %}
- {{ rule | to_json }}
{% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
+5 -1
View File
@@ -12,7 +12,11 @@
wikideck_ingress_host: "{{ invalid_case.host | default('wikideck.example.com') }}" wikideck_ingress_host: "{{ invalid_case.host | default('wikideck.example.com') }}"
wikideck_public_origin: "{{ invalid_case.origin | default('') }}" wikideck_public_origin: "{{ invalid_case.origin | default('') }}"
wikideck_resources: "{{ invalid_case.resources | default({'requests': {'cpu': '100m', 'memory': '128Mi'}, 'limits': {'cpu': '1', 'memory': '512Mi'}}) }}" wikideck_resources: "{{ invalid_case.resources | default({'requests': {'cpu': '100m', 'memory': '128Mi'}, 'limits': {'cpu': '1', 'memory': '512Mi'}}) }}"
wikideck_offline: "{{ invalid_case.offline | default(false) }}" wikideck_peers: "{{ invalid_case.peers | default([]) }}"
wikideck_allow_private_peers: "{{ invalid_case.allow_private | default(false) }}"
wikideck_startup_probe_failure_threshold: "{{ invalid_case.startup_threshold | default(180) }}"
wikideck_network_policy_https_ports: "{{ invalid_case.https_ports | default([443]) }}"
wikideck_network_policy_peer_egress: "{{ invalid_case.peer_egress | default([]) }}"
wikideck_image_pull_secrets: "{{ invalid_case.pull_secrets | default([]) }}" wikideck_image_pull_secrets: "{{ invalid_case.pull_secrets | default([]) }}"
wikideck_network_policy_ingress_from: "{{ invalid_case.ingress_from | default([{'podSelector': {}}]) }}" wikideck_network_policy_ingress_from: "{{ invalid_case.ingress_from | default([{'podSelector': {}}]) }}"
block: block:
+64 -7
View File
@@ -13,7 +13,9 @@
wikideck_image: "{{ render_case.image | default('registry.example.com/wikideck:1.0.0') }}" wikideck_image: "{{ render_case.image | default('registry.example.com/wikideck:1.0.0') }}"
wikideck_name: "{{ render_case.name | default('wikideck') }}" wikideck_name: "{{ render_case.name | default('wikideck') }}"
wikideck_namespace: "{{ render_case.namespace | default('wikideck') }}" wikideck_namespace: "{{ render_case.namespace | default('wikideck') }}"
wikideck_offline: "{{ render_case.offline | default(false) }}" wikideck_peers: "{{ render_case.peers | default([]) }}"
wikideck_allow_private_peers: "{{ render_case.allow_private | default(false) }}"
wikideck_startup_probe_failure_threshold: "{{ render_case.startup_threshold | default(180) }}"
wikideck_ingress_enabled: "{{ render_case.ingress | default(false) }}" wikideck_ingress_enabled: "{{ render_case.ingress | default(false) }}"
wikideck_ingress_host: wikideck.example.com wikideck_ingress_host: wikideck.example.com
wikideck_public_origin: "{{ 'https://wikideck.example.com' if render_case.ingress | default(false) else '' }}" wikideck_public_origin: "{{ 'https://wikideck.example.com' if render_case.ingress | default(false) else '' }}"
@@ -25,9 +27,12 @@
wikideck_network_policy_ingress_from: "{{ render_case.ingress_from | default([{'podSelector': {}}]) }}" wikideck_network_policy_ingress_from: "{{ render_case.ingress_from | default([{'podSelector': {}}]) }}"
wikideck_network_policy_dns_to: "{{ render_case.dns_to | default([{'namespaceSelector': {}}]) }}" wikideck_network_policy_dns_to: "{{ render_case.dns_to | default([{'namespaceSelector': {}}]) }}"
wikideck_network_policy_https_to: "{{ render_case.https_to | default([{'ipBlock': {'cidr': '0.0.0.0/0'}}]) }}" wikideck_network_policy_https_to: "{{ render_case.https_to | default([{'ipBlock': {'cidr': '0.0.0.0/0'}}]) }}"
wikideck_network_policy_https_ports: "{{ render_case.https_ports | default([443]) }}"
wikideck_network_policy_peer_egress: "{{ render_case.peer_egress | default([]) }}"
loop: loop:
- label: defaults - label: isolated validator defaults
- label: TLS ingress and existing storage - label: TLS ingress and existing storage
peers: [https://peer-a.example.org, https://peer-b.example.org]
ingress: true ingress: true
ingress_class: nginx ingress_class: nginx
annotations: annotations:
@@ -37,15 +42,35 @@
pull_secrets: [registry-credentials] pull_secrets: [registry-credentials]
name: custom-deck name: custom-deck
namespace: games namespace: games
- label: offline and no storage class - label: isolated validator and no storage class
offline: true
storage_class: "" storage_class: ""
- label: default-deny all traffic - label: default-deny all traffic
ingress_from: [] ingress_from: []
dns_to: [] dns_to: []
https_to: [] https_to: []
- label: DNS-only offline mode - label: public peers with custom HTTPS port and long startup
offline: true peers: [https://peer.example.org:8443]
https_ports: [443, 8443]
startup_threshold: 360
- label: private HTTP testnet with restricted egress
peers: [http://other-node.games.svc.cluster.local:80]
allow_private: true
https_to: []
peer_egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: games
podSelector:
matchLabels:
app.kubernetes.io/instance: other-node
ports:
- protocol: TCP
port: 3001
- label: public peer with all egress explicitly denied
peers: [https://peer.example.org]
dns_to: []
https_to: []
- label: digest-pinned image - label: digest-pinned image
image: registry.example.com/wikideck@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa image: registry.example.com/wikideck@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
loop_control: loop_control:
@@ -84,11 +109,43 @@
resources: resources:
requests: {cpu: 100m, memory: 128Mi} requests: {cpu: 100m, memory: 128Mi}
- label: ambiguous boolean - label: ambiguous boolean
offline: "false" allow_private: "false"
- label: wrong list type - label: wrong list type
pull_secrets: not-a-list pull_secrets: not-a-list
- label: empty peer allows all traffic - label: empty peer allows all traffic
ingress_from: [{}] ingress_from: [{}]
- label: too many peers
peers: [https://a.example.org, https://b.example.org, https://c.example.org, https://d.example.org, https://e.example.org, https://f.example.org, https://g.example.org, https://h.example.org, https://i.example.org, https://j.example.org, https://k.example.org, https://l.example.org, https://m.example.org, https://n.example.org, https://o.example.org, https://p.example.org, https://q.example.org]
- label: peers must be a list
peers: https://peer.example.org
- label: HTTP peers need explicit opt-in
peers: [http://peer.example.org]
- label: credentials in peer URL
peers: [https://user:password@peer.example.org]
- label: path in peer URL
peers: [https://peer.example.org/p2p]
- label: query in peer URL
peers: ['https://peer.example.org?x=1']
- label: comma can inject a second peer
peers: ['https://peer.example.org,https://another.example.org']
- label: zero startup threshold
startup_threshold: 0
- label: invalid HTTPS egress port
https_ports: [65536]
- label: egress rule without destinations
peer_egress:
- ports: [{protocol: TCP, port: 3001}]
- label: egress rule without ports
peer_egress:
- to: [{podSelector: {}}]
- label: egress rule with an empty peer
peer_egress:
- to: [{}]
ports: [{protocol: TCP, port: 3001}]
- label: non-TCP peer egress
peer_egress:
- to: [{podSelector: {}}]
ports: [{protocol: UDP, port: 3001}]
loop_control: loop_control:
loop_var: invalid_case loop_var: invalid_case
label: "{{ invalid_case.label }}" label: "{{ invalid_case.label }}"
+33 -2
View File
@@ -36,6 +36,8 @@
- container.resources == wikideck_resources - container.resources == wikideck_resources
- container.ports[0].containerPort == 3001 - container.ports[0].containerPort == 3001
- container.startupProbe.httpGet.path == '/healthz' - container.startupProbe.httpGet.path == '/healthz'
- container.startupProbe.failureThreshold == wikideck_startup_probe_failure_threshold
- container.startupProbe.periodSeconds == 5
- container.readinessProbe.httpGet.path == '/healthz' - container.readinessProbe.httpGet.path == '/healthz'
- container.livenessProbe.httpGet.path == '/healthz' - container.livenessProbe.httpGet.path == '/healthz'
- pod.terminationGracePeriodSeconds >= 30 - pod.terminationGracePeriodSeconds >= 30
@@ -44,7 +46,12 @@
- container.volumeMounts[0].mountPath == '/app/data' - container.volumeMounts[0].mountPath == '/app/data'
- env.HOST == '0.0.0.0' and env.PORT == '3001' - env.HOST == '0.0.0.0' and env.PORT == '3001'
- env.WIKIDECK_DATA_DIR == '/app/data' and env.NODE_ENV == 'production' - env.WIKIDECK_DATA_DIR == '/app/data' and env.NODE_ENV == 'production'
- env.WIKIDECK_OFFLINE == ('1' if wikideck_offline else '0') - env.WIKIDECK_PEERS == wikideck_peers | join(',')
- env.WIKIDECK_ALLOW_PRIVATE_PEERS == ('1' if wikideck_allow_private_peers else '0')
- env.WIKIDECK_OFFLINE is not defined
- env.RANDOMX_BINARY is not defined
- container.command is not defined and container.args is not defined
- pod.containers | length == 1
- (env.PUBLIC_ORIGIN | default('')) == wikideck_public_origin - (env.PUBLIC_ORIGIN | default('')) == wikideck_public_origin
- wikideck_test_manifests.pvc.spec.accessModes == wikideck_storage_access_modes - wikideck_test_manifests.pvc.spec.accessModes == wikideck_storage_access_modes
- wikideck_test_manifests.ingress.spec.tls[0].secretName == wikideck_tls_secret_name - wikideck_test_manifests.ingress.spec.tls[0].secretName == wikideck_tls_secret_name
@@ -56,6 +63,8 @@
- wikideck_test_manifests.networkpolicy.spec.policyTypes == ['Ingress', 'Egress'] - wikideck_test_manifests.networkpolicy.spec.policyTypes == ['Ingress', 'Egress']
- wikideck_test_manifests.networkpolicy.spec.ingress | length == (1 if wikideck_network_policy_ingress_from | length > 0 else 0) - wikideck_test_manifests.networkpolicy.spec.ingress | length == (1 if wikideck_network_policy_ingress_from | length > 0 else 0)
- wikideck_test_manifests.networkpolicy.spec.egress | length == expected_egress_count | int - wikideck_test_manifests.networkpolicy.spec.egress | length == expected_egress_count | int
- wikideck_resources.requests.memory == '512Mi'
- wikideck_resources.limits.memory == '1Gi'
vars: vars:
deployment: "{{ wikideck_test_manifests.deployment }}" deployment: "{{ wikideck_test_manifests.deployment }}"
pod: "{{ deployment.spec.template.spec }}" pod: "{{ deployment.spec.template.spec }}"
@@ -64,7 +73,29 @@
expected_claim: "{{ wikideck_existing_claim if wikideck_existing_claim | length > 0 else wikideck_name ~ '-data' }}" expected_claim: "{{ wikideck_existing_claim if wikideck_existing_claim | length > 0 else wikideck_name ~ '-data' }}"
expected_egress_count: >- expected_egress_count: >-
{{ (1 if wikideck_network_policy_dns_to | length > 0 else 0) {{ (1 if wikideck_network_policy_dns_to | length > 0 else 0)
+ (1 if not wikideck_offline and wikideck_network_policy_https_to | length > 0 else 0) }} + (1 if wikideck_peers | length > 0 and wikideck_network_policy_https_to | length > 0
and wikideck_network_policy_https_ports | length > 0 else 0)
+ (wikideck_network_policy_peer_egress | length if wikideck_peers | length > 0 else 0) }}
- name: Check public peer egress destinations and ports
ansible.builtin.assert:
that:
- public_rule.to == wikideck_network_policy_https_to
- public_rule.ports | map(attribute='port') | list == wikideck_network_policy_https_ports
- public_rule.ports | map(attribute='protocol') | unique | list == ['TCP']
vars:
public_rule: "{{ wikideck_test_manifests.networkpolicy.spec.egress[1 if wikideck_network_policy_dns_to | length > 0 else 0] }}"
when:
- wikideck_peers | length > 0
- wikideck_network_policy_https_to | length > 0
- wikideck_network_policy_https_ports | length > 0
- name: Check explicit peer egress survives unchanged
ansible.builtin.assert:
that: wikideck_test_manifests.networkpolicy.spec.egress[-(wikideck_network_policy_peer_egress | length):] == wikideck_network_policy_peer_egress
when:
- wikideck_peers | length > 0
- wikideck_network_policy_peer_egress | length > 0
- name: Check omitted default StorageClass - name: Check omitted default StorageClass
ansible.builtin.assert: ansible.builtin.assert:
+67
View File
@@ -0,0 +1,67 @@
// Image-level compatibility test only: no mining, wallets, peers, or live-chain writes.
import assert from 'node:assert/strict';
import { spawn, execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { readFile, access } from 'node:fs/promises';
assert.equal(process.env.WIKIDECK_DATA_DIR, '/app/data');
assert.equal(process.env.WIKIDECK_PEERS, '');
assert.ok(process.env.RANDOMX_BINARY, 'Use the updated production image');
execFileSync(process.env.RANDOMX_BINARY, ['--self-test'], {
timeout: 120_000,
stdio: 'inherit',
});
async function runNode() {
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
cwd: '/app',
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const exited = once(child, 'exit');
let output = '';
const timeout = setTimeout(() => child.kill('SIGKILL'), 120_000);
try {
await new Promise((resolve, reject) => {
child.on('error', reject);
child.once('exit', (code) => reject(new Error(`Node exited before ready: ${code}\n${output}`)));
child.stderr.on('data', (chunk) => { output = (output + chunk).slice(-8192); });
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-8192);
if (output.includes('Wikideck RandomX testnet:')) resolve();
});
});
for (const path of ['/healthz', '/p2p/status', '/api/peers']) {
const response = await fetch(`http://127.0.0.1:3001${path}`, {
signal: AbortSignal.timeout(10_000),
});
assert.equal(response.status, 200, path);
const body = await response.json();
if (path === '/healthz') {
assert.equal(body.status, 'ok');
assert.equal(body.network, 'wikideck-randomx-testnet-2');
} else if (path === '/p2p/status') {
assert.equal(body.network, 'wikideck-randomx-testnet-2');
assert.equal(body.size, 0);
assert.match(body.chainId, /^[a-f0-9]{64}$/);
} else assert.deepEqual(body.peers, []);
}
await access('/app/data/testnet-node.lock');
const ledger = JSON.parse(await readFile('/app/data/testnet/ledger.json', 'utf8'));
assert.equal(ledger.network, 'wikideck-randomx-testnet-2');
assert.deepEqual(ledger.blocks, []);
child.kill('SIGTERM');
assert.deepEqual(await exited, [0, null], output);
await assert.rejects(access('/app/data/testnet-node.lock'), { code: 'ENOENT' });
} finally {
clearTimeout(timeout);
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await exited;
}
}
}
await runNode();
await runNode();
console.log('PASS: native RandomX, node API, graceful lock cleanup and empty-ledger restart');