# 👨‍🔧 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](screenshots/user_menu.png)

```twig
{% 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](screenshots/purple_header.png)

`**templates/layout/parts/sandbox_banner.html.twig**`

```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`):

```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()`:

```php
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

```php
// 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:

```sql
-- 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:

```bash
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

```bash
# 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.