L3: mesh-vault mints, retires and signs; nats makes its certificate; messenger asks each engine (issue 774, ADR 0561), rebased onto L2 #556
@@ -1,5 +1,5 @@
|
||||
# The modules of this catalogue that still read the controller's assignments (mesh-controller.assignments), each with
|
||||
# why. The check of this directory refuses any other module that reads them, and an entry here whose module no longer
|
||||
# why. The check of this directory (noplacement) refuses any other module that reads them, and an entry here whose module no longer
|
||||
# does: the list only shrinks, and it is empty before the controller's lease is written to a holder that is no
|
||||
# controller (novox/hq ADR 0549 §3, §6; issue 774).
|
||||
gitea the provisioner's plan of a module (go/provider) rides only in an assignment's value; its home after the lease is issue 774's provisioner-plan subtask
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/noplacement
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
|
||||
//
|
||||
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
|
||||
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`.
|
||||
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`;
|
||||
//
|
||||
// 4. a manifest, other than mesh-controller's, that reads the controller's assignments (`mesh-controller.assignments`):
|
||||
// every reader takes placement from the seat mesh-placement (novox/hq ADR 0549 §3, ADR 0567), and the lease write is
|
||||
// refused while one still reads them (ADR 0549 §6; issue 774). The modules that still read them for what the
|
||||
// placement does not carry are named in checks/noplacement/assignments-readers, each with why; that list only
|
||||
// shrinks: an entry whose module no longer reads them is refused too.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
@@ -55,9 +61,74 @@ func Findings(root string) ([]string, error) {
|
||||
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
|
||||
}
|
||||
}
|
||||
readers, err := assignmentsReaders(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, readers...)
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AssignmentsRead is the controller's assignments as a manifest reads them, and Allowed the file, relative to the
|
||||
// catalogue's root, that names the modules still reading them: `<module> <why>` per line, `#` a comment.
|
||||
const (
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
Allowed = "checks/noplacement/assignments-readers"
|
||||
)
|
||||
|
||||
// assignmentsReaders is every finding on the reads of the controller's assignments: a module other than the controller
|
||||
// reading them that Allowed does not name, and a module Allowed names that no longer reads them (issue 774).
|
||||
func assignmentsReaders(root string) ([]string, error) {
|
||||
allowed := map[string]bool{}
|
||||
raw, err := os.ReadFile(filepath.Join(root, Allowed))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 0 && !strings.HasPrefix(f[0], "#") {
|
||||
allowed[f[0]] = true
|
||||
}
|
||||
}
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found []string
|
||||
reads := map[string]bool{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []string `json:"reads"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil || m.Module == "mesh-controller" {
|
||||
continue // a manifest that does not read is the manifest checks' to refuse
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if strings.TrimSpace(r) != AssignmentsRead {
|
||||
continue
|
||||
}
|
||||
reads[m.Module] = true
|
||||
if !allowed[m.Module] {
|
||||
found = append(found, fmt.Sprintf("%s reads %s: read where modules run from mesh-delivery-assigner.placement, "+
|
||||
"the seat mesh-placement's (novox/hq ADR 0549 §3, ADR 0567); the lease write is refused while a module reads "+
|
||||
"the assignments (ADR 0549 §6)", m.Module, AssignmentsRead))
|
||||
}
|
||||
}
|
||||
}
|
||||
for module := range allowed {
|
||||
if !reads[module] {
|
||||
found = append(found, fmt.Sprintf("%s names %s, which no longer reads %s: take it off the list, which only shrinks",
|
||||
Allowed, module, AssignmentsRead))
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// named collects the strings of a decoded manifest that start with one of the Prefixes.
|
||||
func named(v any, into []string) []string {
|
||||
switch v := v.(type) {
|
||||
@@ -95,5 +166,5 @@ func main() {
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
|
||||
fmt.Println("no placement directory, no manifest reads the indexer's placement, and only the modules listed read the controller's assignments")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
|
||||
@@ -13,12 +15,18 @@ func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
|
||||
files map[string]string
|
||||
want string // empty: clean
|
||||
}{
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""},
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.placement"]}`}, ""},
|
||||
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
|
||||
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
|
||||
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
|
||||
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
|
||||
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
|
||||
// Issue 774 (ADR 0549 §3, §6): a reader of the controller's assignments is refused unless the list names it, the
|
||||
// controller's own manifest is not judged, and a name on the list whose module no longer reads them is refused.
|
||||
{"an unlisted reader of the assignments", map[string]string{"modules/r/module.json": `{"module":"r","reads":["mesh-controller.assignments"]}`}, "r reads mesh-controller.assignments"},
|
||||
{"a listed reader and the controller", map[string]string{"modules/l/module.json": `{"module":"l","reads":["mesh-controller.assignments"]}`,
|
||||
"modules/mesh-controller/module.json": `{"module":"mesh-controller","reads":["mesh-controller.assignments"]}`, Allowed: "# why\nl its plan\n"}, ""},
|
||||
{"a listed module that reads them no more", map[string]string{Allowed: "gone no longer reads\n"}, "names gone, which no longer reads"},
|
||||
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
@@ -52,4 +60,29 @@ func TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) {
|
||||
for _, f := range got {
|
||||
t.Error(f)
|
||||
}
|
||||
// Issue 774 (ADR 0549 §3, §6; the comparison of novox/hq ADR 0529 clause 2): the list of modules still reading the
|
||||
// controller's assignments gains none the trunk's copy at the merge base does not hold. Its first copy gains nothing.
|
||||
path, ok := trunk.Copy(t, Allowed)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
names := func(path string) map[string]bool {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 0 && !strings.HasPrefix(f[0], "#") {
|
||||
out[f[0]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
base := names(path)
|
||||
for module := range names(filepath.Join("..", "..", Allowed)) {
|
||||
if !base[module] {
|
||||
t.Errorf("%s gains %s, which the trunk's copy does not hold: the list only shrinks", Allowed, module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ account alone writes its buckets.
|
||||
|
||||
## How a value resolves
|
||||
|
||||
For every module the catalogue's placement puts on a node (`mesh-delivery-indexer.placement`, ADR 0549 §3), from the
|
||||
For every module the seat mesh-placement's placement puts on a node (`mesh-delivery-assigner.placement`, ADR 0567), from the
|
||||
controller's mirror of each module's declarations (`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
|
||||
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
|
||||
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
|
||||
|
||||
@@ -121,13 +121,13 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
||||
m.states[read][key] = []byte(raw)
|
||||
}
|
||||
|
||||
// assign puts module on node in the catalogue's placement (ADR 0549 §3), beside what is placed there already.
|
||||
// assign puts module on node in the seat's placement (ADR 0549 §3), beside what is placed there already.
|
||||
func (m *memStore) assign(t *testing.T, node, module string) {
|
||||
t.Helper()
|
||||
m.place(t, node, func(modules []string) []string { return append(modules, module) })
|
||||
}
|
||||
|
||||
// unassign takes module off node in the catalogue's placement.
|
||||
// unassign takes module off node in the seat's placement.
|
||||
func (m *memStore) unassign(t *testing.T, node, module string) {
|
||||
t.Helper()
|
||||
m.place(t, node, func(modules []string) []string {
|
||||
@@ -592,7 +592,7 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774 (ADR 0549 §3, §5): the catalogue's placement alone says where modules stand. Before any node's placement
|
||||
// Issue 774 (ADR 0549 §3, §5): the seat's placement alone says where modules stand. Before any node's placement
|
||||
// is read nothing is resolved anew and what is resolved stays; a placement that does not read resolves nothing on its
|
||||
// node, said.
|
||||
func TestThePlacementAloneSaysWhereValuesResolve(t *testing.T) {
|
||||
|
||||
@@ -121,7 +121,7 @@ type Published struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Assignment is a module the catalogue's placement puts on a node (ADR 0549 §3): what a resolution resolves values for.
|
||||
// Assignment is a module the seat's placement puts on a node (ADR 0549 §3): what a resolution resolves values for.
|
||||
type Assignment struct {
|
||||
Node string
|
||||
Module string
|
||||
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1126 (ADR 0549 §3), issue 774: a module the catalogue's placement names on a node resolves there; it goes when the
|
||||
// 1126 (ADR 0549 §3), issue 774: a module the seat's placement names on a node resolves there; it goes when the
|
||||
// placement no longer names it. A module another pulls in is placed in its own right (the seed placed what runs).
|
||||
func TestAModulePlacedHasItsValuesResolvedOnItsNode(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
|
||||
@@ -122,10 +122,10 @@ type Resolved struct {
|
||||
// ResolvedKey is a resolved value's key in mesh-config_resolved.
|
||||
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
|
||||
|
||||
// Sources is everything a resolution reads: where modules stand (the catalogue's placement), every module's
|
||||
// Sources is everything a resolution reads: where modules stand (the seat's placement), every module's
|
||||
// declarations, the published list of predefined keys, and every value set.
|
||||
type Sources struct {
|
||||
// Assignments is every module the catalogue's placement puts on a node (ADR 0549 §3), and Placed whether any node's
|
||||
// Assignments is every module the seat's placement puts on a node (ADR 0549 §3), and Placed whether any node's
|
||||
// placement was read: without one nothing is resolved anew.
|
||||
Assignments []Assignment
|
||||
Placed bool
|
||||
|
||||
@@ -140,7 +140,7 @@ func watchWants(env *Env) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// placement reads the indexer's placement through the runtime under the vault's own account (its manifest reads it).
|
||||
// placement reads the seat mesh-placement's placement through the runtime under the vault's own account (its manifest reads it).
|
||||
var placement = PlacementReader{
|
||||
Keys: func() ([]string, error) { return stdio.State(PlacementRead).Keys() },
|
||||
Read: func(key string) ([]byte, error) {
|
||||
|
||||
@@ -317,7 +317,7 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range []string{"mesh-vault_bus-accounts", "mesh-grants_accounts", "mesh-controller_lease",
|
||||
"mesh-controller_assignments", "node-engine_have", "mesh-delivery-indexer_placement"} {
|
||||
"mesh-controller_assignments", "node-engine_have", "mesh-delivery-assigner_placement"} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
// Where modules stand, from the catalogue's placement (novox/hq ADR 0549 §3, §5; ADR 0561; issue 774). The controller's
|
||||
// assignments mirror goes with its lease, so the vault reads which module stands on which node from the indexer's
|
||||
// placement first, and the assignments beside it while both stand: a module either names stands. The provisioner's plan,
|
||||
// Where modules stand, from the placement (novox/hq ADR 0549 §3, §5; ADR 0561; issue 774), the seat mesh-placement's
|
||||
// state held by the assigner (ADR 0567). The controller's assignments mirror goes with its lease, so the vault reads which
|
||||
// module stands on which node from the seat's placement first, and the assignments beside it while both stand: a module either names stands. The provisioner's plan,
|
||||
// which only an assignment's value carries, is read from the assignments still: its home after the lease is a subtask of
|
||||
// its own (issue 774).
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// PlacementRead is the indexer's placement, as the vault's manifest reads it.
|
||||
const PlacementRead = delivery.IndexerModule + "." + delivery.PlacementState
|
||||
// PlacementRead is the seat mesh-placement's placement, as the vault's manifest reads it (mesh-sdk's contract).
|
||||
const PlacementRead = delivery.PlacementRead
|
||||
|
||||
// PlacementReader reads the indexer's placement: every key, and one key's value (nil when there is none).
|
||||
// PlacementReader reads the seat's placement: every key, and one key's value (nil when there is none).
|
||||
type PlacementReader struct {
|
||||
Keys func() ([]string, error)
|
||||
Read func(key string) ([]byte, error)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// placementsOf is the indexer's placement of each node, as the vault reads it.
|
||||
// placementsOf is the seat mesh-placement's placement of each node, as the vault reads it.
|
||||
func placementsOf(t *testing.T, nodes map[string][]string) PlacementReader {
|
||||
t.Helper()
|
||||
values := map[string][]byte{}
|
||||
@@ -35,7 +35,7 @@ func placementsOf(t *testing.T, nodes map[string][]string) PlacementReader {
|
||||
}
|
||||
}
|
||||
|
||||
// 774/AC11 (novox/hq ADR 0561, ADR 0549 §3, §5): with no assignments to read, the vault tells from the catalogue's
|
||||
// 774/AC11 (novox/hq ADR 0561, ADR 0549 §3, §5): with no assignments to read, the vault tells from the seat's
|
||||
// placement which node holds the bus and whether a consumer's module stands on its node; while both stand, a module
|
||||
// either names stands.
|
||||
func TestTheVaultReadsWhereModulesStandFromPlacement(t *testing.T) {
|
||||
@@ -47,15 +47,15 @@ func TestTheVaultReadsWhereModulesStandFromPlacement(t *testing.T) {
|
||||
asked := func(node, module string) CredentialAsked {
|
||||
return CredentialAsked{Role: "consumer", Provision: "postgres", Consumer: node, ConsumerModule: module, Provider: "novox"}
|
||||
}
|
||||
if why := v.credentialRefusal("ace", asked("ace", "shop")); why != "" {
|
||||
if why := v.credentialRefusal("ace", asked("ace", "shop"), v.plansOf("ace")); why != "" {
|
||||
t.Errorf("shop stands on ace by placement, refused: %s", why)
|
||||
}
|
||||
if why := v.credentialRefusal("ace", asked("ace", "web")); !strings.Contains(why, "is not on ace") {
|
||||
if why := v.credentialRefusal("ace", asked("ace", "web"), v.plansOf("ace")); !strings.Contains(why, "is not on ace") {
|
||||
t.Errorf("web is not placed on ace, and is answered %q", why)
|
||||
}
|
||||
// While both stand, an assignment still names a module placement does not yet.
|
||||
v.Askers = Declared{Read: assignments{"ace.web": `{"node":"ace","module":"web"}`}.read, Store: v.Store}
|
||||
if why := v.credentialRefusal("ace", asked("ace", "web")); why != "" {
|
||||
if why := v.credentialRefusal("ace", asked("ace", "web"), v.plansOf("ace")); why != "" {
|
||||
t.Errorf("web assigned on ace, refused: %s", why)
|
||||
}
|
||||
// Two nodes placed with the bus: the vault adopts from neither.
|
||||
|
||||
@@ -312,7 +312,7 @@ func (v *Vault) internalName(node string) (string, error) {
|
||||
return "", fmt.Errorf(notYetPrefix+"the placement of %s could not be read: %w", node, err)
|
||||
}
|
||||
if raw == nil {
|
||||
return "", fmt.Errorf("%s has no placement in the catalogue, so it has no name inside the mesh", node)
|
||||
return "", fmt.Errorf("%s has no placement of the seat mesh-placement, so it has no name inside the mesh", node)
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil {
|
||||
|
||||
@@ -64,7 +64,7 @@ type Vault struct {
|
||||
adoption adoptionKept
|
||||
// WantRead reads one wanted account afresh (issue-account): nil reads the wants as the watch handed them.
|
||||
WantRead func(user string) ([]byte, bool, error)
|
||||
// Placement reads the catalogue's placement (placed.go): where modules stand once the assignments are gone.
|
||||
// Placement reads the seat's placement (placed.go): where modules stand once the assignments are gone.
|
||||
Placement PlacementReader
|
||||
// LeaseRead reads the controller's lease afresh before a mint (lease.go): nil reads it only as the watch hands it.
|
||||
LeaseRead func() ([]byte, bool, error)
|
||||
|
||||
@@ -134,7 +134,7 @@ func filesUnder(t *testing.T, dir string) map[string]string {
|
||||
// (ADR 0547 clause 9), opened in accounts.go alone. It reads five buckets of others', and only reads them: the
|
||||
// controller's assignments, for what each node's plan names (askers.go, design 57 V4), the node-engines' haves, where
|
||||
// the bus node hands the loaded list's hashes sealed to the vault (adopt.go, ADR 0547 clause 9), the grants part's wanted
|
||||
// accounts and the controller's lease (mint.go, lease.go, ADR 0561 §2, §3), and the catalogue's placement (placed.go), in
|
||||
// accounts and the controller's lease (mint.go, lease.go, ADR 0561 §2, §3), and the seat's placement (placed.go), in
|
||||
// main.go alone.
|
||||
func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
@@ -149,7 +149,7 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+
|
||||
"buckets are the accounts' hashes (ADR 0547 clause 9) and the signing keys' public halves (ADR 0550 §2)", s)
|
||||
}
|
||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts","mesh-controller.lease","mesh-delivery-indexer.placement"]` {
|
||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts","mesh-controller.lease","mesh-delivery-assigner.placement"]` {
|
||||
t.Errorf("the manifest reads %s: the vault reads the controller's assignments, the haves and the grants part's "+
|
||||
"wanted accounts alone", r)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
// And the controller's lease, one read and one watch (ADR 0561 §3).
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(LeaseRead).Watch(", "", 1))
|
||||
// And the catalogue's placement, where modules stand (placed.go): its keys and one key, read.
|
||||
// And the seat's placement, where modules stand (placed.go): its keys and one key, read.
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Keys(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Get(", "", 1))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"node-engine.have",
|
||||
"mesh-grants.accounts",
|
||||
"mesh-controller.lease",
|
||||
"mesh-delivery-indexer.placement"
|
||||
"mesh-delivery-assigner.placement"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user