drivers

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jun 22, 2026 License: BSD-3-Clause Imports: 2 Imported by: 0

README

openweft

weft-drivers

Driver interfaces + types for weft. This Go module is the only dependency every driver implementation (Apple VZ, QEMU/KVM, WireGuard, Ceph, …) needs to import.

The module is intentionally tiny and dependency-free — no imports beyond the Go standard library — so its semver contract can stay stable for years.

Planned extraction

This module currently lives inside the mock monorepo to keep the dev loop fast. The eventual home is its own git repo:

github.com/cloud-boot/weft-drivers-api

See the weft-one-repo-per-driver memory entry for the full multi-repo layout and rationale.

Adding a new driver type

Each <x>.go file at the root of this package declares one interface (HypervisorDriver, NetworkDriver, VolumeDriver, ImageDriver). To add a new driver type:

  1. Pick a name — singular noun describing what gets driven (e.g. firewall.go for a per-host nftables/eBPF driver).
  2. Define the interface following the existing conventions:
    • Every method takes context.Context first.
    • Use only protobuf-friendly types (no pointers, no interface-typed fields, string-keyed maps).
    • Ensure* / Attach* methods are idempotent.
    • Lifecycle Delete* returns nil on "already gone".
  3. Add or extend a Spec struct in types.go.
  4. Document the contract in the package doc comment.

Compatibility contract

  • Patch / minor releases (v1.x.y) — backward-compatible: methods can be added, fields can be added with sensible zero defaults.
  • Major releases (v2.0.0) — breaking changes allowed. Drivers opt in to the new major version by importing github.com/cloud-boot/weft-drivers-api/v2.
  • weft-control supports multiple major versions simultaneously via the driver dispatch layer.

Documentation

Overview

Package drivers defines the side-effecting interfaces that turn weft registry state into actual host-level resources: VMs running on a hypervisor, virtio-net devices wired to a bridge or WireGuard interface, disk images on a storage backend, OCI artifacts in a local cache.

The split between this package and the registries in weft/ is deliberate (see [[weft-driver-registry-split]] memory entry):

  • weft/<name>.go owns data + ACL + HCL + Storage. Stays in-process. No side effects.
  • drivers/<name>.go owns the actual implementation that talks to the kernel / hypervisor / SAN. Designed from day one as a context-aware interface so it can later be swapped for a go-plugin process or a remote weft-agent without touching call sites.

All driver methods:

  • Take `context.Context` for cancellation, deadlines, and trace propagation.
  • Use protobuf-friendly types only (no in-process Go pointers, no interface-typed values inside spec structs, no maps with non-string keys). This keeps the path to a gRPC plugin boundary clear.
  • Are stateless from the caller's POV — drivers re-derive everything from the spec they receive. The source of truth stays in the registries.
  • Are idempotent for "Ensure" / "Attach" operations — calling them again with the same spec is a no-op, not an error. This matters for reconciliation loops.

Multi-host shape (target): one driver instance per (Host, type). weft-control's scheduler picks the Host UUID for a workload, then dispatches the driver call to that host's agent. Today the "agent" is in-process; tomorrow it's gRPC over MTLS.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotApplicable is returned by a driver when the request
	// is well-formed but doesn't apply to this implementation
	// (e.g. RotateMeshPeer on a non-mesh NetworkDriver).
	ErrNotApplicable = errors.New("driver: operation not applicable to this driver type")

	// ErrUnsupported is returned when the driver knows what's
	// being asked but doesn't (yet) implement it. Different from
	// ErrNotApplicable: the latter means "by design", this one
	// means "patch welcome".
	ErrUnsupported = errors.New("driver: unsupported")

	// ErrNotFound is returned by lookup-shaped methods (LocalPath,
	// resolution helpers) when the queried entity doesn't exist
	// in the driver's view of the world. Lifecycle methods
	// (DeleteVM, DestroyNetwork, …) treat "not found" as success
	// per their idempotence contract — they do NOT return this.
	ErrNotFound = errors.New("driver: not found")

	// ErrInUse is returned by destructive snapshot/backup operations
	// (RevertSnapshot, DeleteSnapshot of the head's parent, …) when
	// the volume is currently attached or otherwise in active use.
	// The caller is expected to detach + retry.
	ErrInUse = errors.New("driver: volume in use")
)

Functions

This section is empty.

Types

type AttachedVolume

type AttachedVolume struct {
	BackingPath string // /var/lib/weft/.../disk.qcow2 — local file path or rbd:// URI
	ReadOnly    bool
}

AttachedVolume is what AttachVolume returns: the path / URI the hypervisor opens, plus access mode.

type Backup added in v0.2.0

type Backup struct {
	VolumeUUID      string
	SnapshotName    string
	URL             string
	ParentURL       string // "" → full backup ; non-empty → incremental, points at the previous backup
	Encryption      BackupEncryptionInfo
	SizeBytes       int64
	CreatedAtUnixNs int64
	Labels          map[string]string
	State           string // "in-progress" | "complete" | "error" | "unknown"
	Error           string // human-readable error when State == "error"
}

Backup is the descriptor VolumeDriver.{Create,List}Backups returns for one backup. URL is the full backup reference at the target store (e.g. "oci://registry/repo:vol-snap" or "s3://bucket@region/path?…"). CreatedAtUnixNs is the backup-time wallclock. ParentURL echoes BackupSpec.ParentURL (empty for full backups), so List + Restore can walk the chain. Encryption echoes the algorithm + KDF params + per- backup salt — empty Algorithm = plaintext backup.

type BackupEncryption added in v0.2.0

type BackupEncryption struct {
	// Algorithm is "" (no encryption) | "chacha20-poly1305" | "aes-256-gcm".
	Algorithm string
	// PassphraseEnv names the env var holding the encryption passphrase
	// (e.g. "WEFT_BACKUP_PASSPHRASE"). Required when Algorithm != "" ;
	// empty disables encryption regardless of Algorithm.
	PassphraseEnv string
	// KDF is the key derivation function : "argon2id" (default) for
	// passphrase-based ; "raw" treats the env var's value as a hex-
	// encoded 256-bit key (advanced, for KMS-managed deployments where
	// the operator's tool already did the KDF).
	KDF string
	// KDFParams are passed to the KDF. For argon2id : "memory_kib" (default
	// 65536), "iterations" (default 3), "parallelism" (default 2). Empty
	// map uses defaults.
	KDFParams map[string]string
}

BackupEncryption configures end-to-end encryption applied BEFORE the backup body hits the target. Empty algorithm = no encryption (the target sees plaintext) ; non-empty enables an AEAD pass over every chunk shipped. The same struct is echoed back in the Backup descriptor so restore can re-derive the key without the operator restating the algorithm / KDF params (only the passphrase env name + salt are needed at restore time).

Algorithms supported by weft-block today :

  • "chacha20-poly1305" : pure-Go AEAD via golang.org/x/crypto. 256-bit keys, 96-bit nonces, fast without hardware AES, single-nonce safe up to ≈256 GiB per stream — bigger volumes get auto-chunked with per-chunk nonce derivation.
  • "aes-256-gcm" : hardware-accelerated on AESNI/ARM64 ; same security level as above. Pick this on hosts that have AESNI for better throughput.

KDFs :

  • "argon2id" (default) : memory-hard, OWASP-recommended. Params (memory, iterations, parallelism) live in the Backup descriptor so restore uses the SAME settings the create-time operator chose.

type BackupEncryptionInfo added in v0.2.0

type BackupEncryptionInfo struct {
	Algorithm string
	KDF       string
	KDFParams map[string]string
	// SaltHex is the per-backup random salt the KDF was seeded with,
	// hex-encoded. Without it the operator's passphrase derives a
	// different key each backup, defeating restore.
	SaltHex string
}

BackupEncryptionInfo is the descriptor echoed back in the Backup struct. Carries everything restore needs EXCEPT the passphrase : algorithm + KDF + per-backup random salt (so the key derivation is reproducible).

type BackupSpec added in v0.2.0

type BackupSpec struct {
	VolumeUUID   string
	SnapshotName string
	Target       string
	ParentURL    string // "" → full backup ; URL of a prior backup → incremental delta
	Encryption   BackupEncryption
	Labels       map[string]string
}

BackupSpec is what VolumeDriver.CreateBackup consumes. Snapshot is the source snapshot name (the driver always backs up FROM a snapshot, not from the live head — caller is responsible for taking the snapshot first). Target is the backupstore URL ("oci://registry/repo:tag", "s3://bucket@region/path", "sftp://user@host:port/path", …). Labels are propagated to the backupstore metadata.

ParentURL, when non-empty, makes the backup INCREMENTAL : the driver uses BackupStatus.CompareSnapshot(new, parent) to ship only the block ranges that differ from the parent backup's snapshot. Empty ParentURL = full backup. Restore walks the parent chain back to a full and applies the deltas. The parent backup must still exist at the target for restore to work ; weft-block guards Delete against unlinking a backup that's a parent of another backup in the same target.

Encryption, when Algorithm != "", wraps every shipped chunk in AEAD — the target only stores ciphertext. Encryption is orthogonal to ParentURL : incremental + encrypted is the common production path.

type DiskSpec

type DiskSpec struct {
	VolumeUUID  string
	BackingPath string
	Bus         string // "virtio" | "scsi" | "nvme" — hypervisor-dependent
	SizeGiB     int    // transitional: > 0 lets the hypervisor lazily create the backing file
	ReadOnly    bool
	Boot        bool // true for the root disk
}

DiskSpec describes one disk attachment on a VM. The driver looks up the volume via VolumeDriver.BackingPath using VolumeUUID, but the Adapter caches that resolution in BackingPath so a stand-alone driver call doesn't need to re-resolve.

SizeGiB is the requested backing-file size. Used by HypervisorDriver.AttachDisk in the transitional "the driver also creates the backing file when missing" mode — once the VolumeDriver path is wired end-to-end (post-Phase-F), this field drops out and creation moves to VolumeDriver.EnsureVolume. A value of 0 means "the file must already exist" and the driver returns an error if BackingPath is missing.

type HostInfo

type HostInfo struct {
	UUID         string
	Hostname     string
	AZ           string // availability zone label, e.g. "us-east-1a"
	Hypervisor   string // "apple-vz" | "qemu-kvm" | "cloud-hypervisor"
	Architecture string // "arm64" | "amd64" | "riscv64" | "loongarch64"
	// Version is the driver plugin's compile-time build version
	// (e.g. "v0.6.0"). Reported via the HostInfo() RPC so weft can
	// surface per-driver versions in the TUI / webui chrome.
	// Empty when the driver isn't built with -X main.version (dev
	// builds) — TUI shows "(dev)" or empty in that case.
	Version string
}

HostInfo identifies a compute node in the cluster. Returned by every driver's HostInfo() so the scheduler + audit logs can confirm where a side effect landed.

type HypervisorDriver

type HypervisorDriver interface {
	HostInfo(ctx context.Context) (HostInfo, error)

	// CreateVM provisions the VM's static state (nvram, machine
	// identifier, base config). Disks + NICs come via Attach*.
	CreateVM(ctx context.Context, spec VMSpec) error

	// StartVM boots the VM. The driver returns once the
	// hypervisor reports "running" (or the equivalent).
	StartVM(ctx context.Context, vmUUID string) error

	// StopVM signals graceful shutdown. After ctx deadline,
	// drivers may escalate to a hard stop — caller controls the
	// deadline.
	StopVM(ctx context.Context, vmUUID string) error

	// DeleteVM removes every host-local state for the VM.
	// Returns nil for "already gone".
	DeleteVM(ctx context.Context, vmUUID string) error

	AttachDisk(ctx context.Context, vmUUID string, disk DiskSpec) error
	DetachDisk(ctx context.Context, vmUUID, volumeUUID string) error

	AttachNIC(ctx context.Context, vmUUID string, nic NICHandle) error
	DetachNIC(ctx context.Context, vmUUID, nicDevice string) error
}

HypervisorDriver materialises a VMSpec into a running VM on one specific compute host. One driver instance per (host, hypervisor type): Apple VZ on macOS, QEMU/KVM on Linux, Cloud Hypervisor on Linux, …

Lifecycle contract:

CreateVM   → idempotent: re-calling with same UUID is no-op
StartVM    → idempotent: already-running is no-op
StopVM     → idempotent: already-stopped is no-op
DeleteVM   → idempotent: missing is no-op (not an error)
AttachDisk → idempotent on (vmUUID, disk.VolumeUUID)
DetachDisk → idempotent: missing attachment is no-op
AttachNIC  / DetachNIC follow the same contract

Idempotence matters because the scheduler / reconciler may retry on transient failures (network blip, transient lock contention). Drivers that turn retries into errors create unfixable stuck states.

Errors:

  • Return a concrete error for "this can never succeed" (bad spec, hypervisor missing on host, hardware fault).
  • Return a wrapped context error for cancellation / timeout — callers expect `errors.Is(err, ctx.Err())` to work.
  • NEVER panic across the interface boundary; the gRPC plugin transport can't surface them cleanly.

type ImageDriver

type ImageDriver interface {
	HostInfo(ctx context.Context) (HostInfo, error)

	// Pull fetches the OCI ref into the local cache. Idempotent:
	// already-present is a no-op, in-progress concurrent pulls
	// of the same ref deduplicate.
	Pull(ctx context.Context, ref string) error

	// LocalPath returns the absolute path of the cached artifact.
	// Returns an error when the ref is not in cache — caller is
	// expected to Pull first.
	LocalPath(ctx context.Context, ref string) (string, error)

	// Delete removes an entry from the cache. No-op if missing.
	// The host-local cache is GC'd separately based on LRU;
	// Delete is the explicit "remove this now" hook.
	Delete(ctx context.Context, ref string) error

	// InCache is the cheap existence check the scheduler runs
	// before deciding whether to pre-pull on a placement.
	InCache(ctx context.Context, ref string) (bool, error)
}

ImageDriver caches OCI artifacts (cloud images, UKI bundles, kernel/initrd pairs) on a host so CreateVM can clone from a local copy instead of pulling from the registry on every boot.

One driver instance per (host, cache backend) — typically one per weft-agent, backed by a host-local directory. Future: driver that delegates to a per-AZ zot mirror so the same blob only crosses the WAN once per AZ.

type NICHandle

type NICHandle struct {
	Device string // tap0 / vmnet1 / opaque handle ID
	MAC    string // may differ from PortSpec.MAC if the driver enforced uniqueness
}

NICHandle is what AttachPort returns: the OS-level identifier the hypervisor binds to (e.g. a /dev/tap name on Linux, a SocketDeviceConfiguration handle on Apple VZ).

type NetworkDriver

type NetworkDriver interface {
	HostInfo(ctx context.Context) (HostInfo, error)

	// EnsureNetwork creates the host-side construct for this
	// network: a bridge for nat/bridged, a WireGuard interface
	// for mesh, nothing visible for isolated. Idempotent.
	EnsureNetwork(ctx context.Context, spec NetworkSpec) error

	// DestroyNetwork tears down the host-side construct. No-op
	// if it never existed.
	DestroyNetwork(ctx context.Context, networkUUID string) error

	// AttachPort wires a VM NIC to the network. Returns the
	// NICHandle the hypervisor needs to plug into its VM config
	// (tap device name, MAC, …).
	AttachPort(ctx context.Context, spec PortSpec) (NICHandle, error)

	// DetachPort tears down the host-side port state.
	DetachPort(ctx context.Context, portUUID string) error

	// RotateMeshPeer updates the WireGuard peer entry for one
	// port without dropping existing connections. Only meaningful
	// for mesh-type networks; drivers serving other types may
	// return ErrNotApplicable.
	RotateMeshPeer(ctx context.Context, spec PortSpec) error
}

NetworkDriver materialises Networks and Ports on one host. Different network types may be served by different drivers (a "linux-bridge" driver for nat/bridged/isolated and a "wireguard" driver for mesh, or one combined driver) — the Adapter routes to the right one based on NetworkSpec.Type.

Lifecycle contract: all Ensure* / Attach* / Detach* methods MUST be idempotent. The reconciler invokes them whenever it believes the desired state has drifted; spurious retries must not break things.

AllocateIP / AllocateMAC are optional helpers — the Adapter may choose to manage IP/MAC allocation centrally (in the port registry) and pass them in via PortSpec, in which case the driver doesn't see Allocate* at all. Drivers that own allocation (e.g. integrating with an external IPAM like Infoblox) implement them.

type NetworkSpec

type NetworkSpec struct {
	UUID           string
	ProjectUUID    string
	Name           string
	CIDR           string
	Gateway        string
	DNSServers     []string
	Type           string // matches weft.NetworkType
	MeshListenPort int    // mesh only
	MeshEndpoint   string // mesh only
}

NetworkSpec is what NetworkDriver consumes — mirrors weft.Network minus the timestamps and minus the DefaultSecurityGroups reference (the driver only enforces SG rules; the cross-registry resolution happens in the Adapter before the spec is shipped).

type PortSpec

type PortSpec struct {
	UUID            string
	ProjectUUID     string
	VMUUID          string
	NetworkUUID     string
	MAC             string
	IP              string
	WireguardPubKey string // mesh only
	MeshEndpoint    string // mesh only — per-port override of network's endpoint
	// EffectiveSecurityGroups is the resolved SG UUID list the
	// driver should program at the firewall layer. The Adapter
	// merges Port.SecurityGroups (override) with the network's
	// DefaultSecurityGroups before handing the PortSpec down.
	EffectiveSecurityGroups []string
}

PortSpec is what NetworkDriver consumes when it attaches a VM NIC. The driver returns NICHandle so the hypervisor knows the device path / tap name to plug into the VM config.

type Snapshot added in v0.2.0

type Snapshot struct {
	VolumeUUID      string
	Name            string
	Parent          string            // name of the parent snapshot, "" if root
	SizeBytes       int64             // delta on disk (sparse-aware)
	CreatedAtUnixNs int64             // driver-side creation timestamp
	Labels          map[string]string // copy of SnapshotSpec.Labels
	UserCreated     bool              // true if not an automatic / system snapshot
}

Snapshot is the descriptor VolumeDriver.{Create,List}Snapshots returns for one snapshot. SizeBytes is the on-disk delta from the parent (zero for the initial / head-derived snapshot). CreatedAtUnixNs is the driver-side creation time in nanoseconds since the Unix epoch (we keep the wire shape primitive — see types.go header note).

type SnapshotSpec added in v0.2.0

type SnapshotSpec struct {
	VolumeUUID string
	Name       string            // empty → driver-generated
	Labels     map[string]string // optional, e.g. {"reason":"pre-upgrade"}
}

SnapshotSpec is what VolumeDriver.CreateSnapshot consumes. Name is the snapshot identifier the driver will key by; empty asks the driver to generate one (e.g. timestamped). Labels are opaque key/value pairs stored alongside the snapshot for filtering / debugging.

type VMSpec

type VMSpec struct {
	UUID        string
	ProjectUUID string
	Name        string
	CPUCount    int
	MemoryMiB   int
	BootKind    string // "uki" | "direct_linux" | "oci_image"
	BootRef     string // path or ref depending on BootKind
	Cmdline     string // optional kernel cmdline override
	// VsockCID is the AF_VSOCK guest CID the agent allocated for
	// this VM (deterministic hash, range [0x10000, 0xfffefffe]).
	// Drivers that support virtio-vsock must bind this exact CID
	// so GuestPodPlane.Attach's strict-when-known peer check sees
	// the agent's expected value. QEMU :
	//   -device vhost-vsock-pci,guest-cid=<VsockCID>
	// Apple VZ : VZVirtioSocketDevice (note: the API doesn't let
	// userland pick a CID, so the field is advisory there). 0 =
	// no CID assigned (legacy VM ; the agent then treats the pod
	// as "unknown" and falls back to the permissive guard).
	VsockCID uint32
}

VMSpec is what HypervisorDriver consumes at CreateVM time. The driver materialises this into the hypervisor's native config (vz.VirtualMachineConfiguration, qemu cmdline, etc.).

type VolumeDriver

type VolumeDriver interface {
	// Name identifies the backend, e.g. "file" / "ceph" / "iscsi".
	// Used in startup logs + the Host registry's VolumeBackends
	// capability list.
	Name() string

	// Local reports whether volumes from this driver are bound to
	// the driver's host (true) or accessible from any host (false).
	// File-backed → true; Ceph → false.
	Local() bool

	// HostInfo is only meaningful when Local() == true. Cluster-
	// wide drivers may return a synthetic HostInfo with UUID == ""
	// and Hostname == "<backend>-cluster".
	HostInfo(ctx context.Context) (HostInfo, error)

	// EnsureVolume creates (or grows) the backing storage.
	// Idempotent. Re-running with a smaller size is rejected;
	// the registry already enforces grow-only, but defence in
	// depth is cheap here.
	EnsureVolume(ctx context.Context, spec VolumeSpec) error

	// DestroyVolume removes the backing storage. No-op if missing.
	DestroyVolume(ctx context.Context, volumeUUID string) error

	// AttachVolume prepares the volume for hypervisor consumption
	// on the given host (e.g. maps an RBD device, locks the file,
	// stages a snapshot). Returns the BackingPath the hypervisor
	// opens.
	AttachVolume(ctx context.Context, volumeUUID, hostUUID string) (AttachedVolume, error)

	// DetachVolume releases the per-host attachment (unmap RBD,
	// release file lock). Does NOT delete the volume.
	DetachVolume(ctx context.Context, volumeUUID, hostUUID string) error

	// CreateSnapshot freezes the volume's current state under the given
	// name (or a driver-generated one if SnapshotSpec.Name is empty) and
	// returns its descriptor. Drivers that don't support snapshots return
	// ErrUnsupported — file-backed drivers (qcow2 internal snapshots) +
	// cluster drivers (Longhorn / Ceph RBD) implement it.
	CreateSnapshot(ctx context.Context, spec SnapshotSpec) (Snapshot, error)

	// ListSnapshots returns every snapshot known for volumeUUID, in driver-
	// stable order (typically oldest-first by Parent chain). Empty list +
	// nil error means "no snapshots", not "unsupported" — use ErrUnsupported
	// for the latter.
	ListSnapshots(ctx context.Context, volumeUUID string) ([]Snapshot, error)

	// DeleteSnapshot drops a snapshot's on-disk delta. The driver may
	// physically merge it into its parent / child, which can take a while
	// for large deltas. Idempotent : deleting a missing snapshot is a no-op.
	DeleteSnapshot(ctx context.Context, volumeUUID, snapshotName string) error

	// RevertSnapshot rolls the volume back to the given snapshot's state.
	// The volume must be detached first (the driver may enforce this with
	// ErrInUse). Effectively the inverse of CreateSnapshot.
	RevertSnapshot(ctx context.Context, volumeUUID, snapshotName string) error

	// CreateBackup ships a snapshot's contents to the backupstore at
	// spec.Target and returns the resulting backup descriptor. The driver
	// runs the transfer asynchronously when the backupstore is remote ;
	// callers poll ListBackups (the State field) for completion. The
	// snapshot referenced by spec.SnapshotName must already exist.
	CreateBackup(ctx context.Context, spec BackupSpec) (Backup, error)

	// ListBackups enumerates the backups stored at target for volumeUUID
	// (empty volumeUUID = every volume at the target). The target string
	// is the same schema as BackupSpec.Target.
	ListBackups(ctx context.Context, target, volumeUUID string) ([]Backup, error)

	// DeleteBackup removes one backup from the store. Idempotent. The full
	// backup URL (as returned in Backup.URL) is the addressing key — backups
	// can move between volumes during restore, so we don't key by (volume,
	// name).
	DeleteBackup(ctx context.Context, backupURL string) error

	// RestoreBackup creates a new volume (spec) populated from backupURL.
	// The new volume's UUID is spec.UUID ; the driver materialises the
	// backing storage at the requested size (which must be ≥ the original
	// volume size — restore can grow but not shrink). Idempotent : a second
	// restore with the same (spec.UUID, backupURL) is a no-op once the new
	// volume already exists.
	RestoreBackup(ctx context.Context, backupURL string, spec VolumeSpec) error
}

VolumeDriver materialises a Volume on its backing storage. One driver instance per backend: "file" (host-local qcow2/raw), "ceph" (cluster-wide RBD), "iscsi" (SAN), "nfs", …

`Local()` is the key dispatch hint for the scheduler: a file- backed volume on host A can only attach to VMs that run on host A; a Ceph volume can attach anywhere.

All methods are idempotent. EnsureVolume re-running with the same spec is a no-op; with a larger size it grows (matching the registry's grow-only contract).

type VolumeSpec

type VolumeSpec struct {
	UUID        string
	ProjectUUID string
	Name        string
	SizeGiB     int
	Format      string // "raw" | "qcow2"
}

VolumeSpec is what VolumeDriver consumes. Mirrors weft.Volume.

Jump to

Keyboard shortcuts

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