Skip to content

Discovery configuration

Paths in the tables are relative to [discovery] or the nested array-table heading. Check parsing, unknown keys, and cross-field rules with gdsgate doctor --config <PATH> --json. See also the resource catalog lifecycle.

[discovery]

Used by: Authority.

The resource catalog Authority writes to the store at start-up. It is used by gdsgate ls filtering, by Cedar policies on resource attributes and labels (read on every authorization), and by per-resource JIT approval thresholds.

Routing does not need the catalog: connectors declare what they serve at registration. Policies that read resource.environment or resource.getTag("team") need it.

The catalog can also be changed through Authority's catalog API. Each Authority start upserts the rows declared here and removes nothing. Keep [discovery] empty when the catalog is managed through the API. See Admin guide → Resource catalog lifecycle.

Key Type Default Purpose
lease_sweep_secs u64 60 Interval at which Authority deletes catalog rows with expired leases. The lease length is lease_secs on each auto-scope.

[[discovery.resources]]: declared resources

Key Type Default Purpose
id string unset, required Resource id. Matches what the connector registers and what clients request.
kind string unset, required One of ssh, postgres, mysql, kubernetes, tcp, mcp, llm, web, web-egress, rdp. Selects the Cedar entity type (SshHost, Database, KubernetesCluster, TcpService, McpServer, LlmService, WebApp, WebEgress, RdpHost). The catalog has no cockroach resource kind.
project string "default" Owning project (Cedar parent: <entity> in Project::"<project>"). Use it to partition by team or tenant.
min_approvers usize unset Resource-level JIT approval threshold, the narrowest level of the approvals cascade.
hostname string the resource id, when it is a legal name Name clients can use for the resource. One DNS label: letters, digits and -, case-insensitive, up to 63 bytes, no - at either end. An id with _ or . has no name until hostname is set.
aliases array of string [] More names for the resource, same rules as hostname.
port u16 ssh 22, postgres 5432, mysql 3306, kubernetes 6443, web 80, rdp 3389 Port clients connect on. Required for tcp, mcp, llm and web-egress. Not related to the connector-side [[connector.backends]] addr, which is never published.
native_name string unset web only. The application's own domain (app.example.com, at least two labels). A resident client answers to it locally only when its user lists it in [client].intercept_names. It is the name the connector sends in Host ([[connector.backends]] url). hostname is a single label inside the cluster's client zone. See Resident client → An application's own name.
pin_tool_descriptors bool false mcp only. The first descriptor seen for a tool becomes its baseline; a tools/call on a tool whose descriptor changed is refused until the change is approved. See Pinning tool descriptors. On another kind, start-up is refused.

A [[connector.backends]] entry with kind = "cockroach" (CockroachDB, port 26257) is published as kind = "postgres" with port = 26257. The catalog has no cockroach kind.

Names are unique across the catalog, native_name included. A duplicate name, declared or taken from an id, is refused at start-up, as is a tcp, mcp, llm or web-egress resource without a port.

[[discovery.import_rules]]: label and environment rules

Rules apply in order to each declared resource whose id matches the glob. The environment defaults to dev; the last matching rule sets it. Labels merge across matches; a later rule wins per key.

Key Type Default Purpose
match string unset, required Glob over resource id. Only * is special and it matches any run, including empty.
environment optional string unset, resolved environment dev Environment to set on a match.
labels table of string to string {} Labels merged on a match and exposed to Cedar as resource tags.

Reserved labels

These labels also change gdsgate behavior. Like other labels, they merge across rules and are visible to Cedar through resource.getTag(...).

Key Value Effect
pin_src_ip "true" Access certificates for this resource are bound to the client's TCP peer address at issue time. The connector rejects a session from another address. Any other value or no label: not pinned.

[[discovery.auto_scopes]]: rows a connector writes

An auto-scope lets one connector write catalog rows within limits set here. Each row has a lease and is removed when the connector stops renewing it. Use it for short-lived targets: test databases, pods, cluster services.

Rows in a scope are listed by gdsgate ls, matched by Cedar policies and reachable by clients like other rows. They also store the scope, the lease, and that they were written by a connector.

Key Type Default Purpose
id_prefix string unset, required Prefix every resource id inside the scope starts with. A scope whose prefix covers a declared resource, or overlaps another scope's prefix, is refused at startup and by authority set-scope.
node string unset, required Enrolled node id (gdsgate authority list-nodes) of the connector allowed to publish here. Authority matches the publishing peer's verified key fingerprint against this node.
kinds array of string unset, required Resource kinds a target may be published as: ssh, postgres, mysql, kubernetes, tcp, mcp, llm, web, web-egress, rdp. A snapshot carrying any other kind is refused.
project string "default" Project written on every row in this scope.
environment string unset, required Environment written on every row in this scope. Not taken from the source.
labels table of string to string {} Labels written on every row in this scope, overriding source labels with the same key.
accept_label_keys array of string [] Label keys accepted from the source. Keys outside this list are dropped. pin_src_ip is always dropped. On rows from an agent host, agent is set by Authority to the work that declares the face, whatever this list holds; a face no declaration names gets no agent. labels overrides both.
max_live usize unset, required Maximum rows the scope holds at once, counting rows from earlier snapshots whose leases have not run out. A snapshot that would leave the scope over it is refused in full. See choosing max_live.
lease_secs u64 unset, required How long a row stays after the snapshot that carried it.
fallback parent | deny parent What an absent row means. parent: the parent resource's rules apply; a database is authorized through its host, and a target created between two probe passes is reachable. deny: a target with no row is not served.
ssh_host_key annotation | tofu annotation Where an ssh target's host-key fingerprint comes from. annotation: the source states it, and a target that does not is not served. tofu: the first key seen is accepted for the lease.
onward_auth array of local | certificate | client_key [] Onward modes an ssh target here may name. Required when kinds holds ssh, and refused when it does not. A snapshot naming any other mode is refused in full.
enabled bool true Whether the scope accepts publishes. Setting it to false deletes every row the scope holds.
[[discovery.auto_scopes]]
id_prefix         = "test-"
node              = "kube-conn-1"
kinds             = ["postgres", "ssh"]
project           = "qa"
environment       = "test"
labels            = { origin = "k8s-auto" }
accept_label_keys = ["app", "pr"]
max_live          = 2000
lease_secs        = 900
onward_auth       = ["certificate"]

A scope with ssh in kinds and no onward_auth is refused at start-up and by authority set-scope. Each target picks its mode from the list.

gdsgate authority set-scope and remove-scope change scopes on a running cluster; authority list-scopes shows each scope's row count against max_live. A scope declared in the file is rewritten from the file at each Authority start; put set-scope changes into [[discovery.auto_scopes]] too.

Choosing max_live

A row missing from a new snapshot stays until its lease runs out. The scope holds every target published during one lease window. With a probe interval of interval_secs and a lease of lease_secs, a source that replaces all its targets on every pass leaves up to

targets_per_pass × (1 + lease_secs / interval_secs)

rows in the scope. For 300 targets, a 300s interval and a 900s lease that is 1200 rows; a lower max_live refuses snapshots until older leases run out.

Shorten lease_secs (a removed target then stays reachable for less time) or raise max_live. gdsgate doctor warns at 90% of max_live.

A row can outlive its lease by up to [discovery].lease_sweep_secs. gdsgate ls shows each row's remaining lease; gdsgate ls --leased lists only rows written inside a scope.

Resource ids and names

A row's id is the scope prefix plus the name the source uses for the target: a cluster service orders/orders-db inside scope test- becomes test-orders-orders-db; a database orders on host prod-pg inside scope prod-pg. becomes prod-pg.orders.

The row's name is derived from the id: characters not allowed in a DNS label become -, repeated separators collapse, and the result is truncated to 63 bytes. prod-pg.orders is addressed as prod-pg-orders. If another resource already answers to that name, the row is written without a name and remains reachable by id; the connector logs which resource holds the name.

Publishing from a probe

A probe publishes into a scope when its [connector.backends.discovery] block sets scope. A database or MCP probe then publishes inventory and writes a catalog row per sub-resource. A kubernetes probe with mode = "list_kube_targets" writes rows only.

Key Type Default Purpose
scope string unset Scope prefix the probe publishes into. Required for mode = "list_kube_targets".
label_selector string unset Label selector passed to the Kubernetes API. Required for mode = "list_kube_targets".
sources array of string ["service"] Objects a cluster probe enumerates: service (ClusterIP, port from the spec), pod (pod IP, port from the annotation), node (internal address, port 22 unless annotated).
[[connector.backends]]
resource   = "prod-k8s"
kind       = "kubernetes"
api_url    = "https://kubernetes.example:6443"
ca_path    = "/etc/gdsgate/k8s-ca.pem"
token_path = "/var/run/secrets/gdsgate/token"

  [connector.backends.discovery]
  mode           = "list_kube_targets"
  label_selector = "gdsgate.io/expose=true"
  sources        = ["service", "node"]
  scope          = "test-"
  interval_secs  = 60

Database probe targets are named <host>.<db>, so the scope prefix is the host id and a dot:

  [connector.backends.discovery]
  mode          = "list_databases"
  connection    = "postgres://disc_ro@prod-pg/postgres"
  password_file = "/etc/gdsgate/disc_ro.pass"
  scope         = "prod-pg."
  interval_secs = 300

mode = "list_namespaces" enumerates the cluster's namespaces and publishes them as inventory under the cluster's own resource id, which gdsgate kube ls reads. It needs no scope.

What the cluster objects must declare

Annotation or label Required Purpose
gdsgate.io/expose required What the probe's label_selector matches on. The value is not read.
gdsgate.io/kind required for service and pod Resource kind. A node defaults to ssh. An object without it is skipped.
gdsgate.io/port required for pod Port to connect to. A service takes the first port of its spec; a node defaults to 22.
gdsgate.io/ssh-host-key required for ssh under ssh_host_key = "annotation" Host-key fingerprint(s) of the target's sshd, comma-separated.
apiVersion: v1
kind: Service
metadata:
  name: orders-db
  namespace: orders
  labels:
    gdsgate.io/expose: "true"
  annotations:
    gdsgate.io/kind: postgres
spec:
  ports:
    - port: 5432

A fingerprint is a SHA256:<base64> string as printed by ssh-keygen -lf or gdsgate ca learn-host-key <host>, compared with the key the target presents. List several when the host holds keys of more than one algorithm or is being rotated:

  annotations:
    gdsgate.io/ssh-host-key: "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU,SHA256:8sHc4rGZLmvKV8OoK1qN0KUwZ5Tz4EDx2H6pJXbYQ3o"

Objects skipped by the probe: one without gdsgate.io/kind (except nodes), a headless service (clusterIP: None; publish the pods instead), a pod without gdsgate.io/port.

The Connector serves discovered ssh, postgres, mysql, tcp and web targets. A web target uses HTTP at the object's IP and selected port; requests pass through the web lane and require connect and httpRequest permission on its catalog resource. Add web to the scope's kinds before setting gdsgate.io/kind: web on an object.

Discovered web targets use the default web policy: WebSocket channels, identity headers and forwarded headers are disabled; bodies are not recorded. HTTPS, a custom upstream CA and named routes require a configured web backend.

Service account permissions

A cluster probe uses the connector's service account. Grant list on the collections it enumerates:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: gdsgate-discovery
rules:
  - apiGroups: [""]
    resources: ["services", "pods", "nodes", "namespaces"]
    verbs: ["list"]

ClusterIP and pod addresses are reachable only inside the cluster. Run the connector that serves discovered cluster targets in the cluster.

Troubleshooting

The connector logs refused publishes (the cluster refused this scoped publish, with the reason); Authority audits them as scope.refused (CAT-0302). Accepted snapshots are audited as scope.published (CAT-0301), one row per snapshot, with sha256 over the published targets and held_sha256 over the ids the scope holds afterwards. They differ while rows from earlier snapshots are still under lease.

These rows, lease expiry and set-scope / remove-scope changes are queued for the audit chain. gdsgate_audit_outbox_depth, gdsgate_audit_outbox_oldest_age_secs and the gdsgate doctor finding state.audit.outbox show a queue that is not draining, which means no instance holds the audit write leadership. state.audit.outbox_held reports a queued row the leader refused to seal; see queued rows.

Symptom Cause Fix
listing /api/v1/services answered 403 The service account lacks list on that collection. Apply the ClusterRole above. Rows already published keep their leases meanwhile.
no such boundary The probe's scope names a prefix no scope declares. Declare it, or correct scope. authority list-scopes shows what exists.
this boundary is filled by another node The scope's node is not the publishing connector. Set node to the id from authority list-nodes.
boundary is disabled The scope has enabled = false. Re-enable it with authority set-scope without --disabled.
target ... declares kind ..., which this boundary does not admit The object's gdsgate.io/kind is outside the scope's kinds. Add the kind to the scope, or correct the annotation. The whole snapshot is refused until then, so existing rows stop being renewed.
the boundary holds N rows and this snapshot would leave M, over its ceiling of K The scope's live rows plus the snapshot's targets exceed max_live. Rows from earlier snapshots count until their leases run out. Raise max_live (in the file and with authority set-scope), shorten lease_secs, or narrow label_selector. See choosing max_live.
target ... is outside the boundary An id the probe built does not start with the scope prefix. Correct scope; for a database probe it must be <host>..
target ... is a resource somebody declared A declared resource has the same id. Remove that resource, or move the scope.
the boundary changed while this snapshot was being checked The scope was rewritten (disabled, moved to another node, narrowed) between the probe's pass and the write. Expected once after authority set-scope; the next pass is judged against the scope as it now stands. Persisting means something rewrites the scope every interval.
target ... appears twice in this snapshot The probe built the same id for two cluster objects. Rename one of the two objects, or narrow label_selector.
stale generation Authority holds a newer snapshot generation than the connector sent. Expected briefly after a connector restart with a lost state directory; it resolves on the next pass. If it persists, check the connector's clock.
A target exists in the cluster but no row appears The object is missing an annotation, or is skipped (headless service, pod without port), or the last snapshot was refused. Check the connector log for the refusal, and the object against the table above.
A row remains after the target is gone The lease has not expired, or the sweep has not run. Wait up to lease_secs plus lease_sweep_secs. gdsgate ls shows expired in between.
A row is listed without a name The name its id proposes is held by another resource. The connector log names the holder. Rename that resource, or reach this row by id.
A client is refused a target that exists in the cluster Under fallback = "deny" the row is not published yet. Wait for the next probe pass, or set fallback = "parent".

A full discovery example

[[discovery.resources]]
id            = "web-01"
kind          = "ssh"
project       = "frontend"

[[discovery.resources]]
id            = "web-02_prod"
kind          = "ssh"
project       = "frontend"
min_approvers = 2

[[discovery.resources]]
id            = "db-orders_prod"
kind          = "postgres"
project       = "backend"
min_approvers = 3
hostname      = "orders-db"    # the id is not a legal name (`_`)
aliases       = ["orders"]

[[discovery.resources]]
id = "redis-cache_prod"
kind = "tcp"
port = 6379                    # required: tcp has no well-known port

[[discovery.resources]]
id = "tools-mcp"
kind = "mcp"
port = 9000

# Glob rules:
[[discovery.import_rules]]
match       = "*_prod"
environment = "prod"
labels      = { criticality = "high", on_call = "primary" }

[[discovery.import_rules]]
match       = "*_staging"
environment = "staging"
labels      = { criticality = "medium" }

[[discovery.import_rules]]
match  = "web-*"
labels = { team = "frontend", tier = "edge" }

[[discovery.import_rules]]
match  = "db-*"
labels = { team = "backend", owner_squad = "orders" }

# Bind issued certificates to the caller's TCP peer address.
[[discovery.import_rules]]
match  = "*_prod"
labels = { pin_src_ip = "true" }

The catalog is read at Authority start-up. Restart Authority to apply changes.