Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ Interface + `sql*Dao` implementation using SessionFactory:
- On write errors: call `db.MarkForRollback(ctx, err)`

### Plugin Registration
Each resource registers via `init()` function:
Generic entity types (Channel, Version, WifConfig) are config-driven — declared in `config.yaml` under `entities:`,
registered at startup via `registry.LoadDescriptors()`, routes auto-generated by `plugins/entities/plugin.go`.
No per-entity Go code needed. See `plugins/CLAUDE.md` for details.

Cluster and NodePool use legacy typed plugins with `init()` registration:
- Reference: `plugins/clusters/plugin.go`
- `registry.RegisterService()`, `server.RegisterRoutes()`, `presenters.RegisterPath()`, `presenters.RegisterKind()`

Expand All @@ -102,7 +106,8 @@ Each resource registers via `init()` function:
- Read requests (GET) skip transaction creation for performance
- OpenAPI spec and code generation: see [openapi/README.md](openapi/README.md) — run `make generate` before building; generated files in `pkg/api/openapi/` are **never edited**
- Status aggregation: Service layer synthesizes `Available`, `Reconciled`, and `LastKnownReconciled` conditions from adapter reports
- Plugin-based: each resource type registers routes/services in `plugins/*/plugin.go`
- Generic entities are config-driven (`config.yaml` → `registry.LoadDescriptors` → auto-generated routes)
- Cluster/NodePool use legacy typed plugins in `plugins/*/plugin.go`

## Boundaries

Expand All @@ -122,7 +127,7 @@ Subdirectories contain context-specific guidance that loads when you work in tho
- `pkg/dao/CLAUDE.md` — DAO interface, session access, and rollback patterns
- `pkg/db/CLAUDE.md` — SessionFactory and transaction middleware
- `pkg/errors/CLAUDE.md` — Error constructors, codes, and RFC 9457 details
- `plugins/CLAUDE.md` — Plugin registration (init-based)
- `plugins/CLAUDE.md` — Plugin registration (config-driven entities + legacy init-based)
- `test/CLAUDE.md` — Test conventions, factories, and environment variables
- `charts/CLAUDE.md` — Helm chart testing and configuration
- `openapi/README.md` — OpenAPI schema import, code generation, schema validation, and oapi-codegen config
6 changes: 6 additions & 0 deletions charts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ Adapter arrays are required — templates fail without them:
--set 'adapters.nodepool=["validation"]'
```

## Entity Registration

Entity descriptors are configured in `config.entities`. Default values include Channel, Version, and WifConfig.
Override with `--set-json 'config.entities=[...]'` for custom entity sets. An empty list disables all
generic entity routes.

## Database Modes

Two database configurations supported:
Expand Down
3 changes: 2 additions & 1 deletion charts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ helm install hyperfleet-api oci://REGISTRY/hyperfleet-api \
| ports.api | int | `8000` | API server port |
| ports.health | int | `8080` | Health check endpoint port |
| ports.metrics | int | `9090` | Prometheus metrics endpoint port |
| config | object | `{"adapters":{"required":{"cluster":[],"nodepool":[]}},"database":{"debug":false,"dialect":"postgres","host":"","name":"hyperfleet","pool":{"conn_max_idle_time":"1m","conn_max_lifetime":"5m","conn_retry_attempts":10,"conn_retry_interval":"3s","max_connections":50,"max_idle_connections":10,"request_timeout":"30s"},"port":5432,"ssl":{"mode":"disable","root_cert_file":""}},"existingConfigMap":"","health":{"db_ping_timeout":"2s","host":"0.0.0.0","port":8080,"shutdown_timeout":"20s","tls":{"enabled":false}},"logging":{"format":"json","level":"info","masking":{"enabled":true,"fields":["password","secret","token","api_key","access_token","refresh_token","client_secret"],"headers":["Authorization","X-API-Key","Cookie","X-Auth-Token","X-Forwarded-Authorization","X-HyperFleet-Identity"]},"otel":{"enabled":false},"output":"stdout"},"metrics":{"host":"0.0.0.0","label_metrics_inclusion_duration":"168h","port":9090,"reconciliation_stuck_threshold":"10m","tls":{"enabled":false}},"server":{"host":"0.0.0.0","hostname":"","identity_header":"","jwk":{"cert_file":"","cert_url":""},"jwt":{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}}` | Application configuration. All settings in this section generate the ConfigMap consumed by the API server. Set `config.existingConfigMap` to use a pre-existing ConfigMap instead. |
| config | object | `{"adapters":{"required":{"cluster":[],"nodepool":[]}},"database":{"debug":false,"dialect":"postgres","host":"","name":"hyperfleet","pool":{"conn_max_idle_time":"1m","conn_max_lifetime":"5m","conn_retry_attempts":10,"conn_retry_interval":"3s","max_connections":50,"max_idle_connections":10,"request_timeout":"30s"},"port":5432,"ssl":{"mode":"disable","root_cert_file":""}},"entities":[{"kind":"Channel","plural":"channels","search_disallowed_fields":["spec"],"spec_schema_name":"ChannelSpec"},{"kind":"Version","on_parent_delete":"restrict","parent_kind":"Channel","plural":"versions","search_disallowed_fields":["spec"],"spec_schema_name":"VersionSpec"},{"kind":"WifConfig","plural":"wifconfigs","search_disallowed_fields":["spec"],"spec_schema_name":"WifConfigSpec"}],"existingConfigMap":"","health":{"db_ping_timeout":"2s","host":"0.0.0.0","port":8080,"shutdown_timeout":"20s","tls":{"enabled":false}},"logging":{"format":"json","level":"info","masking":{"enabled":true,"fields":["password","secret","token","api_key","access_token","refresh_token","client_secret"],"headers":["Authorization","X-API-Key","Cookie","X-Auth-Token","X-Forwarded-Authorization","X-HyperFleet-Identity"]},"otel":{"enabled":false},"output":"stdout"},"metrics":{"host":"0.0.0.0","label_metrics_inclusion_duration":"168h","port":9090,"reconciliation_stuck_threshold":"10m","tls":{"enabled":false}},"server":{"host":"0.0.0.0","hostname":"","identity_header":"","jwk":{"cert_file":"","cert_url":""},"jwt":{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}}` | Application configuration. All settings in this section generate the ConfigMap consumed by the API server. Set `config.existingConfigMap` to use a pre-existing ConfigMap instead. |
| config.existingConfigMap | string | `""` | Use an existing ConfigMap instead of generating one. When set, all other `config.*` values are ignored. |
| config.server | object | `{"host":"0.0.0.0","hostname":"","identity_header":"","jwk":{"cert_file":"","cert_url":""},"jwt":{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}` | HTTP server settings |
| config.server.hostname | string | `""` | Public hostname advertised by the API (leave empty for auto-detect) |
Expand Down Expand Up @@ -111,6 +111,7 @@ helm install hyperfleet-api oci://REGISTRY/hyperfleet-api \
| config.adapters.required | object | `{"cluster":[],"nodepool":[]}` | Adapters required for cluster resources |
| config.adapters.required.cluster | list | `[]` | Required cluster adapters (e.g. `["validation", "dns", "pullsecret", "hypershift"]`) |
| config.adapters.required.nodepool | list | `[]` | Required nodepool adapters (e.g. `["validation", "hypershift"]`) |
| config.entities | list | `[{"kind":"Channel","plural":"channels","search_disallowed_fields":["spec"],"spec_schema_name":"ChannelSpec"},{"kind":"Version","on_parent_delete":"restrict","parent_kind":"Channel","plural":"versions","search_disallowed_fields":["spec"],"spec_schema_name":"VersionSpec"},{"kind":"WifConfig","plural":"wifconfigs","search_disallowed_fields":["spec"],"spec_schema_name":"WifConfigSpec"}]` | Entity descriptors registered at startup. Each entry auto-generates REST endpoints, spec validation, and delete policies. Cluster and NodePool use legacy typed handlers and are NOT listed here. |
| serviceAccount | object | `{"annotations":{},"create":true,"name":""}` | ServiceAccount configuration |
| serviceAccount.create | bool | `true` | Create a ServiceAccount for the API server |
| serviceAccount.annotations | object | `{}` | Annotations added to the ServiceAccount (e.g. for Workload Identity) |
Expand Down
44 changes: 44 additions & 0 deletions charts/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,48 @@ data:
{{- else }}
[]
{{- end }}

# NOTE: When EntityDescriptor gains many more fields, consider replacing
# the explicit range below with: {{ .Values.config.entities | toYaml | indent 6 }}
{{- if .Values.config.entities }}
entities:
{{- range .Values.config.entities }}
- kind: {{ .kind }}
plural: {{ .plural }}
{{- if .parent_kind }}
parent_kind: {{ .parent_kind }}
{{- end }}
{{- if .on_parent_delete }}
on_parent_delete: {{ .on_parent_delete }}
{{- end }}
spec_schema_name: {{ .spec_schema_name }}
Comment on lines +145 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Unquoted entity scalars — YAML injection/malformed-config risk (CWE-1284-adjacent).

Every other string value in this template is piped through | quote (see hostname, cert_file, issuer_url, dialect, level, etc.). The new entities block does not quote kind, plural, spec_schema_name, parent_kind, on_parent_delete, ref_type, target_kind. Since config.entities is documented as overridable via --set-json, an operator-supplied value containing :, or a bare yes/no/null-like token, will corrupt the rendered YAML or get silently reinterpreted as a non-string type, breaking LoadDescriptors at startup.

🔒 Proposed fix
       - kind: {{ .kind }}
-        plural: {{ .plural }}
+      - kind: {{ .kind | quote }}
+        plural: {{ .plural | quote }}
 {{- if .parent_kind }}
-        parent_kind: {{ .parent_kind }}
+        parent_kind: {{ .parent_kind | quote }}
 {{- end }}
 {{- if .on_parent_delete }}
-        on_parent_delete: {{ .on_parent_delete }}
+        on_parent_delete: {{ .on_parent_delete | quote }}
 {{- end }}
-        spec_schema_name: {{ .spec_schema_name }}
+        spec_schema_name: {{ .spec_schema_name | quote }}
@@
-          - ref_type: {{ .ref_type }}
-            target_kind: {{ .target_kind }}
+          - ref_type: {{ .ref_type | quote }}
+            target_kind: {{ .target_kind | quote }}

Also applies to: 169-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@charts/templates/configmap.yaml` around lines 145 - 153, The entities block
in the configmap template is rendering several string fields without quoting,
which can produce malformed YAML or type coercion when values are overridden.
Update the entity entries in the template to pipe `kind`, `plural`,
`spec_schema_name`, `parent_kind`, `on_parent_delete`, `ref_type`, and
`target_kind` through `| quote`, matching the existing pattern used for other
settings, so `LoadDescriptors` receives valid string values.

{{- if .search_disallowed_fields }}
search_disallowed_fields:
{{- range .search_disallowed_fields }}
- {{ . }}
{{- end }}
{{- end }}
{{- if .required_adapters }}
required_adapters:
{{- range .required_adapters }}
- {{ . }}
{{- end }}
{{- end }}
{{- if .references }}
references:
{{- range .references }}
- ref_type: {{ .ref_type }}
target_kind: {{ .target_kind }}
{{- if .min }}
min: {{ .min }}
{{- end }}
{{- if .max }}
max: {{ .max }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- else }}
entities: []
{{- end }}
{{- end }}
67 changes: 67 additions & 0 deletions charts/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,73 @@
}
}
}
},
"entities": {
"type": "array",
"description": "Entity descriptors registered at startup. Each entry auto-generates REST endpoints, spec validation, and delete policies.",
"items": {
"type": "object",
"required": ["kind", "plural"],
"properties": {
"kind": {
"type": "string",
"description": "Discriminator value stored in Resource.Kind (e.g. Channel, Version)"
},
"plural": {
"type": "string",
"description": "URL path segment for this entity (e.g. channels, versions)"
},
"parent_kind": {
"type": "string",
"description": "Kind of the parent entity (empty for top-level entities)"
},
"on_parent_delete": {
"type": "string",
"enum": ["restrict", "cascade"],
"description": "Child behavior when parent is deleted (restrict or cascade)"
},
"spec_schema_name": {
"type": "string",
"description": "OpenAPI component name for spec field validation (e.g. ChannelSpec)"
},
"search_disallowed_fields": {
"type": "array",
"items": { "type": "string" },
"description": "Fields blocked from TSL search queries"
},
"required_adapters": {
"type": "array",
"items": { "type": "string" },
"description": "Adapters that must finalize before hard-delete"
},
"references": {
"type": "array",
"description": "Non-ownership associations to other entity types (HYPERFLEET-1156)",
"items": {
"type": "object",
"required": ["ref_type", "target_kind"],
"properties": {
"ref_type": {
"type": "string",
"description": "Key in the references map (e.g. wif_config)"
},
"target_kind": {
"type": "string",
"description": "Kind of the referenced entity (e.g. WifConfig)"
},
"min": {
"type": "integer",
"description": "Minimum references of this type (0 = optional)"
},
"max": {
"type": "integer",
"description": "Maximum references of this type (0 = unlimited)"
}
}
}
}
}
}
}
}
},
Expand Down
19 changes: 19 additions & 0 deletions charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,25 @@ config:
# -- Required nodepool adapters (e.g. `["validation", "hypershift"]`)
nodepool: []

# -- Entity descriptors registered at startup. Each entry auto-generates
# REST endpoints, spec validation, and delete policies.
# Cluster and NodePool use legacy typed handlers and are NOT listed here.
entities:
- kind: Channel
plural: channels
spec_schema_name: ChannelSpec
search_disallowed_fields: [spec]
- kind: Version
plural: versions
parent_kind: Channel
on_parent_delete: restrict
spec_schema_name: VersionSpec
search_disallowed_fields: [spec]
- kind: WifConfig
plural: wifconfigs
spec_schema_name: WifConfigSpec
search_disallowed_fields: [spec]

# -- ServiceAccount configuration
serviceAccount:
# -- Create a ServiceAccount for the API server
Expand Down
4 changes: 1 addition & 3 deletions cmd/hyperfleet-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,11 @@ import (
// Import plugins to trigger their init() functions
// _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/events" // REMOVED: Events plugin no longer exists
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/channels"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/entities"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/versions"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/wifconfigs"
)

// nolint
Expand Down
8 changes: 8 additions & 0 deletions cmd/hyperfleet-api/servecmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/health"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Panic-based startup validation breaks the established fail-fast pattern (CWE-248).

registry.LoadDescriptors/Validate/ValidateSchemas all panic() on bad config (per pkg/registry/registry.go), but every other failure path in this same function (config load, Initialize()) uses logger.WithError(ctx, err).Error(...) + os.Exit(1). Since this call happens before initLogger() (line 74), a malformed config.entities (e.g. bad Helm --set-json) crashes with a raw Go stack trace on stderr instead of the app's structured logger/format — inconsistent operator experience and harder to correlate in log aggregation. Wrap the calls with a local recover() and route through logger.WithError(...).Error(...) + os.Exit(1) for parity with the rest of runServe, or have the registry package return error instead of panicking.

🛡️ Proposed fix
-	registry.LoadDescriptors(cfg.Entities)
-	registry.Validate()
-	registry.ValidateSchemas(cfg.Server.OpenAPISchemaPath)
+	func() {
+		defer func() {
+			if r := recover(); r != nil {
+				logger.With(ctx, "panic", r).Error("Entity registry validation failed")
+				os.Exit(1)
+			}
+		}()
+		registry.LoadDescriptors(cfg.Entities)
+		registry.Validate()
+		registry.ValidateSchemas(cfg.Server.OpenAPISchemaPath)
+	}()

Also applies to: 59-65

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/hyperfleet-api/servecmd/cmd.go` at line 22, The registry validation path
in runServe currently lets `registry.LoadDescriptors`, `Validate`, and
`ValidateSchemas` panic, which bypasses the existing fail-fast logging flow. Add
a local `recover()` around those calls in `cmd/hyperfleet-api/servecmd/cmd.go`
and convert any panic into a `logger.WithError(...).Error(...)` followed by
`os.Exit(1)`, matching the handling used for config load and `Initialize()`. Use
the `runServe` function and the `registry` package calls as the main points to
update, and keep the startup behavior consistent with the rest of the command.

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/telemetry"
)

Expand Down Expand Up @@ -55,6 +56,13 @@ func runServe(cmd *cobra.Command, args []string) {
// and ensure SessionFactory, clients, services, handlers all use the correct config
environments.Environment().Config = cfg

// Load entity descriptors from config before services and routes are built.
// Descriptors must be registered before Initialize() because services call
// registry.MustGet() at construction time.
registry.LoadDescriptors(cfg.Entities)
registry.Validate()
registry.ValidateSchemas(cfg.Server.OpenAPISchemaPath)

// Initialize environment (applies overrides, creates SessionFactory, loads clients, services, handlers)
err = environments.Environment().Initialize()
if err != nil {
Expand Down
22 changes: 22 additions & 0 deletions configs/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ adapters:
- validation
- hypershift

# Entity Registration
# Generic resource types registered at startup. Each entry auto-generates
# REST endpoints, spec validation, and delete policies.
# Cluster and NodePool use legacy typed handlers and are NOT listed here.
entities:
- kind: Channel
plural: channels
spec_schema_name: ChannelSpec
search_disallowed_fields: [spec]

- kind: Version
plural: versions
parent_kind: Channel
on_parent_delete: restrict
spec_schema_name: VersionSpec
search_disallowed_fields: [spec]

- kind: WifConfig
plural: wifconfigs
spec_schema_name: WifConfigSpec
search_disallowed_fields: [spec]

# ----------------------------------------------------------------------------
# Configuration Priority (highest to lowest):
# 1. Command-line flags (e.g., --server-host=0.0.0.0 --server-port=8000)
Expand Down
15 changes: 9 additions & 6 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package config

import "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"

// ApplicationConfig holds all application configuration
// Follows HyperFleet Configuration Standard with validation and structured marshaling
type ApplicationConfig struct {
Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"`
Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"`
Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"`
Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"`
Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"`
Adapters *AdapterRequirementsConfig `mapstructure:"adapters" json:"adapters" validate:"required"`
Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"`
Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"`
Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"`
Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"`
Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"`
Adapters *AdapterRequirementsConfig `mapstructure:"adapters" json:"adapters" validate:"required"`
Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"`
}

// NewApplicationConfig returns default ApplicationConfig with all sub-configs initialized
Expand Down
14 changes: 14 additions & 0 deletions pkg/config/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package config

import (
"fmt"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
)

// DumpConfig returns a human-readable string representation of configuration
Expand Down Expand Up @@ -35,6 +37,7 @@ func DumpConfig(config *ApplicationConfig) string {
Adapters:
ClusterAdapters: %v
NodePoolAdapters: %v
Entities: %d registered (kinds: %v)
`,
config.Server.BindAddress(),
config.Server.TLS.Enabled,
Expand All @@ -53,9 +56,20 @@ func DumpConfig(config *ApplicationConfig) string {
config.Health.BindAddress(),
safeAdapterList(config.Adapters, true),
safeAdapterList(config.Adapters, false),
len(config.Entities),
entityKindNames(config.Entities),
)
}

// entityKindNames extracts Kind strings from entity descriptors for logging.
func entityKindNames(entities []registry.EntityDescriptor) []string {
kinds := make([]string, len(entities))
for i, e := range entities {
kinds[i] = e.Kind
}
return kinds
}

// safeAdapterList safely extracts adapter list, handling nil config
func safeAdapterList(adapters *AdapterRequirementsConfig, cluster bool) []string {
if adapters == nil {
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ func (l *ConfigLoader) bindAllEnvVars() {
// Adapters config
l.bindEnv("adapters.required.cluster")
l.bindEnv("adapters.required.nodepool")

// Entities: config-file-only (complex list-of-struct type).
// No env var or CLI flag bindings — loaded exclusively via YAML config.
}

// bindFlags binds command-line flags to their corresponding Viper config keys
Expand Down
Loading