Upgrading from v0.54.0 to v0.55.0

This document provides instructions for upgrading Perses specially when dealing with breaking changes. As Perses is a rapidly evolving project, it's important to keep your installation up to date to benefit from the latest features and improvements.

Then, upgrading Perses is also depending on your usage as Perses can be used in different ways (e.g. as a standalone application, as a library, etc.). Therefore, the upgrade process may vary based on your specific use case.

Perses application#

Important dashboards are now grouped#

frontend.important_dashboards is now a list of groups, each with an optional title and description, and a list of dashboard selectors. A selector without dashboard marks every dashboard of the project as important.

The previous flat list of selectors is deprecated and will be removed in v0.57.0. It is still accepted (and converted into a single untitled group), but a deprecation warning is logged at startup. Mixing both formats is rejected.

# Before
frontend:
  important_dashboards:
    - project: "perses"
      dashboard: "Demo"

# After
frontend:
  important_dashboards:
    - title: "Quick links" # Optional
      dashboards:
        - project: "perses"
          dashboard: "Demo"

If you set important dashboards through environment variables, the legacy shape is not supported there: Perses will fail to start until the variables are updated accordingly:

PERSES_FRONTEND_IMPORTANT_DASHBOARDS_0_PROJECT   -> PERSES_FRONTEND_IMPORTANT_DASHBOARDS_0_DASHBOARDS_0_PROJECT
PERSES_FRONTEND_IMPORTANT_DASHBOARDS_0_DASHBOARD -> PERSES_FRONTEND_IMPORTANT_DASHBOARDS_0_DASHBOARDS_0_DASHBOARD

The datasource proxy restricts the destinations it can reach#

To prevent the datasource proxy from being used to reach services that are not datasources (Server-Side Request Forgery), the destinations of the proxy are now verified. By default, the proxy refuses to reach:

  • the loopback interface (127.0.0.0/8, ::1, localhost)
  • the link-local addresses (169.254.0.0/16, fe80::/10) and the known cloud metadata / credentials endpoints
  • the multicast, reserved and unspecified addresses
  • the Kubernetes API service when Perses is running in a Kubernetes cluster
  • any URL scheme other than http and https, the URLs containing credentials (http://user:password@host), and the Unix sockets for the SQL datasources
  • the IPv4 addresses not written in the dotted-decimal notation (e.g. 2130706433, 0x7f000001 or 127.1)

The private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ...) remain allowed by default.

If one of your datasources is running on the same host as Perses (e.g. http://localhost:9090), the requests to this datasource are now refused with a 403 Forbidden error, and saving such a datasource is refused with a 400 Bad Request error. You have to explicitly allow the loopback interface:

datasource:
  proxy:
    allowed_networks:
      - "127.0.0.0/8"
      - "::1/128"

The same applies to the OAuth token URL of the secrets used by the datasources. Saving a secret whose OAuth token URL is not allowed is refused with a 400 Bad Request error, and the requests to a datasource using such a secret are refused with a 403 Forbidden error. If you restrict the destinations with allowed_hosts, this list must also contain the host of the OAuth token URL.

When an IP address is part of both an allowed and a denied network, the most specific network wins (on equal prefix lengths, the allowed network wins). Allowing a large network therefore doesn't allow a more specific denied network, which has to be allowed explicitly. For example:

  • allowing 127.0.0.0/8 allows the loopback interface;
  • allowing 10.0.0.0/8 (e.g. with deny_private_networks: true) doesn't allow the Kubernetes API service IP;
  • allowing 169.254.0.0/16 doesn't allow the cloud metadata endpoint 169.254.169.254.

If Perses is using an HTTP proxy configured through the environment (HTTP_PROXY, HTTPS_PROXY), a datasource can no longer target the address of this proxy, neither directly (for example when the proxy is bypassed with NO_PROXY) nor through the proxy itself.

You can also further restrict the destinations, for example to deny the private networks or to only allow a list of hosts. See the DatasourceProxy config for more details.

Note for the users of v0.55.0-beta.4: the configuration of the HTTP proxy introduced in this beta (datasource.http_proxy) has moved to datasource.proxy.http, so the whole configuration of the datasource proxy lives in a single section.

# Before (v0.55.0-beta.4)
datasource:
  http_proxy:
    max_timeout: 30s

# After
datasource:
  proxy:
    http:
      max_timeout: 30s

The datasource proxy no longer forwards the credentials used to authenticate against Perses#

Before forwarding a request to a datasource, the proxy now removes the credentials that the client used to authenticate against Perses:

  • The Cookie header sent by the client is always removed, because it contains the Perses session (and possibly the tokens of the OIDC/OAuth provider). You can't forward it with allowHeaders. A Cookie header explicitly defined in the datasource configuration is still sent.
  • When the Perses native authorization is enabled, the Authorization header sent by the client (which contains the Perses token) is always removed. You can't forward it with allowHeaders. Use a Secret or the oauthPassthrough option to authenticate against the datasource.
  • When the authorization is delegated (i.e. Kubernetes), the Authorization header sent by the client is still forwarded to the datasource. To prevent this, add Authorization to dropHeaders.
  • An Authorization header defined in the headers of the datasource configuration is now always ignored, whatever its case (e.g. authorization). If you were using it to authenticate against the datasource, move the credentials to a Secret (authorization, basicAuth or oauth) and reference it in the datasource configuration:
# Before
kind: Datasource
spec:
  plugin:
    kind: PrometheusDatasource
    spec:
      proxy:
        kind: HTTPProxy
        spec:
          url: https://prometheus.example.com
          headers:
            Authorization: "Bearer <token>"

# After
kind: Secret
metadata:
  name: prometheus-token
  project: my-project
spec:
  authorization:
    type: Bearer
    credentials: "<token>"
---
kind: Datasource
spec:
  plugin:
    kind: PrometheusDatasource
    spec:
      proxy:
        kind: HTTPProxy
        spec:
          url: https://prometheus.example.com
          secret: prometheus-token

The responses returned by the datasources through the proxy are also hardened, as they are served under the Perses origin:

  • they get the headers Content-Security-Policy: sandbox; default-src 'none'; frame-ancestors 'none' and X-Content-Type-Options: nosniff;
  • the headers that would apply to the Perses origin are removed: Set-Cookie, Set-Cookie2, Clear-Site-Data, Refresh, Strict-Transport-Security, Alt-Svc, Service-Worker-Allowed and the CORS headers (the CORS policy is defined by security.cors);
  • only the relative Location / Content-Location headers are kept.

Finally, the SQL proxy now rejects the queries using INTO OUTFILE or INTO DUMPFILE (MySQL / MariaDB).

See the proxy documentation for more details.

Files referenced in secrets must be in an allowed directory#

The Secrets and GlobalSecrets can reference files from the Perses server (basicAuth.passwordFile, authorization.credentialsFile, oauth.clientSecretFile, tlsConfig.caFile, tlsConfig.certFile, tlsConfig.keyFile). This could be used by anyone able to create a secret to read arbitrary files through the datasource proxy.

These file references are now rejected by default. If you rely on them, you must list the directories where the files are located with the new setting security.secret_file_allowed_directories:

security:
  secret_file_allowed_directories:
    - /etc/perses/secrets

Each directory must be an absolute path, and the root directory (/) is not accepted. The file paths used in the secrets must also be absolute. Symlinks are resolved, and the resolved file must remain inside one of the allowed directories. The verification is done when saving a secret and before the proxy uses it. Saving a secret referencing a file that is not allowed is refused, and the requests to a datasource using such a secret fail.

encryption_key is mandatory when the authentication is enabled#

When the authentication is enabled (security.enable_auth: true), the encryption key is also used to encrypt the access and refresh tokens. Perses was previously falling back on a default (and publicly known) key when none was provided.

Now, if the authentication is enabled and neither security.encryption_key nor security.encryption_key_file is set, Perses refuses to start. The key must be exactly 32 bytes long. You can generate one with one of these commands:

LC_ALL=C tr -dc '[:graph:]' < /dev/urandom | head -c 32
openssl rand -base64 24 | head -c 32
security:
  enable_auth: true
  encryption_key: "<32 bytes long secret>"
  # or
  encryption_key_file: /etc/perses/encryption_key

Note that if you were already running Perses with the auth enabled and without any encryption key, setting a new key means the data previously encrypted with the default key (e.g. the secrets stored in the database) can no longer be decrypted. You will have to re-create these secrets after the upgrade, and users will need to log in again.

Plugin developer#

Go SDK: datasource.Selector is now a union type#

datasource.Selector has been restructured as a union type to make the distinction between a concrete datasource reference and a variable reference explicit. The flat struct (with public Kind and Name fields) no longer exists.

Before:

// Concrete datasource — struct literal
sel := &datasource.Selector{Kind: "PrometheusDatasource", Name: "myPrometheus"}

// Reading fields
fmt.Println(sel.Kind, sel.Name)

After:

// Concrete datasource — use the constructor
sel := datasource.NewStaticSelector("PrometheusDatasource", "myPrometheus")

// Variable reference — new in this release; name may be "foo", "$foo", or "${foo}"
sel, err := datasource.NewVariableSelector("myDatasource")

// Reading fields — check which variant is set first
if sel.Static != nil {
    fmt.Println(sel.Static.Kind, sel.Static.Name)
}
if sel.Variable != nil {
    fmt.Println(sel.Variable.Name) // bare name without "$"
}

If you maintain a datasource plugin with a Selector helper, update it to call the constructors:

// Before
func Selector(datasourceName string) *datasource.Selector {
    return &datasource.Selector{
        Kind: PluginKind,
        Name: datasourceName,
    }
}

// After
func Selector(datasourceName string) *datasource.Selector {
    return datasource.NewStaticSelector(PluginKind, datasourceName)
}

func VariableSelector(datasourceName string) (*datasource.Selector, error) {
    return datasource.NewVariableSelector(datasourceName)
}

A static selector continues to serialize as {"kind":"...","name":"..."} (unchanged from previous versions). A variable selector now serializes as the plain JSON/YAML string "$myVar" — this is a new addition in this release; previously there was no way to express a variable reference in the datasource selector wire format. Note that marshaling a zero-value Selector (neither Static nor Variable set) or one with both fields set returns an error.

If you don't know in advance whether the name is a variable reference or not, use datasource.NewSelector(kind, name): it returns a variable selector when name starts with $, and a static selector otherwise.

Go: the old dashboard and datasource specification is removed#

Since v0.54.0, the dashboard and datasource specification lives in the repository perses/spec. The deprecated copies kept in perses/perses are now removed. If you still import them, replace the import paths as follows:

Removed package (github.com/perses/perses/...)Replacement (github.com/perses/spec/...)
pkg/model/api/v1/commongo/common (Plugin is now in go/plugin)
pkg/model/api/v1/dashboardgo/dashboard
pkg/model/api/v1/variablego/dashboard/variable
pkg/model/api/v1/plugingo/plugin
pkg/model/api/v1/datasource/httpgo/datasource/proxy/http
pkg/model/api/v1/datasource/sqlgo/datasource/proxy/sql

The deprecated types in github.com/perses/perses/pkg/model/api/v1 (DashboardSpec, Panel, PanelSpec, PanelDisplay, Query, QuerySpec, Link, DatasourceSpec, ...) are also removed. Use their equivalent from github.com/perses/spec/go/dashboard and github.com/perses/spec/go/datasource (e.g. dashboard.Spec, dashboard.Panel, datasource.Spec).

CUE: the old schemas are removed#

For the same reason, the following CUE packages are removed:

Removed packageReplacement
github.com/perses/perses/cue/model/api/v1/commongithub.com/perses/spec/cue/common (#Plugin is in github.com/perses/spec/cue/plugin)
github.com/perses/perses/cue/model/api/v1/dashboardgithub.com/perses/spec/cue/dashboard
github.com/perses/perses/cue/model/api/v1/variablegithub.com/perses/spec/cue/dashboard/variable
github.com/perses/perses/cue/model/api/v1/plugingithub.com/perses/spec/cue/plugin
github.com/perses/perses/cue/model/api/v1/datasource/*github.com/perses/spec/cue/datasource/proxy/http and .../proxy/sql
github.com/perses/shared/cue/common/proxygithub.com/perses/spec/cue/datasource/proxy/http and .../proxy/sql

v1.#DashboardSpec and v1.#Panel are replaced by dashboard.#Spec and dashboard.#Panel (from github.com/perses/spec/cue/dashboard).

If your plugin schemas were using the deprecated github.com/perses/shared/cue/common/proxy package, replace its definitions as follows:

Removed definitionReplacementPackage
proxy.#HTTPProxyhttp.#Proxygithub.com/perses/spec/cue/datasource/proxy/http
proxy.#HTTPAllowedEndpointhttp.#AllowedEndpointgithub.com/perses/spec/cue/datasource/proxy/http
proxy.#baseHTTPDatasourceSpecdatasource.#HTTPDatasourceSpecgithub.com/perses/spec/cue/datasource
proxy.#SQLProxysql.#Proxygithub.com/perses/spec/cue/datasource/proxy/sql
proxy.#MySQLsql.#MySQLConfiggithub.com/perses/spec/cue/datasource/proxy/sql
proxy.#Postgressql.#PostgresConfiggithub.com/perses/spec/cue/datasource/proxy/sql
proxy.#baseSQLDatasourceSpecdatasource.#SQLDatasourceSpecgithub.com/perses/spec/cue/datasource
// Before
import "github.com/perses/shared/cue/common/proxy"

spec: proxy.#baseHTTPDatasourceSpec

// After
import "github.com/perses/spec/cue/datasource"

spec: datasource.#HTTPDatasourceSpec

Don't forget to bump the dependencies in your cue.mod/module.cue (github.com/perses/shared/cue@v0 to v0.55.x and github.com/perses/spec/cue@v0 to v0.3.x).

@perses-dev/core is removed#

As announced in v0.54.0, the package @perses-dev/core is no longer published, and the other packages don't depend on it anymore. If you are still importing it, follow the migration described in Core package deprecated: its content is now available in @perses-dev/spec, @perses-dev/client, @perses-dev/components, @perses-dev/plugin-system and @perses-dev/dashboards.

Toolchain and dependencies upgrade#

The Perses npm packages (@perses-dev/client, @perses-dev/components, @perses-dev/plugin-system, @perses-dev/dashboards, @perses-dev/explore) changed their requirements:

  • Node.js 24 (LTS) and npm 11 are now required ("engines": { "node": ">=24", "npm": ">=11" }).
  • ESM only: the CommonJS build (dist/cjs) is no longer published. main and module both point to the ES module output. Projects consuming these packages must support ES modules (e.g. no require('@perses-dev/...')).
  • React 17 is no longer supported: the peer dependencies are now react and react-dom ^18.3.0.
  • Zod 4: zod is upgraded from ^3.25 to ^4. The Zod schemas exported by the packages are typed with both their input and output types: z.ZodType<T> becomes z.ZodType<T, T> (e.g. datasourcesSchema, roleSpecSchema, userSchema...). Follow the Zod 4 migration guide for your own schemas.
  • react-hook-form is upgraded to ^7.87.0 and @hookform/resolvers to ^5.9.1.
  • @perses-dev/spec must be upgraded to ^0.3.0.
  • The code is compiled with TypeScript 7 and targets ES2023.

If you are maintaining a plugin, the easiest way to align your project is to compare it with a project freshly generated with percli plugin generate. The main changes are:

  • in package.json:
    • add "type": "module" and the engines field;
    • remove the build:cjs script and the file .cjs.swcrc;
    • set "main": "lib/index.js";
    • upgrade the peer dependencies (@perses-dev/* to ^0.55.0, @perses-dev/spec to ^0.3.0, react / react-dom to ^18.3.0, react-hook-form to ^7.87.0, @hookform/resolvers to ^5.9.1);
  • in .swcrc, target es2023;
  • in tsconfig.json, target es2023 and use "moduleResolution": "bundler";
  • in rsbuild.config.ts, add @perses-dev/client to the shared singletons of the Module Federation configuration:
pluginModuleFederation({
  // ...
  shared: {
    // ...
    '@perses-dev/client': { singleton: true },
    '@perses-dev/components': { singleton: true },
    '@perses-dev/plugin-system': { singleton: true },
    '@perses-dev/explore': { singleton: true },
    '@perses-dev/dashboards': { singleton: true },
  },
});

Embedding Perses: the host must register its shared modules#

The plugin runtime (Module Federation) is now ESM-native: the Perses packages are no longer loaded with require when a remote plugin asks for them. Instead, the application hosting the plugins must provide its own instance of the Perses packages before any plugin is loaded, using registerHostSharedModules. Otherwise, loading a remote plugin fails with the error Shared module "..." was not registered before a plugin tried to consume it.

// Before
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
// After
import * as PersesClient from '@perses-dev/client';
import * as PersesComponents from '@perses-dev/components';
import * as PersesDashboards from '@perses-dev/dashboards';
import * as PersesExplore from '@perses-dev/explore';
import * as PersesPluginSystem from '@perses-dev/plugin-system';
import { registerHostSharedModules } from '@perses-dev/plugin-system';
import * as PersesSpec from '@perses-dev/spec';

// Provide the host's already-loaded Perses packages to the plugin runtime as Module Federation singletons.
registerHostSharedModules({
  '@perses-dev/spec': PersesSpec,
  '@perses-dev/client': PersesClient,
  '@perses-dev/components': PersesComponents,
  '@perses-dev/plugin-system': PersesPluginSystem,
  '@perses-dev/explore': PersesExplore,
  '@perses-dev/dashboards': PersesDashboards,
});

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

@perses-dev/components: removed hooks and stricter types#

To comply with the React Compiler rules, the following helpers are removed from @perses-dev/components:

  • useMemoized and useDeepMemo: use React's useMemo instead.
  • useId(prefix): use React's useId instead.
// Before
import { useId } from '@perses-dev/components';
const id = useId('MyComponent');

// After
import { useId } from 'react';
const id = `MyComponent-${useId()}`;

Some types are also stricter (any is replaced by unknown):

  • @perses-dev/client: the spec of a Resource is now unknown. Narrow it (or cast it to the expected type) before using it.
  • @perses-dev/components: the cell values of a TableColumnConfig are now unknown, and cellDescription now takes a CellContext<T, unknown>. In the cell callbacks, use getValue<ValueType>() when the value type is known, or narrow the value before using it.

@perses-dev/dashboards: annotation store refactor#

The annotation data is now fetched on demand through the TanStack Query cache (like the panel queries) instead of being loaded when the dashboard is opened and copied into the annotation store. As a consequence:

  • AnnotationHydrationWrapper is removed.
  • The annotationState field and the setAnnotationState action of the annotation store are removed.
  • useAnnotationsWithData, useAnnotationStates and useAnnotationSpecAndState now fetch the data themselves. They must be used under the QueryClientProvider, the plugin registry, the time range, the variable and the datasource store providers.
  • usePanelAnnotationsWithData no longer returns the hidden annotations.