Sandbox mode


RAW specs need rework !!


GLPI Sandbox Mode — Specification

Overview

Sandbox mode lets administrators spin up an isolated GLPI environment that shares the same application codebase but operates against a separate database. This single feature covers three distinct use cases:

Use case

Primary need

Break-testing

Admin wants to try risky config changes without touching production data

Demo / discovery

Cloud instance pre-loaded with seeded data so users see a useful GLPI on first login

Testing environment

QA or developers want a near-production dataset to test against

Upgrade dry-run

Admin validates a GLPI version upgrade against a production-data copy before touching production


Architecture decision: separate database

A dedicated database was selected after comparing several approaches:

Option

Isolation

Complexity

Sync cost

Separate DB (proposed)

Full

Low

Moderate

Table prefix (sandbox_*)

Partial

High (every query touched)

Low

Schema flag / soft delete

None

Very high

None

Separate GLPI install

Full

None in code

Manual

Dedicated sandbox entity

Partial

Low

Near zero

A dedicated sandbox entity is worth noting: create a top-level entity flagged as the sandbox, scoped to it when sandbox mode is active. Sync cost is near zero since data lives in the same database and can simply be copied between entities. The trade-offs are significant however: it forces the instance into multi-entity mode (a non-trivial change for instances that were not already using it), isolation is only partial (global configuration, rules, and instance-level settings are shared), and any bug or misconfiguration that ignores entity scoping could leak into production data. It is a valid option for instances that already run multi-entity and accept weaker isolation in exchange for simpler setup.

A separate DB follows the existing replica pattern exactly — GLPI already has config_db.php → DB and config_db_replica.php → DBReplica. Sandbox adds a third file config_db_sandbox.php → DBSandbox following the same convention, and DBConnection grows analogous switchToSandbox() / isSandboxActive() helpers.

The global $DB is swapped at session activation time, exactly as switchToReplica() replaces it today. All existing code (search, forms, history, API) operates unchanged against $DB without knowing it is in sandbox mode.

Migration cost

A separate DB introduces a schema synchronization obligation: both databases must remain on the identical schema version at all times. A sync operation between databases with divergent schemas (e.g. a column added to production but not yet to sandbox) would produce errors or silent data corruption.

The enforced migration order is sandbox first, then production:

php bin/console database:update --sandbox   # migrate sandbox schema
php bin/console database:update             # migrate production schema

This is the natural workflow for the upgrade dry-run use case and costs nothing extra — the sandbox migration run is the validation step.

Two guardrails enforce this:

  • At sync time — sandbox:sync checks that both schema versions match before copying any data. If the sandbox schema is behind, it refuses the sync and prompts the admin to run database:update --sandbox first.

  • At sandbox entry — Session::enterSandbox() checks schema version parity. If schemas differ, entry is blocked with a clear message indicating that a migration is pending on the sandbox.

database:update (without --sandbox) should also warn if a sandbox is configured but its schema is now behind the freshly migrated production DB, so the divergence does not go unnoticed.


Availability

Sandbox is opt-in for self-hosted instances

Sandbox mode is disabled by default and requires explicit provisioning by the administrator. It is not enabled at install time. Several reasons justify this:

  • Resource cost — a sandbox requires a second database of comparable size to production. On self-hosted instances this may double storage requirements, which is not acceptable as a default.

  • Data sensitivity — some organizations have strict policies about copies of production data. An opt-in model ensures administrators make a conscious decision before any data is duplicated.

  • Relevance — small or single-user instances gain little from a sandbox. The overhead of maintaining one is not justified for every deployment.

  • Infrastructure dependency — sandbox may require access to a second database server, credentials, and (for mysqldump sync) CLI access. These cannot be assumed to be available.

Sandbox becomes available only after an administrator explicitly configures it under Setup > Sandbox, providing the sandbox DB credentials. At that point config_db_sandbox.php is written and the feature is live.

Cloud instances

For GLPI cloud instances, the sandbox will be provisioned automatically for all new instances, pre-loaded with seeded demo data. This directly serves the demo/discovery use case and removes the setup friction entirely.

TO DISCUSS: Existing cloud instances: sandbox will be opt-in, offered as an upgrade from the platform side, not pushed automatically (to avoid surprising administrators with a new database appearing in their environment).


Session activation model

Sandbox mode is a per-session flag, stored in $_SESSION, mirroring user impersonation.

$_SESSION['glpi_sandbox_active'] = true;

Session::isSandboxActive(): bool — checks the flag.

Session::enterSandbox(): bool — sets the flag, swaps $DB to DBSandbox, logs the event.

Session::exitSandbox(): bool — clears the flag, swaps $DB back to DB, logs the event.

On every request bootstrap (after session resume), if isSandboxActive() the application calls DBConnection::switchToSandbox(). This means the flag survives page navigation without any persistent config change.

Permission guard: at launch, sandbox entry is reserved for users with Config:UPDATE (super-admins). Impersonated users cannot enter sandbox mode (sandbox + impersonation together would be confusing and hard to reason about).

UI entry point

The primary entry point is the top-right user menu dropdown, alongside the existing debug mode toggle. This is consistent with the "mode switch" pattern already established in GLPI. The item is only rendered when sandbox is configured (DBConnection::isSandboxConfigured()):

User menu with sandbox entry

User menu with sandbox entry


{% if is_sandbox_configured %}
   <a href="{{ path('/ajax/switchsandbox.php') }}"
      class="dropdown-item {% if is_sandbox_active %}bg-purple-lt{% endif %}"
      title="{{ __('Change mode') }}">
      <i class="ti ti-flask"></i>
      {{ is_sandbox_active ? __('Sandbox mode enabled') : __('Sandbox mode disabled') }}
   </a>
{% endif %}

The item mirrors the debug toggle pattern: same placement, same guard, same toggle behaviour. When sandbox is active the item gets a purple tint (bg-purple-lt) matching the banner colour, giving a second visible cue of the active mode directly in the menu.

Exit is also reachable via the purple banner button, which remains the primary dismissal point since it is always visible on screen.


Visual indicator

A persistent purple banner is shown at the top of every page when sandbox mode is active, analogous to the existing banner-impersonate (red banner for impersonation). It is rendered by a new Twig partial:

Sandbox banner mockup

Sandbox banner mockup


templates/layout/parts/sandbox_banner.html.twig

{% if is_sandbox_active %}
<div class="banner-sandbox">
   {{ __('You are working in the sandbox environment.') }}
   <form method="post" action="{{ path('sandbox_exit') }}" data-submit-once>
      <input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token() }}" />
      <button type="submit" class="btn btn-outline-light">
         {{ __('Exit sandbox') }}
      </button>
   </form>
</div>
{% endif %}

Included in templates/layout/parts/page_header.html.twig immediately after the impersonate banner include (line 45 today).

Html::getPageHeaderTplVars() adds 'is_sandbox_active' => Session::isSandboxActive() alongside the existing is_impersonate_active key.

CSS (css/legacy/includes/_styles.scss):

div.banner-sandbox {
   background: #7c3aed; // purple — distinct from impersonate red
   color: #fff;
   font-weight: bold;
   padding: 10px;
   text-align: right;
   display: flex;
   align-items: center;
   justify-content: flex-end;
   gap: 10px;
}

The color must be visually distinct from both impersonation (red) and debug mode. Purple is immediately recognizable as a distinct mode indicator and has no existing semantic meaning in GLPI's UI.


Configuration

Database config file

GLPI_CONFIG_DIR/config_db_sandbox.php — generated by DBConnection::createSandboxConfig(), following the exact same structure as createReplicaConnectionFile():

class DBSandbox extends DBmysql {
   public $dbhost = 'localhost';
   public $dbuser = 'glpi_sandbox';
   public $dbpassword = '...';
   public $dbdefault = 'glpi_sandbox';
   public bool $sandbox = true;
}

The $sandbox = true property can be used as a guard to prevent DBSandbox from being accidentally used for writes in contexts where only the main DB should be touched (e.g. cron jobs that run outside of a user session should not respect the sandbox flag).

DBConnection additions

// analogous to isDBReplicaActive()
public static function isSandboxConfigured(): bool;

// analogous to switchToReplica()
public static function switchToSandbox(): bool;

// analogous to createReplicaConnectionFile()
public static function createSandboxConfig(string $host, string $user, string $password, string $dbname): bool;

public static function deleteSandboxConfig(): void;

Admin UI

A new section under Setup > System (or a dedicated Setup > Sandbox) with:

  • Current sandbox DB connection info and status (connected / not configured)

  • Button: Initialize sandbox (provisions the DB and runs the schema)

  • Button: Sync from production (see below)

  • Button: Enter sandbox (switches the current session)

  • Sandbox DB credentials form (host, user, password, dbname) — same form as the existing replica config


Sandbox initialization

When an admin first sets up the sandbox, two paths are available:

A. Empty database (for testing env)

Run the standard GLPI install migrations against DBSandbox. The result is an empty, schema-identical sandbox. The admin can then seed it manually or via the seeding feature (see Demo use case below).

This is just: create the database, run php bin/console database:install targeting the sandbox config.

B. Clone from production (for break-testing)

Copy the production database into the sandbox. See Sync section.


Data sync: production → sandbox

Sync is the most performance-critical operation. Two strategies are proposed; they are not mutually exclusive.

Strategy 1 — Application-level table copy (simple, always works)

GLPI iterates all tables and copies rows from $DB (production) to DBSandbox. This is done via a cron task or a CLI command (php bin/console sandbox:sync).

Advantages: pure PHP, no OS-level requirements, works on shared hosting. Disadvantage: slow for large datasets (millions of rows); blocks if tables are large.

Mitigation: copy in batches; skip tables that are irrelevant to sandbox (logs, job queues, sent notifications). Provide a config list of tables to exclude.

Estimated feasibility: acceptable for instances < 500k total rows; painful above that.

Strategy 2 — mysqldump / binary backup (CLI only, recommended for large instances)

Execute mysqldump on the production DB and pipe the output into the sandbox DB. This is the standard "clone a MySQL database" operation and is I/O-bound rather than PHP-bound, making it significantly faster than Strategy 1 for large datasets.

This strategy is CLI-only. GLPI deliberately avoids spawning OS-level processes from the web/browser execution path. Invoking system binaries from a web request would expose the underlying system in a way GLPI explicitly does not allow. The web UI must never trigger this path directly.

Acceptable contexts:

  • php bin/console sandbox:sync — the console command runs in CLI where spawning a subprocess is acceptable

  • A CronTask that runs via the CLI cron runner (php bin/console system:run-crons)

The operation performed at CLI level:

  1. Lock sandbox (set a flag that blocks user entry for the duration)

  2. Drop and recreate sandbox DB tables

  3. mysqldump ... | mysql ...

  4. Unlock sandbox

The web UI's "Sync now" button therefore does not run the sync inline. It either:

  • Schedules the next cron run immediately (same mechanism as "force-run a cron task" in the existing GLPI cron UI), or

  • Displays the CLI command for the admin to run manually if cron is not configured

Advantages: fast; scales to any DB size; reliable. Disadvantage: requires mysqldump binary reachable by the CLI PHP process. Not available on all hosting environments.

bin/console sandbox:sync detects whether mysqldump is available and falls back to Strategy 1 automatically if it is not, with a warning in the output.

Strategy 3 — MySQL native replication (future / optional)

Configure the sandbox DB as a MySQL replica of production (using SHOW BINARY LOG STATUS / CHANGE REPLICATION SOURCE TO). GLPI already has monitoring infrastructure for this in DBConnection::getReplicationStatus() and cronCheckDBreplicate.

This only works for the read-only / break-testing use case, because any write to the sandbox DB will break replication. This means:

  • When the user enters sandbox mode and makes writes, replication must be paused.

  • When the user exits sandbox, replication can optionally be re-enabled (losing sandbox changes).

This is complex to manage and probably not worth implementing in v1. It is noted here as a future direction.

Sync scope configuration

The admin can configure which data categories are synced:

Category

Default

Assets & CMDB

Yes

Tickets & ITIL objects

Yes

Users & profiles

Yes

Config (entities, groups, etc.)

Yes

Logs (glpi_logs)

No

Email queue (glpi_queuednotifications)

No

Cron history

No

API tokens

No

Excluding logs and queues significantly reduces sync time and sandbox clutter.


Seeded data for demo instances

For cloud / discovery use cases, the sandbox ships with a seed pack — a static SQL dump that makes GLPI look and feel like a live, mature installation on first load.

Approach: static dump + post-import date shift

The seed pack is a plain mysqldump output, compressed as seed.sql.gz and shipped as a versioned release artifact alongside GLPI. Import is the standard mysql < seed.sql — fast, no PHP involved, no dependency on any framework.

The only problem with a static dump is stale dates: tickets and assets would show creation dates years in the past, making dashboards and timelines look abandoned. This is solved by a small companion script seed_shift_dates.sql that runs immediately after import:

-- The newest date baked into this seed pack.
-- Update this constant each time the dump is regenerated.
SET @anchor = '2025-06-01';
SET @shift = DATEDIFF(CURDATE(), @anchor);

UPDATE glpi_tickets
    SET date      = DATE_ADD(date,      INTERVAL @shift DAY),
        date_mod  = DATE_ADD(date_mod,  INTERVAL @shift DAY),
        closedate = DATE_ADD(closedate, INTERVAL @shift DAY),
        solvedate = DATE_ADD(solvedate, INTERVAL @shift DAY);

UPDATE glpi_itilfollowups
    SET date     = DATE_ADD(date,     INTERVAL @shift DAY),
        date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);

UPDATE glpi_itiltasks
    SET date     = DATE_ADD(date,     INTERVAL @shift DAY),
        date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY),
        begin     = DATE_ADD(begin,    INTERVAL @shift DAY),
        end       = DATE_ADD(end,      INTERVAL @shift DAY);

UPDATE glpi_itilsolutions
    SET date_creation = DATE_ADD(date_creation, INTERVAL @shift DAY),
        date_mod      = DATE_ADD(date_mod,      INTERVAL @shift DAY);

UPDATE glpi_computers SET date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
UPDATE glpi_logs SET date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
-- ( + other itemtypes with meaningful dates )

@shift is computed at runtime as the number of days between the anchor and today. On each import the dump lands perfectly current, regardless of when it was generated. The only maintenance cost is updating @anchor when the dump is regenerated — a one-line change.

This script runs in under a second on the target dataset size.

Why not bake expressions like DATE_SUB(NOW(), INTERVAL 30 DAY) directly into the SQL? — Not valid mysqldump output; requires hand-crafting every INSERT row and breaks standard import tools.

Why not sed/regex preprocessing? — Fragile: risks matching version strings, IDs, or config values that happen to look like dates.


Dataset scale

Category

Count

Notes

Entity hierarchy

12 entities

Root → 3 subsidiaries → 3 departments each

Users

~120

Varied profiles, spread across entities (see below)

Computers

600

Desktops, laptops, servers

Monitors

120

Linked to computers

Network devices

80

Switches, routers, APs

Printers

40

With consumables

Phones / mobile

60


Software & licenses

30 titles, 200 licenses


Tickets (incidents)

350

Full lifecycle, spanning 24 months

Tickets (requests)

200


Problems

25

Linked to related incidents

Changes

15

Pre/post/rollback steps

Knowledge base articles

150

Across categories

Service catalog forms

20


Contracts / suppliers

15 / 10


SLAs

4

2 incident, 2 request

Assignment rules

8

Category + entity based

Dashboards

3

Central, assets, ITIL

Total DB size: ~80–100 MB uncompressed, ~10 MB compressed. Import time: < 5 seconds.


What makes data representative

Volume alone does not make a convincing dataset. The quality constraints matter more than the counts.

Entity hierarchy — not flat. Entities have realistic names (Acme Corp → Paris HQ → IT Department), inherit some config, override others. Every asset and ticket belongs to a specific leaf entity, not all dumped into root. This makes entity filtering and per-entity dashboards non-trivial to explore.

Users — realistic names and emails, meaningful profile/entity distribution:

Profile

Count

Scope

Super-admin

2

Root

Local admin

3

One per subsidiary

Technician

20

Mixed entities

Manager

6

One per subsidiary

Observer

10

Various

Self-service (end user)

~80

Leaf entities

Assets — not homogeneous. Each computer has an assigned user, a location, an OS, a hardware status. ~10% are in a non-nominal state (being repaired, retired, stored). Asset types are distributed so the CMDB dashboard shows a non-trivial breakdown.

Tickets — the most important part for a realistic feel:

  • Dates span 24 months (after the shift) so timeline charts are populated.

  • ~60% closed with a solution, ~25% open/in progress, ~15% pending.

  • Closed tickets have at least one follow-up, one task, and a solution.

  • Priority distribution: ~50% normal, ~30% high, ~15% low, ~5% very high.

  • ~20 tickets linked to a problem, ~10 to a change.

KB articles — use a small hand-written corpus of plausible IT support articles (password reset, VPN setup, printer troubleshooting…) rather than Lorem Ipsum. Realistic article content is what makes the KB feel usable rather than placeholder-filled.


How the dump is produced and maintained

The seed dump is generated once manually by setting up a GLPI instance and filling it with representative data, then running:

mysqldump glpi_seed > seed.sql
gzip seed.sql

It is stored in install/seed/seed.sql.gz alongside install/seed/seed_shift_dates.sql. Both are versioned in the GLPI repository and updated on each major release (schema changes would break the dump anyway).

When a major GLPI version changes the schema, the seed dump must be regenerated. The date-shift script must also be audited for any renamed date columns.


Reset and re-seed

# Import seed data into sandbox DB
php bin/console sandbox:seed

# Wipe sandbox and re-import from scratch
php bin/console sandbox:seed --reset

--reset truncates all GLPI data tables (keeps schema intact), re-imports seed.sql.gz, then runs seed_shift_dates.sql. Table truncation order follows reverse foreign-key dependency; a hardcoded list is acceptable for v1.

A seed marker in glpi_configs (context=sandbox_seed, name=seeded_at) prevents accidental double-import when --reset is not passed.

The reset is also available in Setup > Sandbox as a "Reset to demo data" button with a confirmation dialog.


Complexity estimates

Component

Effort

SeedCommand (import + date shift + reset)

1 d

seed_shift_dates.sql (identify all date columns)

0.5 d

Seed marker + idempotency guard

0.5 d

Admin UI reset button

0.5 d

Infrastructure total

2.5 d

Producing the seed dump (manual GLPI setup + data entry)

3–4 d

Grand total

~6 d

The dominant cost is producing the dump itself — setting up a realistic GLPI instance by hand takes time to get right. The infrastructure to import it and keep dates current is straightforward.


Snapshots (optional, future)

Snapshots allow the admin to save the sandbox state and restore it later. This is valuable for "before/after a risky change" testing.

Implementation sketch:

  • A snapshot is a mysqldump stored in GLPI_VAR_DIR/sandbox/snapshots/<timestamp>.sql.gz

  • sandbox:snapshot:create — dumps sandbox DB to file

  • sandbox:snapshot:restore <id> — replaces sandbox DB from file

  • sandbox:snapshot:list — lists available snapshots with metadata

Snapshots are not in scope for v1 but the architecture (sandbox as a separate DB) makes them trivially implementable later.


Cron / background jobs in sandbox

Cron tasks run outside of user sessions, against the global $DB (production). They must not be affected by sandbox mode. This is guaranteed by the session-only nature of the flag — $_SESSION['glpi_sandbox_active'] is not set in CLI/cron context.

However, some cron tasks (like sending emails for sandbox tickets) would be undesirable. A guard should be added to notification sending: if the sandbox DB is configured and the current request is writing to it, suppress external side effects (emails, webhooks, SMS). This requires DBmysql::isSandbox(): bool returning $this->sandbox ?? false.


Write protection / production safety

The most critical risk is accidentally writing production data while believing you are in sandbox mode. The mitigation:

  1. The sandbox banner is always visible and prominently colored — never subtle.

  2. Session::enterSandbox() logs an audit event (itemtype Sandbox, action enter).

  3. DBSandbox::$sandbox = true flag makes it possible for sensitive operations to assert !$DB->isSandbox() before running destructive changes to "real" data.

  4. The production $DB object is NOT reachable while in sandbox mode — $GLOBALS['DB'] points to DBSandbox for the duration of the request. No accidental fallback.


Summary of new files / changes

File

Change

src/Session.php

Add isSandboxActive(), enterSandbox(), exitSandbox()

src/DBConnection.php

Add isSandboxConfigured(), switchToSandbox(), createSandboxConfig(), deleteSandboxConfig()

src/DBmysql.php

Add public bool $sandbox = false and isSandbox(): bool

src/Html.php

Add is_sandbox_active to getPageHeaderTplVars()

templates/layout/parts/sandbox_banner.html.twig

New file, modelled on impersonate_banner

templates/layout/parts/page_header.html.twig

Include sandbox_banner after impersonate_banner

css/legacy/includes/_styles.scss

Add div.banner-sandbox styles

src/Glpi/Console/Sandbox/

New console commands: SyncCommand, SeedCommand, SnapshotCommand

install/seed/seed.sql.gz

Compressed seed dump, versioned release artifact

install/seed/seed_shift_dates.sql

Post-import date-shift script

front/setup.sandbox.php

Admin configuration UI

templates/pages/setup/sandbox.html.twig

Template for above

GLPI_CONFIG_DIR/config_db_sandbox.php

Generated at setup time


Upgrade dry-run

Upgrading GLPI is the highest-stakes admin operation: a failed migration can corrupt the production database or leave it in a partially-migrated state that is hard to recover from. The sandbox makes it safe by providing a production-data clone to run the upgrade against first.

User story

As a GLPI administrator, before upgrading my production instance from version N to N+1, I want to run the upgrade migrations against a copy of my production data so I can verify that they complete successfully and that none of my plugins break.

How it works

The existing UpdateCommand (php bin/console database:update) reads the global $DB, which is initialized from config_db.php. Adding a --sandbox flag makes the command swap to DBSandbox before running any migration:

php bin/console database:update --sandbox

This is the only code change needed. No new migration infrastructure, no separate command. The flag simply calls DBConnection::switchToSandbox() at the start of UpdateCommand::execute() before any schema work begins.

The command output already includes per-migration status lines. When run against the sandbox, those lines confirm exactly what would run against production.

Suggested workflow surfaced in the UI

When GLPI detects that a newer version has been downloaded and the update is pending (the Update::isDbUpToDate() check), the update screen should display:

Before updating your production database, we recommend running
a dry-run against your sandbox.

  [Run upgrade on sandbox first]     [Upgrade production]

"Run upgrade on sandbox first" triggers a background job (or redirects to a CLI instruction) that:

  1. Syncs sandbox from production (to ensure the test is representative)

  2. Runs database:update --sandbox

  3. Reports success / failure with the migration log

Only after a successful sandbox run does the "Upgrade production" button become the recommended action (though it is not blocked — the admin can skip).

Plugin compatibility check

Plugin upgrades are the most common source of post-update failures. The dry-run should also invoke each active plugin's migration scripts. This is already handled by database:update for plugins that properly implement the GLPI migration interface — the --sandbox flag covers this at no additional cost.

A post-run report should list:

  • Migrations that ran successfully

  • Migrations that failed (with the exception message)

  • Plugins that were active before the run but could not be loaded after (potential incompatibility)

What this does NOT cover

  • Code changes — the dry-run tests DB migrations, not PHP code compatibility. A plugin that works at schema level may fail at runtime with the new version. That requires running functional tests, which is out of scope here.

  • Rollback — the sandbox is disposable. If the dry-run fails, nothing was harmed; the sandbox can be re-synced from production for another attempt. No rollback mechanism is needed.

CLI summary

Command

Effect

php bin/console sandbox:sync

Sync sandbox DB from production

php bin/console database:update --sandbox

Run pending migrations against sandbox DB

php bin/console database:update

Run pending migrations against production DB (unchanged)

The sync + update steps can be chained in a single CLI invocation for automation:

php bin/console sandbox:sync && php bin/console database:update --sandbox

Open questions

  1. Multi-instance cloud: For SaaS where each tenant has their own GLPI, should sandbox be a different DB on the same server or a different server entirely? The spec supports both (host is configurable).

  2. API in sandbox mode: The REST API uses session tokens. Should an API client be able to enter sandbox mode? Probably yes — useful for testing integrations. The entry mechanism would be a dedicated API endpoint POST /api/sandbox/enter + POST /api/sandbox/exit.

  3. Sync frequency: Should sync be schedulable via the cron UI (like other GLPI cron tasks)? Yes — daily sync makes sense for most use cases.