2022-05-19 19:56:03 +00:00
|
|
|
## Nim-Codex
|
2021-08-30 19:25:20 +00:00
|
|
|
## Copyright (c) 2021 Status Research & Development GmbH
|
|
|
|
## Licensed under either of
|
|
|
|
## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
|
|
|
|
## * MIT license ([LICENSE-MIT](LICENSE-MIT))
|
|
|
|
## at your option.
|
|
|
|
## This file may not be copied, modified, or distributed except according to
|
|
|
|
## those terms.
|
|
|
|
|
2022-01-10 15:32:56 +00:00
|
|
|
import std/sequtils
|
2023-09-13 14:17:56 +00:00
|
|
|
import std/strutils
|
2022-01-10 15:32:56 +00:00
|
|
|
import std/os
|
2022-12-03 00:00:55 +00:00
|
|
|
import std/tables
|
2024-03-23 09:56:35 +00:00
|
|
|
import std/cpuinfo
|
2022-01-10 15:32:56 +00:00
|
|
|
|
|
|
|
import pkg/chronos
|
|
|
|
import pkg/presto
|
|
|
|
import pkg/libp2p
|
|
|
|
import pkg/confutils
|
|
|
|
import pkg/confutils/defs
|
|
|
|
import pkg/nitro
|
|
|
|
import pkg/stew/io2
|
2022-04-13 16:32:35 +00:00
|
|
|
import pkg/stew/shims/net as stewnet
|
2022-12-03 00:00:55 +00:00
|
|
|
import pkg/datastore
|
2023-09-13 14:17:56 +00:00
|
|
|
import pkg/ethers except Rng
|
2024-03-12 09:57:13 +00:00
|
|
|
import pkg/stew/io2
|
2024-03-23 09:56:35 +00:00
|
|
|
import pkg/taskpools
|
2022-01-10 15:32:56 +00:00
|
|
|
|
|
|
|
import ./node
|
|
|
|
import ./conf
|
|
|
|
import ./rng
|
|
|
|
import ./rest/api
|
2022-03-02 16:30:42 +00:00
|
|
|
import ./stores
|
2024-03-12 09:57:13 +00:00
|
|
|
import ./slots
|
2022-01-10 15:32:56 +00:00
|
|
|
import ./blockexchange
|
|
|
|
import ./utils/fileutils
|
2022-04-06 00:34:29 +00:00
|
|
|
import ./erasure
|
2022-04-13 16:32:35 +00:00
|
|
|
import ./discovery
|
2022-04-13 12:15:22 +00:00
|
|
|
import ./contracts
|
2023-11-22 11:35:26 +00:00
|
|
|
import ./systemclock
|
2023-04-19 13:06:00 +00:00
|
|
|
import ./contracts/clock
|
2023-05-03 07:24:25 +00:00
|
|
|
import ./contracts/deployment
|
2022-11-02 00:58:41 +00:00
|
|
|
import ./utils/addrutils
|
2022-12-03 00:00:55 +00:00
|
|
|
import ./namespaces
|
2024-02-19 18:12:10 +00:00
|
|
|
import ./codextypes
|
feat: create logging proxy (#663)
* implement a logging proxy
The logging proxy:
- prevents the need to import chronicles (as well as export except toJson),
- prevents the need to override `writeValue` or use or import nim-json-seralization elsewhere in the codebase, allowing for sole use of utils/json for de/serialization,
- and handles json formatting correctly in chronicles json sinks
* Rename logging -> logutils to avoid ambiguity with common names
* clean up
* add setProperty for JsonRecord, remove nim-json-serialization conflict
* Allow specifying textlines and json format separately
Not specifying a LogFormat will apply the formatting to both textlines and json sinks.
Specifying a LogFormat will apply the formatting to only that sink.
* remove unneeded usages of std/json
We only need to import utils/json instead of std/json
* move serialization from rest/json to utils/json so it can be shared
* fix NoColors ambiguity
Was causing unit tests to fail on Windows.
* Remove nre usage to fix Windows error
Windows was erroring with `could not load: pcre64.dll`. Instead of fixing that error, remove the pcre usage :)
* Add logutils module doc
* Shorten logutils.formatIt for `NBytes`
Both json and textlines formatIt were not needed, and could be combined into one formatIt
* remove debug integration test config
debug output and logformat of json for integration test logs
* Use ## module doc to support docgen
* bump nim-poseidon2 to export fromBytes
Before the changes in this branch, fromBytes was likely being resolved by nim-stew, or other dependency. With the changes in this branch, that dependency was removed and fromBytes could no longer be resolved. By exporting fromBytes from nim-poseidon, the correct resolution is now happening.
* fixes to get compiling after rebasing master
* Add support for Result types being logged using formatIt
2024-01-23 07:35:03 +00:00
|
|
|
import ./logutils
|
2022-11-02 00:58:41 +00:00
|
|
|
|
|
|
|
logScope:
|
|
|
|
topics = "codex node"
|
2022-01-10 15:32:56 +00:00
|
|
|
|
|
|
|
type
|
2022-05-19 19:56:03 +00:00
|
|
|
CodexServer* = ref object
|
|
|
|
config: CodexConf
|
2022-01-10 15:32:56 +00:00
|
|
|
restServer: RestServerRef
|
2022-05-19 19:56:03 +00:00
|
|
|
codexNode: CodexNodeRef
|
2022-12-03 00:00:55 +00:00
|
|
|
repoStore: RepoStore
|
2023-03-08 15:04:54 +00:00
|
|
|
maintenance: BlockMaintainer
|
2024-03-23 09:56:35 +00:00
|
|
|
taskpool: Taskpool
|
2022-01-10 15:32:56 +00:00
|
|
|
|
2022-11-02 00:58:41 +00:00
|
|
|
CodexPrivateKey* = libp2p.PrivateKey # alias
|
2023-09-13 14:17:56 +00:00
|
|
|
EthWallet = ethers.Wallet
|
2022-11-02 00:58:41 +00:00
|
|
|
|
2024-03-01 07:50:29 +00:00
|
|
|
proc waitForSync(provider: Provider): Future[void] {.async.} =
|
|
|
|
var sleepTime = 1
|
2024-03-29 11:51:17 +00:00
|
|
|
trace "Checking sync state of Ethereum provider..."
|
2024-03-01 07:50:29 +00:00
|
|
|
while await provider.isSyncing:
|
|
|
|
notice "Waiting for Ethereum provider to sync..."
|
|
|
|
await sleepAsync(sleepTime.seconds)
|
|
|
|
if sleepTime < 10:
|
|
|
|
inc sleepTime
|
2024-03-29 11:51:17 +00:00
|
|
|
trace "Ethereum provider is synced."
|
2024-03-01 07:50:29 +00:00
|
|
|
|
2023-06-22 15:11:18 +00:00
|
|
|
proc bootstrapInteractions(
|
2024-01-15 16:45:04 +00:00
|
|
|
s: CodexServer): Future[void] {.async.} =
|
2023-08-21 10:26:43 +00:00
|
|
|
## bootstrap interactions and return contracts
|
2023-06-22 15:11:18 +00:00
|
|
|
## using clients, hosts, validators pairings
|
2023-08-21 10:26:43 +00:00
|
|
|
##
|
2023-11-22 11:35:26 +00:00
|
|
|
let
|
|
|
|
config = s.config
|
|
|
|
repo = s.repoStore
|
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
if config.persistence:
|
|
|
|
if not config.ethAccount.isSome and not config.ethPrivateKey.isSome:
|
2023-05-03 07:24:25 +00:00
|
|
|
error "Persistence enabled, but no Ethereum account was set"
|
2023-09-13 14:17:56 +00:00
|
|
|
quit QuitFailure
|
2023-05-03 07:24:25 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
let provider = JsonRpcProvider.new(config.ethProvider)
|
|
|
|
await waitForSync(provider)
|
|
|
|
var signer: Signer
|
|
|
|
if account =? config.ethAccount:
|
|
|
|
signer = provider.getSigner(account)
|
|
|
|
elif keyFile =? config.ethPrivateKey:
|
|
|
|
without isSecure =? checkSecureFile(keyFile):
|
|
|
|
error "Could not check file permissions: does Ethereum private key file exist?"
|
|
|
|
quit QuitFailure
|
|
|
|
if not isSecure:
|
|
|
|
error "Ethereum private key file does not have safe file permissions"
|
|
|
|
quit QuitFailure
|
|
|
|
without key =? keyFile.readAllChars():
|
|
|
|
error "Unable to read Ethereum private key file"
|
|
|
|
quit QuitFailure
|
|
|
|
without wallet =? EthWallet.new(key.strip(), provider):
|
|
|
|
error "Invalid Ethereum private key in file"
|
|
|
|
quit QuitFailure
|
|
|
|
signer = wallet
|
|
|
|
|
|
|
|
let deploy = Deployment.new(provider, config)
|
|
|
|
without marketplaceAddress =? await deploy.address(Marketplace):
|
|
|
|
error "No Marketplace address was specified or there is no known address for the current network"
|
|
|
|
quit QuitFailure
|
2023-05-03 07:24:25 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
let marketplace = Marketplace.new(marketplaceAddress, signer)
|
|
|
|
let market = OnChainMarket.new(marketplace)
|
|
|
|
let clock = OnChainClock.new(provider)
|
2023-08-21 10:26:43 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
var client: ?ClientInteractions
|
|
|
|
var host: ?HostInteractions
|
|
|
|
var validator: ?ValidatorInteractions
|
2023-11-22 11:35:26 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
if config.validator or config.persistence:
|
|
|
|
s.codexNode.clock = clock
|
2023-06-22 10:32:18 +00:00
|
|
|
else:
|
2024-03-12 09:57:13 +00:00
|
|
|
s.codexNode.clock = SystemClock()
|
2023-08-21 10:26:43 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
if config.persistence:
|
|
|
|
# This is used for simulation purposes. Normal nodes won't be compiled with this flag
|
|
|
|
# and hence the proof failure will always be 0.
|
|
|
|
when codex_enable_proof_failures:
|
|
|
|
let proofFailures = config.simulateProofFailures
|
|
|
|
if proofFailures > 0:
|
|
|
|
warn "Enabling proof failure simulation!"
|
|
|
|
else:
|
|
|
|
let proofFailures = 0
|
|
|
|
if config.simulateProofFailures > 0:
|
|
|
|
warn "Proof failure simulation is not enabled for this build! Configuration ignored"
|
|
|
|
|
|
|
|
let purchasing = Purchasing.new(market, clock)
|
|
|
|
let sales = Sales.new(market, clock, repo, proofFailures)
|
|
|
|
client = some ClientInteractions.new(clock, purchasing)
|
|
|
|
host = some HostInteractions.new(clock, sales)
|
|
|
|
|
|
|
|
if config.validator:
|
|
|
|
let validation = Validation.new(clock, market, config.validatorMaxSlots)
|
|
|
|
validator = some ValidatorInteractions.new(clock, validation)
|
2023-05-03 07:24:25 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
s.codexNode.contracts = (client, host, validator)
|
2023-05-03 07:24:25 +00:00
|
|
|
|
2022-05-19 19:56:03 +00:00
|
|
|
proc start*(s: CodexServer) {.async.} =
|
2023-11-09 05:35:55 +00:00
|
|
|
trace "Starting codex node", config = $s.config
|
2022-12-03 00:00:55 +00:00
|
|
|
|
|
|
|
await s.repoStore.start()
|
2023-03-08 15:04:54 +00:00
|
|
|
s.maintenance.start()
|
2022-01-10 15:32:56 +00:00
|
|
|
|
2023-09-04 09:12:20 +00:00
|
|
|
await s.codexNode.switch.start()
|
|
|
|
|
2022-11-02 00:58:41 +00:00
|
|
|
let
|
2022-12-03 00:00:55 +00:00
|
|
|
# TODO: Can't define these as constants, pity
|
2022-11-02 00:58:41 +00:00
|
|
|
natIpPart = MultiAddress.init("/ip4/" & $s.config.nat & "/")
|
|
|
|
.expect("Should create multiaddress")
|
|
|
|
anyAddrIp = MultiAddress.init("/ip4/0.0.0.0/")
|
|
|
|
.expect("Should create multiaddress")
|
|
|
|
loopBackAddrIp = MultiAddress.init("/ip4/127.0.0.1/")
|
|
|
|
.expect("Should create multiaddress")
|
|
|
|
|
|
|
|
# announce addresses should be set to bound addresses,
|
|
|
|
# but the IP should be mapped to the provided nat ip
|
|
|
|
announceAddrs = s.codexNode.switch.peerInfo.addrs.mapIt:
|
|
|
|
block:
|
|
|
|
let
|
|
|
|
listenIPPart = it[multiCodec("ip4")].expect("Should get IP")
|
|
|
|
|
|
|
|
if listenIPPart == anyAddrIp or
|
|
|
|
(listenIPPart == loopBackAddrIp and natIpPart != loopBackAddrIp):
|
|
|
|
it.remapAddr(s.config.nat.some)
|
|
|
|
else:
|
|
|
|
it
|
|
|
|
|
|
|
|
s.codexNode.discovery.updateAnnounceRecord(announceAddrs)
|
|
|
|
s.codexNode.discovery.updateDhtRecord(s.config.nat, s.config.discoveryPort)
|
|
|
|
|
2023-11-22 11:35:26 +00:00
|
|
|
await s.bootstrapInteractions()
|
2023-09-04 09:12:20 +00:00
|
|
|
await s.codexNode.start()
|
|
|
|
s.restServer.start()
|
|
|
|
|
2022-05-19 19:56:03 +00:00
|
|
|
proc stop*(s: CodexServer) {.async.} =
|
2022-12-03 00:00:55 +00:00
|
|
|
notice "Stopping codex node"
|
|
|
|
|
2024-03-23 09:56:35 +00:00
|
|
|
|
|
|
|
s.taskpool.syncAll()
|
|
|
|
s.taskpool.shutdown()
|
|
|
|
|
2022-01-10 15:32:56 +00:00
|
|
|
await allFuturesThrowing(
|
2022-12-03 00:00:55 +00:00
|
|
|
s.restServer.stop(),
|
2023-09-04 09:12:20 +00:00
|
|
|
s.codexNode.switch.stop(),
|
2022-12-03 00:00:55 +00:00
|
|
|
s.codexNode.stop(),
|
2023-03-08 15:04:54 +00:00
|
|
|
s.repoStore.stop(),
|
|
|
|
s.maintenance.stop())
|
2022-01-10 15:32:56 +00:00
|
|
|
|
2023-06-21 22:02:05 +00:00
|
|
|
proc new*(
|
2024-01-15 16:45:04 +00:00
|
|
|
T: type CodexServer,
|
|
|
|
config: CodexConf,
|
|
|
|
privateKey: CodexPrivateKey): CodexServer =
|
2023-06-21 22:02:05 +00:00
|
|
|
## create CodexServer including setting up datastore, repostore, etc
|
2022-01-10 15:32:56 +00:00
|
|
|
let
|
|
|
|
switch = SwitchBuilder
|
|
|
|
.new()
|
2022-04-13 16:32:35 +00:00
|
|
|
.withPrivateKey(privateKey)
|
2022-10-27 13:44:56 +00:00
|
|
|
.withAddresses(config.listenAddrs)
|
2022-01-10 15:32:56 +00:00
|
|
|
.withRng(Rng.instance())
|
|
|
|
.withNoise()
|
|
|
|
.withMplex(5.minutes, 5.minutes)
|
|
|
|
.withMaxConnections(config.maxPeers)
|
|
|
|
.withAgentVersion(config.agentString)
|
2022-04-13 16:32:35 +00:00
|
|
|
.withSignedPeerRecord(true)
|
2022-01-10 15:32:56 +00:00
|
|
|
.withTcpTransport({ServerFlags.ReuseAddr})
|
|
|
|
.build()
|
|
|
|
|
2022-08-04 23:51:05 +00:00
|
|
|
var
|
2022-11-15 15:46:21 +00:00
|
|
|
cache: CacheStore = nil
|
2022-08-04 23:51:05 +00:00
|
|
|
|
2023-07-06 23:23:27 +00:00
|
|
|
if config.cacheSize > 0'nb:
|
|
|
|
cache = CacheStore.new(cacheSize = config.cacheSize)
|
2023-03-08 15:04:54 +00:00
|
|
|
## Is unused?
|
2022-03-02 16:30:42 +00:00
|
|
|
|
2022-01-10 15:32:56 +00:00
|
|
|
let
|
2022-12-03 00:00:55 +00:00
|
|
|
discoveryDir = config.dataDir / CodexDhtNamespace
|
|
|
|
|
|
|
|
if io2.createPath(discoveryDir).isErr:
|
|
|
|
trace "Unable to create discovery directory for block store", discoveryDir = discoveryDir
|
|
|
|
raise (ref Defect)(
|
|
|
|
msg: "Unable to create discovery directory for block store: " & discoveryDir)
|
|
|
|
|
|
|
|
let
|
|
|
|
discoveryStore = Datastore(
|
2024-05-30 06:57:10 +00:00
|
|
|
LevelDbDatastore.new(config.dataDir / CodexDhtProvidersNamespace)
|
2022-12-03 00:00:55 +00:00
|
|
|
.expect("Should create discovery datastore!"))
|
2022-10-27 13:44:56 +00:00
|
|
|
|
2022-11-02 00:58:41 +00:00
|
|
|
discovery = Discovery.new(
|
2022-10-27 13:44:56 +00:00
|
|
|
switch.peerInfo.privateKey,
|
2022-11-02 00:58:41 +00:00
|
|
|
announceAddrs = config.listenAddrs,
|
|
|
|
bindIp = config.discoveryIp,
|
|
|
|
bindPort = config.discoveryPort,
|
2022-10-27 13:44:56 +00:00
|
|
|
bootstrapNodes = config.bootstrapNodes,
|
|
|
|
store = discoveryStore)
|
2022-04-13 16:32:35 +00:00
|
|
|
|
2022-01-10 15:32:56 +00:00
|
|
|
wallet = WalletRef.new(EthPrivateKey.random())
|
|
|
|
network = BlockExcNetwork.new(switch)
|
2022-08-08 21:42:05 +00:00
|
|
|
|
2023-03-14 22:32:15 +00:00
|
|
|
repoData = case config.repoKind
|
|
|
|
of repoFS: Datastore(FSDatastore.new($config.dataDir, depth = 5)
|
|
|
|
.expect("Should create repo file data store!"))
|
|
|
|
of repoSQLite: Datastore(SQLiteDatastore.new($config.dataDir)
|
|
|
|
.expect("Should create repo SQLite data store!"))
|
2024-05-30 06:57:10 +00:00
|
|
|
of repoLevelDb: Datastore(LevelDbDatastore.new($config.dataDir)
|
|
|
|
.expect("Should create repo LevelDB data store!"))
|
2023-03-14 22:32:15 +00:00
|
|
|
|
2022-12-03 00:00:55 +00:00
|
|
|
repoStore = RepoStore.new(
|
2023-03-14 22:32:15 +00:00
|
|
|
repoDs = repoData,
|
2024-05-30 06:57:10 +00:00
|
|
|
metaDs = LevelDbDatastore.new(config.dataDir / CodexMetaNamespace)
|
|
|
|
.expect("Should create metadata store!"),
|
2022-12-03 00:00:55 +00:00
|
|
|
quotaMaxBytes = config.storageQuota.uint,
|
2023-07-06 23:23:27 +00:00
|
|
|
blockTtl = config.blockTtl)
|
2023-03-08 15:04:54 +00:00
|
|
|
|
|
|
|
maintenance = BlockMaintainer.new(
|
|
|
|
repoStore,
|
2023-07-06 23:23:27 +00:00
|
|
|
interval = config.blockMaintenanceInterval,
|
2023-03-08 15:04:54 +00:00
|
|
|
numberOfBlocksPerInterval = config.blockMaintenanceNumberOfBlocks)
|
2022-08-08 21:42:05 +00:00
|
|
|
|
2024-05-21 12:33:52 +00:00
|
|
|
taskpool = Taskpool.new(num_threads = countProcessors().max(2))
|
2024-05-20 19:45:01 +00:00
|
|
|
|
2022-05-19 02:29:15 +00:00
|
|
|
peerStore = PeerCtxStore.new()
|
|
|
|
pendingBlocks = PendingBlocksManager.new()
|
2022-12-03 00:00:55 +00:00
|
|
|
blockDiscovery = DiscoveryEngine.new(repoStore, peerStore, network, discovery, pendingBlocks)
|
|
|
|
engine = BlockExcEngine.new(repoStore, wallet, network, blockDiscovery, peerStore, pendingBlocks)
|
|
|
|
store = NetworkStore.new(engine, repoStore)
|
2024-05-20 19:45:01 +00:00
|
|
|
|
2024-03-12 09:57:13 +00:00
|
|
|
prover = if config.prover:
|
|
|
|
if not fileAccessible($config.circomR1cs, {AccessFlags.Read}) and
|
|
|
|
endsWith($config.circomR1cs, ".r1cs"):
|
|
|
|
error "Circom R1CS file not accessible"
|
|
|
|
raise (ref Defect)(
|
|
|
|
msg: "r1cs file not readable, doesn't exist or wrong extension (.r1cs)")
|
|
|
|
|
|
|
|
if not fileAccessible($config.circomWasm, {AccessFlags.Read}) and
|
|
|
|
endsWith($config.circomWasm, ".wasm"):
|
|
|
|
error "Circom wasm file not accessible"
|
|
|
|
raise (ref Defect)(
|
|
|
|
msg: "wasm file not readable, doesn't exist or wrong extension (.wasm)")
|
|
|
|
|
|
|
|
let zkey = if not config.circomNoZkey:
|
|
|
|
if not fileAccessible($config.circomZkey, {AccessFlags.Read}) and
|
|
|
|
endsWith($config.circomZkey, ".zkey"):
|
|
|
|
error "Circom zkey file not accessible"
|
|
|
|
raise (ref Defect)(
|
|
|
|
msg: "zkey file not readable, doesn't exist or wrong extension (.zkey)")
|
|
|
|
|
|
|
|
$config.circomZkey
|
|
|
|
else: ""
|
|
|
|
|
2024-05-20 14:31:34 +00:00
|
|
|
let params = CircomCompatParams.init($config.circomR1cs, $config.circomWasm, zkey)
|
2024-03-12 09:57:13 +00:00
|
|
|
some Prover.new(
|
2024-05-20 19:45:01 +00:00
|
|
|
store, AsyncCircomCompat.init(params, taskpool),
|
2024-03-12 09:57:13 +00:00
|
|
|
config.numProofSamples)
|
|
|
|
else:
|
|
|
|
none Prover
|
2024-02-19 18:12:10 +00:00
|
|
|
|
|
|
|
codexNode = CodexNodeRef.new(
|
|
|
|
switch = switch,
|
|
|
|
networkStore = store,
|
|
|
|
engine = engine,
|
2024-03-12 09:57:13 +00:00
|
|
|
prover = prover,
|
2024-03-23 09:56:35 +00:00
|
|
|
discovery = discovery,
|
|
|
|
taskpool = taskpool)
|
2024-02-19 18:12:10 +00:00
|
|
|
|
2022-01-10 15:32:56 +00:00
|
|
|
restServer = RestServerRef.new(
|
2023-12-14 10:57:16 +00:00
|
|
|
codexNode.initRestApi(config, repoStore),
|
2023-03-08 11:45:55 +00:00
|
|
|
initTAddress(config.apiBindAddress , config.apiPort),
|
2022-01-10 15:32:56 +00:00
|
|
|
bufferSize = (1024 * 64),
|
|
|
|
maxRequestBodySize = int.high)
|
2022-11-02 00:58:41 +00:00
|
|
|
.expect("Should start rest server!")
|
2022-01-10 15:32:56 +00:00
|
|
|
|
|
|
|
switch.mount(network)
|
2023-06-21 22:02:05 +00:00
|
|
|
|
2023-06-22 15:11:18 +00:00
|
|
|
CodexServer(
|
2022-01-10 15:32:56 +00:00
|
|
|
config: config,
|
2022-05-19 19:56:03 +00:00
|
|
|
codexNode: codexNode,
|
2022-12-03 00:00:55 +00:00
|
|
|
restServer: restServer,
|
2023-03-08 15:04:54 +00:00
|
|
|
repoStore: repoStore,
|
2024-03-23 09:56:35 +00:00
|
|
|
maintenance: maintenance,
|
|
|
|
taskpool: taskpool)
|