migration

package
v0.35.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package migration provides integration with Flyway for database migrations

Index

Constants

View Source
const DefaultSecretsPrefix = "gobricks/migrate/"

DefaultSecretsPrefix is the default name prefix used to look up tenant database credentials in a secret store. The full secret name is DefaultSecretsPrefix + tenantID.

View Source
const PrincipalUnspecified = "<unspecified>"

PrincipalUnspecified is the sentinel emitted when an operator does not supply AppliedByPrincipal. The audit event still fires (so the gap is itself auditable) and the emitter logs a warning. Operators MUST pass an explicit principal in well-behaved callers; the framework refuses to invent one from IAM/OS context per ADR-019.

Variables

View Source
var ErrEmptyTenantID = errors.New("migration: tenantID is empty")

ErrEmptyTenantID is returned when DBConfig is invoked with a blank tenant ID.

View Source
var ErrEnvFieldHasControlChar = errors.New("migration: env field contains forbidden control character (CR/LF/NUL)")

ErrEnvFieldHasControlChar is returned when a DatabaseConfig field destined for the Flyway subprocess environment contains a forbidden control character (CR, LF, or NUL). Go's exec.Cmd.Env passes strings to execve(2) verbatim and does not split on newlines, so this isn't a known injection path — but rejecting at the boundary prevents a compromised secret writer from propagating multi-line surprises into downstream logs or env-parsing tools.

View Source
var ErrInvalidPGIdentifier = errors.New("migration: PostgreSQL identifier rejected")

ErrInvalidPGIdentifier is returned by Validate when a role or schema name fails the safe-identifier check enforced by ProvisionPGRoles.

View Source
var ErrInvalidPrefix = errors.New("migration: invalid secrets prefix (must end with '/')")

ErrInvalidPrefix indicates the configured prefix is unusable.

View Source
var ErrInvalidTenantID = errors.New("migration: tenantID contains characters outside [A-Za-z0-9_-] or exceeds 128 characters")

ErrInvalidTenantID is returned when DBConfig is invoked with a tenant ID that contains characters outside the [A-Za-z0-9_-] allowlist or exceeds the 128-character length bound.

View Source
var ErrNoConfigProvider = errors.New("migration: database.DBConfigProvider is nil")

ErrNoConfigProvider is returned when MigrateAll is called without a DBConfigProvider.

View Source
var ErrNoFetcher = errors.New("migration: SecretsProvider.Fetch is nil")

ErrNoFetcher indicates the SecretsProvider was constructed without a Fetch function.

View Source
var ErrNoLister = errors.New("migration: TenantLister is nil")

ErrNoLister is returned when MigrateAll is called without a TenantLister.

View Source
var ErrSecretMalformed = errors.New("migration: secret payload malformed")

ErrSecretMalformed indicates the secret payload could not be parsed into a usable DatabaseConfig in either canonical or RDS-rotation form.

Functions

func PGRoleProvisioningSQL added in v0.32.0

func PGRoleProvisioningSQL(spec *PGRoleSpec) ([]string, error)

PGRoleProvisioningSQL returns the SQL statements that ProvisionPGRoles would execute for spec, in order. Use this when operators want to inspect or apply the provisioning manually via psql, or feed it into their own migration runner (Flyway, Liquibase) rather than the Go helper.

Returns ErrInvalidPGIdentifier when spec fails Validate. The returned slice does not include trailing semicolons; callers concatenating them into a single script should add separators themselves.

SECURITY: when spec.MigratorPassword or spec.RuntimePassword is non-empty, the returned statements include the password as an in-clear SQL literal (`ALTER ROLE "..." PASSWORD '<secret>'`). Treat the returned slice as a sensitive value: do not echo it to logs, CI build artifacts, or anywhere the original credential wouldn't be acceptable. Callers preparing scripts for review should redact the literal before persisting to disk.

func ProvisionPGRoles added in v0.32.0

func ProvisionPGRoles(ctx context.Context, db *sql.DB, spec *PGRoleSpec) error

ProvisionPGRoles applies the role-pair + schema described by spec to the PostgreSQL instance reachable via db. All statements are idempotent: a rerun against an already-provisioned tenant is a no-op, except that MigratorPassword / RuntimePassword (when non-empty) are reapplied on every call to support secret rotation.

db MUST be authenticated as a role with CREATEROLE plus the right to CREATE SCHEMA AUTHORIZATION <other> — typically the instance bootstrap superuser or a dedicated provisioner role granted those capabilities. The migrator and runtime roles created here cannot self-provision: they are denied SUPERUSER, CREATEDB, CREATEROLE, BYPASSRLS, and REPLICATION per the deliverables of #378.

PostgreSQL is not fully transactional across role + schema boundaries (CREATE ROLE in particular is not transactional), so a partial-progress failure can leak intermediate state. Callers should rerun the same spec to converge; the idempotent template makes that safe.

Types

type Action added in v0.31.0

type Action int

Action selects which Flyway operation MigrateAll runs against each tenant.

const (
	// ActionMigrate applies pending migrations.
	ActionMigrate Action = iota
	// ActionValidate verifies migrations without applying them.
	ActionValidate
	// ActionInfo prints the migration status table.
	ActionInfo
)

func (Action) String added in v0.31.0

func (a Action) String() string

String returns the human-readable form of the action.

type AuditContext added in v0.32.0

type AuditContext struct {
	// Principal identifies who triggered the migration (operator username,
	// service account name, pipeline identifier). Empty values emit with
	// PrincipalUnspecified + a warning so the gap is itself auditable.
	Principal string

	// GitCommitSHA records the source-tree commit the migration was built
	// from. Useful for correlating an audit event to a specific deployment.
	GitCommitSHA string

	// PipelineRunID is an opaque CI/CD run identifier (e.g. a GitHub
	// Actions run ID, a Jenkins build number). Lets compliance reporting
	// trace an audit event back to a pipeline run.
	PipelineRunID string

	// Target overrides the audit event's Target field. Defaults to the
	// database name (db.Database) when empty. Useful for multi-tenant runs
	// where the tenant ID is more informative for compliance correlation
	// than the per-tenant schema name.
	Target string
}

AuditContext groups the per-call audit fields that flow into every migration.applied event. Operators MUST supply Principal explicitly per ADR-019; GitCommitSHA, PipelineRunID, and Target are optional but strongly recommended for deployment-time runs.

type AuditEvent added in v0.32.0

type AuditEvent struct {
	Type               AuditEventType
	Target             string
	AppliedByPrincipal string
	StartedAt          time.Time
	CompletedAt        time.Time
	Outcome            AuditOutcome

	// Version is the Flyway version applied. Set on migration.applied.
	Version string

	// FromState / ToState describe a provisioning-state-machine transition.
	// Set on state.transitioned.
	FromState string
	ToState   string

	// ErrorClass is set when Outcome == failed; one of the published
	// constants above. Empty for success/skipped outcomes.
	ErrorClass ErrorClass

	// GitCommitSHA and PipelineRunID are optional but strongly recommended
	// for deployment-time runs; sourced from explicit caller input.
	GitCommitSHA  string
	PipelineRunID string

	// Attributes is a free-form extension point for callsite-specific
	// metadata. Keys SHOULD use dotted lowercase (e.g. "migration.vendor").
	Attributes map[string]string
}

AuditEvent is the canonical payload that flows into both the OpenTelemetry emission path and the optional AuditRecorder. The two paths share this struct so schemas cannot drift. Backwards-compatible additions follow Go's struct-additive rules; removing a field is a breaking change.

Target is an opaque schema/database identifier (tenant ID or schema name) and MUST NOT be a DSN — credentials never appear in audit events.

type AuditEventType added in v0.32.0

type AuditEventType string

AuditEventType enumerates the four migration audit-event types defined by ADR-019. Engine-layer emission covers migration.applied; orchestrator-layer events (state.transitioned, quiesce.*) are emitted by their respective subsystems when those land (#379, #380).

const (
	// AuditEventTypeMigrationApplied marks a Flyway migration application
	// (successful or failed) against a target.
	AuditEventTypeMigrationApplied AuditEventType = "migration.applied"
	// AuditEventTypeStateTransitioned marks a provisioning-state-machine
	// transition. Emitted by #379 (not in this PR).
	AuditEventTypeStateTransitioned AuditEventType = "state.transitioned"
	// AuditEventTypeQuiesceSet marks an operator setting the deployment
	// quiesce flag. Emitted by #380 (not in this PR).
	AuditEventTypeQuiesceSet AuditEventType = "quiesce.set"
	// AuditEventTypeQuiesceCleared marks an operator clearing the deployment
	// quiesce flag. Emitted by #380 (not in this PR).
	AuditEventTypeQuiesceCleared AuditEventType = "quiesce.cleared"
)

type AuditOutcome added in v0.32.0

type AuditOutcome string

AuditOutcome is the terminal outcome of the audited operation.

const (
	AuditOutcomeSuccess AuditOutcome = "success"
	AuditOutcomeFailed  AuditOutcome = "failed"
	AuditOutcomeSkipped AuditOutcome = "skipped"
)

type AuditRecorder added in v0.32.0

type AuditRecorder interface {
	Record(ctx context.Context, event *AuditEvent) error
}

AuditRecorder is the opt-in delivery path described in ADR-019. When wired (typically via FlywayMigrator.WithAuditRecorder), every AuditEvent fires to Record after the OTel emission, on a separate goroutine with a bounded send queue.

Record receives a non-nil *AuditEvent — the pointer matches the framework convention for medium-sized event payloads (see outbox.OutboxPublisher). Implementations SHOULD treat the event as read-only; the framework does not synchronize concurrent reads if a sink decides to mutate.

The framework calls Record with a fresh background context that may be cancelled by FlywayMigrator.Close. Implementations SHOULD respect ctx.Done() for prompt cancellation, but the framework does not retry on the sink's behalf — sink owners requiring zero-loss audit must back their implementation with a durable buffer (Kafka commit-log, S3 staging, etc.).

Errors returned from Record are logged as warnings and increment the migration.audit.sink_failures counter; they do NOT abort the migration. This is a deliberate trade-off per ADR-019: audit must not block business work.

type Config

type Config struct {
	FlywayPath    string        // Path to the Flyway executable
	ConfigPath    string        // Path to the configuration file
	MigrationPath string        // Path to migration scripts
	Timeout       time.Duration // Timeout for migration operations
	Environment   string        // Environment (development, testing, production)
	DryRun        bool          // Only validate, do not execute

	// Audit carries the per-call audit-event context required by ADR-019.
	// Populated by operators (CLI flags) or pipelines (env vars or library
	// call argument). The framework will NOT infer Principal from IAM/OS
	// context — empty values flow through with a warning log.
	Audit AuditContext
}

Config configuration for migrations

type ErrorClass added in v0.32.0

type ErrorClass string

ErrorClass is a stable string from a published taxonomy that downstream alerting can pin on. ADR-019 publishes seven values; the list is additive (new classes are non-breaking; removing one is breaking). Set only when Outcome == failed; otherwise leave empty.

const (
	// ErrorClassChecksumMismatch — Flyway detected an applied script was
	// modified after the fact.
	ErrorClassChecksumMismatch ErrorClass = "checksum_mismatch"
	// ErrorClassLockTimeout — could not acquire the advisory / DBMS_LOCK
	// within the configured timeout.
	ErrorClassLockTimeout ErrorClass = "lock_timeout"
	// ErrorClassSchemaHistoryCorrupt — flyway_schema_history is in an
	// inconsistent state.
	ErrorClassSchemaHistoryCorrupt ErrorClass = "schema_history_corrupt"
	// ErrorClassTargetNotReady — the state-machine target is not in a state
	// that allows migration. Set by the orchestrator (#379), not the engine.
	ErrorClassTargetNotReady ErrorClass = "target_not_ready"
	// ErrorClassTargetUnreachable — the target database refused, timed out,
	// or DNS-failed.
	ErrorClassTargetUnreachable ErrorClass = "target_unreachable"
	// ErrorClassQuiesceBlocked — the quiesce flag was set; the run aborted
	// before any Flyway work. Set by the orchestrator (#380), not the engine.
	ErrorClassQuiesceBlocked ErrorClass = "quiesce_blocked"
	// ErrorClassInternal is the catch-all for unclassified panics and
	// unexpected errors.
	ErrorClassInternal ErrorClass = "internal_error"
)

type FlywayMigrator

type FlywayMigrator struct {
	// contains filtered or unexported fields
}

FlywayMigrator handles database migrations using Flyway

func NewFlywayMigrator

func NewFlywayMigrator(cfg *config.Config, log logger.Logger) *FlywayMigrator

NewFlywayMigrator creates a new instance of the migrator with the always-on OpenTelemetry audit-emission path wired up. Call WithAuditRecorder to add an optional compliance-grade durable delivery path per ADR-019.

func (*FlywayMigrator) Close added in v0.32.0

func (fm *FlywayMigrator) Close(ctx context.Context) error

Close drains the optional AuditRecorder queue and tears down the audit consumer goroutine. Safe to call when no sink is configured. Honors ctx for shutdown deadline; events still in flight when ctx expires are silently dropped (their OTel emission already succeeded).

func (*FlywayMigrator) DefaultMigrationConfig added in v0.19.0

func (fm *FlywayMigrator) DefaultMigrationConfig() *Config

DefaultMigrationConfig returns the default configuration for migrations

func (*FlywayMigrator) DefaultMigrationConfigForVendor added in v0.31.0

func (fm *FlywayMigrator) DefaultMigrationConfigForVendor(vendor string) *Config

DefaultMigrationConfigForVendor returns the default migration config for the given database vendor (e.g. "postgresql", "oracle"). Used by multi-tenant migrations where each tenant may run a different vendor than the migrator's own cfg.Database.Type. Unknown vendors fall back to the migrator's configured Database.Type so the vendor string never reaches filesystem path interpolation unvalidated; if even that is unknown, an "unknown" segment is used so callers see an obvious error rather than a path-traversal artifact.

func (*FlywayMigrator) Info

func (fm *FlywayMigrator) Info(ctx context.Context, cfg *Config) error

Info shows information about the status of migrations against the migrator's database.

func (*FlywayMigrator) InfoFor added in v0.31.0

func (fm *FlywayMigrator) InfoFor(ctx context.Context, db *config.DatabaseConfig, cfg *Config) error

InfoFor shows migration status for the supplied database.

func (*FlywayMigrator) Migrate

func (fm *FlywayMigrator) Migrate(ctx context.Context, cfg *Config) (Result, error)

Migrate executes pending migrations against the migrator's configured database. The Result carries the parsed per-target outcome; on parse failure it is zero-valued and the error return is authoritative.

func (*FlywayMigrator) MigrateFor added in v0.31.0

func (fm *FlywayMigrator) MigrateFor(ctx context.Context, db *config.DatabaseConfig, cfg *Config) (Result, error)

MigrateFor executes pending migrations against the supplied database. Used by multi-tenant migrations to target a tenant-specific DatabaseConfig. See Migrate for the Result contract.

func (*FlywayMigrator) RunMigrationsAtStartup

func (fm *FlywayMigrator) RunMigrationsAtStartup(ctx context.Context) error

RunMigrationsAtStartup executes migrations automatically at application startup. The structured Result is discarded here; downstream consumers pick it up via the migration.applied audit event.

func (*FlywayMigrator) Validate

func (fm *FlywayMigrator) Validate(ctx context.Context, cfg *Config) error

Validate validates migrations without executing them against the migrator's database.

func (*FlywayMigrator) ValidateFor added in v0.31.0

func (fm *FlywayMigrator) ValidateFor(ctx context.Context, db *config.DatabaseConfig, cfg *Config) error

ValidateFor validates migrations for the supplied database.

func (*FlywayMigrator) WithAuditRecorder added in v0.32.0

func (fm *FlywayMigrator) WithAuditRecorder(sink AuditRecorder) *FlywayMigrator

WithAuditRecorder registers an optional AuditRecorder for compliance-grade durable delivery alongside the always-on OpenTelemetry emission. Replaces any previously-configured sink. Returns the receiver for chaining.

Intended to be called once at startup. The sink runs on its own goroutine with a bounded send queue per ADR-019 — slow sinks cannot stall migrations, and sink errors are logged but do not abort the migration. Call Close to drain the queue on shutdown.

type MigrateAllOptions added in v0.31.0

type MigrateAllOptions struct {
	// BaseConfig supplies Flyway timeout / paths. ConfigPath and
	// MigrationPath are auto-resolved per vendor when zero.
	BaseConfig *Config

	// ContinueOnError keeps iterating after the first per-tenant failure.
	// Default false (fail-fast).
	ContinueOnError bool

	// Parallelism caps concurrent tenant migrations. 0 or 1 = sequential.
	// Implementation caps the value to a reasonable maximum to avoid
	// connection storms.
	Parallelism int

	// Logger receives progress updates. May be nil.
	Logger logger.Logger

	// Hook is invoked after each tenant completes (success or failure).
	// Useful for streaming progress to the CLI / CI logs. May be nil.
	Hook func(TenantResult)
}

MigrateAllOptions tunes per-tenant execution.

type MigrateAllResult added in v0.31.0

type MigrateAllResult struct {
	Action  Action
	Results []TenantResult
}

MigrateAllResult aggregates per-tenant results from a MigrateAll run.

func MigrateAll added in v0.31.0

func MigrateAll(
	ctx context.Context,
	migrator *FlywayMigrator,
	lister TenantLister,
	configs database.DBConfigProvider,
	action Action,
	opts MigrateAllOptions,
) (*MigrateAllResult, error)

MigrateAll lists tenants via lister, resolves each tenant's database config via configs (the existing database.DBConfigProvider abstraction), and runs the chosen Flyway action against every one. Sequential fail-fast unless opts say otherwise.

func (*MigrateAllResult) Failed added in v0.31.0

func (r *MigrateAllResult) Failed() []TenantResult

Failed returns only the tenant results whose Err is non-nil.

type PGRoleSpec added in v0.32.0

type PGRoleSpec struct {
	// Schema is the per-tenant schema name (e.g. "tenant_a"). Owned by
	// MigratorRole after provisioning.
	Schema string

	// MigratorRole owns Schema and is used exclusively by the migration runner.
	// Must differ from RuntimeRole.
	MigratorRole string

	// MigratorPassword is optionally assigned to MigratorRole via ALTER ROLE
	// PASSWORD on every call. Useful for the one-time bootstrap and for
	// secret rotation. Leave empty when credentials are managed externally
	// (e.g., the role is created out-of-band and password set via a
	// privileged migration pipeline).
	MigratorPassword string

	// RuntimeRole is the per-tenant DML-only role consumed by the running
	// service. Must differ from MigratorRole.
	RuntimeRole string

	// RuntimePassword is optionally assigned to RuntimeRole. Same semantics
	// as MigratorPassword — passing it on every call makes secret rotation a
	// no-op rerun.
	RuntimePassword string
}

PGRoleSpec describes a PostgreSQL role-pair plus per-tenant schema for the migrator-vs-runtime role-separation model defined in issue #378.

Migrator role: owns the per-tenant schema, holds DDL privileges, used exclusively by the migration runner. Created with NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION so even a compromised migrator credential cannot escalate itself.

Runtime role: per-tenant LOGIN role granted only DML on the tenant schema. Does not own the schema, so PostgreSQL's default ownership model rejects ALTER/CREATE/DROP statements from this role without any explicit REVOKE. Granted SELECT/INSERT/UPDATE/DELETE on existing AND future tables via ALTER DEFAULT PRIVILEGES so subsequent migrations don't need per-script grants.

func (*PGRoleSpec) Validate added in v0.32.0

func (s *PGRoleSpec) Validate() error

Validate reports whether the spec's identifiers pass the safe-identifier check and the two roles differ. Returns ErrInvalidPGIdentifier wrapped with the offending field name (and value) on failure.

type Result added in v0.32.0

type Result struct {
	// Operation is the Flyway verb. Empty on the error envelope.
	Operation string

	// Success is false whenever Flyway emitted an error envelope, even if
	// the JSON parsed cleanly.
	Success bool

	// AppliedVersions enumerates the migration versions Flyway applied in
	// this run, in the order Flyway reported them. Empty on no-op reruns.
	AppliedVersions []string

	// StartingVersion is the schema version before this run (Flyway's
	// initialSchemaVersion). Empty when Flyway reported it as null —
	// typically on the first migrate against a fresh schema.
	StartingVersion string

	// EndingVersion is the schema version after this run. Flyway reports
	// targetSchemaVersion as null on no-op runs; the parser falls back to
	// StartingVersion in that case so callers always see a usable terminus.
	EndingVersion string

	// DurationMillis is Flyway's totalMigrationTime in milliseconds.
	DurationMillis int64

	// FlywayVersion is the engine version that produced this result.
	FlywayVersion string

	// DatabaseType is Flyway's databaseType field ("PostgreSQL", "Oracle").
	DatabaseType string

	// ErrorCode is Flyway's errorCode on the failure envelope (e.g.
	// "VALIDATE_ERROR" for a checksum mismatch). Empty when Success is true.
	ErrorCode string

	// ErrorMessage is the human-readable error message from Flyway when
	// Success is false. May contain embedded newlines from Flyway.
	ErrorMessage string
}

Result captures the structured outcome of a single Flyway migrate invocation, populated from the engine's -outputType=json output. Fields are best-effort: an empty Result is returned when the subprocess crashed before emitting JSON or the payload was malformed. Callers that need an authoritative pass/fail signal should still consult the error returned alongside the Result.

type SecretFetcher added in v0.31.0

type SecretFetcher func(ctx context.Context, secretName string) ([]byte, error)

SecretFetcher resolves an opaque secret name to its raw payload bytes. The framework stays decoupled from any specific cloud SDK; callers wire AWS Secrets Manager, HashiCorp Vault, or another store behind this seam.

type SecretsProvider added in v0.31.0

type SecretsProvider struct {
	// Prefix is prepended to each tenant ID when composing the secret name.
	// Empty defaults to DefaultSecretsPrefix at lookup time.
	Prefix string

	// Fetch resolves a secret name to its payload. Required.
	Fetch SecretFetcher
	// contains filtered or unexported fields
}

SecretsProvider implements database.DBConfigProvider on top of a SecretFetcher. It composes the secret name as Prefix + tenantID, fetches the bytes, and parses them as either the canonical go-bricks DatabaseConfig shape or the AWS-managed RDS rotation shape.

func (*SecretsProvider) DBConfig added in v0.31.0

func (p *SecretsProvider) DBConfig(ctx context.Context, tenantID string) (*config.DatabaseConfig, error)

DBConfig satisfies database.DBConfigProvider. Looks up the tenant's secret, parses the payload, and returns the resulting DatabaseConfig.

func (*SecretsProvider) SecretName added in v0.31.0

func (p *SecretsProvider) SecretName(tenantID string) string

SecretName composes the full secret name for the given tenant ID using the provider's prefix (or DefaultSecretsPrefix when unset).

func (*SecretsProvider) Validate added in v0.31.0

func (p *SecretsProvider) Validate() error

Validate checks that the provider is wired correctly. Callers may invoke it eagerly at startup; DBConfig also calls it lazily on first lookup so library callers who skip the explicit check still get a clear error before any tenant fetch.

type TenantLister added in v0.31.0

type TenantLister interface {
	ListTenants(ctx context.Context) ([]string, error)
}

TenantLister enumerates the tenant IDs that should receive migrations. Implementations include the HTTP source (for control-plane APIs) and a static source backed by config.TenantStore.

type TenantResult added in v0.31.0

type TenantResult struct {
	TenantID string
	Vendor   string
	Err      error
	Duration time.Duration

	// Result is the parsed Flyway outcome for ActionMigrate. Zero-valued
	// for Validate / Info, or when Flyway crashed before emitting JSON.
	Result Result
}

TenantResult captures the outcome of running an Action against one tenant.

Directories

Path Synopsis
Package provisioning implements a durable, crash-recoverable state machine for dynamic per-tenant provisioning under the multi-tenant migration model defined in issue #379.
Package provisioning implements a durable, crash-recoverable state machine for dynamic per-tenant provisioning under the multi-tenant migration model defined in issue #379.
testing
Package testing provides test utilities for the provisioning state machine.
Package testing provides test utilities for the provisioning state machine.
source
http
Package http provides a TenantLister that pulls tenant IDs from a control-plane API conforming to the go-bricks pre-defined contract.
Package http provides a TenantLister that pulls tenant IDs from a control-plane API conforming to the go-bricks pre-defined contract.
static
Package static provides a TenantLister that enumerates tenant IDs from a config-backed source (typically the YAML-driven multitenant.tenants block).
Package static provides a TenantLister that enumerates tenant IDs from a config-backed source (typically the YAML-driven multitenant.tenants block).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL