Merge pull request #7648 from dokku/k3s-vector-integration
feat: add ability to ship k3s container logs via vector
This commit is contained in:
@@ -531,7 +531,7 @@ dokku scheduler-k3s:ensure-charts --force
|
|||||||
Alternatively, a comma separated list of chart names can be specified to only force install the specified charts:
|
Alternatively, a comma separated list of chart names can be specified to only force install the specified charts:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
dokku scheduler-k3s:ensure-charts --force --chart-names cert-manager
|
dokku scheduler-k3s:ensure-charts --charts cert-manager
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scheduler Interface
|
## Scheduler Interface
|
||||||
@@ -575,7 +575,19 @@ The following Dokku functionality is not implemented at this time.
|
|||||||
|
|
||||||
### Logging support
|
### Logging support
|
||||||
|
|
||||||
App logs for the `logs` command are fetched by Dokku from running containers via the `kubectl` cli. Persisting logs via Vector is not implemented at this time. Users may choose to configure the Vector Kubernetes integration directly by following [this guide](https://vector.dev/docs/setup/installation/platforms/kubernetes/).
|
App logs for the `logs` command are fetched by Dokku from running containers via the Kubernetes api. While the `k3s` scheduler does not integrate with the `logs:vector-*` subcommands, it does respect the global `vector-sink` logs property. When that property is set, the `scheduler-k3s:ensure-charts` command can be utilized to reconfigure vector to ship kubernetes logs to the provided sink.
|
||||||
|
|
||||||
|
To setup log shipping, configure the global `vector-sink` property for the `logs` plugin.. Note that this can be run before _or_ after the `scheduler-k3s:ensure-charts` command - if run before, the `scheduler-k3s:ensure-charts` subcommand will pick up the value.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
dokku logs:set --global vector-sink "console://?encoding[codec]=json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, run the `scheduler-k3s:ensure-charts` command with the `vector` chart to force the `k3s` scheduler to reconfigure vector with the specified sink:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
dokku scheduler-k3s:ensure-charts --charts vector
|
||||||
|
```
|
||||||
|
|
||||||
### Supported Resource Management Properties
|
### Supported Resource Management Properties
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dokku/dokku/plugins/common"
|
"github.com/dokku/dokku/plugins/common"
|
||||||
"github.com/joncalhoun/qson"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type vectorConfig struct {
|
type vectorConfig struct {
|
||||||
Sources map[string]vectorSource `json:"sources"`
|
Sources map[string]vectorSource `json:"sources"`
|
||||||
Sinks map[string]vectorSink `json:"sinks"`
|
Sinks map[string]VectorSink `json:"sinks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type vectorSource struct {
|
type vectorSource struct {
|
||||||
@@ -31,8 +29,6 @@ type vectorTemplateData struct {
|
|||||||
VectorImage string
|
VectorImage string
|
||||||
}
|
}
|
||||||
|
|
||||||
type vectorSink map[string]interface{}
|
|
||||||
|
|
||||||
const vectorContainerName = "vector-vector-1"
|
const vectorContainerName = "vector-vector-1"
|
||||||
const vectorOldContainerName = "vector"
|
const vectorOldContainerName = "vector"
|
||||||
|
|
||||||
@@ -195,53 +191,11 @@ func stopVectorContainer() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sinkValueToConfig(appName string, sinkValue string) (vectorSink, error) {
|
|
||||||
var data vectorSink
|
|
||||||
if strings.Contains(sinkValue, "://") {
|
|
||||||
parts := strings.SplitN(sinkValue, "://", 2)
|
|
||||||
parts[0] = strings.ReplaceAll(parts[0], "_", "-")
|
|
||||||
sinkValue = strings.Join(parts, "://")
|
|
||||||
}
|
|
||||||
u, err := url.Parse(sinkValue)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.Query().Get("sinks") != "" {
|
|
||||||
return data, errors.New("Invalid option sinks")
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Scheme = strings.ReplaceAll(u.Scheme, "-", "_")
|
|
||||||
|
|
||||||
query := u.RawQuery
|
|
||||||
query = strings.TrimPrefix(query, "&")
|
|
||||||
|
|
||||||
b, err := qson.ToJSON(query)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data["type"] = u.Scheme
|
|
||||||
data["inputs"] = []string{"docker-source:" + appName}
|
|
||||||
if appName == "--global" {
|
|
||||||
data["inputs"] = []string{"docker-global-source"}
|
|
||||||
}
|
|
||||||
if appName == "--null" {
|
|
||||||
data["inputs"] = []string{"docker-null-source"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeVectorConfig() error {
|
func writeVectorConfig() error {
|
||||||
apps, _ := common.UnfilteredDokkuApps()
|
apps, _ := common.UnfilteredDokkuApps()
|
||||||
data := vectorConfig{
|
data := vectorConfig{
|
||||||
Sources: map[string]vectorSource{},
|
Sources: map[string]vectorSource{},
|
||||||
Sinks: map[string]vectorSink{},
|
Sinks: map[string]VectorSink{},
|
||||||
}
|
}
|
||||||
for _, appName := range apps {
|
for _, appName := range apps {
|
||||||
value := common.PropertyGet("logs", appName, "vector-sink")
|
value := common.PropertyGet("logs", appName, "vector-sink")
|
||||||
@@ -250,7 +204,7 @@ func writeVectorConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inflectedAppName := strings.ReplaceAll(appName, ".", "-")
|
inflectedAppName := strings.ReplaceAll(appName, ".", "-")
|
||||||
sink, err := sinkValueToConfig(inflectedAppName, value)
|
sink, err := SinkValueToConfig(inflectedAppName, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -265,7 +219,7 @@ func writeVectorConfig() error {
|
|||||||
|
|
||||||
value := common.PropertyGet("logs", "--global", "vector-sink")
|
value := common.PropertyGet("logs", "--global", "vector-sink")
|
||||||
if value != "" {
|
if value != "" {
|
||||||
sink, err := sinkValueToConfig("--global", value)
|
sink, err := SinkValueToConfig("--global", value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -288,7 +242,7 @@ func writeVectorConfig() error {
|
|||||||
|
|
||||||
if len(data.Sinks) == 0 {
|
if len(data.Sinks) == 0 {
|
||||||
// write logs to a blackhole
|
// write logs to a blackhole
|
||||||
sink, err := sinkValueToConfig("--null", VectorDefaultSink)
|
sink, err := SinkValueToConfig("--null", VectorDefaultSink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ package logs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dokku/dokku/plugins/common"
|
"github.com/dokku/dokku/plugins/common"
|
||||||
|
"github.com/joncalhoun/qson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VectorSink is a map of vector sink properties
|
||||||
|
type VectorSink map[string]interface{}
|
||||||
|
|
||||||
// MaxSize is the default max retention size for docker logs
|
// MaxSize is the default max retention size for docker logs
|
||||||
const MaxSize = "10m"
|
const MaxSize = "10m"
|
||||||
|
|
||||||
@@ -53,3 +61,46 @@ func GetFailedLogs(appName string) error {
|
|||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SinkValueToConfig converts a sink DSN value to a VectorSink
|
||||||
|
func SinkValueToConfig(appName string, sinkValue string) (VectorSink, error) {
|
||||||
|
var data VectorSink
|
||||||
|
if strings.Contains(sinkValue, "://") {
|
||||||
|
parts := strings.SplitN(sinkValue, "://", 2)
|
||||||
|
parts[0] = strings.ReplaceAll(parts[0], "_", "-")
|
||||||
|
sinkValue = strings.Join(parts, "://")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(sinkValue)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Query().Get("sinks") != "" {
|
||||||
|
return data, errors.New("Invalid option sinks")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Scheme = strings.ReplaceAll(u.Scheme, "-", "_")
|
||||||
|
|
||||||
|
query := u.RawQuery
|
||||||
|
query = strings.TrimPrefix(query, "&")
|
||||||
|
|
||||||
|
b, err := qson.ToJSON(query)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(b, &data); err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data["type"] = u.Scheme
|
||||||
|
data["inputs"] = []string{"docker-source:" + appName}
|
||||||
|
if appName == "--global" {
|
||||||
|
data["inputs"] = []string{"docker-global-source"}
|
||||||
|
}
|
||||||
|
if appName == "--null" {
|
||||||
|
data["inputs"] = []string{"docker-null-source"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func validateVectorSink(appName string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := sinkValueToConfig(appName, value)
|
_, err := SinkValueToConfig(appName, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
appjson "github.com/dokku/dokku/plugins/app-json"
|
appjson "github.com/dokku/dokku/plugins/app-json"
|
||||||
"github.com/dokku/dokku/plugins/common"
|
"github.com/dokku/dokku/plugins/common"
|
||||||
|
"github.com/dokku/dokku/plugins/logs"
|
||||||
nginxvhosts "github.com/dokku/dokku/plugins/nginx-vhosts"
|
nginxvhosts "github.com/dokku/dokku/plugins/nginx-vhosts"
|
||||||
resty "github.com/go-resty/resty/v2"
|
resty "github.com/go-resty/resty/v2"
|
||||||
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
|
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
|
||||||
@@ -1527,6 +1528,10 @@ func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chart.ReleaseName == "vector" {
|
||||||
|
values = updateVectorValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
chartProperties, err := common.PropertyGetAllByPrefix("scheduler-k3s", "--global", "chart."+chart.ReleaseName+".")
|
chartProperties, err := common.PropertyGetAllByPrefix("scheduler-k3s", "--global", "chart."+chart.ReleaseName+".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error getting chart properties: %w", err)
|
return fmt.Errorf("Error getting chart properties: %w", err)
|
||||||
@@ -1566,6 +1571,28 @@ func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldIn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateVectorValues(values map[string]interface{}) map[string]interface{} {
|
||||||
|
value := common.PropertyGet("logs", "--global", "vector-sink")
|
||||||
|
if value == "" {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
sink, err := logs.SinkValueToConfig("--global", value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sink["inputs"] = []string{"kubernetes_container_logs"}
|
||||||
|
|
||||||
|
sinkMap := map[string]interface{}{
|
||||||
|
"kubernetes_global_sink": sink,
|
||||||
|
}
|
||||||
|
|
||||||
|
values["customConfig"].(map[string]interface{})["sinks"] = sinkMap
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
func installHelperCommands(ctx context.Context) error {
|
func installHelperCommands(ctx context.Context) error {
|
||||||
urls := map[string]string{
|
urls := map[string]string{
|
||||||
"kubectx": "https://github.com/ahmetb/kubectx/releases/latest/download/kubectx",
|
"kubectx": "https://github.com/ahmetb/kubectx/releases/latest/download/kubectx",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
|
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
|
||||||
github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000
|
github.com/dokku/dokku/plugins/config v0.0.0-00010101000000-000000000000
|
||||||
github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000
|
github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/dokku/dokku/plugins/logs v0.0.0-00010101000000-000000000000
|
||||||
github.com/dokku/dokku/plugins/nginx-vhosts v0.0.0-00010101000000-000000000000
|
github.com/dokku/dokku/plugins/nginx-vhosts v0.0.0-00010101000000-000000000000
|
||||||
github.com/fatih/color v1.18.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/go-openapi/jsonpointer v0.21.0
|
github.com/go-openapi/jsonpointer v0.21.0
|
||||||
@@ -59,6 +60,7 @@ require (
|
|||||||
github.com/docker/docker-credential-helpers v0.7.0 // indirect
|
github.com/docker/docker-credential-helpers v0.7.0 // indirect
|
||||||
github.com/docker/go-connections v0.5.0 // indirect
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
|
github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.12.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.12.0 // indirect
|
||||||
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
|
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
|
||||||
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
||||||
@@ -96,6 +98,7 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jmoiron/sqlx v1.3.5 // indirect
|
github.com/jmoiron/sqlx v1.3.5 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/joncalhoun/qson v0.0.0-20200422171543-84433dcd3da0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.11-0.20241004063537-dbd6c381492a // indirect
|
github.com/klauspost/compress v1.17.11-0.20241004063537-dbd6c381492a // indirect
|
||||||
@@ -194,6 +197,10 @@ replace github.com/dokku/dokku/plugins/config => ../config
|
|||||||
|
|
||||||
replace github.com/dokku/dokku/plugins/cron => ../cron
|
replace github.com/dokku/dokku/plugins/cron => ../cron
|
||||||
|
|
||||||
|
replace github.com/dokku/dokku/plugins/docker-options => ../docker-options
|
||||||
|
|
||||||
|
replace github.com/dokku/dokku/plugins/logs => ../logs
|
||||||
|
|
||||||
replace github.com/dokku/dokku/plugins/nginx-vhosts => ../nginx-vhosts
|
replace github.com/dokku/dokku/plugins/nginx-vhosts => ../nginx-vhosts
|
||||||
|
|
||||||
replace github.com/joho/godotenv => github.com/joho/godotenv v1.2.0
|
replace github.com/joho/godotenv => github.com/joho/godotenv v1.2.0
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
|||||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
github.com/joho/godotenv v1.2.0 h1:vGTvz69FzUFp+X4/bAkb0j5BoLC+9bpqTWY8mjhA9pc=
|
github.com/joho/godotenv v1.2.0 h1:vGTvz69FzUFp+X4/bAkb0j5BoLC+9bpqTWY8mjhA9pc=
|
||||||
github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||||
|
github.com/joncalhoun/qson v0.0.0-20200422171543-84433dcd3da0 h1:ct2XA1aDw8A07Dr8gtrrZgIgLKcZNAl2o9nn0WRMK4Y=
|
||||||
|
github.com/joncalhoun/qson v0.0.0-20200422171543-84433dcd3da0/go.mod h1:DFXrEwSRX0p/aSvxE21319menCBFeQO0jXpRj7LEZUA=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ var HelmCharts = []HelmChart{
|
|||||||
RepoURL: "https://kedacore.github.io/charts",
|
RepoURL: "https://kedacore.github.io/charts",
|
||||||
Version: "0.8.0",
|
Version: "0.8.0",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ChartPath: "vector",
|
||||||
|
CreateNamespace: true,
|
||||||
|
Namespace: "vector",
|
||||||
|
ReleaseName: "vector",
|
||||||
|
RepoURL: "https://helm.vector.dev",
|
||||||
|
Version: "0.42.0",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type HelmRepository struct {
|
type HelmRepository struct {
|
||||||
|
|||||||
42
plugins/scheduler-k3s/templates/helm-config/vector.yaml
Normal file
42
plugins/scheduler-k3s/templates/helm-config/vector.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
role: Agent
|
||||||
|
|
||||||
|
tolerations:
|
||||||
|
- effect: NoSchedule
|
||||||
|
key: node-role.kubernetes.io/master
|
||||||
|
operator: Exists
|
||||||
|
- effect: NoSchedule
|
||||||
|
key: node-role.kubernetes.io/infra
|
||||||
|
operator: Exists
|
||||||
|
|
||||||
|
service:
|
||||||
|
ports:
|
||||||
|
- name: prom-exporter
|
||||||
|
port: 9090
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
customConfig:
|
||||||
|
data_dir: /vector-data-dir
|
||||||
|
|
||||||
|
sources:
|
||||||
|
kubernetes_logs:
|
||||||
|
type: kubernetes_logs
|
||||||
|
|
||||||
|
transforms:
|
||||||
|
kubernetes_container_logs:
|
||||||
|
type: remap
|
||||||
|
inputs:
|
||||||
|
- kubernetes_logs
|
||||||
|
source: |
|
||||||
|
.message = .message
|
||||||
|
.container = .kubernetes.container_name
|
||||||
|
.pod = .kubernetes.pod_name
|
||||||
|
.namespace = .kubernetes.pod_namespace
|
||||||
|
|
||||||
|
sinks:
|
||||||
|
default_global_sink:
|
||||||
|
type: console
|
||||||
|
inputs:
|
||||||
|
- kubernetes_container_logs
|
||||||
|
encoding:
|
||||||
|
codec: json
|
||||||
Reference in New Issue
Block a user