> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rootly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Catalog Sync CLI

> Use the rootly-catalog-sync CLI to keep services, teams, and metadata in your Rootly Catalog continuously synced from GitHub, Backstage, APIs, and more.

`rootly-catalog-sync` is a standalone CLI tool that reconciles external sources of truth into Rootly's Catalog. It pulls data from your existing systems — GitHub repos, Backstage, internal APIs, CSV files, or any command — and syncs it one-way into Rootly, keeping services, teams, and metadata up to date automatically.

<Frame>
  <img src="https://mintcdn.com/rootly/7bAS_3N025YTT2mX/images/CleanShot2026-03-30at15.35.17@2x.png?fit=max&auto=format&n=7bAS_3N025YTT2mX&q=85&s=2788933a49bb8603b66f9b499dfe72de" alt="Rootly Catalog" width="3454" height="1986" data-path="images/CleanShot2026-03-30at15.35.17@2x.png" />
</Frame>

## Why use Catalog Sync?

* **Single source of truth** — your service catalog lives in GitHub, Backstage, or a database. Rootly mirrors it automatically.
* **No manual data entry** — add a service to your repo, it appears in Rootly on the next sync.
* **Safe by default** — deletes are opt-in, empty sources abort, prune ratio thresholds prevent mass deletion.
* **Terraform-style workflow** — `plan` to preview, `apply` to execute, `status` to check drift.

## Install

```bash theme={null}
# Homebrew
brew install rootlyhq/tap/rootly-catalog-sync

# Go
go install github.com/rootlyhq/rootly-catalog-sync/cmd/rootly-catalog-sync@latest

# Docker (mount your config + catalog data)
docker run --rm -e ROOTLY_API_KEY \
  -v $PWD/rootly-catalog-sync.yaml:/config.yaml:ro \
  -v $PWD/catalog:/catalog:ro \
  rootlyhub/rootly-catalog-sync sync --config=/config.yaml

# Helm (Kubernetes)
helm repo add rootly https://rootlyhq.github.io/helm-charts
helm install catalog-sync rootly/rootly-catalog-sync \
  --set rootly.apiKey=$ROOTLY_API_KEY \
  --set-file configYaml=rootly-catalog-sync.yaml
```

## Quick start

```bash theme={null}
export ROOTLY_API_KEY=rootly_...

# Option 1: Scaffold a complete working example (recommended)
rootly-catalog-sync init --demo

# Option 2: Create a minimal config to customize
# rootly-catalog-sync init

# Then run:
rootly-catalog-sync doctor   # verify auth + connectivity
rootly-catalog-sync plan     # preview changes
rootly-catalog-sync sync     # apply
```

## Authentication

Two methods are supported, in priority order:

### API key (CI / non-interactive)

```bash theme={null}
export ROOTLY_API_KEY=rootly_...
```

Create an API key at **Settings → API Keys** in your Rootly dashboard.

### OAuth 2.0 (interactive)

```bash theme={null}
# Login via browser (Authorization Code + PKCE)
rootly-catalog-sync login

# Tokens saved to ~/.rootly-catalog-sync/config.yaml
# Auto-refreshed transparently on expiry

# Clear stored tokens
rootly-catalog-sync logout
```

If `ROOTLY_API_KEY` is set, it always takes precedence over OAuth tokens.

## Configuration

The sync tool uses a declarative config file (v2 format) that defines **sync entries** — each entry connects a source to a catalog or native resource target.

<CodeGroup>
  ```yaml YAML theme={null}
  version: 2
  sync:
    - from:
        local:
          files: ["catalog/*.yaml"]
      to: Services
      map:
        external_id: "{{ .id }}"
        name: "{{ .name }}"
        owner: "{{ .owner }}"
        tier: "{{ .tier }}"
  ```

  ```yaml YAML (v1) theme={null}
  version: 1
  sync_id: services
  pipelines:
    - sources:
        - local:
            files: ["catalog/*.yaml"]
      outputs:
        - catalog: "Services"
          external_id: "{{ .id }}"
          name: "{{ .name }}"
          fields:
            owner: "{{ .owner }}"
            tier: "{{ .tier }}"
  ```

  ```jsonnet Jsonnet theme={null}
  {
    version: 2,
    sync: [
      {
        from: {
          "local": {
            files: ["catalog/*.yaml"],
          },
        },
        to: "Services",
        map: {
          external_id: "{{ .id }}",
          name: "{{ .name }}",
          owner: "{{ .owner }}",
          tier: "{{ .tier }}",
        },
      },
    ],
  }
  ```

  ```hcl HCL theme={null}
  version = 2

  sync {
    from {
      local {
        files = ["catalog/*.yaml"]
      }
    }
    to = "Services"
    map = {
      external_id = "{{ .id }}"
      name        = "{{ .name }}"
      owner       = "{{ .owner }}"
      tier        = "{{ .tier }}"
    }
  }
  ```
</CodeGroup>

Config files are detected by extension: `.yaml` (default), `.jsonnet`, or `.hcl`. Credentials use `$(ENV_VAR)` substitution.

## Sources

| Source      | Description                                              |
| ----------- | -------------------------------------------------------- |
| `inline`    | Entries defined directly in config                       |
| `local`     | YAML/JSON files from disk (glob patterns)                |
| `github`    | Files from GitHub repositories (supports `**` patterns)  |
| `exec`      | Run a command, parse stdout as JSON/YAML                 |
| `backstage` | Backstage catalog API with pagination                    |
| `graphql`   | Arbitrary GraphQL endpoint with cursor/offset pagination |
| `csv`       | CSV files with header row                                |
| `url`       | Fetch YAML/JSON from remote URLs                         |
| `http`      | Generic REST API with JSONPath extraction                |

### Inline source

```yaml theme={null}
from:
  inline:
    entries:
      - id: payments
        name: Payments Service
        owner: platform-team
        tier: critical
      - id: auth
        name: Auth Service
        owner: security-team
        tier: critical
```

### Local source

```yaml theme={null}
from:
  local:
    files: ["catalog/*.yaml", "services/**/*.json"]
```

### GitHub source

```yaml theme={null}
from:
  github:
    token: "$(GITHUB_TOKEN)"
    owner: acme
    repos: ["payments", "auth", "gateway"]
    files: ["**/catalog.yaml"]
    ref: main
```

Omit `repos` to scan all repositories in the org. Set `archived: true` to include archived repos.

### Backstage source

```yaml theme={null}
from:
  backstage:
    url: https://backstage.internal.com
    token: "$(BACKSTAGE_TOKEN)"
    kind: Component
    filter: "kind=Component,metadata.annotations.rootly.com/sync=true"
```

### Exec source

```yaml theme={null}
from:
  exec:
    command: bq
    args: ["query", "--format=json", "SELECT id, name, owner FROM dataset.services"]
```

### GraphQL source

```yaml theme={null}
from:
  graphql:
    url: https://api.internal.com/graphql
    headers:
      Authorization: "Bearer $(API_TOKEN)"
    query: |
      query($cursor: String) {
        services(after: $cursor) {
          nodes { id name owner tier }
          pageInfo { hasNextPage endCursor }
        }
      }
    result: data.services.nodes
    paginate:
      cursor: data.services.pageInfo.endCursor
      has_next: data.services.pageInfo.hasNextPage
```

### CSV source

```yaml theme={null}
from:
  csv:
    files: ["data/services.csv"]
    delimiter: ","
```

The first row is used as field names. Each subsequent row becomes an entry.

### URL source

```yaml theme={null}
from:
  url:
    urls:
      - https://internal.company.com/catalog/services.yaml
      - https://internal.company.com/catalog/teams.json
    headers:
      Authorization: "Bearer $(API_TOKEN)"
```

### HTTP source

```yaml theme={null}
from:
  http:
    url: https://api.internal.com/v1/services
    method: GET
    headers:
      Authorization: "Bearer $(API_TOKEN)"
    result: data.services
```

## Output targets

Each sync entry uses `to:` to specify the target. The value of `to:` determines whether entries go to a custom catalog or a native Rootly resource.

* **Native resource**: use a lowercase type name — `to: service`, `to: team`, `to: functionality`, `to: environment`
* **Custom catalog**: use the catalog display name — `to: "Services"`, `to: "Tiers"`

### Custom catalog

Creates entities in a named Rootly catalog with arbitrary fields.

```yaml theme={null}
sync:
  - from:
      local:
        files: ["catalog/*.yaml"]
    to: "Services"
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      owner: "{{ .owner }}"
      tier: "{{ .tier }}"
```

### Native resources

Sync directly to built-in Rootly resource types.

| Type            | Description            |
| --------------- | ---------------------- |
| `service`       | Rootly services        |
| `functionality` | Rootly functionalities |
| `environment`   | Rootly environments    |
| `team`          | Rootly teams           |

```yaml theme={null}
sync:
  - from:
      local:
        files: ["catalog/services.yaml"]
    to: service
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      description: "{{ .description }}"
      pagerduty_id: "{{ .pagerduty_id }}"
      github_repository_name: "{{ .repo }}"
```

```yaml theme={null}
sync:
  - from:
      local:
        files: ["catalog/teams.yaml"]
    to: team
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      description: "{{ .description }}"
      opsgenie_id: "{{ .opsgenie_id }}"
      slack_channel: "{{ .slack_channel }}"
```

For native resources, known attributes (like `description`, `pagerduty_id`, `github_repository_name`) are set directly on the resource. Custom properties are auto-created on first sync for SDK-supported kinds (text, boolean, group, service, etc.). Reference properties are auto-created when the referenced catalog exists.

## Custom properties

Native resources (services, teams, functionalities, environments) support custom properties that extend the built-in attributes. These properties are managed automatically during sync.

### Auto-created properties

Text properties are auto-created on first sync. Simply include them in your `map:` and they will appear on the resource:

```yaml theme={null}
sync:
  - from:
      local:
        files: ["catalog/services.yaml"]
    to: service
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      description: "{{ .description }}"
      cost_center: "{{ .cost_center }}"       # Auto-created as text property
      documentation_url: "{{ .docs_url }}"    # Auto-created as text property
```

### Reference properties

Reference properties link native resources to catalog entities. To use them, first sync the referenced catalog, then reference it in the native resource mapping.

For example, to link services to a "Tiers" catalog:

```yaml tiers.yaml theme={null}
- id: critical
  name: Critical
  sla: 99.99%
- id: standard
  name: Standard
  sla: 99.9%
```

```yaml rootly-catalog-sync.yaml theme={null}
version: 2
sync:
  - from:
      local:
        files: ["tiers.yaml"]
    to: "Tiers"
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      sla: "{{ .sla }}"

  - from:
      local:
        files: ["catalog/services.yaml"]
    to: service
    map:
      external_id: "{{ .id }}"
      name: "{{ .name }}"
      tier:
        value: "{{ .tier }}"
        reference: Tiers
```

The `reference: Tiers` shorthand tells the sync tool to resolve the human-readable tier name to the corresponding catalog entity UUID. Matching is case-sensitive and compares the field value against the catalog entity's `name` (not `external_id`). The referenced catalog ("Tiers" in this example) must exist — sync it in an earlier sync entry so it is available when the service entry runs.

## Template syntax

Field mappings use Go template syntax to transform source entries into output fields.

### Field access

```yaml theme={null}
map:
  name: "{{ .name }}"                           # Direct field access
  owner: "{{ get .metadata \"team\" }}"         # Nested map access
  slug: "{{ .org }}/{{ .name }}"                # String concatenation
  tier: "{{ default .tier \"unknown\" }}"       # Fallback for nil/empty values
  region: us-east-1                              # Static value (no template needed)
```

### Conditionals

```yaml theme={null}
environment: "{{ if .production }}prod{{ else }}staging{{ end }}"
```

Templates are compiled with `missingkey=error` — a missing field in the source data causes an immediate error rather than a silent empty string.

## Commands

| Command           | Description                                                                                           |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `plan`            | Preview changes (creates a saved plan file)                                                           |
| `apply <plan>`    | Apply a saved plan (validates freshness first)                                                        |
| `sync`            | Plan + apply in one step                                                                              |
| `status`          | Read-only drift check (`--fail-on-drift` for CI gates)                                                |
| `init`            | Create a config file (add `--interactive` for guided wizard, `--demo` for a complete working example) |
| `init --demo`     | Scaffold a complete working example with sample data and config                                       |
| `validate`        | Check config syntax                                                                                   |
| `doctor`          | Verify API key, connectivity, and permissions                                                         |
| `sources inspect` | Dump raw source entries before mapping                                                                |
| `explain <id>`    | Trace one entry through source → mapping → diff                                                       |
| `adopt`           | Claim existing UI entries under sync management                                                       |
| `import`          | One-shot seed (no prune, no lock)                                                                     |
| `watch`           | Continuous sync loop (`--interval=5m`)                                                                |
| `tui`             | Interactive terminal UI for selective apply                                                           |
| `login`           | Authenticate via browser OAuth 2.0 (PKCE)                                                             |
| `logout`          | Clear stored OAuth tokens                                                                             |

## Safety guarantees

* **Deletes are opt-in** — `--allow-prune` required, off by default
* **Empty source aborts** — never wipes a catalog on a source failure
* **Prune ratio threshold** — aborts if deletes exceed 20% of live entities (configurable via `--prune-threshold`)
* **Manual entries are safe** — only entries with `external_id` (created by sync) are prunable
* **Order: create/update first, delete last** — no window where entries are missing
* **Plan freshness** — `apply` validates that live state hasn't changed since the plan was created

## CI/CD integration

### GitHub Actions

```yaml theme={null}
name: Catalog Sync
on:
  push:
    branches: [main]
    paths:
      - "catalog/**"
      - "rootly-catalog-sync.yaml"

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: stable
      - run: |
          go install github.com/rootlyhq/rootly-catalog-sync/cmd/rootly-catalog-sync@latest
          $(go env GOPATH)/bin/rootly-catalog-sync sync
        env:
          ROOTLY_API_KEY: ${{ secrets.ROOTLY_API_KEY }}
```

### Dry-run on PRs

```yaml theme={null}
- run: rootly-catalog-sync plan --dry-run --output=json
  env:
    ROOTLY_API_KEY: ${{ secrets.ROOTLY_API_KEY }}
```

### Nightly drift detection

```yaml theme={null}
- run: rootly-catalog-sync status --fail-on-drift
  env:
    ROOTLY_API_KEY: ${{ secrets.ROOTLY_API_KEY }}
```

## Kubernetes deployment

Deploy catalog sync to Kubernetes using the official Helm chart:

```bash theme={null}
helm repo add rootly https://rootlyhq.github.io/helm-charts
helm install catalog-sync rootly/rootly-catalog-sync \
  --set rootly.apiKey=$ROOTLY_API_KEY \
  --set-file configYaml=rootly-catalog-sync.yaml
```

By default the chart creates a **CronJob** that runs every 30 minutes.

To run in **watch mode** (continuous sync loop):

```bash theme={null}
helm install catalog-sync rootly/rootly-catalog-sync \
  --set rootly.apiKey=$ROOTLY_API_KEY \
  --set-file configYaml=rootly-catalog-sync.yaml \
  --set mode=watch \
  --set watch.interval=5m
```

To include local data files referenced by your config, use a values file:

```yaml theme={null}
# values.yaml
rootly:
  apiKey: rootly_...
configYaml: |
  version: 2
  sync:
    - from:
        local:
          files: ["/data/services.yaml"]
      to: service
      map:
        external_id: "{{ .id }}"
        name: "{{ .name }}"
dataFiles:
  services.yaml: |
    - id: api-gateway
      name: API Gateway
```

```bash theme={null}
helm install catalog-sync rootly/rootly-catalog-sync -f values.yaml
```

## Environment variables

| Variable          | Description                        | Default                  |
| ----------------- | ---------------------------------- | ------------------------ |
| `ROOTLY_API_KEY`  | API key (or use `login` for OAuth) | —                        |
| `ROOTLY_API_URL`  | Override base URL                  | `https://api.rootly.com` |
| `ROOTLY_API_PATH` | Override API path prefix           | `/v1`                    |

## Interactive TUI

The `tui` command launches a full-screen terminal UI for reviewing and selectively applying changes:

* Browse changes with colored badges (CREATE/UPDATE/DELETE/NOOP)
* Toggle individual changes with `space`, expand field diffs with `enter`
* Filter by operation type (`c`/`u`/`d`) or search (`/`)
* Detail pane shows full entity fields on wide terminals
* Apply only selected changes with `A`

## Resources

* [GitHub repository](https://github.com/rootlyhq/rootly-catalog-sync)
* [Helm chart](https://github.com/rootlyhq/helm-charts/tree/master/charts/rootly-catalog-sync)
* [Docker Hub](https://hub.docker.com/r/rootlyhub/rootly-catalog-sync)
* [Troubleshooting guide](https://github.com/rootlyhq/rootly-catalog-sync/blob/master/docs/troubleshooting.md)
* [Template syntax reference](https://github.com/rootlyhq/rootly-catalog-sync/blob/master/docs/templates.md)
* [Working examples](https://github.com/rootlyhq/rootly-catalog-sync/tree/master/docs/examples)
