Skip to content

Commit c32546a

Browse files
richlundeenCopilot
andauthored
FEAT: Allowing Reinitialize in GUI for admins (#2833)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 961decd commit c32546a

63 files changed

Lines changed: 2274 additions & 176 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.pyrit_conf_example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ operation: op_trash_panda
111111
# Applies only to the pyrit_backend server.
112112
max_concurrent_scenario_runs: 3
113113

114+
# Live Reinitialization
115+
# ---------------------
116+
# Allows administrators to apply saved configuration changes without restarting
117+
# the backend. Enable only when the deployment has exactly one backend process
118+
# and one replica. Known worker and replica settings are checked as an additional
119+
# safeguard. Live apply rejects busy runtimes and requires a restart if
120+
# initialization fails after replacement begins.
121+
enable_live_reinitialization: false
122+
114123
# Custom Initializer Registration (REST API)
115124
# -------------------------------------------
116125
# When true, the REST API accepts POST /api/initializers to register custom

‎doc/code/setup/0_setup.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,31 @@ PyRIT offers flexible configuration through:
2727
- **Custom initializers** for project-specific defaults
2828

2929
See the detailed sections below for comprehensive setup guidance.
30+
31+
## Reinitializing a running backend
32+
33+
The CoPyRIT configuration page separates **Save** from **Reinitialize PyRIT**.
34+
On a single-process, single-replica backend, administrators can apply saved
35+
configuration, environment documents, and custom initializer scripts without
36+
restarting Python. The saved `.pyrit_conf` must set `enable_live_reinitialization: true`.
37+
See [the GUI reinitialization guide](../../gui/0_gui.md) for status, failure behavior, and deployment limits.
38+
39+
The backend first validates the saved sources without changing live setup state. It rejects live apply while any
40+
runtime work is active. When the runtime is idle, it closes admission, checks for work again, and then rebuilds
41+
setup-owned registries and defaults. The replacement reuses the same memory object, which preserves in-memory and
42+
persisted history.
43+
Memory type, connection, or storage changes require a process restart.
44+
Library callers must provide their own admission and idle-runtime boundary
45+
before using this configuration-level reinitialization path.
46+
47+
Reinitialization explicitly replaces process environment values supplied by the
48+
selected sources, including deployment-provided values. Omitted variables remain;
49+
there is no historical tracking or removal. Key Vault selection and `.env.local`
50+
priority still apply, and interpolation uses newly selected values. Ordinary
51+
initialization retains its existing environment precedence. The lower-level
52+
`initialize_pyrit_async(reinitialize=True)` option provides replacement values and
53+
memory reuse; `ConfigurationLoader` owns the complete registry/script rebuild.
54+
55+
Failure after mutation blocks backend runtime operations until a process restart.
56+
Configuration repair stays available under the original authorization policy.
57+
Initializer side effects and environment updates are not rolled back.

‎doc/gui/0_gui.md‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,55 @@ The **Configuration** page provides administrator-only editing for the files and
358358
- **Initializers** shows the read-only startup sequence from the active `.pyrit_conf`, in run order, along with the catalog of registered initializers.
359359
- **Custom Initializers** registers or removes Python initializer scripts. This tab requires `allow_custom_initializers: true`; scripts are stored in the configured local directory or Azure Blob container and must define a concrete `PyRITInitializer` subclass.
360360

361-
Use **Reload** to discard local edits and fetch the latest source content. Saved configuration and environment changes take effect after restarting PyRIT. Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them.
361+
Use **Reload file** to fetch the latest source content; unsaved edits require explicit discard confirmation.
362+
**Save** only persists a source. **Reinitialize PyRIT** separately applies saved configuration, environment sources,
363+
and stored initializer scripts for every user of this backend. Save or discard editor changes first.
364+
Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them.
365+
366+
### Reinitializing without a process restart
367+
368+
Set `enable_live_reinitialization: true` in the saved `.pyrit_conf` to enable this administrator action. This setting
369+
is an explicit operator acknowledgement that the deployment has **one backend process and one replica**.
370+
It is disabled when `WEB_CONCURRENCY`, `UVICORN_WORKERS`, `PYRIT_API_WORKERS`, or `PYRIT_REPLICAS` specifies anything
371+
other than `1`. Do not use it behind a multi-worker server or across multiple replicas; it is not a distributed
372+
configuration update. External scaling settings cannot be discovered from within a process.
373+
374+
Before replacement, PyRIT validates the saved configuration, environment sources, scripts, initializer parameters,
375+
and required environment values. Custom initializer scripts are trusted code. Importing a script or constructing its
376+
initializer can have side effects during validation.
377+
378+
Reinitialization resets setup-owned component registries and recreates backend services. Components created only
379+
through the GUI must be recreated. The same memory object and persisted history are retained, including an in-memory
380+
database. Changing the memory type, Azure SQL connection, or Azure results storage configuration requires a backend
381+
restart and is rejected before replacement.
382+
383+
Live apply does not stop or drain work. It rejects the request if a scenario, preparation, send, estimate, or other
384+
runtime operation is active. Wait for the work to finish, or cancel it with its existing control, and then retry.
385+
When the runtime is idle, PyRIT closes admission and checks again before it changes runtime state. This second check
386+
prevents newly admitted work from overlapping replacement.
387+
388+
Operation status survives a browser disconnect or navigation; other connected clients detect runtime generation
389+
changes and refresh catalogs without discarding chat or configuration drafts.
390+
391+
If validation fails, PyRIT does not change the live runtime. Repair the saved source and retry. If startup fails, or
392+
if live initialization fails after replacement starts, runtime operations stay unavailable until you restart the
393+
backend. The administration UI stays available for configuration repair. Authentication and authorization retain
394+
their process-start settings; reinitialization does not recreate them.
395+
396+
Selected environment assignments replace existing process values, **including deployment-provided values**.
397+
Omitted variables remain unchanged; empty assignments set an empty value. Key Vault source selection and
398+
`.env.local` priority are preserved, and interpolation uses the new selected values. Ordinary library initialization
399+
keeps its existing precedence; replacement is an explicit reinitialization option. There is no rollback of
400+
environment assignments, initializer side effects, memory writes, or external actions if initialization fails.
401+
Listener and authentication settings remain process-start-only. This does not run a process supervisor, restart a
402+
container, or make local source files durable when a container is replaced.
403+
404+
API clients can use administrator-only `GET /api/config/runtime`, `POST /api/config/runtime/apply`
405+
(`version`). Obtain the configuration version and opt-in state from `GET /api/config`. A newly admitted operation
406+
returns HTTP 202 and is tracked in status. Outcomes distinguish busy, unsupported, version-conflict,
407+
invalid-configuration, and restart-required. Authenticated non-admin clients can read readiness and generation only
408+
at `GET /api/runtime`.
409+
`GET /api/health` reports server responsiveness, not runtime readiness.
362410

363411
---
364412

‎frontend/e2e/_runtime.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { RuntimeReadiness, RuntimeStatus } from '../src/types'
2+
3+
export const READY_RUNTIME: RuntimeReadiness = {
4+
ready: true,
5+
state: 'ready',
6+
generation: 'e2e-runtime',
7+
}
8+
9+
export const READY_RUNTIME_STATUS: RuntimeStatus = {
10+
state: 'ready',
11+
generation: READY_RUNTIME.generation,
12+
version: 'e2e-config',
13+
enabled: true,
14+
applying: false,
15+
outcome: 'success',
16+
message: 'PyRIT is ready.',
17+
}

‎frontend/e2e/numeric-controls.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from '@playwright/test'
22

33
import type { RegisteredScenario } from '../src/types'
44

5+
import { READY_RUNTIME } from './_runtime'
56
import { makeTarget } from './_targets'
67

78
const SCENARIO: RegisteredScenario = {
@@ -40,6 +41,7 @@ async function mockNumericControlApis(page: Page): Promise<void> {
4041
'/api/auth/config': { clientId: '', tenantId: '', allowedGroupIds: '' },
4142
'/api/auth/access': { isAdmin: true },
4243
'/api/health': { status: 'healthy' },
44+
'/api/runtime': READY_RUNTIME,
4345
'/api/version': { version: '1.2.0', display: 'Mock PyRIT', default_labels: { operator: 'test' } },
4446
'/api/targets': {
4547
items: [makeTarget({ target_registry_name: 'mock-target' })],

‎frontend/e2e/themes.spec.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { expect, test } from '@playwright/test'
22
import type { Page, Route, TestInfo } from '@playwright/test'
33

4+
import { READY_RUNTIME, READY_RUNTIME_STATUS } from './_runtime'
5+
46
interface PresetCase {
57
readonly id: string
68
readonly label: string
@@ -29,6 +31,7 @@ const API_RESPONSES: Record<string, unknown> = {
2931
'/api/auth/config': { clientId: '', tenantId: '', allowedGroupIds: '' },
3032
'/api/auth/access': { isAdmin: true },
3133
'/api/health': { status: 'healthy' },
34+
'/api/runtime': READY_RUNTIME,
3235
'/api/version': { version: 'theme-preview', display: 'theme-preview' },
3336
'/api/targets': EMPTY_PAGE,
3437
'/api/targets/catalog': { items: [] },
@@ -47,6 +50,7 @@ const API_RESPONSES: Record<string, unknown> = {
4750
queued: [],
4851
},
4952
'/api/config': { content: 'initializers: []\n', source: 'theme-preview', version: '1' },
53+
'/api/config/runtime': READY_RUNTIME_STATUS,
5054
'/api/config/env-files': { items: [] },
5155
'/api/initializers': EMPTY_PAGE,
5256
'/api/initializers/custom': { items: [] },

‎frontend/e2e/touch-targets.spec.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, type Locator, type Page } from "@playwright/test";
22
import { makeAddMessageResponse } from "./_attacks";
3+
import { READY_RUNTIME, READY_RUNTIME_STATUS } from "./_runtime";
34
import { makeTarget } from "./_targets";
45

56
const MOBILE_VIEWPORT = { width: 390, height: 844 };
@@ -143,6 +144,10 @@ async function installTouchTargetMocks(page: Page): Promise<void> {
143144
await route.fulfill(jsonResponse({ isAdmin: true }));
144145
return;
145146
}
147+
if (apiPath === "/runtime") {
148+
await route.fulfill(jsonResponse(READY_RUNTIME));
149+
return;
150+
}
146151
if (apiPath === "/version") {
147152
await route.fulfill(
148153
jsonResponse({
@@ -179,6 +184,10 @@ async function installTouchTargetMocks(page: Page): Promise<void> {
179184
);
180185
return;
181186
}
187+
if (apiPath === "/config/runtime" && method === "GET") {
188+
await route.fulfill(jsonResponse(READY_RUNTIME_STATUS));
189+
return;
190+
}
182191
if (apiPath === "/initializers/settings" && method === "GET") {
183192
await route.fulfill(
184193
jsonResponse({

‎frontend/src/App.labels.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ jest.mock('@/utils/conversationExport', () => ({
3535
}))
3636

3737
jest.mock('@/services/api', () => ({
38+
runtimeApi: {
39+
getReadiness: jest.fn().mockResolvedValue({ ready: true, state: 'ready', generation: '' }),
40+
},
3841
authApi: { getAccess: jest.fn().mockResolvedValue({ isAdmin: false }) },
3942
versionApi: { getVersion: jest.fn() },
4043
labelsApi: { getLabels: jest.fn() },

‎frontend/src/App.targets.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jest.mock('@/components/Layout/MainLayout', () => {
3939
}
4040
})
4141
jest.mock('@/services/api', () => ({
42+
runtimeApi: {
43+
getReadiness: jest.fn().mockResolvedValue({ ready: true, state: 'ready', generation: '' }),
44+
},
4245
attacksApi: {
4346
getAttack: jest.fn(),
4447
getMessages: jest.fn(),

‎frontend/src/App.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ jest.mock("@azure/msal-react", () => ({
7070
}));
7171

7272
jest.mock("./services/api", () => ({
73+
runtimeApi: {
74+
getReadiness: jest.fn().mockResolvedValue({ ready: true, state: 'ready', generation: '' }),
75+
},
7376
attacksApi: {
7477
getAttack: jest.fn(),
7578
listAttacks: jest.fn(),

0 commit comments

Comments
 (0)