mirror of
https://github.com/kubernetes-sigs/kustomize.git
synced 2026-05-17 10:15:22 +00:00
Merge pull request #5679 from koba1t/implements_to_replacements_value_in_the_structured_data
implements to replacements value in the structured data
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
package replacement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -188,6 +189,14 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
||||
if selector.Options != nil && selector.Options.Create {
|
||||
createKind = value.YNode().Kind
|
||||
}
|
||||
|
||||
// Check if this fieldPath contains structured data access
|
||||
if err := setValueInStructuredData(target, value, fp, createKind); err == nil {
|
||||
// Successfully handled as structured data
|
||||
continue
|
||||
}
|
||||
|
||||
// Fall back to normal path handling
|
||||
targetFieldList, err := target.Pipe(&yaml.PathMatcher{
|
||||
Path: kyaml_utils.SmarterPathSplitter(fp, "."),
|
||||
Create: createKind})
|
||||
@@ -204,7 +213,7 @@ func copyValueToTarget(target *yaml.RNode, value *yaml.RNode, selector *types.Ta
|
||||
|
||||
for _, t := range targetFields {
|
||||
if err := setFieldValue(selector.Options, t, value); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,3 +256,146 @@ func setFieldValue(options *types.FieldOptions, targetField *yaml.RNode, value *
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setValueInStructuredData handles setting values within structured data (JSON/YAML) in scalar fields
|
||||
func setValueInStructuredData(target *yaml.RNode, value *yaml.RNode, fieldPath string, createKind yaml.Kind) error {
|
||||
pathParts := kyaml_utils.SmarterPathSplitter(fieldPath, ".")
|
||||
if len(pathParts) < 2 {
|
||||
return fmt.Errorf("not a structured data path")
|
||||
}
|
||||
|
||||
// Find the potential scalar field that might contain structured data
|
||||
var scalarFieldPath []string
|
||||
var structuredDataPath []string
|
||||
var foundScalar = false
|
||||
|
||||
// Try to find where the scalar field ends and structured data begins
|
||||
for i := 1; i <= len(pathParts); i++ {
|
||||
potentialScalarPath := pathParts[:i]
|
||||
scalarField, err := target.Pipe(yaml.Lookup(potentialScalarPath...))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if scalarField != nil && scalarField.YNode().Kind == yaml.ScalarNode && i < len(pathParts) {
|
||||
// Try to parse the scalar value as structured data
|
||||
scalarValue := scalarField.YNode().Value
|
||||
var parsedNode yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err == nil {
|
||||
// Successfully parsed - this is structured data
|
||||
scalarFieldPath = potentialScalarPath
|
||||
structuredDataPath = pathParts[i:]
|
||||
foundScalar = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundScalar {
|
||||
return fmt.Errorf("no structured data found in path")
|
||||
}
|
||||
|
||||
// Get the scalar field containing structured data
|
||||
scalarField, err := target.Pipe(yaml.Lookup(scalarFieldPath...))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// Parse the structured data
|
||||
scalarValue := scalarField.YNode().Value
|
||||
var parsedNode yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
structuredData := yaml.NewRNode(&parsedNode)
|
||||
|
||||
// Navigate to the target location within the structured data
|
||||
targetInStructured, err := structuredData.Pipe(&yaml.PathMatcher{
|
||||
Path: structuredDataPath,
|
||||
Create: createKind,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
targetFields, err := targetInStructured.Elements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
if len(targetFields) == 0 {
|
||||
return fmt.Errorf("unable to find field in structured data")
|
||||
}
|
||||
|
||||
// Set the value in the structured data
|
||||
for _, t := range targetFields {
|
||||
if t.YNode().Kind == yaml.ScalarNode {
|
||||
t.YNode().Value = value.YNode().Value
|
||||
} else {
|
||||
t.SetYNode(value.YNode())
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the modified structured data back to the scalar field
|
||||
// Try to detect if original was JSON or YAML and preserve formatting
|
||||
serializedData, err := serializeStructuredData(structuredData, scalarValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
// Update the original scalar field
|
||||
scalarField.YNode().Value = serializedData
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// serializeStructuredData handles the serialization of structured data back to string format
|
||||
// preserving the original format (JSON vs YAML) and style (pretty vs compact)
|
||||
func serializeStructuredData(structuredData *yaml.RNode, originalValue string) (string, error) {
|
||||
firstChar := rune(strings.TrimSpace(originalValue)[0])
|
||||
if firstChar == '{' || firstChar == '[' {
|
||||
return serializeAsJSON(structuredData, originalValue)
|
||||
}
|
||||
|
||||
// Fallback to YAML format
|
||||
return serializeAsYAML(structuredData)
|
||||
}
|
||||
|
||||
// serializeAsJSON converts structured data back to JSON format
|
||||
func serializeAsJSON(structuredData *yaml.RNode, originalValue string) (string, error) {
|
||||
modifiedData, err := structuredData.String()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize structured data: %w", err)
|
||||
}
|
||||
|
||||
// Parse the YAML output as JSON
|
||||
var jsonData interface{}
|
||||
if err := yaml.Unmarshal([]byte(modifiedData), &jsonData); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal YAML data: %w", err)
|
||||
}
|
||||
|
||||
// Check if original was pretty-printed by looking for newlines and indentation
|
||||
if strings.Contains(originalValue, "\n") && strings.Contains(originalValue, " ") {
|
||||
// Pretty-print the JSON to match original formatting
|
||||
if prettyJSON, err := json.MarshalIndent(jsonData, "", " "); err == nil {
|
||||
return string(prettyJSON), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Compact JSON
|
||||
if compactJSON, err := json.Marshal(jsonData); err == nil {
|
||||
return string(compactJSON), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to marshal JSON data")
|
||||
}
|
||||
|
||||
// serializeAsYAML converts structured data back to YAML format
|
||||
func serializeAsYAML(structuredData *yaml.RNode) (string, error) {
|
||||
modifiedData, err := structuredData.String()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize YAML data: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(modifiedData), nil
|
||||
}
|
||||
|
||||
@@ -2779,7 +2779,7 @@ spec:
|
||||
name: myingress
|
||||
fieldPaths:
|
||||
- spec.tls.0.hosts.0
|
||||
- spec.tls.0.secretName
|
||||
- spec.tls.0.secretName
|
||||
options:
|
||||
create: true
|
||||
`,
|
||||
@@ -4498,3 +4498,536 @@ metadata:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueInlineStructuredData(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
input string
|
||||
replacements string
|
||||
expected string
|
||||
expectedErr string
|
||||
}{
|
||||
"replacement contain jsonfield": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
annotations:
|
||||
hostname: www.example.com
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"id": "42",
|
||||
"hostname": "REPLACE_TARGET_HOSTNAME"
|
||||
}
|
||||
}
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: target-configmap
|
||||
fieldPath: metadata.annotations.hostname
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
fieldPaths:
|
||||
- data.config\.json.config.hostname
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
annotations:
|
||||
hostname: www.example.com
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"hostname": "www.example.com",
|
||||
"id": "42"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
"replacement json field with source from separate configmap": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-configmap
|
||||
data:
|
||||
HOSTNAME: www.example.com
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"id": "42",
|
||||
"hostname": "REPLACE_TARGET_HOSTNAME"
|
||||
}
|
||||
}
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-configmap
|
||||
fieldPath: data.HOSTNAME
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: target-configmap
|
||||
fieldPaths:
|
||||
- data.config\.json.config.hostname
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-configmap
|
||||
data:
|
||||
HOSTNAME: www.example.com
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-configmap
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"config": {
|
||||
"hostname": "www.example.com",
|
||||
"id": "42"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
"replacement yaml field in configmap": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: environment-config
|
||||
data:
|
||||
env: dev
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
data:
|
||||
prometheus.yml: |-
|
||||
global:
|
||||
external_labels:
|
||||
prometheus_env: TARGET_ENVIRONMENT
|
||||
scrape_configs:
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: environment-config
|
||||
fieldPath: data.env
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: prometheus-config
|
||||
fieldPaths:
|
||||
- data.prometheus\.yml.global.external_labels.prometheus_env
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: environment-config
|
||||
data:
|
||||
env: dev
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
data:
|
||||
prometheus.yml: |-
|
||||
global:
|
||||
external_labels:
|
||||
prometheus_env: dev
|
||||
scrape_configs:
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]`,
|
||||
},
|
||||
"replacement json field in annotations": {
|
||||
input: `apiVersion: cloud.google.com/v1
|
||||
kind: BackendConfig
|
||||
metadata:
|
||||
name: debug-backend-config
|
||||
spec:
|
||||
securityPolicy:
|
||||
name: "debug-security-policy"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: appA-svc
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"ports": {"appA":"gke-default-backend-config"}}'
|
||||
spec:
|
||||
ports:
|
||||
- name: appA
|
||||
port: 1234
|
||||
protocol: TCP
|
||||
targetPort: 8080
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: BackendConfig
|
||||
name: debug-backend-config
|
||||
fieldPath: metadata.name
|
||||
targets:
|
||||
- select:
|
||||
kind: Service
|
||||
name: appA-svc
|
||||
fieldPaths:
|
||||
- metadata.annotations.cloud\.google\.com/backend-config.ports.appA
|
||||
`,
|
||||
expected: `apiVersion: cloud.google.com/v1
|
||||
kind: BackendConfig
|
||||
metadata:
|
||||
name: debug-backend-config
|
||||
spec:
|
||||
securityPolicy:
|
||||
name: "debug-security-policy"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: appA-svc
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"ports":{"appA":"debug-backend-config"}}'
|
||||
spec:
|
||||
ports:
|
||||
- name: appA
|
||||
port: 1234
|
||||
protocol: TCP
|
||||
targetPort: 8080`,
|
||||
},
|
||||
"replacement yaml nested value": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
replicas: "3"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-template
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:latest
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPath: data.replicas
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: deployment-template
|
||||
fieldPaths:
|
||||
- data.deployment\.yaml.spec.replicas
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
replicas: "3"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: deployment-template
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:latest`,
|
||||
},
|
||||
"replacement json complex nested structure": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-config
|
||||
data:
|
||||
database_host: prod-db.example.com
|
||||
database_port: "5432"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"connections": {
|
||||
"primary": {
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"ssl": true
|
||||
},
|
||||
"secondary": {
|
||||
"host": "backup-host",
|
||||
"port": 3307
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"min": 5,
|
||||
"max": 10
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-config
|
||||
fieldPath: data.database_host
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPaths:
|
||||
- data.config\.json.database.connections.primary.host
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-config
|
||||
fieldPath: data.database_port
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: app-config
|
||||
fieldPaths:
|
||||
- data.config\.json.database.connections.primary.port
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-config
|
||||
data:
|
||||
database_host: prod-db.example.com
|
||||
database_port: "5432"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"database": {
|
||||
"connections": {
|
||||
"primary": {
|
||||
"host": "prod-db.example.com",
|
||||
"port": 5432,
|
||||
"ssl": true
|
||||
},
|
||||
"secondary": {
|
||||
"host": "backup-host",
|
||||
"port": 3307
|
||||
}
|
||||
},
|
||||
"pool": {
|
||||
"max": 10,
|
||||
"min": 5
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"level": "info"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
"replacement yaml array element": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: image-config
|
||||
data:
|
||||
new_image: nginx:1.20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: k8s-manifest
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.18
|
||||
- name: sidecar
|
||||
image: busybox:latest
|
||||
`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: image-config
|
||||
fieldPath: data.new_image
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: k8s-manifest
|
||||
fieldPaths:
|
||||
- data.deployment\.yaml.spec.template.spec.containers.[name=web].image
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: image-config
|
||||
data:
|
||||
new_image: nginx:1.20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: k8s-manifest
|
||||
data:
|
||||
deployment.yaml: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-app
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx:1.20
|
||||
- name: sidecar
|
||||
image: busybox:latest`,
|
||||
},
|
||||
"replacement yaml flow style (kyaml) field": {
|
||||
input: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-values
|
||||
data:
|
||||
app_name: "my-awesome-app"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {
|
||||
app: "REPLACE_APP_NAME",
|
||||
version: "1.0.0",
|
||||
env: "production",
|
||||
}
|
||||
spec: {
|
||||
replicas: 3,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: "REPLACE_APP_NAME"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
replacements: `replacements:
|
||||
- source:
|
||||
kind: ConfigMap
|
||||
name: source-values
|
||||
fieldPath: data.app_name
|
||||
targets:
|
||||
- select:
|
||||
kind: ConfigMap
|
||||
name: target-config
|
||||
fieldPaths:
|
||||
- data.config\.yaml.labels.app
|
||||
- data.config\.yaml.spec.selector.matchLabels.app
|
||||
`,
|
||||
expected: `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: source-values
|
||||
data:
|
||||
app_name: "my-awesome-app"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: target-config
|
||||
data:
|
||||
config.yaml: |-
|
||||
labels: {app: "my-awesome-app", version: "1.0.0", env: "production"}
|
||||
spec: {replicas: 3, selector: {matchLabels: {app: "my-awesome-app"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for tn := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
// Test one case to see if structured data replacement works
|
||||
f := Filter{}
|
||||
err := yaml.Unmarshal([]byte(testCases[tn].replacements), &f)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
actual, err := filtertest.RunFilterE(t, testCases[tn].input, f)
|
||||
if err != nil {
|
||||
if testCases[tn].expectedErr == "" {
|
||||
t.Errorf("unexpected error: %s\n", err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
if !assert.Contains(t, err.Error(), testCases[tn].expectedErr) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
if !assert.Equal(t, strings.TrimSpace(testCases[tn].expected), strings.TrimSpace(actual)) {
|
||||
t.FailNow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user