29
.playwright-mcp/console-2026-03-15T17-34-15-943Z.log
Normal file
29
.playwright-mcp/console-2026-03-15T17-34-15-943Z.log
Normal file
@@ -0,0 +1,29 @@
|
||||
[ 4219ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 73166ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 74210ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 76255ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 80299ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 121662ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 122707ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 124752ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 132300ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 133345ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 135391ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 163462ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 165512ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 169559ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 206249ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 208295ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 229228ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 231273ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 240041ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 242090ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 246135ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 254184ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 270230ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 300275ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 335152ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 337223ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 341292ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 349341ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 365388ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
1
.playwright-mcp/console-2026-03-15T17-40-47-271Z.log
Normal file
1
.playwright-mcp/console-2026-03-15T17-40-47-271Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 80ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example:0
|
||||
70
.playwright-mcp/console-2026-03-15T17-40-57-477Z.log
Normal file
70
.playwright-mcp/console-2026-03-15T17-40-57-477Z.log
Normal file
@@ -0,0 +1,70 @@
|
||||
[ 28066ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 29193ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 31313ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 36650ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 38721ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 180246ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 181291ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 183336ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 187382ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 195427ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 211472ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 308815ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 309884ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 311956ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 316026ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 324096ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 400986ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 411491ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 445156ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 649304ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 659749ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 675977ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 678046ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 754725ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 755871ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 757999ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 763330ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 765397ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 978143ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 980214ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 984281ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1080173ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1082246ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1086330ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1121684ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1123773ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1141045ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1143115ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1147188ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1155258ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1502223ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1504292ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1513751ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1515821ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1519891ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1527937ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1543982ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1574052ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1654209ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1655305ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1657399ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1661443ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1775829ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1776875ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1778919ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1783494ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1784539ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1786584ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1821582ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1822627ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1824676ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1861759ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1864040ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1865688ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1867222ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1868813ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1870286ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1871945ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1873590ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 1875131ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
7
.playwright-mcp/console-2026-03-15T18-28-02-722Z.log
Normal file
7
.playwright-mcp/console-2026-03-15T18-28-02-722Z.log
Normal file
@@ -0,0 +1,7 @@
|
||||
[ 8921ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 93203ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 94247ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 96291ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 124526ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 126598ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 130667ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
6
.playwright-mcp/console-2026-03-15T18-30-49-631Z.log
Normal file
6
.playwright-mcp/console-2026-03-15T18-30-49-631Z.log
Normal file
@@ -0,0 +1,6 @@
|
||||
[ 9418ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 10488ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 12557ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 16626ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 24672ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 40718ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
|
||||
1
.playwright-mcp/console-2026-03-15T18-31-57-555Z.log
Normal file
1
.playwright-mcp/console-2026-03-15T18-31-57-555Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 53ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ chrome-error://chromewebdata/:0
|
||||
1
.playwright-mcp/console-2026-03-15T18-32-21-150Z.log
Normal file
1
.playwright-mcp/console-2026-03-15T18-32-21-150Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 55ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example:0
|
||||
@@ -90,6 +90,7 @@ Follow the VSDD pipeline **religiously**. No shortcuts, no skipping phases.
|
||||
- Routes are organized by feature in `routes/` modules
|
||||
- All public API endpoints return proper HTTP status codes
|
||||
- Configuration via environment variables with sensible defaults
|
||||
- **Forms with conditional sections**: When a form has multiple sections toggled by a dropdown (e.g. policy type), inputs in hidden sections **must be disabled** so they are excluded from submission. Duplicate `name` attributes across sections cause axum's form deserializer to fail with "unsupported value". Always call the toggle function on page load to disable hidden inputs from the start.
|
||||
- **Tests live in separate files**, never inline in the main source file:
|
||||
- Unit tests for private functions: `#[cfg(test)] mod tests` in the same file (e.g., `forest_client.rs`)
|
||||
- Route/integration tests: `src/tests/` directory with files per feature area (e.g., `auth_tests.rs`, `platform_tests.rs`)
|
||||
|
||||
@@ -247,6 +247,10 @@ pub enum PolicyConfig {
|
||||
target_environment: String,
|
||||
branch_pattern: String,
|
||||
},
|
||||
Approval {
|
||||
target_environment: String,
|
||||
required_approvals: i32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -267,6 +271,24 @@ pub struct PolicyEvaluation {
|
||||
pub policy_type: String,
|
||||
pub passed: bool,
|
||||
pub reason: String,
|
||||
#[serde(default)]
|
||||
pub approval_state: Option<ApprovalState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApprovalState {
|
||||
pub required_approvals: i32,
|
||||
pub current_approvals: i32,
|
||||
pub decisions: Vec<ApprovalDecisionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApprovalDecisionEntry {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub decision: String,
|
||||
pub decided_at: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -567,6 +589,45 @@ pub trait ForestPlatform: Send + Sync {
|
||||
channel: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), PlatformError>;
|
||||
|
||||
async fn evaluate_policies(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
target_environment: &str,
|
||||
release_intent_id: Option<&str>,
|
||||
) -> Result<Vec<PolicyEvaluation>, PlatformError>;
|
||||
|
||||
async fn approve_release(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
comment: Option<&str>,
|
||||
force_bypass: bool,
|
||||
) -> Result<ApprovalState, PlatformError>;
|
||||
|
||||
async fn reject_release(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
comment: Option<&str>,
|
||||
) -> Result<ApprovalState, PlatformError>;
|
||||
|
||||
async fn get_approval_state(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
) -> Result<ApprovalState, PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -108,14 +108,17 @@ impl FromRequestParts<AppState> for Session {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Backfill: if we have a user but empty orgs, try to fetch them.
|
||||
// This handles the case where list_my_organisations failed during login.
|
||||
let needs_org_backfill = session_data
|
||||
// Refresh orgs if they're empty OR if the session hasn't been seen
|
||||
// for a while (e.g. after server restart, PG session loaded with stale orgs).
|
||||
let now = chrono::Utc::now();
|
||||
let orgs_empty = session_data
|
||||
.user
|
||||
.as_ref()
|
||||
.is_some_and(|u| u.orgs.is_empty());
|
||||
let orgs_stale = now - session_data.last_seen_at > chrono::Duration::minutes(5);
|
||||
let needs_org_refresh = orgs_empty || orgs_stale;
|
||||
|
||||
if needs_org_backfill {
|
||||
if needs_org_refresh {
|
||||
if let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
@@ -126,7 +129,8 @@ impl FromRequestParts<AppState> for Session {
|
||||
tracing::info!(
|
||||
user_id = %user.user_id,
|
||||
org_count = orgs.len(),
|
||||
"backfilled empty org list"
|
||||
was_empty = orgs_empty,
|
||||
"refreshed org list"
|
||||
);
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
|
||||
@@ -3,11 +3,12 @@ use forage_core::auth::{
|
||||
UserProfile,
|
||||
};
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput,
|
||||
CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment,
|
||||
ForestPlatform, NotificationPreference, Organisation, OrgMember, PipelineStage,
|
||||
PipelineStageConfig, PlatformError, Policy, PolicyConfig, ReleasePipeline, Trigger,
|
||||
UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput,
|
||||
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
|
||||
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
|
||||
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
|
||||
OrgMember, PipelineStage, PipelineStageConfig, PlatformError, Policy, PolicyConfig,
|
||||
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
|
||||
UpdateTriggerInput,
|
||||
};
|
||||
use forage_grpc::policy_service_client::PolicyServiceClient;
|
||||
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
|
||||
@@ -582,6 +583,9 @@ fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
|
||||
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
|
||||
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
|
||||
}
|
||||
Some(forage_grpc::pipeline_stage::Config::Plan(_)) => {
|
||||
PipelineStageConfig::Deploy { environment: String::new() }
|
||||
}
|
||||
None => PipelineStageConfig::Deploy { environment: String::new() },
|
||||
};
|
||||
PipelineStage {
|
||||
@@ -698,6 +702,7 @@ fn convert_policy(p: forage_grpc::Policy) -> Policy {
|
||||
let policy_type_str = match forage_grpc::PolicyType::try_from(p.policy_type) {
|
||||
Ok(forage_grpc::PolicyType::SoakTime) => "soak_time",
|
||||
Ok(forage_grpc::PolicyType::BranchRestriction) => "branch_restriction",
|
||||
Ok(forage_grpc::PolicyType::ExternalApproval) => "approval",
|
||||
_ => "unknown",
|
||||
};
|
||||
let config = match p.config {
|
||||
@@ -712,6 +717,10 @@ fn convert_policy(p: forage_grpc::Policy) -> Policy {
|
||||
branch_pattern: c.branch_pattern,
|
||||
}
|
||||
}
|
||||
Some(forage_grpc::policy::Config::ExternalApproval(c)) => PolicyConfig::Approval {
|
||||
target_environment: c.target_environment,
|
||||
required_approvals: c.required_approvals,
|
||||
},
|
||||
None => PolicyConfig::SoakTime {
|
||||
source_environment: String::new(),
|
||||
target_environment: String::new(),
|
||||
@@ -761,6 +770,20 @@ fn policy_config_to_grpc(
|
||||
),
|
||||
),
|
||||
),
|
||||
PolicyConfig::Approval {
|
||||
target_environment,
|
||||
required_approvals,
|
||||
} => (
|
||||
forage_grpc::PolicyType::ExternalApproval as i32,
|
||||
Some(
|
||||
forage_grpc::create_policy_request::Config::ExternalApproval(
|
||||
forage_grpc::ExternalApprovalConfig {
|
||||
target_environment: target_environment.clone(),
|
||||
required_approvals: *required_approvals,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,8 +798,9 @@ fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
|
||||
|
||||
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
|
||||
PlatformError::NotAuthenticated
|
||||
tonic::Code::Unauthenticated => PlatformError::NotAuthenticated,
|
||||
tonic::Code::PermissionDenied => {
|
||||
PlatformError::Other(status.message().into())
|
||||
}
|
||||
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
|
||||
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
|
||||
@@ -1270,6 +1294,7 @@ impl ForestPlatform for GrpcForestClient {
|
||||
environments: environments.to_vec(),
|
||||
force: false,
|
||||
use_pipeline,
|
||||
prepare_only: false,
|
||||
},
|
||||
)
|
||||
.map_err(|e| PlatformError::Other(e.to_string()))?;
|
||||
@@ -1481,6 +1506,9 @@ impl ForestPlatform for GrpcForestClient {
|
||||
Some(forage_grpc::create_policy_request::Config::BranchRestriction(b)) => {
|
||||
forage_grpc::update_policy_request::Config::BranchRestriction(b)
|
||||
}
|
||||
Some(forage_grpc::create_policy_request::Config::ExternalApproval(a)) => {
|
||||
forage_grpc::update_policy_request::Config::ExternalApproval(a)
|
||||
}
|
||||
None => forage_grpc::update_policy_request::Config::SoakTime(
|
||||
forage_grpc::SoakTimeConfig::default(),
|
||||
),
|
||||
@@ -1724,6 +1752,168 @@ impl ForestPlatform for GrpcForestClient {
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn evaluate_policies(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
target_environment: &str,
|
||||
release_intent_id: Option<&str>,
|
||||
) -> Result<Vec<PolicyEvaluation>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::EvaluatePoliciesRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
target_environment: target_environment.into(),
|
||||
branch: None,
|
||||
release_intent_id: release_intent_id.map(|s| s.to_string()),
|
||||
},
|
||||
)?;
|
||||
let resp = self
|
||||
.policy_client()
|
||||
.evaluate_policies(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(resp
|
||||
.into_inner()
|
||||
.evaluations
|
||||
.into_iter()
|
||||
.map(convert_policy_evaluation)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn approve_release(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
comment: Option<&str>,
|
||||
force_bypass: bool,
|
||||
) -> Result<ApprovalState, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ExternalApproveReleaseRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
release_intent_id: release_intent_id.into(),
|
||||
target_environment: target_environment.into(),
|
||||
comment: comment.map(|s| s.to_string()),
|
||||
force_bypass,
|
||||
},
|
||||
)?;
|
||||
let resp = self
|
||||
.policy_client()
|
||||
.external_approve_release(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(convert_approval_state(resp.into_inner().state))
|
||||
}
|
||||
|
||||
async fn reject_release(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
comment: Option<&str>,
|
||||
) -> Result<ApprovalState, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ExternalRejectReleaseRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
release_intent_id: release_intent_id.into(),
|
||||
target_environment: target_environment.into(),
|
||||
comment: comment.map(|s| s.to_string()),
|
||||
},
|
||||
)?;
|
||||
let resp = self
|
||||
.policy_client()
|
||||
.external_reject_release(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(convert_approval_state(resp.into_inner().state))
|
||||
}
|
||||
|
||||
async fn get_approval_state(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
target_environment: &str,
|
||||
) -> Result<ApprovalState, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetExternalApprovalStateRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
release_intent_id: release_intent_id.into(),
|
||||
target_environment: target_environment.into(),
|
||||
},
|
||||
)?;
|
||||
let resp = self
|
||||
.policy_client()
|
||||
.get_external_approval_state(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(convert_approval_state(resp.into_inner().state))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluation {
|
||||
let policy_type = match e.policy_type {
|
||||
1 => "soak_time",
|
||||
2 => "branch_restriction",
|
||||
3 => "approval",
|
||||
_ => "unknown",
|
||||
};
|
||||
let approval_state = e.approval_state.map(|s| convert_approval_state(Some(s)));
|
||||
PolicyEvaluation {
|
||||
policy_name: e.policy_name,
|
||||
policy_type: policy_type.into(),
|
||||
passed: e.passed,
|
||||
reason: e.reason,
|
||||
approval_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_approval_state(state: Option<forage_grpc::ExternalApprovalState>) -> ApprovalState {
|
||||
match state {
|
||||
Some(s) => ApprovalState {
|
||||
required_approvals: s.required_approvals,
|
||||
current_approvals: s.current_approvals,
|
||||
decisions: s
|
||||
.decisions
|
||||
.into_iter()
|
||||
.map(|d| ApprovalDecisionEntry {
|
||||
user_id: d.user_id,
|
||||
username: d.username,
|
||||
decision: d.decision,
|
||||
decided_at: d.decided_at,
|
||||
comment: d.comment,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
None => ApprovalState {
|
||||
required_approvals: 0,
|
||||
current_approvals: 0,
|
||||
decisions: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -100,6 +100,14 @@ pub fn router() -> Router<AppState> {
|
||||
"/orgs/{org}/projects/{project}/policies/{name}/delete",
|
||||
post(delete_policy),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/projects/{project}/releases/{slug}/approve",
|
||||
post(approve_release_submit),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/projects/{project}/releases/{slug}/reject",
|
||||
post(reject_release_submit),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/projects/{project}/pipelines",
|
||||
get(pipelines_page).post(create_pipeline_submit),
|
||||
@@ -682,7 +690,8 @@ async fn project_detail(
|
||||
Path((org, project)): Path<(String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
let current_role = current_org.role.clone();
|
||||
|
||||
if !validate_slug(&project) {
|
||||
return Err(error_page(
|
||||
@@ -767,6 +776,7 @@ async fn project_detail(
|
||||
org_name => &org,
|
||||
project_name => &project,
|
||||
projects => projects,
|
||||
current_role => ¤t_role,
|
||||
active_tab => "project_overview",
|
||||
timeline => data.timeline,
|
||||
lanes => data.lanes,
|
||||
@@ -968,6 +978,74 @@ async fn artifact_detail(
|
||||
|
||||
let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;
|
||||
|
||||
// Fetch policy evaluations for active release intents.
|
||||
let mut policy_evaluations: Vec<minijinja::Value> = Vec::new();
|
||||
let mut release_intent_id_str = String::new();
|
||||
let is_release_author = false;
|
||||
for ri in &release_intents {
|
||||
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
|
||||
release_intent_id_str = ri.release_intent_id.clone();
|
||||
// Collect unique environments from the pipeline stages.
|
||||
let environments: Vec<String> = ri
|
||||
.stages
|
||||
.iter()
|
||||
.filter_map(|s| s.environment.clone())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for env in &environments {
|
||||
if let Ok(evals) = state
|
||||
.platform_client
|
||||
.evaluate_policies(
|
||||
&session.access_token,
|
||||
&org,
|
||||
&project,
|
||||
env,
|
||||
Some(&ri.release_intent_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
for eval in evals {
|
||||
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
|
||||
let decisions: Vec<minijinja::Value> = s
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|d| {
|
||||
context! {
|
||||
username => d.username,
|
||||
decision => d.decision,
|
||||
comment => d.comment,
|
||||
decided_at => d.decided_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
context! {
|
||||
required_approvals => s.required_approvals,
|
||||
current_approvals => s.current_approvals,
|
||||
decisions => decisions,
|
||||
}
|
||||
});
|
||||
policy_evaluations.push(context! {
|
||||
policy_name => eval.policy_name,
|
||||
policy_type => eval.policy_type,
|
||||
passed => eval.passed,
|
||||
reason => eval.reason,
|
||||
target_environment => env,
|
||||
approval_state => approval_state_ctx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Only one active intent per artifact.
|
||||
}
|
||||
}
|
||||
|
||||
let current_org_entry = orgs.iter().find(|o| o.name == org);
|
||||
let is_admin = current_org_entry
|
||||
.map(|o| o.role == "owner" || o.role == "admin")
|
||||
.unwrap_or(false);
|
||||
|
||||
// Build env groups.
|
||||
let env_groups = build_env_groups(&matching_states);
|
||||
|
||||
@@ -1045,6 +1123,10 @@ async fn artifact_detail(
|
||||
}).collect::<Vec<_>>(),
|
||||
has_release_intents => release_intents.iter().any(|ri| ri.artifact_id == artifact.artifact_id),
|
||||
artifact_spec => if artifact_spec.is_empty() { None::<String> } else { Some(artifact_spec) },
|
||||
policy_evaluations => policy_evaluations,
|
||||
release_intent_id => &release_intent_id_str,
|
||||
is_release_author => is_release_author,
|
||||
is_admin => is_admin,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -1965,6 +2047,8 @@ pub enum ApiTimelineItem {
|
||||
pub struct ApiRelease {
|
||||
pub artifact_id: String,
|
||||
pub slug: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release_intent_id: Option<String>,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub web: Option<String>,
|
||||
@@ -2016,6 +2100,8 @@ pub struct ApiPipelineStage {
|
||||
pub completed_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub wait_until: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -2033,6 +2119,7 @@ fn build_timeline_json(
|
||||
deployment_states: &forage_core::platform::DeploymentStates,
|
||||
release_intents: &[forage_core::platform::ReleaseIntentState],
|
||||
pipelines_by_project: &PipelinesByProject,
|
||||
approval_envs: &[String],
|
||||
) -> ApiTimelineResponse {
|
||||
// Index destination states by artifact_id.
|
||||
let mut states_by_artifact: std::collections::HashMap<
|
||||
@@ -2045,14 +2132,17 @@ fn build_timeline_json(
|
||||
}
|
||||
}
|
||||
|
||||
// Index pipeline run stages by artifact_id.
|
||||
// Index pipeline run stages and intent IDs by artifact_id.
|
||||
let mut intent_stages_by_artifact: std::collections::HashMap<
|
||||
&str,
|
||||
&[forage_core::platform::PipelineRunStageState],
|
||||
> = std::collections::HashMap::new();
|
||||
let mut intent_id_by_artifact: std::collections::HashMap<&str, &str> =
|
||||
std::collections::HashMap::new();
|
||||
for ri in release_intents {
|
||||
if !ri.stages.is_empty() {
|
||||
intent_stages_by_artifact.insert(ri.artifact_id.as_str(), &ri.stages);
|
||||
intent_id_by_artifact.insert(ri.artifact_id.as_str(), ri.release_intent_id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2189,6 +2279,14 @@ fn build_timeline_json(
|
||||
} else {
|
||||
rs.status.clone()
|
||||
};
|
||||
let blocked_by = if display_status == "PENDING"
|
||||
&& rs.stage_type == "deploy"
|
||||
&& rs.environment.as_deref().map(|e| approval_envs.iter().any(|a| a == e)).unwrap_or(false)
|
||||
{
|
||||
Some("Awaiting approval".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
stages.push(ApiPipelineStage {
|
||||
id: rs.stage_id.clone(),
|
||||
stage_type: rs.stage_type.clone(),
|
||||
@@ -2199,6 +2297,7 @@ fn build_timeline_json(
|
||||
completed_at: rs.completed_at.clone(),
|
||||
error_message: rs.error_message.clone(),
|
||||
wait_until: rs.wait_until.clone(),
|
||||
blocked_by,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2238,6 +2337,9 @@ fn build_timeline_json(
|
||||
|
||||
raw_releases.push(RawRelease {
|
||||
release: ApiRelease {
|
||||
release_intent_id: intent_id_by_artifact
|
||||
.get(artifact.artifact_id.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
artifact_id: artifact.artifact_id,
|
||||
slug: artifact.slug,
|
||||
title: artifact.context.title,
|
||||
@@ -2309,7 +2411,8 @@ fn build_timeline_json(
|
||||
let mut seen_deployed = false;
|
||||
|
||||
for raw in raw_releases {
|
||||
if raw.has_dests {
|
||||
let needs_action = raw.release.pipeline_stages.iter().any(|s| s.blocked_by.is_some());
|
||||
if raw.has_dests || needs_action {
|
||||
if !hidden_buf.is_empty() {
|
||||
let count = hidden_buf.len();
|
||||
timeline.push(ApiTimelineItem::Hidden {
|
||||
@@ -2317,7 +2420,9 @@ fn build_timeline_json(
|
||||
releases: std::mem::take(&mut hidden_buf),
|
||||
});
|
||||
}
|
||||
seen_deployed = true;
|
||||
if raw.has_dests {
|
||||
seen_deployed = true;
|
||||
}
|
||||
timeline.push(ApiTimelineItem::Release {
|
||||
release: Box::new(raw.release),
|
||||
});
|
||||
@@ -2358,7 +2463,7 @@ async fn timeline_api(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (artifacts, environments, dest_states, release_intents, project_pipelines) = tokio::join!(
|
||||
let (artifacts, environments, dest_states, release_intents, project_pipelines, policies) = tokio::join!(
|
||||
state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org, &project),
|
||||
@@ -2374,6 +2479,9 @@ async fn timeline_api(
|
||||
state
|
||||
.platform_client
|
||||
.list_release_pipelines(&session.access_token, &org, &project),
|
||||
state
|
||||
.platform_client
|
||||
.list_policies(&session.access_token, &org, &project),
|
||||
);
|
||||
let artifacts = artifacts.map_err(|e| {
|
||||
tracing::error!("timeline_api list_artifacts: {e:#}");
|
||||
@@ -2401,7 +2509,18 @@ async fn timeline_api(
|
||||
pipelines_map.insert(project.clone(), project_pipelines);
|
||||
}
|
||||
|
||||
let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map);
|
||||
let policies = warn_default("list_policies", policies);
|
||||
|
||||
let approval_envs: Vec<String> = policies
|
||||
.iter()
|
||||
.filter(|p| p.enabled && p.policy_type == "approval")
|
||||
.filter_map(|p| match &p.config {
|
||||
PolicyConfig::Approval { target_environment, .. } => Some(target_environment.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map, &approval_envs);
|
||||
|
||||
Ok(Json(data).into_response())
|
||||
}
|
||||
@@ -2463,6 +2582,7 @@ async fn org_timeline_api(
|
||||
&dest_states,
|
||||
&release_intents,
|
||||
&pipelines_by_project,
|
||||
&[], // org timeline doesn't have per-project policy context
|
||||
);
|
||||
|
||||
Ok(Json(data).into_response())
|
||||
@@ -3710,6 +3830,16 @@ async fn policies_page(
|
||||
branch_pattern => branch_pattern,
|
||||
},
|
||||
),
|
||||
PolicyConfig::Approval {
|
||||
target_environment,
|
||||
required_approvals,
|
||||
} => (
|
||||
"approval",
|
||||
context! {
|
||||
target_environment => target_environment,
|
||||
required_approvals => required_approvals,
|
||||
},
|
||||
),
|
||||
};
|
||||
context! {
|
||||
id => p.id,
|
||||
@@ -3810,6 +3940,9 @@ struct CreatePolicyForm {
|
||||
// BranchRestriction fields
|
||||
#[serde(default)]
|
||||
branch_pattern: String,
|
||||
// Approval fields
|
||||
#[serde(default)]
|
||||
required_approvals: Option<i32>,
|
||||
}
|
||||
|
||||
async fn create_policy_submit(
|
||||
@@ -3875,12 +4008,28 @@ async fn create_policy_submit(
|
||||
branch_pattern: pattern.to_string(),
|
||||
}
|
||||
}
|
||||
"approval" => {
|
||||
let target = form.target_environment.trim();
|
||||
let required = form.required_approvals.unwrap_or(1);
|
||||
if target.is_empty() || required < 1 {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Approval requires a target environment and at least 1 required approval.",
|
||||
));
|
||||
}
|
||||
PolicyConfig::Approval {
|
||||
target_environment: target.to_string(),
|
||||
required_approvals: required,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Invalid policy type. Must be 'soak_time' or 'branch_restriction'.",
|
||||
"Invalid policy type.",
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -4042,6 +4191,16 @@ async fn edit_policy_page(
|
||||
branch_pattern => branch_pattern,
|
||||
},
|
||||
),
|
||||
PolicyConfig::Approval {
|
||||
target_environment,
|
||||
required_approvals,
|
||||
} => (
|
||||
"approval",
|
||||
context! {
|
||||
target_environment => target_environment,
|
||||
required_approvals => required_approvals,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
let policy_ctx = context! {
|
||||
@@ -4464,3 +4623,138 @@ fn non_empty(s: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approval routes ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalForm {
|
||||
csrf_token: String,
|
||||
#[serde(default)]
|
||||
release_intent_id: String,
|
||||
#[serde(default)]
|
||||
target_environment: String,
|
||||
#[serde(default)]
|
||||
comment: String,
|
||||
#[serde(default)]
|
||||
force_bypass: Option<String>,
|
||||
}
|
||||
|
||||
fn approval_error(
|
||||
state: &AppState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
status: StatusCode,
|
||||
message: &str,
|
||||
) -> Response {
|
||||
let wants_json = headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.contains("application/json"));
|
||||
|
||||
if wants_json {
|
||||
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||
} else {
|
||||
error_page(state, status, "Approval failed", message)
|
||||
}
|
||||
}
|
||||
|
||||
async fn approve_release_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path((org, project, slug)): Path<(String, String, String)>,
|
||||
Form(form): Form<ApprovalForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
if form.csrf_token != session.csrf_token {
|
||||
return Err(approval_error(
|
||||
&state,
|
||||
&headers,
|
||||
StatusCode::FORBIDDEN,
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
let force_bypass = form.force_bypass.as_deref() == Some("true");
|
||||
let comment = non_empty(&form.comment);
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.approve_release(
|
||||
&session.access_token,
|
||||
&org,
|
||||
&project,
|
||||
&form.release_intent_id,
|
||||
&form.target_environment,
|
||||
comment.as_deref(),
|
||||
force_bypass,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
forage_core::platform::PlatformError::NotAuthenticated => {
|
||||
axum::response::Redirect::to("/login").into_response()
|
||||
}
|
||||
other => approval_error(
|
||||
&state,
|
||||
&headers,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{other}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"/orgs/{org}/projects/{project}/releases/{slug}"
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn reject_release_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path((org, project, slug)): Path<(String, String, String)>,
|
||||
Form(form): Form<ApprovalForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
if form.csrf_token != session.csrf_token {
|
||||
return Err(approval_error(
|
||||
&state,
|
||||
&headers,
|
||||
StatusCode::FORBIDDEN,
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
let comment = non_empty(&form.comment);
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.reject_release(
|
||||
&session.access_token,
|
||||
&org,
|
||||
&project,
|
||||
&form.release_intent_id,
|
||||
&form.target_environment,
|
||||
comment.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
forage_core::platform::PlatformError::NotAuthenticated => {
|
||||
axum::response::Redirect::to("/login").into_response()
|
||||
}
|
||||
other => approval_error(
|
||||
&state,
|
||||
&headers,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{other}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"/orgs/{org}/projects/{project}/releases/{slug}"
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
|
||||
@@ -714,6 +714,65 @@ impl ForestPlatform for MockPlatformClient {
|
||||
.clone()
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
async fn evaluate_policies(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
_project: &str,
|
||||
_target_environment: &str,
|
||||
_release_intent_id: Option<&str>,
|
||||
) -> Result<Vec<forage_core::platform::PolicyEvaluation>, PlatformError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn approve_release(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
_project: &str,
|
||||
_release_intent_id: &str,
|
||||
_target_environment: &str,
|
||||
_comment: Option<&str>,
|
||||
_force_bypass: bool,
|
||||
) -> Result<forage_core::platform::ApprovalState, PlatformError> {
|
||||
Ok(forage_core::platform::ApprovalState {
|
||||
required_approvals: 1,
|
||||
current_approvals: 1,
|
||||
decisions: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn reject_release(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
_project: &str,
|
||||
_release_intent_id: &str,
|
||||
_target_environment: &str,
|
||||
_comment: Option<&str>,
|
||||
) -> Result<forage_core::platform::ApprovalState, PlatformError> {
|
||||
Ok(forage_core::platform::ApprovalState {
|
||||
required_approvals: 1,
|
||||
current_approvals: 0,
|
||||
decisions: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_approval_state(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
_project: &str,
|
||||
_release_intent_id: &str,
|
||||
_target_environment: &str,
|
||||
) -> Result<forage_core::platform::ApprovalState, PlatformError> {
|
||||
Ok(forage_core::platform::ApprovalState {
|
||||
required_approvals: 1,
|
||||
current_approvals: 0,
|
||||
decisions: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_templates() -> TemplateEngine {
|
||||
|
||||
BIN
dashboard-after-fix.png
Normal file
BIN
dashboard-after-fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -9,6 +9,9 @@
|
||||
// Props from attributes
|
||||
export let org = "";
|
||||
export let project = "";
|
||||
export let csrf = "";
|
||||
export let username = "";
|
||||
export let role = "";
|
||||
|
||||
// Reactive state
|
||||
let timeline = [];
|
||||
@@ -29,6 +32,70 @@
|
||||
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
|
||||
const DEPLOYED = new Set(["SUCCEEDED"]);
|
||||
|
||||
// ── Approval action ──────────────────────────────────────────────
|
||||
|
||||
let approving = new Set();
|
||||
let approvalError = null;
|
||||
|
||||
function isAdmin() {
|
||||
return role === "owner" || role === "admin";
|
||||
}
|
||||
|
||||
function isAuthor(release) {
|
||||
return username && release.source_user === username;
|
||||
}
|
||||
|
||||
async function approveRelease(release, stage, bypass = false) {
|
||||
const key = `${release.release_intent_id}:${stage.environment}`;
|
||||
if (approving.has(key)) return;
|
||||
approving.add(key);
|
||||
approving = approving; // trigger reactivity
|
||||
approvalError = null;
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.set("csrf_token", csrf);
|
||||
formData.set("release_intent_id", release.release_intent_id);
|
||||
formData.set("target_environment", stage.environment);
|
||||
if (bypass) formData.set("force_bypass", "true");
|
||||
|
||||
const res = await fetch(
|
||||
`/orgs/${org}/projects/${release.project_name}/releases/${release.slug}/approve`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
// 303/302 redirect = success (form handler redirects after approval)
|
||||
if (res.ok || res.status === 303 || res.status === 302 || res.status === 0) {
|
||||
await refreshData();
|
||||
} else {
|
||||
// Try JSON error first, then extract from HTML
|
||||
const text = await res.text().catch(() => "");
|
||||
let msg;
|
||||
try { msg = JSON.parse(text).error; } catch {}
|
||||
if (!msg) {
|
||||
const match = text.match(/<p[^>]*>\s*(.*?)\s*<\/p>/);
|
||||
msg = match?.[1];
|
||||
}
|
||||
approvalError = msg || `Approval failed (${res.status})`;
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
approvalError = err.message || "Approval request failed";
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
} finally {
|
||||
approving.delete(key);
|
||||
approving = approving;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────
|
||||
|
||||
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
|
||||
@@ -367,6 +434,16 @@
|
||||
|
||||
<svelte:window on:resize={handleResize} />
|
||||
|
||||
{#if approvalError}
|
||||
<div class="max-w-5xl mx-auto mb-4 px-4 py-3 border border-red-200 bg-red-50 rounded-lg flex items-center gap-2 text-sm text-red-700">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{approvalError}
|
||||
<button class="ml-auto text-red-400 hover:text-red-600" on:click={() => approvalError = null}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if initialLoading}
|
||||
<div class="max-w-5xl mx-auto p-12 text-center text-gray-400">
|
||||
<span class="w-5 h-5 inline-block border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin"></span>
|
||||
@@ -466,6 +543,8 @@
|
||||
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else if summary.icon === "clock"}
|
||||
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else if summary.icon === "shield"}
|
||||
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
{/if}
|
||||
@@ -480,6 +559,21 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if stage.blocked_by && release.release_intent_id && csrf}
|
||||
{#if isAuthor(release) && isAdmin()}
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
disabled={approving.has(`${release.release_intent_id}:${stage.environment}`)}
|
||||
on:click|stopPropagation={() => { if (confirm('You are the release author. Bypass approval?')) approveRelease(release, stage, true); }}
|
||||
>Bypass</button>
|
||||
{:else if !isAuthor(release)}
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
disabled={approving.has(`${release.release_intent_id}:${stage.environment}`)}
|
||||
on:click|stopPropagation={() => approveRelease(release, stage)}
|
||||
>Approve</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<span class="text-xs text-gray-400">{summary.done}/{summary.total}</span>
|
||||
|
||||
@@ -24,8 +24,11 @@ export function pipelineSummary(stages) {
|
||||
if (s.stage_type === "wait" && s.status === "RUNNING") anyWaiting = true;
|
||||
}
|
||||
|
||||
let anyApprovalBlocked = stages.some(s => s.blocked_by);
|
||||
|
||||
if (allDone) return { label: "Pipeline complete", color: "text-gray-600", icon: "check-circle", iconColor: "text-green-500", done, total };
|
||||
if (anyFailed) return { label: "Pipeline failed", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500", done, total };
|
||||
if (anyApprovalBlocked) return { label: "Awaiting approval", color: "text-emerald-700", icon: "shield", iconColor: "text-emerald-500", done, total };
|
||||
if (anyWaiting) return { label: "Waiting for time window", color: "text-yellow-700", icon: "clock", iconColor: "text-yellow-500", done, total };
|
||||
if (anyRunning) return { label: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500", done, total };
|
||||
if (anyQueued) return { label: "Queued", color: "text-blue-600", icon: "clock", iconColor: "text-blue-400", done, total };
|
||||
|
||||
109
interface/proto/forest/v1/apps.proto
Normal file
109
interface/proto/forest/v1/apps.proto
Normal file
@@ -0,0 +1,109 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service AppService {
|
||||
// App lifecycle
|
||||
rpc CreateApp(CreateAppRequest) returns (CreateAppResponse);
|
||||
rpc GetApp(GetAppRequest) returns (GetAppResponse);
|
||||
rpc ListApps(ListAppsRequest) returns (ListAppsResponse);
|
||||
rpc DeleteApp(DeleteAppRequest) returns (DeleteAppResponse);
|
||||
rpc SuspendApp(SuspendAppRequest) returns (SuspendAppResponse);
|
||||
|
||||
// App tokens
|
||||
rpc CreateAppToken(CreateAppTokenRequest) returns (CreateAppTokenResponse);
|
||||
rpc ListAppTokens(ListAppTokensRequest) returns (ListAppTokensResponse);
|
||||
rpc RevokeAppToken(RevokeAppTokenRequest) returns (RevokeAppTokenResponse);
|
||||
}
|
||||
|
||||
// ─── Core types ──────────────────────────────────────────────────────
|
||||
|
||||
message App {
|
||||
string app_id = 1;
|
||||
string organisation_id = 2;
|
||||
string name = 3;
|
||||
string description = 4;
|
||||
repeated string permissions = 5;
|
||||
bool suspended = 6;
|
||||
google.protobuf.Timestamp created_at = 7;
|
||||
}
|
||||
|
||||
message AppToken {
|
||||
string token_id = 1;
|
||||
string name = 2;
|
||||
google.protobuf.Timestamp expires_at = 3;
|
||||
google.protobuf.Timestamp last_used = 4;
|
||||
bool revoked = 5;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
}
|
||||
|
||||
// ─── App lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
message CreateAppRequest {
|
||||
string organisation_id = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
repeated string permissions = 4;
|
||||
}
|
||||
|
||||
message CreateAppResponse {
|
||||
App app = 1;
|
||||
}
|
||||
|
||||
message GetAppRequest {
|
||||
string app_id = 1;
|
||||
}
|
||||
|
||||
message GetAppResponse {
|
||||
App app = 1;
|
||||
}
|
||||
|
||||
message ListAppsRequest {
|
||||
string organisation_id = 1;
|
||||
}
|
||||
|
||||
message ListAppsResponse {
|
||||
repeated App apps = 1;
|
||||
}
|
||||
|
||||
message DeleteAppRequest {
|
||||
string app_id = 1;
|
||||
}
|
||||
|
||||
message DeleteAppResponse {}
|
||||
|
||||
message SuspendAppRequest {
|
||||
string app_id = 1;
|
||||
bool suspended = 2;
|
||||
}
|
||||
|
||||
message SuspendAppResponse {}
|
||||
|
||||
// ─── App tokens ──────────────────────────────────────────────────────
|
||||
|
||||
message CreateAppTokenRequest {
|
||||
string app_id = 1;
|
||||
string name = 2;
|
||||
int64 expires_in_seconds = 3; // 0 = no expiry
|
||||
}
|
||||
|
||||
message CreateAppTokenResponse {
|
||||
AppToken token = 1;
|
||||
string raw_token = 2; // only returned on creation
|
||||
}
|
||||
|
||||
message ListAppTokensRequest {
|
||||
string app_id = 1;
|
||||
}
|
||||
|
||||
message ListAppTokensResponse {
|
||||
repeated AppToken tokens = 1;
|
||||
}
|
||||
|
||||
message RevokeAppTokenRequest {
|
||||
string token_id = 1;
|
||||
}
|
||||
|
||||
message RevokeAppTokenResponse {}
|
||||
62
interface/proto/forest/v1/artifacts.proto
Normal file
62
interface/proto/forest/v1/artifacts.proto
Normal file
@@ -0,0 +1,62 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
message BeginUploadArtifactRequest {}
|
||||
message BeginUploadArtifactResponse {
|
||||
string upload_id = 1;
|
||||
}
|
||||
|
||||
message UploadArtifactRequest {
|
||||
string upload_id = 1;
|
||||
|
||||
string env = 2;
|
||||
string destination = 3;
|
||||
|
||||
string file_name = 4;
|
||||
string file_content = 5;
|
||||
|
||||
// Category of the file: "deployment" (default), "spec", or "attachment"
|
||||
string category = 6;
|
||||
}
|
||||
message UploadArtifactResponse {}
|
||||
|
||||
message CommitArtifactRequest{
|
||||
string upload_id = 1;
|
||||
}
|
||||
message CommitArtifactResponse {
|
||||
string artifact_id = 1;
|
||||
}
|
||||
|
||||
message GetArtifactFilesRequest {
|
||||
// The artifact_id (UUID from annotations/artifacts table)
|
||||
string artifact_id = 1;
|
||||
// Optional filter: "deployment", "spec", "attachment". Empty = all categories.
|
||||
optional string category = 2;
|
||||
}
|
||||
message GetArtifactFilesResponse {
|
||||
repeated ArtifactFile files = 1;
|
||||
}
|
||||
message ArtifactFile {
|
||||
string file_name = 1;
|
||||
string category = 2;
|
||||
string env = 3;
|
||||
string destination = 4;
|
||||
string content = 5;
|
||||
}
|
||||
|
||||
message GetArtifactSpecRequest {
|
||||
string artifact_id = 1;
|
||||
}
|
||||
message GetArtifactSpecResponse {
|
||||
// The spec file content (forest.cue), empty string if no spec was uploaded.
|
||||
string content = 1;
|
||||
}
|
||||
|
||||
service ArtifactService {
|
||||
rpc BeginUploadArtifact(BeginUploadArtifactRequest) returns (BeginUploadArtifactResponse);
|
||||
rpc UploadArtifact(stream UploadArtifactRequest) returns (UploadArtifactResponse);
|
||||
rpc CommitArtifact(CommitArtifactRequest) returns (CommitArtifactResponse);
|
||||
rpc GetArtifactFiles(GetArtifactFilesRequest) returns (GetArtifactFilesResponse);
|
||||
rpc GetArtifactSpec(GetArtifactSpecRequest) returns (GetArtifactSpecResponse);
|
||||
}
|
||||
119
interface/proto/forest/v1/events.proto
Normal file
119
interface/proto/forest/v1/events.proto
Normal file
@@ -0,0 +1,119 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
// ── Event streaming ───────────────────────────────────────────────
|
||||
|
||||
service EventService {
|
||||
// Ephemeral server-streaming subscription. Client manages its own cursor.
|
||||
rpc Subscribe(SubscribeEventsRequest) returns (stream OrgEvent);
|
||||
|
||||
// Durable subscription: resumes from the subscription's persisted cursor.
|
||||
// Events are streamed, and the cursor is advanced as events are sent.
|
||||
// Client should call AcknowledgeEvents to confirm processing.
|
||||
rpc SubscribeDurable(SubscribeDurableRequest) returns (stream OrgEvent);
|
||||
|
||||
// Acknowledge that events up to (and including) the given sequence have
|
||||
// been processed. Advances the subscription's cursor. Idempotent.
|
||||
rpc AcknowledgeEvents(AcknowledgeEventsRequest) returns (AcknowledgeEventsResponse);
|
||||
}
|
||||
|
||||
message SubscribeEventsRequest {
|
||||
string organisation = 1;
|
||||
string project = 2; // optional — empty means all projects in org
|
||||
repeated string resource_types = 3; // optional filter: "release", "destination", etc.
|
||||
repeated string actions = 4; // optional filter: "created", "updated", etc.
|
||||
int64 since_sequence = 5; // 0 = latest only, >0 = replay from that sequence
|
||||
}
|
||||
|
||||
message SubscribeDurableRequest {
|
||||
string organisation = 1;
|
||||
string subscription_name = 2; // the registered subscription name
|
||||
}
|
||||
|
||||
message AcknowledgeEventsRequest {
|
||||
string organisation = 1;
|
||||
string subscription_name = 2;
|
||||
int64 sequence = 3; // advance cursor to this sequence
|
||||
}
|
||||
|
||||
message AcknowledgeEventsResponse {
|
||||
int64 cursor = 1; // the new cursor value
|
||||
}
|
||||
|
||||
message OrgEvent {
|
||||
int64 sequence = 1; // monotonic cursor — client stores this for reconnect
|
||||
string event_id = 2; // UUID, dedup key
|
||||
string timestamp = 3; // RFC 3339
|
||||
string organisation = 4;
|
||||
string project = 5; // empty for org-level events
|
||||
string resource_type = 6; // "release", "destination", "environment", "pipeline", "artifact", "policy", "app", "organisation"
|
||||
string action = 7; // "created", "updated", "deleted", "status_changed"
|
||||
string resource_id = 8; // ID of the changed resource
|
||||
map<string, string> metadata = 9; // lightweight context (e.g. "status" → "SUCCEEDED")
|
||||
}
|
||||
|
||||
// ── Subscription management ───────────────────────────────────────
|
||||
|
||||
service EventSubscriptionService {
|
||||
rpc CreateEventSubscription(CreateEventSubscriptionRequest) returns (CreateEventSubscriptionResponse);
|
||||
rpc UpdateEventSubscription(UpdateEventSubscriptionRequest) returns (UpdateEventSubscriptionResponse);
|
||||
rpc DeleteEventSubscription(DeleteEventSubscriptionRequest) returns (DeleteEventSubscriptionResponse);
|
||||
rpc ListEventSubscriptions(ListEventSubscriptionsRequest) returns (ListEventSubscriptionsResponse);
|
||||
}
|
||||
|
||||
message EventSubscription {
|
||||
string id = 1;
|
||||
string organisation = 2;
|
||||
string name = 3;
|
||||
repeated string resource_types = 4;
|
||||
repeated string actions = 5;
|
||||
repeated string projects = 6;
|
||||
string status = 7; // "active", "paused"
|
||||
int64 cursor = 8; // last acknowledged sequence
|
||||
string created_at = 9;
|
||||
string updated_at = 10;
|
||||
}
|
||||
|
||||
message CreateEventSubscriptionRequest {
|
||||
string organisation = 1;
|
||||
string name = 2;
|
||||
repeated string resource_types = 3; // empty = all
|
||||
repeated string actions = 4; // empty = all
|
||||
repeated string projects = 5; // empty = all projects in org
|
||||
}
|
||||
|
||||
message CreateEventSubscriptionResponse {
|
||||
EventSubscription subscription = 1;
|
||||
}
|
||||
|
||||
message UpdateEventSubscriptionRequest {
|
||||
string organisation = 1;
|
||||
string name = 2;
|
||||
optional string status = 3; // "active" or "paused"
|
||||
// To update filters, set update_filters = true and provide new values.
|
||||
// Empty arrays mean "all" (no filter).
|
||||
bool update_filters = 4;
|
||||
repeated string resource_types = 5;
|
||||
repeated string actions = 6;
|
||||
repeated string projects = 7;
|
||||
}
|
||||
|
||||
message UpdateEventSubscriptionResponse {
|
||||
EventSubscription subscription = 1;
|
||||
}
|
||||
|
||||
message DeleteEventSubscriptionRequest {
|
||||
string organisation = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message DeleteEventSubscriptionResponse {}
|
||||
|
||||
message ListEventSubscriptionsRequest {
|
||||
string organisation = 1;
|
||||
}
|
||||
|
||||
message ListEventSubscriptionsResponse {
|
||||
repeated EventSubscription subscriptions = 1;
|
||||
}
|
||||
864
interface/proto/forest/v1/forage.proto
Normal file
864
interface/proto/forest/v1/forage.proto
Normal file
@@ -0,0 +1,864 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ForageService — the control plane RPC surface that forest-server uses to
|
||||
// drive deployments against a forage cluster. The scheduler calls
|
||||
// ApplyResources with the full desired-state bundle; forage reconciles.
|
||||
// ---------------------------------------------------------------------------
|
||||
service ForageService {
|
||||
// Apply a batch of resources (create / update / delete).
|
||||
// This is the main entry-point used by the forage/containers@1 destination.
|
||||
rpc ApplyResources(ApplyResourcesRequest) returns (ApplyResourcesResponse);
|
||||
|
||||
// Poll / stream the rollout status of a previous apply.
|
||||
rpc WatchRollout(WatchRolloutRequest) returns (stream RolloutEvent);
|
||||
|
||||
// Tear down all resources associated with a release / project.
|
||||
rpc DeleteResources(DeleteResourcesRequest) returns (DeleteResourcesResponse);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply
|
||||
// ---------------------------------------------------------------------------
|
||||
message ApplyResourcesRequest {
|
||||
// Caller-chosen idempotency key (release_state id works well).
|
||||
string apply_id = 1;
|
||||
|
||||
// Namespace / tenant isolation — maps to the forest organisation.
|
||||
string namespace = 2;
|
||||
|
||||
// The ordered list of resources to reconcile. Forage processes them in
|
||||
// order so that dependencies (e.g. Service before HTTPRoute) are met.
|
||||
repeated ForageResource resources = 3;
|
||||
|
||||
// Labels propagated to every resource for bookkeeping.
|
||||
map<string, string> labels = 4;
|
||||
}
|
||||
|
||||
message ApplyResourcesResponse {
|
||||
// Server-generated rollout id for status tracking.
|
||||
string rollout_id = 1;
|
||||
}
|
||||
|
||||
message WatchRolloutRequest {
|
||||
string rollout_id = 1;
|
||||
}
|
||||
|
||||
message RolloutEvent {
|
||||
string resource_name = 1;
|
||||
string resource_kind = 2;
|
||||
RolloutStatus status = 3;
|
||||
string message = 4;
|
||||
}
|
||||
|
||||
enum RolloutStatus {
|
||||
ROLLOUT_STATUS_UNSPECIFIED = 0;
|
||||
ROLLOUT_STATUS_PENDING = 1;
|
||||
ROLLOUT_STATUS_IN_PROGRESS = 2;
|
||||
ROLLOUT_STATUS_SUCCEEDED = 3;
|
||||
ROLLOUT_STATUS_FAILED = 4;
|
||||
ROLLOUT_STATUS_ROLLED_BACK = 5;
|
||||
}
|
||||
|
||||
message DeleteResourcesRequest {
|
||||
string namespace = 1;
|
||||
// Selector labels — all resources matching these labels are removed.
|
||||
map<string, string> labels = 2;
|
||||
}
|
||||
|
||||
message DeleteResourcesResponse {}
|
||||
|
||||
// ===========================================================================
|
||||
// Resource envelope — every item in the apply list is one of these.
|
||||
// ===========================================================================
|
||||
message ForageResource {
|
||||
// Unique name within the namespace (e.g. "my-api", "my-api-worker").
|
||||
string name = 1;
|
||||
|
||||
oneof spec {
|
||||
ContainerServiceSpec container_service = 10;
|
||||
ServiceSpec service = 11;
|
||||
RouteSpec route = 12;
|
||||
CronJobSpec cron_job = 13;
|
||||
JobSpec job = 14;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ContainerServiceSpec — the primary workload.
|
||||
// Combines the concerns of Deployment + Pod in a single cohesive spec.
|
||||
// ===========================================================================
|
||||
message ContainerServiceSpec {
|
||||
// ---- Scheduling & scaling ------------------------------------------------
|
||||
ScalingPolicy scaling = 1;
|
||||
|
||||
// ---- Pod-level settings --------------------------------------------------
|
||||
// Main application container (exactly one required).
|
||||
Container container = 2;
|
||||
|
||||
// Optional sidecar containers that share the pod network.
|
||||
repeated Container sidecars = 3;
|
||||
|
||||
// Init containers run sequentially before the main container starts.
|
||||
repeated Container init_containers = 4;
|
||||
|
||||
// ---- Volumes available to all containers in the pod ----------------------
|
||||
repeated Volume volumes = 5;
|
||||
|
||||
// ---- Update strategy -----------------------------------------------------
|
||||
UpdateStrategy update_strategy = 6;
|
||||
|
||||
// ---- Pod-level configuration ---------------------------------------------
|
||||
PodConfig pod_config = 7;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container — describes a single OCI container.
|
||||
// ---------------------------------------------------------------------------
|
||||
message Container {
|
||||
// Human-readable name (must be unique within the pod).
|
||||
string name = 1;
|
||||
|
||||
// OCI image reference, e.g. "registry.forage.sh/org/app:v1.2.3".
|
||||
string image = 2;
|
||||
|
||||
// Override the image entrypoint.
|
||||
repeated string command = 3;
|
||||
|
||||
// Arguments passed to the entrypoint.
|
||||
repeated string args = 4;
|
||||
|
||||
// Working directory inside the container.
|
||||
string working_dir = 5;
|
||||
|
||||
// Environment variables — static values and references.
|
||||
repeated EnvVar env = 6;
|
||||
|
||||
// Ports the container listens on.
|
||||
repeated ContainerPort ports = 7;
|
||||
|
||||
// Resource requests and limits.
|
||||
ResourceRequirements resources = 8;
|
||||
|
||||
// Volume mounts into this container's filesystem.
|
||||
repeated VolumeMount volume_mounts = 9;
|
||||
|
||||
// Health probes.
|
||||
Probe liveness_probe = 10;
|
||||
Probe readiness_probe = 11;
|
||||
Probe startup_probe = 12;
|
||||
|
||||
// Lifecycle hooks.
|
||||
Lifecycle lifecycle = 13;
|
||||
|
||||
// Security context for this container.
|
||||
ContainerSecurityContext security_context = 14;
|
||||
|
||||
// Image pull policy: "Always", "IfNotPresent", "Never".
|
||||
string image_pull_policy = 15;
|
||||
|
||||
// Whether stdin / tty are allocated (usually false for services).
|
||||
bool stdin = 16;
|
||||
bool tty = 17;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment variables
|
||||
// ---------------------------------------------------------------------------
|
||||
message EnvVar {
|
||||
string name = 1;
|
||||
|
||||
oneof value_source {
|
||||
// Literal value.
|
||||
string value = 2;
|
||||
|
||||
// Reference to a secret key.
|
||||
SecretKeyRef secret_ref = 3;
|
||||
|
||||
// Reference to a config-map key.
|
||||
ConfigKeyRef config_ref = 4;
|
||||
|
||||
// Downward-API field (e.g. "metadata.name", "status.podIP").
|
||||
string field_ref = 5;
|
||||
|
||||
// Resource field (e.g. "limits.cpu").
|
||||
string resource_field_ref = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message SecretKeyRef {
|
||||
string secret_name = 1;
|
||||
string key = 2;
|
||||
}
|
||||
|
||||
message ConfigKeyRef {
|
||||
string config_name = 1;
|
||||
string key = 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
message ContainerPort {
|
||||
// Friendly name (e.g. "http", "grpc", "metrics").
|
||||
string name = 1;
|
||||
|
||||
// The port number inside the container.
|
||||
uint32 container_port = 2;
|
||||
|
||||
// Protocol: TCP (default), UDP, SCTP.
|
||||
string protocol = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
message ResourceRequirements {
|
||||
ResourceList requests = 1;
|
||||
ResourceList limits = 2;
|
||||
}
|
||||
|
||||
message ResourceList {
|
||||
// CPU in Kubernetes quantity format: "100m", "0.5", "2".
|
||||
string cpu = 1;
|
||||
|
||||
// Memory in Kubernetes quantity format: "128Mi", "1Gi".
|
||||
string memory = 2;
|
||||
|
||||
// Ephemeral storage: "1Gi".
|
||||
string ephemeral_storage = 3;
|
||||
|
||||
// GPU / accelerator requests (e.g. "nvidia.com/gpu": "1").
|
||||
map<string, string> extended = 4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Volumes & mounts
|
||||
// ---------------------------------------------------------------------------
|
||||
message Volume {
|
||||
// Volume name referenced by VolumeMount.name.
|
||||
string name = 1;
|
||||
|
||||
oneof source {
|
||||
EmptyDirVolume empty_dir = 10;
|
||||
SecretVolume secret = 11;
|
||||
ConfigMapVolume config_map = 12;
|
||||
PVCVolume pvc = 13;
|
||||
HostPathVolume host_path = 14;
|
||||
NfsVolume nfs = 15;
|
||||
}
|
||||
}
|
||||
|
||||
message EmptyDirVolume {
|
||||
// "Memory" for tmpfs, empty for node disk.
|
||||
string medium = 1;
|
||||
|
||||
// Size limit (e.g. "256Mi"). Empty means node default.
|
||||
string size_limit = 2;
|
||||
}
|
||||
|
||||
message SecretVolume {
|
||||
string secret_name = 1;
|
||||
// Optional: mount only specific keys.
|
||||
repeated KeyToPath items = 2;
|
||||
// Octal file mode (e.g. 0644). Default 0644.
|
||||
uint32 default_mode = 3;
|
||||
bool optional = 4;
|
||||
}
|
||||
|
||||
message ConfigMapVolume {
|
||||
string config_map_name = 1;
|
||||
repeated KeyToPath items = 2;
|
||||
uint32 default_mode = 3;
|
||||
bool optional = 4;
|
||||
}
|
||||
|
||||
message KeyToPath {
|
||||
string key = 1;
|
||||
string path = 2;
|
||||
uint32 mode = 3;
|
||||
}
|
||||
|
||||
message PVCVolume {
|
||||
string claim_name = 1;
|
||||
bool read_only = 2;
|
||||
}
|
||||
|
||||
message HostPathVolume {
|
||||
string path = 1;
|
||||
// "Directory", "File", "DirectoryOrCreate", "FileOrCreate", etc.
|
||||
string type = 2;
|
||||
}
|
||||
|
||||
message NfsVolume {
|
||||
string server = 1;
|
||||
string path = 2;
|
||||
bool read_only = 3;
|
||||
}
|
||||
|
||||
message VolumeMount {
|
||||
// Must match a Volume.name.
|
||||
string name = 1;
|
||||
|
||||
// Absolute path inside the container.
|
||||
string mount_path = 2;
|
||||
|
||||
// Optional sub-path within the volume.
|
||||
string sub_path = 3;
|
||||
|
||||
bool read_only = 4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Probes
|
||||
// ---------------------------------------------------------------------------
|
||||
message Probe {
|
||||
oneof handler {
|
||||
HttpGetProbe http_get = 1;
|
||||
TcpSocketProbe tcp_socket = 2;
|
||||
ExecProbe exec = 3;
|
||||
GrpcProbe grpc = 4;
|
||||
}
|
||||
|
||||
uint32 initial_delay_seconds = 10;
|
||||
uint32 period_seconds = 11;
|
||||
uint32 timeout_seconds = 12;
|
||||
uint32 success_threshold = 13;
|
||||
uint32 failure_threshold = 14;
|
||||
}
|
||||
|
||||
message HttpGetProbe {
|
||||
string path = 1;
|
||||
uint32 port = 2;
|
||||
string scheme = 3; // "HTTP" or "HTTPS"
|
||||
repeated HttpHeader http_headers = 4;
|
||||
}
|
||||
|
||||
message HttpHeader {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message TcpSocketProbe {
|
||||
uint32 port = 1;
|
||||
}
|
||||
|
||||
message ExecProbe {
|
||||
repeated string command = 1;
|
||||
}
|
||||
|
||||
message GrpcProbe {
|
||||
uint32 port = 1;
|
||||
string service = 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
message Lifecycle {
|
||||
LifecycleHandler post_start = 1;
|
||||
LifecycleHandler pre_stop = 2;
|
||||
}
|
||||
|
||||
message LifecycleHandler {
|
||||
oneof action {
|
||||
ExecProbe exec = 1;
|
||||
HttpGetProbe http_get = 2;
|
||||
TcpSocketProbe tcp_socket = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security
|
||||
// ---------------------------------------------------------------------------
|
||||
message ContainerSecurityContext {
|
||||
bool run_as_non_root = 1;
|
||||
int64 run_as_user = 2;
|
||||
int64 run_as_group = 3;
|
||||
bool read_only_root_filesystem = 4;
|
||||
bool privileged = 5;
|
||||
bool allow_privilege_escalation = 6;
|
||||
|
||||
Capabilities capabilities = 7;
|
||||
|
||||
// SELinux options (optional).
|
||||
string se_linux_type = 8;
|
||||
|
||||
// Seccomp profile: "RuntimeDefault", "Unconfined", or a localhost path.
|
||||
string seccomp_profile = 9;
|
||||
}
|
||||
|
||||
message Capabilities {
|
||||
repeated string add = 1;
|
||||
repeated string drop = 2;
|
||||
}
|
||||
|
||||
message PodSecurityContext {
|
||||
int64 run_as_user = 1;
|
||||
int64 run_as_group = 2;
|
||||
bool run_as_non_root = 3;
|
||||
int64 fs_group = 4;
|
||||
|
||||
// Supplemental groups for all containers.
|
||||
repeated int64 supplemental_groups = 5;
|
||||
|
||||
// "OnRootMismatch" or "Always".
|
||||
string fs_group_change_policy = 6;
|
||||
|
||||
string seccomp_profile = 7;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scaling
|
||||
// ---------------------------------------------------------------------------
|
||||
message ScalingPolicy {
|
||||
// Fixed replica count (used when autoscaling is not configured).
|
||||
uint32 replicas = 1;
|
||||
|
||||
// Optional horizontal autoscaler.
|
||||
AutoscalingPolicy autoscaling = 2;
|
||||
}
|
||||
|
||||
message AutoscalingPolicy {
|
||||
uint32 min_replicas = 1;
|
||||
uint32 max_replicas = 2;
|
||||
|
||||
// Target average CPU utilisation percentage (e.g. 70).
|
||||
uint32 target_cpu_utilization_percent = 3;
|
||||
|
||||
// Target average memory utilisation percentage.
|
||||
uint32 target_memory_utilization_percent = 4;
|
||||
|
||||
// Custom metrics (e.g. queue depth, RPS).
|
||||
repeated CustomMetric custom_metrics = 5;
|
||||
|
||||
// Scale-down stabilisation window.
|
||||
google.protobuf.Duration scale_down_stabilization = 6;
|
||||
}
|
||||
|
||||
message CustomMetric {
|
||||
// Metric name as exposed by the metrics adapter.
|
||||
string name = 1;
|
||||
|
||||
// One of "Value", "AverageValue", "Utilization".
|
||||
string target_type = 2;
|
||||
|
||||
// Target threshold (interpretation depends on target_type).
|
||||
string target_value = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update strategy
|
||||
// ---------------------------------------------------------------------------
|
||||
message UpdateStrategy {
|
||||
// "RollingUpdate" (default) or "Recreate".
|
||||
string type = 1;
|
||||
|
||||
RollingUpdateConfig rolling_update = 2;
|
||||
}
|
||||
|
||||
message RollingUpdateConfig {
|
||||
// Absolute number or percentage (e.g. "1", "25%").
|
||||
string max_unavailable = 1;
|
||||
string max_surge = 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pod-level configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
message PodConfig {
|
||||
// Service account name for RBAC / workload identity.
|
||||
string service_account_name = 1;
|
||||
|
||||
// Restart policy: "Always" (default for services), "OnFailure", "Never".
|
||||
string restart_policy = 2;
|
||||
|
||||
// Graceful shutdown window.
|
||||
uint32 termination_grace_period_seconds = 3;
|
||||
|
||||
// DNS policy: "ClusterFirst" (default), "Default", "None".
|
||||
string dns_policy = 4;
|
||||
PodDnsConfig dns_config = 5;
|
||||
|
||||
// Host networking (rare, but needed for some infra workloads).
|
||||
bool host_network = 6;
|
||||
|
||||
// Node scheduling.
|
||||
map<string, string> node_selector = 7;
|
||||
repeated Toleration tolerations = 8;
|
||||
Affinity affinity = 9;
|
||||
|
||||
// Topology spread constraints for HA.
|
||||
repeated TopologySpreadConstraint topology_spread_constraints = 10;
|
||||
|
||||
// Image pull secrets.
|
||||
repeated string image_pull_secrets = 11;
|
||||
|
||||
// Pod-level security context.
|
||||
PodSecurityContext security_context = 12;
|
||||
|
||||
// Priority class name for preemption.
|
||||
string priority_class_name = 13;
|
||||
|
||||
// Runtime class (e.g. "gvisor", "kata").
|
||||
string runtime_class_name = 14;
|
||||
|
||||
// Annotations passed to the pod template (not the workload resource).
|
||||
map<string, string> annotations = 15;
|
||||
|
||||
// Labels passed to the pod template.
|
||||
map<string, string> labels = 16;
|
||||
}
|
||||
|
||||
message PodDnsConfig {
|
||||
repeated string nameservers = 1;
|
||||
repeated string searches = 2;
|
||||
repeated DnsOption options = 3;
|
||||
}
|
||||
|
||||
message DnsOption {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message Toleration {
|
||||
string key = 1;
|
||||
// "Equal" or "Exists".
|
||||
string operator = 2;
|
||||
string value = 3;
|
||||
// "NoSchedule", "PreferNoSchedule", "NoExecute".
|
||||
string effect = 4;
|
||||
// Toleration seconds for NoExecute.
|
||||
int64 toleration_seconds = 5;
|
||||
}
|
||||
|
||||
message Affinity {
|
||||
NodeAffinity node_affinity = 1;
|
||||
PodAffinity pod_affinity = 2;
|
||||
PodAntiAffinity pod_anti_affinity = 3;
|
||||
}
|
||||
|
||||
message NodeAffinity {
|
||||
repeated PreferredSchedulingTerm preferred = 1;
|
||||
NodeSelector required = 2;
|
||||
}
|
||||
|
||||
message PreferredSchedulingTerm {
|
||||
int32 weight = 1;
|
||||
NodeSelectorTerm preference = 2;
|
||||
}
|
||||
|
||||
message NodeSelector {
|
||||
repeated NodeSelectorTerm terms = 1;
|
||||
}
|
||||
|
||||
message NodeSelectorTerm {
|
||||
repeated NodeSelectorRequirement match_expressions = 1;
|
||||
repeated NodeSelectorRequirement match_fields = 2;
|
||||
}
|
||||
|
||||
message NodeSelectorRequirement {
|
||||
string key = 1;
|
||||
// "In", "NotIn", "Exists", "DoesNotExist", "Gt", "Lt".
|
||||
string operator = 2;
|
||||
repeated string values = 3;
|
||||
}
|
||||
|
||||
message PodAffinity {
|
||||
repeated WeightedPodAffinityTerm preferred = 1;
|
||||
repeated PodAffinityTerm required = 2;
|
||||
}
|
||||
|
||||
message PodAntiAffinity {
|
||||
repeated WeightedPodAffinityTerm preferred = 1;
|
||||
repeated PodAffinityTerm required = 2;
|
||||
}
|
||||
|
||||
message WeightedPodAffinityTerm {
|
||||
int32 weight = 1;
|
||||
PodAffinityTerm term = 2;
|
||||
}
|
||||
|
||||
message PodAffinityTerm {
|
||||
LabelSelector label_selector = 1;
|
||||
string topology_key = 2;
|
||||
repeated string namespaces = 3;
|
||||
}
|
||||
|
||||
message LabelSelector {
|
||||
map<string, string> match_labels = 1;
|
||||
repeated LabelSelectorRequirement match_expressions = 2;
|
||||
}
|
||||
|
||||
message LabelSelectorRequirement {
|
||||
string key = 1;
|
||||
// "In", "NotIn", "Exists", "DoesNotExist".
|
||||
string operator = 2;
|
||||
repeated string values = 3;
|
||||
}
|
||||
|
||||
message TopologySpreadConstraint {
|
||||
// Max difference in spread (e.g. 1 for even distribution).
|
||||
int32 max_skew = 1;
|
||||
|
||||
// "zone", "hostname", or any node label.
|
||||
string topology_key = 2;
|
||||
|
||||
// "DoNotSchedule" or "ScheduleAnyway".
|
||||
string when_unsatisfiable = 3;
|
||||
|
||||
LabelSelector label_selector = 4;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ServiceSpec — L4 load balancing & service discovery.
|
||||
// Combines Service + optional gateway route into one resource when desired.
|
||||
// ===========================================================================
|
||||
message ServiceSpec {
|
||||
// The ContainerServiceSpec name this service fronts.
|
||||
string target = 1;
|
||||
|
||||
// Service type: "ClusterIP" (default), "NodePort", "LoadBalancer", "Headless".
|
||||
string type = 2;
|
||||
|
||||
repeated ServicePort ports = 3;
|
||||
|
||||
// Session affinity: "None" (default), "ClientIP".
|
||||
string session_affinity = 4;
|
||||
|
||||
// Optional: expose this service externally via the gateway.
|
||||
// Setting this is equivalent to creating a separate RouteSpec.
|
||||
// Allows combining Service + Route into one resource for simpler configs.
|
||||
InlineRoute inline_route = 5;
|
||||
|
||||
// Extra annotations on the Service object (e.g. cloud LB configs).
|
||||
map<string, string> annotations = 6;
|
||||
}
|
||||
|
||||
message ServicePort {
|
||||
string name = 1;
|
||||
uint32 port = 2;
|
||||
uint32 target_port = 3;
|
||||
string protocol = 4; // TCP, UDP, SCTP
|
||||
// Only for NodePort type.
|
||||
uint32 node_port = 5;
|
||||
}
|
||||
|
||||
message InlineRoute {
|
||||
// Hostname(s) to match (e.g. "api.example.com").
|
||||
repeated string hostnames = 1;
|
||||
|
||||
// Path matching rules. If empty, matches all paths to the first port.
|
||||
repeated RouteRule rules = 2;
|
||||
|
||||
// TLS configuration.
|
||||
RouteTls tls = 3;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// RouteSpec — Gateway API HTTPRoute (standalone).
|
||||
// Use this when you need routing rules separate from the service definition.
|
||||
// ===========================================================================
|
||||
message RouteSpec {
|
||||
// The ServiceSpec name this route targets.
|
||||
string target_service = 1;
|
||||
|
||||
// Hostname(s) this route matches.
|
||||
repeated string hostnames = 2;
|
||||
|
||||
// Matching & routing rules.
|
||||
repeated RouteRule rules = 3;
|
||||
|
||||
// TLS termination config.
|
||||
RouteTls tls = 4;
|
||||
|
||||
// Which gateway to attach to (empty = cluster default).
|
||||
string gateway_ref = 5;
|
||||
|
||||
// Route priority / ordering.
|
||||
int32 priority = 6;
|
||||
}
|
||||
|
||||
message RouteRule {
|
||||
// Path matching.
|
||||
repeated RouteMatch matches = 1;
|
||||
|
||||
// Backend(s) traffic is sent to.
|
||||
repeated RouteBackend backends = 2;
|
||||
|
||||
// Request / response filters applied to this rule.
|
||||
repeated RouteFilter filters = 3;
|
||||
|
||||
// Timeout for the entire request.
|
||||
google.protobuf.Duration timeout = 4;
|
||||
}
|
||||
|
||||
message RouteMatch {
|
||||
// Path match.
|
||||
PathMatch path = 1;
|
||||
|
||||
// Header conditions.
|
||||
repeated HeaderMatch headers = 2;
|
||||
|
||||
// Query parameter conditions.
|
||||
repeated QueryParamMatch query_params = 3;
|
||||
|
||||
// HTTP method constraint.
|
||||
string method = 4;
|
||||
}
|
||||
|
||||
message PathMatch {
|
||||
// "Exact", "PathPrefix" (default), "RegularExpression".
|
||||
string type = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message HeaderMatch {
|
||||
// "Exact" (default), "RegularExpression".
|
||||
string type = 1;
|
||||
string name = 2;
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message QueryParamMatch {
|
||||
string type = 1;
|
||||
string name = 2;
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message RouteBackend {
|
||||
// Service name.
|
||||
string service = 1;
|
||||
// Port on the backend service.
|
||||
uint32 port = 2;
|
||||
// Traffic weight for canary / blue-green (1-100).
|
||||
uint32 weight = 3;
|
||||
}
|
||||
|
||||
message RouteFilter {
|
||||
oneof filter {
|
||||
RequestHeaderModifier request_header_modifier = 1;
|
||||
ResponseHeaderModifier response_header_modifier = 2;
|
||||
RequestRedirect request_redirect = 3;
|
||||
UrlRewrite url_rewrite = 4;
|
||||
RequestMirror request_mirror = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message RequestHeaderModifier {
|
||||
map<string, string> set = 1;
|
||||
map<string, string> add = 2;
|
||||
repeated string remove = 3;
|
||||
}
|
||||
|
||||
message ResponseHeaderModifier {
|
||||
map<string, string> set = 1;
|
||||
map<string, string> add = 2;
|
||||
repeated string remove = 3;
|
||||
}
|
||||
|
||||
message RequestRedirect {
|
||||
string scheme = 1;
|
||||
string hostname = 2;
|
||||
uint32 port = 3;
|
||||
string path = 4;
|
||||
uint32 status_code = 5; // 301, 302, etc.
|
||||
}
|
||||
|
||||
message UrlRewrite {
|
||||
string hostname = 1;
|
||||
PathMatch path = 2;
|
||||
}
|
||||
|
||||
message RequestMirror {
|
||||
string service = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message RouteTls {
|
||||
// "Terminate" (default) or "Passthrough".
|
||||
string mode = 1;
|
||||
|
||||
// Secret name containing the TLS certificate.
|
||||
string certificate_ref = 2;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CronJobSpec — scheduled workload.
|
||||
// ===========================================================================
|
||||
message CronJobSpec {
|
||||
// Cron schedule (e.g. "*/5 * * * *").
|
||||
string schedule = 1;
|
||||
|
||||
// Timezone (e.g. "Europe/Copenhagen"). Empty = UTC.
|
||||
string timezone = 2;
|
||||
|
||||
// Container that runs the job.
|
||||
Container container = 3;
|
||||
|
||||
// Volumes for the job pod.
|
||||
repeated Volume volumes = 4;
|
||||
|
||||
// Job-level config.
|
||||
JobConfig job_config = 5;
|
||||
|
||||
// Pod-level config (node selector, tolerations, etc.).
|
||||
PodConfig pod_config = 6;
|
||||
|
||||
// "Allow", "Forbid", "Replace".
|
||||
string concurrency_policy = 7;
|
||||
|
||||
// Number of successful/failed jobs to retain.
|
||||
uint32 successful_jobs_history_limit = 8;
|
||||
uint32 failed_jobs_history_limit = 9;
|
||||
|
||||
// Suspend the cron schedule.
|
||||
bool suspend = 10;
|
||||
|
||||
// Deadline in seconds for starting the job if it missed its schedule.
|
||||
int64 starting_deadline_seconds = 11;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JobSpec — one-shot workload.
|
||||
// ===========================================================================
|
||||
message JobSpec {
|
||||
// Container that runs the job.
|
||||
Container container = 1;
|
||||
|
||||
// Volumes for the job pod.
|
||||
repeated Volume volumes = 2;
|
||||
|
||||
// Job-level config.
|
||||
JobConfig job_config = 3;
|
||||
|
||||
// Pod-level config.
|
||||
PodConfig pod_config = 4;
|
||||
}
|
||||
|
||||
message JobConfig {
|
||||
// Number of times the job should complete successfully.
|
||||
uint32 completions = 1;
|
||||
|
||||
// Max parallel pods.
|
||||
uint32 parallelism = 2;
|
||||
|
||||
// "NonIndexed" (default) or "Indexed".
|
||||
string completion_mode = 3;
|
||||
|
||||
// Number of retries before marking failed.
|
||||
uint32 backoff_limit = 4;
|
||||
|
||||
// Active deadline (seconds) — job killed if it runs longer.
|
||||
int64 active_deadline_seconds = 5;
|
||||
|
||||
// TTL after finished (seconds) — auto-cleanup.
|
||||
int64 ttl_seconds_after_finished = 6;
|
||||
|
||||
// Restart policy: "OnFailure" (default) or "Never".
|
||||
string restart_policy = 7;
|
||||
}
|
||||
10
interface/proto/forest/v1/health.proto
Normal file
10
interface/proto/forest/v1/health.proto
Normal file
@@ -0,0 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
service StatusService {
|
||||
rpc Status(GetStatusRequest) returns (GetStatusResponse) {}
|
||||
}
|
||||
|
||||
message GetStatusRequest {}
|
||||
message GetStatusResponse {}
|
||||
98
interface/proto/forest/v1/notifications.proto
Normal file
98
interface/proto/forest/v1/notifications.proto
Normal file
@@ -0,0 +1,98 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
enum NotificationType {
|
||||
NOTIFICATION_TYPE_UNSPECIFIED = 0;
|
||||
NOTIFICATION_TYPE_RELEASE_ANNOTATED = 1;
|
||||
NOTIFICATION_TYPE_RELEASE_STARTED = 2;
|
||||
NOTIFICATION_TYPE_RELEASE_SUCCEEDED = 3;
|
||||
NOTIFICATION_TYPE_RELEASE_FAILED = 4;
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
NOTIFICATION_CHANNEL_UNSPECIFIED = 0;
|
||||
NOTIFICATION_CHANNEL_CLI = 1;
|
||||
NOTIFICATION_CHANNEL_SLACK = 2;
|
||||
}
|
||||
|
||||
// Rich context about the release that triggered the notification.
|
||||
// Integrations decide which fields to use.
|
||||
message ReleaseContext {
|
||||
string slug = 1;
|
||||
string organisation = 2;
|
||||
string project = 3;
|
||||
string artifact_id = 4;
|
||||
string release_intent_id = 5;
|
||||
string destination = 6;
|
||||
string environment = 7;
|
||||
// Source info
|
||||
string source_username = 8;
|
||||
string source_email = 9;
|
||||
string source_user_id = 17;
|
||||
// Git ref
|
||||
string commit_sha = 10;
|
||||
string commit_branch = 11;
|
||||
// Artifact context
|
||||
string context_title = 12;
|
||||
string context_description = 13;
|
||||
string context_web = 14;
|
||||
// Error info (populated on failure)
|
||||
string error_message = 15;
|
||||
// Number of destinations involved
|
||||
int32 destination_count = 16;
|
||||
}
|
||||
|
||||
message Notification {
|
||||
string id = 1;
|
||||
NotificationType notification_type = 2;
|
||||
string title = 3;
|
||||
string body = 4;
|
||||
string organisation = 5;
|
||||
string project = 6;
|
||||
ReleaseContext release_context = 7;
|
||||
string created_at = 8;
|
||||
}
|
||||
|
||||
message NotificationPreference {
|
||||
NotificationType notification_type = 1;
|
||||
NotificationChannel channel = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
message GetNotificationPreferencesRequest {}
|
||||
message GetNotificationPreferencesResponse {
|
||||
repeated NotificationPreference preferences = 1;
|
||||
}
|
||||
|
||||
message SetNotificationPreferenceRequest {
|
||||
NotificationType notification_type = 1;
|
||||
NotificationChannel channel = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
message SetNotificationPreferenceResponse {
|
||||
NotificationPreference preference = 1;
|
||||
}
|
||||
|
||||
message ListenNotificationsRequest {
|
||||
optional string organisation = 1;
|
||||
optional string project = 2;
|
||||
}
|
||||
|
||||
message ListNotificationsRequest {
|
||||
int32 page_size = 1;
|
||||
string page_token = 2;
|
||||
optional string organisation = 3;
|
||||
optional string project = 4;
|
||||
}
|
||||
message ListNotificationsResponse {
|
||||
repeated Notification notifications = 1;
|
||||
string next_page_token = 2;
|
||||
}
|
||||
|
||||
service NotificationService {
|
||||
rpc GetNotificationPreferences(GetNotificationPreferencesRequest) returns (GetNotificationPreferencesResponse);
|
||||
rpc SetNotificationPreference(SetNotificationPreferenceRequest) returns (SetNotificationPreferenceResponse);
|
||||
rpc ListenNotifications(ListenNotificationsRequest) returns (stream Notification);
|
||||
rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);
|
||||
}
|
||||
178
interface/proto/forest/v1/policies.proto
Normal file
178
interface/proto/forest/v1/policies.proto
Normal file
@@ -0,0 +1,178 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
import "forest/v1/releases.proto";
|
||||
|
||||
// ── Policy types ────────────────────────────────────────────────────
|
||||
|
||||
enum PolicyType {
|
||||
POLICY_TYPE_UNSPECIFIED = 0;
|
||||
POLICY_TYPE_SOAK_TIME = 1;
|
||||
POLICY_TYPE_BRANCH_RESTRICTION = 2;
|
||||
POLICY_TYPE_EXTERNAL_APPROVAL = 3;
|
||||
}
|
||||
|
||||
message SoakTimeConfig {
|
||||
// Environment that must have a successful deploy before target is allowed
|
||||
string source_environment = 1;
|
||||
// Environment that is gated by this policy
|
||||
string target_environment = 2;
|
||||
// Seconds to wait after source environment succeeds
|
||||
int64 duration_seconds = 3;
|
||||
}
|
||||
|
||||
message BranchRestrictionConfig {
|
||||
// Environment that is restricted
|
||||
string target_environment = 1;
|
||||
// Regex that source branch must match
|
||||
string branch_pattern = 2;
|
||||
}
|
||||
|
||||
message ExternalApprovalConfig {
|
||||
string target_environment = 1;
|
||||
int32 required_approvals = 2;
|
||||
}
|
||||
|
||||
// ── External approval state ─────────────────────────────────────────
|
||||
|
||||
message ExternalApprovalState {
|
||||
int32 required_approvals = 1;
|
||||
int32 current_approvals = 2;
|
||||
repeated ExternalApprovalDecisionEntry decisions = 3;
|
||||
}
|
||||
|
||||
message ExternalApprovalDecisionEntry {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
string decision = 3;
|
||||
string decided_at = 4;
|
||||
optional string comment = 5;
|
||||
}
|
||||
|
||||
// ── Policy resource ─────────────────────────────────────────────────
|
||||
|
||||
message Policy {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
bool enabled = 3;
|
||||
PolicyType policy_type = 4;
|
||||
|
||||
oneof config {
|
||||
SoakTimeConfig soak_time = 10;
|
||||
BranchRestrictionConfig branch_restriction = 11;
|
||||
ExternalApprovalConfig external_approval = 12;
|
||||
}
|
||||
|
||||
string created_at = 20;
|
||||
string updated_at = 21;
|
||||
}
|
||||
|
||||
// ── Policy evaluation result ────────────────────────────────────────
|
||||
|
||||
message PolicyEvaluation {
|
||||
string policy_name = 1;
|
||||
PolicyType policy_type = 2;
|
||||
bool passed = 3;
|
||||
// Human-readable explanation when blocked
|
||||
string reason = 4;
|
||||
optional ExternalApprovalState approval_state = 10;
|
||||
}
|
||||
|
||||
// ── CRUD messages ───────────────────────────────────────────────────
|
||||
|
||||
message CreatePolicyRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
PolicyType policy_type = 3;
|
||||
oneof config {
|
||||
SoakTimeConfig soak_time = 10;
|
||||
BranchRestrictionConfig branch_restriction = 11;
|
||||
ExternalApprovalConfig external_approval = 12;
|
||||
}
|
||||
}
|
||||
message CreatePolicyResponse {
|
||||
Policy policy = 1;
|
||||
}
|
||||
|
||||
message UpdatePolicyRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
optional bool enabled = 3;
|
||||
oneof config {
|
||||
SoakTimeConfig soak_time = 10;
|
||||
BranchRestrictionConfig branch_restriction = 11;
|
||||
ExternalApprovalConfig external_approval = 12;
|
||||
}
|
||||
}
|
||||
message UpdatePolicyResponse {
|
||||
Policy policy = 1;
|
||||
}
|
||||
|
||||
message DeletePolicyRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
}
|
||||
message DeletePolicyResponse {}
|
||||
|
||||
message ListPoliciesRequest {
|
||||
Project project = 1;
|
||||
}
|
||||
message ListPoliciesResponse {
|
||||
repeated Policy policies = 1;
|
||||
}
|
||||
|
||||
message EvaluatePoliciesRequest {
|
||||
Project project = 1;
|
||||
string target_environment = 2;
|
||||
// For branch restriction checks
|
||||
optional string branch = 3;
|
||||
optional string release_intent_id = 4;
|
||||
}
|
||||
message EvaluatePoliciesResponse {
|
||||
repeated PolicyEvaluation evaluations = 1;
|
||||
bool all_passed = 2;
|
||||
}
|
||||
|
||||
// ── External approval RPC messages ──────────────────────────────────
|
||||
|
||||
message ExternalApproveReleaseRequest {
|
||||
Project project = 1;
|
||||
string release_intent_id = 2;
|
||||
string target_environment = 3;
|
||||
optional string comment = 4;
|
||||
bool force_bypass = 5;
|
||||
}
|
||||
message ExternalApproveReleaseResponse {
|
||||
ExternalApprovalState state = 1;
|
||||
}
|
||||
|
||||
message ExternalRejectReleaseRequest {
|
||||
Project project = 1;
|
||||
string release_intent_id = 2;
|
||||
string target_environment = 3;
|
||||
optional string comment = 4;
|
||||
}
|
||||
message ExternalRejectReleaseResponse {
|
||||
ExternalApprovalState state = 1;
|
||||
}
|
||||
|
||||
message GetExternalApprovalStateRequest {
|
||||
Project project = 1;
|
||||
string release_intent_id = 2;
|
||||
string target_environment = 3;
|
||||
}
|
||||
message GetExternalApprovalStateResponse {
|
||||
ExternalApprovalState state = 1;
|
||||
}
|
||||
|
||||
service PolicyService {
|
||||
rpc CreatePolicy(CreatePolicyRequest) returns (CreatePolicyResponse);
|
||||
rpc UpdatePolicy(UpdatePolicyRequest) returns (UpdatePolicyResponse);
|
||||
rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse);
|
||||
rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse);
|
||||
rpc EvaluatePolicies(EvaluatePoliciesRequest) returns (EvaluatePoliciesResponse);
|
||||
rpc ExternalApproveRelease(ExternalApproveReleaseRequest) returns (ExternalApproveReleaseResponse);
|
||||
rpc ExternalRejectRelease(ExternalRejectReleaseRequest) returns (ExternalRejectReleaseResponse);
|
||||
rpc GetExternalApprovalState(GetExternalApprovalStateRequest) returns (GetExternalApprovalStateResponse);
|
||||
}
|
||||
80
interface/proto/forest/v1/registry.proto
Normal file
80
interface/proto/forest/v1/registry.proto
Normal file
@@ -0,0 +1,80 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
service RegistryService {
|
||||
rpc GetComponents(GetComponentsRequest) returns (GetComponentsResponse) {}
|
||||
rpc GetComponent(GetComponentRequest) returns (GetComponentResponse) {}
|
||||
rpc GetComponentVersion(GetComponentVersionRequest) returns (GetComponentVersionResponse) {}
|
||||
rpc BeginUpload(BeginUploadRequest) returns (BeginUploadResponse) {}
|
||||
rpc UploadFile(UploadFileRequest) returns (UploadFileResponse) {}
|
||||
rpc CommitUpload(CommitUploadRequest) returns (CommitUploadResponse) {}
|
||||
rpc GetComponentFiles(GetComponentFilesRequest) returns (stream GetComponentFilesResponse) {}
|
||||
}
|
||||
|
||||
message GetComponentsRequest {}
|
||||
message GetComponentsResponse {}
|
||||
|
||||
message GetComponentRequest {
|
||||
string name = 1;
|
||||
string organisation = 2;
|
||||
}
|
||||
message GetComponentResponse {
|
||||
optional Component component = 1;
|
||||
}
|
||||
|
||||
message Component {
|
||||
string id = 1;
|
||||
string version = 2;
|
||||
}
|
||||
|
||||
// ComponentVersion
|
||||
message GetComponentVersionRequest {
|
||||
string name = 1;
|
||||
string organisation = 2;
|
||||
string version = 3;
|
||||
}
|
||||
message GetComponentVersionResponse {
|
||||
optional Component component = 1;
|
||||
}
|
||||
|
||||
// BeginUpload
|
||||
|
||||
message BeginUploadRequest {
|
||||
string name = 1;
|
||||
string organisation = 2;
|
||||
string version = 3;
|
||||
}
|
||||
message BeginUploadResponse {
|
||||
string upload_context = 1;
|
||||
}
|
||||
|
||||
message UploadFileRequest {
|
||||
string upload_context = 1;
|
||||
string file_path = 2;
|
||||
bytes file_content = 3;
|
||||
}
|
||||
message UploadFileResponse {}
|
||||
|
||||
message CommitUploadRequest {
|
||||
string upload_context = 1;
|
||||
}
|
||||
message CommitUploadResponse {}
|
||||
|
||||
// Get component files
|
||||
message GetComponentFilesRequest {
|
||||
string component_id = 1;
|
||||
}
|
||||
message GetComponentFilesResponse {
|
||||
oneof msg {
|
||||
Done done = 1;
|
||||
ComponentFile component_file = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ComponentFile {
|
||||
string file_path = 1;
|
||||
bytes file_content = 2;
|
||||
}
|
||||
|
||||
message Done {}
|
||||
@@ -10,6 +10,7 @@ enum StageType {
|
||||
STAGE_TYPE_UNSPECIFIED = 0;
|
||||
STAGE_TYPE_DEPLOY = 1;
|
||||
STAGE_TYPE_WAIT = 2;
|
||||
STAGE_TYPE_PLAN = 3;
|
||||
}
|
||||
|
||||
// ── Per-type config messages ─────────────────────────────────────────
|
||||
@@ -22,6 +23,11 @@ message WaitStageConfig {
|
||||
int64 duration_seconds = 1;
|
||||
}
|
||||
|
||||
message PlanStageConfig {
|
||||
string environment = 1;
|
||||
bool auto_approve = 2;
|
||||
}
|
||||
|
||||
// ── A single pipeline stage ──────────────────────────────────────────
|
||||
|
||||
message PipelineStage {
|
||||
@@ -31,6 +37,7 @@ message PipelineStage {
|
||||
oneof config {
|
||||
DeployStageConfig deploy = 10;
|
||||
WaitStageConfig wait = 11;
|
||||
PlanStageConfig plan = 12;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +50,7 @@ enum PipelineStageStatus {
|
||||
PIPELINE_STAGE_STATUS_SUCCEEDED = 3;
|
||||
PIPELINE_STAGE_STATUS_FAILED = 4;
|
||||
PIPELINE_STAGE_STATUS_CANCELLED = 5;
|
||||
PIPELINE_STAGE_STATUS_AWAITING_APPROVAL = 6;
|
||||
}
|
||||
|
||||
// ── Pipeline resource ────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,8 @@ message ReleaseRequest {
|
||||
// When true, use the project's release pipeline (DAG) instead of
|
||||
// deploying directly to the specified destinations/environments.
|
||||
bool use_pipeline = 5;
|
||||
// When true, create a plan-only pipeline (single Plan stage, no deploy).
|
||||
bool prepare_only = 6;
|
||||
}
|
||||
message ReleaseResponse {
|
||||
// List of release intents created (one per destination)
|
||||
@@ -55,9 +57,23 @@ message WaitReleaseEvent {
|
||||
oneof event {
|
||||
ReleaseStatusUpdate status_update = 1;
|
||||
ReleaseLogLine log_line = 2;
|
||||
PipelineStageUpdate stage_update = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Streamed in WaitRelease for pipeline releases: reports stage status changes.
|
||||
message PipelineStageUpdate {
|
||||
string stage_id = 1;
|
||||
string stage_type = 2; // "deploy", "wait"
|
||||
string status = 3; // PENDING, ACTIVE, SUCCEEDED, FAILED, CANCELLED
|
||||
optional string queued_at = 4;
|
||||
optional string started_at = 5;
|
||||
optional string completed_at = 6;
|
||||
optional string wait_until = 7;
|
||||
optional string error_message = 8;
|
||||
optional string approval_status = 9;
|
||||
}
|
||||
|
||||
message ReleaseStatusUpdate {
|
||||
string destination = 1;
|
||||
string status = 2;
|
||||
@@ -90,6 +106,13 @@ message GetProjectsResponse {
|
||||
repeated string projects = 1;
|
||||
}
|
||||
|
||||
message CreateProjectRequest {
|
||||
string organisation = 1;
|
||||
string project = 2;
|
||||
}
|
||||
message CreateProjectResponse {
|
||||
Project project = 1;
|
||||
}
|
||||
|
||||
|
||||
message GetReleasesByActorRequest {
|
||||
@@ -125,6 +148,67 @@ message GetDestinationStatesRequest {
|
||||
|
||||
message GetDestinationStatesResponse {
|
||||
repeated DestinationState destinations = 1;
|
||||
// Active pipeline runs affecting these destinations (if any).
|
||||
repeated PipelineRunState pipeline_runs = 2;
|
||||
}
|
||||
|
||||
// ── Release intent states (release-centric view) ─────────────────────
|
||||
|
||||
message GetReleaseIntentStatesRequest {
|
||||
string organisation = 1;
|
||||
optional string project = 2;
|
||||
// When true, also include recently completed release intents.
|
||||
bool include_completed = 3;
|
||||
}
|
||||
|
||||
message GetReleaseIntentStatesResponse {
|
||||
repeated ReleaseIntentState release_intents = 1;
|
||||
}
|
||||
|
||||
// Full state of a release intent: pipeline stages + individual release steps.
|
||||
message ReleaseIntentState {
|
||||
string release_intent_id = 1;
|
||||
string artifact_id = 2;
|
||||
string project = 3;
|
||||
string created_at = 4;
|
||||
// Pipeline stages (empty for non-pipeline releases).
|
||||
repeated PipelineStageState stages = 5;
|
||||
// All release_states rows for this intent (deploy steps).
|
||||
repeated ReleaseStepState steps = 6;
|
||||
}
|
||||
|
||||
// Status of a single pipeline stage (saga coordinator view).
|
||||
message PipelineStageState {
|
||||
string stage_id = 1;
|
||||
repeated string depends_on = 2;
|
||||
PipelineRunStageType stage_type = 3;
|
||||
PipelineRunStageStatus status = 4;
|
||||
// Consistent timestamps for all stage types.
|
||||
optional string queued_at = 5;
|
||||
optional string started_at = 6;
|
||||
optional string completed_at = 7;
|
||||
optional string error_message = 8;
|
||||
// Type-specific context.
|
||||
optional string environment = 9; // deploy/plan stages
|
||||
optional int64 duration_seconds = 10; // wait stages
|
||||
optional string wait_until = 11; // wait stages
|
||||
repeated string release_ids = 12; // deploy/plan stages: individual release IDs
|
||||
optional string approval_status = 13; // plan stages: AWAITING_APPROVAL, APPROVED, REJECTED
|
||||
optional bool auto_approve = 14; // plan stages
|
||||
}
|
||||
|
||||
// Status of a single release step (release_states row).
|
||||
message ReleaseStepState {
|
||||
string release_id = 1;
|
||||
optional string stage_id = 2;
|
||||
string destination_name = 3;
|
||||
string environment = 4;
|
||||
string status = 5;
|
||||
optional string queued_at = 6;
|
||||
optional string assigned_at = 7;
|
||||
optional string started_at = 8;
|
||||
optional string completed_at = 9;
|
||||
optional string error_message = 10;
|
||||
}
|
||||
|
||||
message DestinationState {
|
||||
@@ -138,6 +222,83 @@ message DestinationState {
|
||||
optional string queued_at = 8;
|
||||
optional string completed_at = 9;
|
||||
optional int32 queue_position = 10;
|
||||
// Pipeline context: set when this release was created by a pipeline stage.
|
||||
optional string release_intent_id = 11;
|
||||
optional string stage_id = 12;
|
||||
// When a runner was assigned to this release.
|
||||
optional string assigned_at = 13;
|
||||
// When the runner actually started executing.
|
||||
optional string started_at = 14;
|
||||
}
|
||||
|
||||
// ── Pipeline run progress ────────────────────────────────────────────
|
||||
|
||||
// Snapshot of an active (or recently completed) pipeline run.
|
||||
message PipelineRunState {
|
||||
string release_intent_id = 1;
|
||||
string artifact_id = 2;
|
||||
string created_at = 3;
|
||||
repeated PipelineRunStage stages = 4;
|
||||
}
|
||||
|
||||
// Status of a single stage within a pipeline run.
|
||||
message PipelineRunStage {
|
||||
string stage_id = 1;
|
||||
repeated string depends_on = 2;
|
||||
PipelineRunStageType stage_type = 3;
|
||||
PipelineRunStageStatus status = 4;
|
||||
// Type-specific context
|
||||
optional string environment = 5; // deploy stages
|
||||
optional int64 duration_seconds = 6; // wait stages
|
||||
optional string queued_at = 7; // when dependencies were met
|
||||
optional string started_at = 8;
|
||||
optional string completed_at = 9;
|
||||
optional string error_message = 10;
|
||||
optional string wait_until = 11;
|
||||
repeated string release_ids = 12; // deploy stages: individual release IDs
|
||||
optional string approval_status = 13; // plan stages: AWAITING_APPROVAL, APPROVED, REJECTED
|
||||
optional bool auto_approve = 14; // plan stages
|
||||
}
|
||||
|
||||
enum PipelineRunStageType {
|
||||
PIPELINE_RUN_STAGE_TYPE_UNSPECIFIED = 0;
|
||||
PIPELINE_RUN_STAGE_TYPE_DEPLOY = 1;
|
||||
PIPELINE_RUN_STAGE_TYPE_WAIT = 2;
|
||||
PIPELINE_RUN_STAGE_TYPE_PLAN = 3;
|
||||
}
|
||||
|
||||
enum PipelineRunStageStatus {
|
||||
PIPELINE_RUN_STAGE_STATUS_UNSPECIFIED = 0;
|
||||
PIPELINE_RUN_STAGE_STATUS_PENDING = 1;
|
||||
PIPELINE_RUN_STAGE_STATUS_ACTIVE = 2;
|
||||
PIPELINE_RUN_STAGE_STATUS_SUCCEEDED = 3;
|
||||
PIPELINE_RUN_STAGE_STATUS_FAILED = 4;
|
||||
PIPELINE_RUN_STAGE_STATUS_CANCELLED = 5;
|
||||
PIPELINE_RUN_STAGE_STATUS_AWAITING_APPROVAL = 6;
|
||||
}
|
||||
|
||||
// ── Plan stage approval ──────────────────────────────────────────────
|
||||
|
||||
message ApprovePlanStageRequest {
|
||||
string release_intent_id = 1;
|
||||
string stage_id = 2;
|
||||
}
|
||||
message ApprovePlanStageResponse {}
|
||||
|
||||
message RejectPlanStageRequest {
|
||||
string release_intent_id = 1;
|
||||
string stage_id = 2;
|
||||
optional string reason = 3;
|
||||
}
|
||||
message RejectPlanStageResponse {}
|
||||
|
||||
message GetPlanOutputRequest {
|
||||
string release_intent_id = 1;
|
||||
string stage_id = 2;
|
||||
}
|
||||
message GetPlanOutputResponse {
|
||||
string plan_output = 1;
|
||||
string status = 2; // RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
|
||||
}
|
||||
|
||||
service ReleaseService {
|
||||
@@ -150,7 +311,13 @@ service ReleaseService {
|
||||
rpc GetReleasesByActor(GetReleasesByActorRequest) returns (GetReleasesByActorResponse);
|
||||
rpc GetOrganisations(GetOrganisationsRequest) returns (GetOrganisationsResponse);
|
||||
rpc GetProjects(GetProjectsRequest) returns (GetProjectsResponse);
|
||||
rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
|
||||
rpc GetDestinationStates(GetDestinationStatesRequest) returns (GetDestinationStatesResponse);
|
||||
rpc GetReleaseIntentStates(GetReleaseIntentStatesRequest) returns (GetReleaseIntentStatesResponse);
|
||||
|
||||
rpc ApprovePlanStage(ApprovePlanStageRequest) returns (ApprovePlanStageResponse);
|
||||
rpc RejectPlanStage(RejectPlanStageRequest) returns (RejectPlanStageResponse);
|
||||
rpc GetPlanOutput(GetPlanOutputRequest) returns (GetPlanOutputResponse);
|
||||
}
|
||||
|
||||
message Source {
|
||||
@@ -158,6 +325,8 @@ message Source {
|
||||
optional string email = 2;
|
||||
optional string source_type = 3;
|
||||
optional string run_url = 4;
|
||||
// The actor ID (user, app, or service account UUID) that created this annotation.
|
||||
optional string user_id = 5;
|
||||
}
|
||||
|
||||
message ArtifactContext {
|
||||
@@ -177,6 +346,7 @@ message Artifact {
|
||||
Project project = 7;
|
||||
repeated ArtifactDestination destinations = 8;
|
||||
string created_at = 9;
|
||||
Ref ref = 10;
|
||||
}
|
||||
|
||||
message ArtifactDestination {
|
||||
|
||||
212
interface/proto/forest/v1/runner.proto
Normal file
212
interface/proto/forest/v1/runner.proto
Normal file
@@ -0,0 +1,212 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
// RunnerService is exposed by the forest-server. Runners (workers) call these
|
||||
// RPCs to register for work, fetch release artifacts, stream logs, and report
|
||||
// completion. Authentication for all post-assignment RPCs uses a release-scoped
|
||||
// opaque token rather than the regular JWT flow.
|
||||
service RunnerService {
|
||||
// Bidirectional stream used for runner registration and work assignment.
|
||||
// The runner sends a RunnerRegister as its first message, then periodic
|
||||
// RunnerHeartbeat messages. The server responds with a RegisterAck followed
|
||||
// by WorkAssignment messages when releases matching the runner's capabilities
|
||||
// become available.
|
||||
rpc RegisterRunner(stream RunnerMessage) returns (stream ServerMessage);
|
||||
|
||||
// Fetch the artifact files for a release assigned to this runner.
|
||||
// Scoped by the release_token received in the WorkAssignment.
|
||||
rpc GetReleaseFiles(GetReleaseFilesRequest) returns (stream ReleaseFile);
|
||||
|
||||
// Stream log lines back to the server for real-time display.
|
||||
// Each message must include the release_token for authentication.
|
||||
rpc PushLogs(stream PushLogRequest) returns (PushLogResponse);
|
||||
|
||||
// Fetch the original spec files for a release.
|
||||
// Scoped by the release_token received in the WorkAssignment.
|
||||
rpc GetSpecFiles(GetSpecFilesRequest) returns (stream ReleaseFile);
|
||||
|
||||
// Fetch the annotation (metadata context) for a release.
|
||||
rpc GetReleaseAnnotation(GetReleaseAnnotationRequest) returns (ReleaseAnnotationResponse);
|
||||
|
||||
// Fetch project info (organisation + project name) for a release.
|
||||
rpc GetProjectInfo(GetProjectInfoRequest) returns (ProjectInfoResponse);
|
||||
|
||||
// Report the final outcome of a release (success or failure).
|
||||
// This commits the release status and revokes the token.
|
||||
rpc CompleteRelease(CompleteReleaseRequest) returns (CompleteReleaseResponse);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connect stream: Runner → Server
|
||||
// ============================================================================
|
||||
|
||||
message RunnerMessage {
|
||||
oneof message {
|
||||
RunnerRegister register = 1;
|
||||
RunnerHeartbeat heartbeat = 2;
|
||||
WorkAck work_ack = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// First message a runner sends on the Connect stream.
|
||||
message RunnerRegister {
|
||||
// Runner-chosen unique identifier. If empty, the server assigns one.
|
||||
string runner_id = 1;
|
||||
// Destination types this runner can handle.
|
||||
repeated DestinationCapability capabilities = 2;
|
||||
// Maximum number of simultaneous releases this runner can process.
|
||||
int32 max_concurrent = 3;
|
||||
}
|
||||
|
||||
// Describes a destination type the runner supports.
|
||||
message DestinationCapability {
|
||||
string organisation = 1;
|
||||
string name = 2;
|
||||
uint64 version = 3;
|
||||
}
|
||||
|
||||
// Periodic keepalive sent by the runner (recommended every 10s).
|
||||
message RunnerHeartbeat {
|
||||
// Current number of in-progress releases on this runner.
|
||||
int32 active_releases = 1;
|
||||
}
|
||||
|
||||
// Runner's response to a WorkAssignment.
|
||||
message WorkAck {
|
||||
string release_token = 1;
|
||||
// false = runner rejects the work (e.g., overloaded). The server will
|
||||
// reassign or fall back to in-process execution.
|
||||
bool accepted = 2;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connect stream: Server → Runner
|
||||
// ============================================================================
|
||||
|
||||
message ServerMessage {
|
||||
oneof message {
|
||||
RegisterAck register_ack = 1;
|
||||
WorkAssignment work_assignment = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Server response to RunnerRegister.
|
||||
message RegisterAck {
|
||||
// Server-confirmed (or server-assigned) runner ID.
|
||||
string runner_id = 1;
|
||||
bool accepted = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
// Work assignment pushed to a runner when a matching release is available.
|
||||
message WorkAssignment {
|
||||
// Scoped opaque auth token. Use this for GetReleaseFiles, PushLogs,
|
||||
// and CompleteRelease. The token restricts access to only the data
|
||||
// associated with this specific release.
|
||||
string release_token = 1;
|
||||
string release_id = 2;
|
||||
string release_intent_id = 3;
|
||||
string artifact_id = 4;
|
||||
string destination_id = 5;
|
||||
// Full destination configuration including metadata.
|
||||
DestinationInfo destination = 6;
|
||||
}
|
||||
|
||||
// Destination configuration sent with the work assignment.
|
||||
message DestinationInfo {
|
||||
string name = 1;
|
||||
string environment = 2;
|
||||
map<string, string> metadata = 3;
|
||||
DestinationCapability type = 4;
|
||||
string organisation = 5;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GetReleaseFiles
|
||||
// ============================================================================
|
||||
|
||||
message GetReleaseFilesRequest {
|
||||
string release_token = 1;
|
||||
}
|
||||
|
||||
message ReleaseFile {
|
||||
string file_name = 1;
|
||||
string file_content = 2;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GetSpecFiles
|
||||
// ============================================================================
|
||||
|
||||
message GetSpecFilesRequest {
|
||||
string release_token = 1;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GetReleaseAnnotation
|
||||
// ============================================================================
|
||||
|
||||
message GetReleaseAnnotationRequest {
|
||||
string release_token = 1;
|
||||
}
|
||||
|
||||
message ReleaseAnnotationResponse {
|
||||
string slug = 1;
|
||||
string source_username = 2;
|
||||
string source_email = 3;
|
||||
string context_title = 4;
|
||||
string context_description = 5;
|
||||
string context_web = 6;
|
||||
string reference_version = 7;
|
||||
string reference_commit_sha = 8;
|
||||
string reference_commit_branch = 9;
|
||||
string reference_commit_message = 10;
|
||||
string created_at = 11;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GetProjectInfo
|
||||
// ============================================================================
|
||||
|
||||
message GetProjectInfoRequest {
|
||||
string release_token = 1;
|
||||
}
|
||||
|
||||
message ProjectInfoResponse {
|
||||
string organisation = 1;
|
||||
string project = 2;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PushLogs
|
||||
// ============================================================================
|
||||
|
||||
message PushLogRequest {
|
||||
string release_token = 1;
|
||||
// "stdout" or "stderr"
|
||||
string channel = 2;
|
||||
string line = 3;
|
||||
uint64 timestamp = 4;
|
||||
}
|
||||
|
||||
message PushLogResponse {}
|
||||
|
||||
// ============================================================================
|
||||
// CompleteRelease
|
||||
// ============================================================================
|
||||
|
||||
message CompleteReleaseRequest {
|
||||
string release_token = 1;
|
||||
ReleaseOutcome outcome = 2;
|
||||
// Error description when outcome is FAILURE.
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
enum ReleaseOutcome {
|
||||
RELEASE_OUTCOME_UNSPECIFIED = 0;
|
||||
RELEASE_OUTCOME_SUCCESS = 1;
|
||||
RELEASE_OUTCOME_FAILURE = 2;
|
||||
}
|
||||
|
||||
message CompleteReleaseResponse {}
|
||||
79
interface/proto/forest/v1/triggers.proto
Normal file
79
interface/proto/forest/v1/triggers.proto
Normal file
@@ -0,0 +1,79 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
import "forest/v1/releases.proto";
|
||||
|
||||
message Trigger {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
bool enabled = 3;
|
||||
optional string branch_pattern = 4;
|
||||
optional string title_pattern = 5;
|
||||
optional string author_pattern = 6;
|
||||
optional string commit_message_pattern = 7;
|
||||
optional string source_type_pattern = 8;
|
||||
repeated string target_environments = 9;
|
||||
repeated string target_destinations = 10;
|
||||
bool force_release = 11;
|
||||
string created_at = 12;
|
||||
string updated_at = 13;
|
||||
// When true, trigger the project's release pipeline instead of
|
||||
// deploying directly to target destinations/environments.
|
||||
bool use_pipeline = 14;
|
||||
}
|
||||
|
||||
message CreateTriggerRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
optional string branch_pattern = 3;
|
||||
optional string title_pattern = 4;
|
||||
optional string author_pattern = 5;
|
||||
optional string commit_message_pattern = 6;
|
||||
optional string source_type_pattern = 7;
|
||||
repeated string target_environments = 8;
|
||||
repeated string target_destinations = 9;
|
||||
bool force_release = 10;
|
||||
bool use_pipeline = 11;
|
||||
}
|
||||
message CreateTriggerResponse {
|
||||
Trigger trigger = 1;
|
||||
}
|
||||
|
||||
message UpdateTriggerRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
optional bool enabled = 3;
|
||||
optional string branch_pattern = 4;
|
||||
optional string title_pattern = 5;
|
||||
optional string author_pattern = 6;
|
||||
optional string commit_message_pattern = 7;
|
||||
optional string source_type_pattern = 8;
|
||||
repeated string target_environments = 9;
|
||||
repeated string target_destinations = 10;
|
||||
optional bool force_release = 11;
|
||||
optional bool use_pipeline = 12;
|
||||
}
|
||||
message UpdateTriggerResponse {
|
||||
Trigger trigger = 1;
|
||||
}
|
||||
|
||||
message DeleteTriggerRequest {
|
||||
Project project = 1;
|
||||
string name = 2;
|
||||
}
|
||||
message DeleteTriggerResponse {}
|
||||
|
||||
message ListTriggersRequest {
|
||||
Project project = 1;
|
||||
}
|
||||
message ListTriggersResponse {
|
||||
repeated Trigger triggers = 1;
|
||||
}
|
||||
|
||||
service TriggerService {
|
||||
rpc CreateTrigger(CreateTriggerRequest) returns (CreateTriggerResponse);
|
||||
rpc UpdateTrigger(UpdateTriggerRequest) returns (UpdateTriggerResponse);
|
||||
rpc DeleteTrigger(DeleteTriggerRequest) returns (DeleteTriggerResponse);
|
||||
rpc ListTriggers(ListTriggersRequest) returns (ListTriggersResponse);
|
||||
}
|
||||
BIN
policies-with-approval.png
Normal file
BIN
policies-with-approval.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
project-overview-hidden.png
Normal file
BIN
project-overview-hidden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
project-overview.png
Normal file
BIN
project-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
19
scripts/sync-protos.sh
Executable file
19
scripts/sync-protos.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
FOREST_PROTO="/home/kjuulh/git/src.rawpotion.io/rawpotion/forest/interface/proto/forest/v1"
|
||||
FORAGE_PROTO="/home/kjuulh/git/git.kjuulh.io/forage/client/interface/proto/forest/v1"
|
||||
|
||||
echo "Syncing protos from forest -> forage..."
|
||||
|
||||
for proto in "$FOREST_PROTO"/*.proto; do
|
||||
name=$(basename "$proto")
|
||||
cp "$proto" "$FORAGE_PROTO/$name"
|
||||
echo " copied $name"
|
||||
done
|
||||
|
||||
echo "Running buf generate..."
|
||||
cd /home/kjuulh/git/git.kjuulh.io/forage/client
|
||||
buf generate
|
||||
|
||||
echo "Done."
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
171
tasks/approval-gate.md
Normal file
171
tasks/approval-gate.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Approval Gate — Implementation Log
|
||||
|
||||
## Overview
|
||||
|
||||
New policy type `POLICY_TYPE_EXTERNAL_APPROVAL` that requires human approval before a release can deploy to a target environment.
|
||||
|
||||
**Rules:**
|
||||
- Scoped to a single release intent + target environment
|
||||
- Release author cannot approve (unless admin → red "Bypass" button)
|
||||
- All org members can approve
|
||||
- Rejection is a vote, not a permanent block
|
||||
- No timer retry — NATS signal on decision triggers re-evaluation
|
||||
|
||||
---
|
||||
|
||||
## Forage (client) Changes — DONE
|
||||
|
||||
### Proto
|
||||
|
||||
**New file:** `interface/proto/forest/v1/policies.proto`
|
||||
- `POLICY_TYPE_EXTERNAL_APPROVAL = 3` added to `PolicyType` enum
|
||||
- `ApprovalConfig { target_environment, required_approvals }` message
|
||||
- `ApprovalState { required_approvals, current_approvals, decisions }` message
|
||||
- `ApprovalDecisionEntry { user_id, username, decision, decided_at, comment }` message
|
||||
- `PolicyEvaluation` extended with `optional ApprovalState approval_state = 10`
|
||||
- `EvaluatePoliciesRequest` extended with `optional string release_intent_id = 4`
|
||||
- `Policy`, `CreatePolicyRequest`, `UpdatePolicyRequest` oneofs extended with `ApprovalConfig approval = 12`
|
||||
- New RPCs: `ApproveRelease`, `RejectRelease`, `GetApprovalState` with request/response messages
|
||||
|
||||
**New file:** `scripts/sync-protos.sh`
|
||||
- Copies all `.proto` files from forest repo to forage, runs `buf generate`
|
||||
|
||||
**Regenerated:** `crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs` and `forest.v1.tonic.rs`
|
||||
|
||||
### Domain Model
|
||||
|
||||
**File:** `crates/forage-core/src/platform/mod.rs`
|
||||
- `PolicyConfig::Approval { target_environment, required_approvals }` variant
|
||||
- `ApprovalState` struct
|
||||
- `ApprovalDecisionEntry` struct
|
||||
- `PolicyEvaluation.approval_state: Option<ApprovalState>` field
|
||||
- New `ForestPlatform` trait methods: `evaluate_policies`, `approve_release`, `reject_release`, `get_approval_state`
|
||||
|
||||
### gRPC Client
|
||||
|
||||
**File:** `crates/forage-server/src/forest_client.rs`
|
||||
- `convert_policy`: handles `policy::Config::Approval` → `PolicyConfig::Approval`
|
||||
- `policy_config_to_grpc`: handles `PolicyConfig::Approval` → gRPC
|
||||
- `convert_policy_evaluation`: maps policy type 3 → "approval", maps `approval_state`
|
||||
- `convert_approval_state`: maps gRPC `ApprovalState` → domain
|
||||
- `evaluate_policies` impl: calls `PolicyServiceClient::evaluate_policies` with `release_intent_id`
|
||||
- `approve_release` impl: calls `PolicyServiceClient::approve_release`
|
||||
- `reject_release` impl: calls `PolicyServiceClient::reject_release`
|
||||
- `get_approval_state` impl: calls `PolicyServiceClient::get_approval_state`
|
||||
- Fixed `PipelineStage::Plan` match arm (new variant from forest proto sync)
|
||||
- Fixed `ReleaseRequest` missing `prepare_only` field (new field from forest proto sync)
|
||||
|
||||
### Test Support
|
||||
|
||||
**File:** `crates/forage-server/src/test_support.rs`
|
||||
- `MockPlatformClient`: default impls for `evaluate_policies`, `approve_release`, `reject_release`, `get_approval_state`
|
||||
|
||||
### Routes
|
||||
|
||||
**File:** `crates/forage-server/src/routes/platform.rs`
|
||||
|
||||
**New routes:**
|
||||
- `POST /orgs/{org}/projects/{project}/releases/{slug}/approve` → `approve_release_submit`
|
||||
- `POST /orgs/{org}/projects/{project}/releases/{slug}/reject` → `reject_release_submit`
|
||||
|
||||
**New handler structs:**
|
||||
- `ApprovalForm { csrf_token, release_intent_id, target_environment, comment, force_bypass }`
|
||||
- `CreatePolicyForm` extended with `required_approvals: Option<i32>`
|
||||
|
||||
**Modified handlers:**
|
||||
- `create_policy_submit`: handles `policy_type = "approval"` with validation
|
||||
- `policies_page`: maps `PolicyConfig::Approval` to template context
|
||||
- `edit_policy_page`: maps `PolicyConfig::Approval` to template context
|
||||
- `artifact_detail`: fetches policy evaluations per environment, passes `policy_evaluations`, `release_intent_id`, `is_release_author`, `is_admin` to template
|
||||
|
||||
### Templates
|
||||
|
||||
**File:** `templates/pages/policies.html.jinja`
|
||||
- Policy list: "Approval Required" badge with target env + approval count
|
||||
- Create form: "Approval Required" option in type dropdown
|
||||
- Approval fields: target environment select + required approvals number input
|
||||
- JavaScript: toggles visibility of soak/branch/approval field sets
|
||||
|
||||
**File:** `templates/pages/artifact_detail.html.jinja`
|
||||
- New "Policy Requirements" section between Pipeline and Destinations
|
||||
- Shows all policy evaluations (soak, branch, approval) with pass/fail icons
|
||||
- Approval UI:
|
||||
- Approval count badge (current/required)
|
||||
- Decision history (username, approved/rejected, comment)
|
||||
- **Approve** button (green) — shown to non-authors
|
||||
- **Bypass (Admin)** button (red) — shown to admin authors with confirmation dialog
|
||||
- **Reject** button (red outline) — shown to all eligible members
|
||||
- "You cannot approve your own release" message for non-admin authors
|
||||
|
||||
---
|
||||
|
||||
## Forest (core) Changes — NEEDS MANUAL APPLICATION
|
||||
|
||||
### Proto
|
||||
**File:** `interface/proto/forest/v1/policies.proto` — same changes as forage copy above
|
||||
|
||||
### DB Migration
|
||||
**New file:** `crates/forest-server/migrations/20260315000001_approval_decisions.sql`
|
||||
```sql
|
||||
CREATE TABLE approval_decisions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
release_intent_id UUID NOT NULL REFERENCES release_intents(id) ON DELETE CASCADE,
|
||||
policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
|
||||
target_environment TEXT NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- unique per user per intent per env, lookup index for counting
|
||||
```
|
||||
|
||||
### Policy Engine
|
||||
**File:** `crates/forest-server/src/services/policy.rs`
|
||||
- `PolicyType::Approval`, `ApprovalConfig` struct
|
||||
- `PolicyConfig::Approval(ApprovalConfig)` variant
|
||||
- `ApprovalStateInfo`, `ApprovalDecisionInfo` structs
|
||||
- `PolicyEvaluation.approval_state: Option<ApprovalStateInfo>`
|
||||
- `evaluate_for_environment` gains `release_intent_id: Option<&Uuid>` param
|
||||
- `check_approval`: queries approval_decisions, compares count vs required
|
||||
- `record_approval_decision`: upserts into approval_decisions
|
||||
- `get_intent_actor_id`: queries release_intents.actor_id
|
||||
- `find_approval_policy_for_environment`: finds enabled approval policy for target env
|
||||
- `get_approval_state`: returns current approval state for display
|
||||
|
||||
### Intent Coordinator
|
||||
**File:** `crates/forest-server/src/intent_coordinator.rs`
|
||||
- `check_approval_policies` called after `check_soak_time_policies` for deploy stages
|
||||
- If blocked: logs and continues (no timer retry, NATS-triggered re-eval on decision)
|
||||
|
||||
### Release Event Store
|
||||
**File:** `crates/forest-server/src/services/release_event_store.rs`
|
||||
- `check_approval_policies(tx, project_id, release_intent_id, target_environment) -> Option<String>`
|
||||
- Loads enabled approval policies, counts approved decisions, blocks if insufficient
|
||||
|
||||
### gRPC Handlers
|
||||
**File:** `crates/forest-server/src/grpc/policies.rs`
|
||||
- `record_to_grpc`: handles `PolicyConfig::Approval`
|
||||
- `eval_to_grpc`: handles `PolicyType::Approval`, maps `approval_state`
|
||||
- `extract_config` / `extract_update_config`: handles approval config
|
||||
- `evaluate_policies`: passes `release_intent_id` through
|
||||
- `approve_release`: validates actor != intent author (unless force_bypass), records decision, publishes NATS
|
||||
- `reject_release`: records rejection decision
|
||||
- `get_approval_state`: returns current approval state
|
||||
|
||||
### Caller Updates
|
||||
- `src/grpc/release.rs`: `evaluate_for_environment` calls gain `None` as 4th arg
|
||||
- `src/scheduler.rs`: same
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
- Forage: **169 tests passing**, compiles clean (0 errors, 0 warnings)
|
||||
- Forest: tool permissions blocked writes — all code is ready, needs to be applied from forest repo context
|
||||
|
||||
## Next Steps
|
||||
1. Apply forest changes (run claude from the forest directory, or grant write access)
|
||||
2. Run `buf generate` in forest to regenerate gRPC interface stubs
|
||||
3. Run forest tests
|
||||
4. E2E test: create approval policy, trigger release, verify UI shows approval buttons
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<meta name="description" content="{{ description }}">
|
||||
<style>/* Inline critical: cap SVG size before Tailwind loads */svg{max-width:1.5em;max-height:1.5em}</style>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="bg-white text-gray-900 antialiased flex flex-col min-h-screen">
|
||||
|
||||
@@ -144,6 +144,97 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Policy evaluations (approval, soak, branch) ──────────── #}
|
||||
{% if policy_evaluations | length > 0 %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Policy Requirements</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for eval in policy_evaluations %}
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
{% if eval.passed %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% endif %}
|
||||
|
||||
{% if eval.policy_type == "approval" %}
|
||||
<span class="bg-emerald-100 text-emerald-700 text-xs font-medium px-1.5 py-0.5 rounded">Approval</span>
|
||||
{% elif eval.policy_type == "soak_time" %}
|
||||
<span class="bg-indigo-100 text-indigo-700 text-xs font-medium px-1.5 py-0.5 rounded">Soak Time</span>
|
||||
{% elif eval.policy_type == "branch_restriction" %}
|
||||
<span class="bg-orange-100 text-orange-700 text-xs font-medium px-1.5 py-0.5 rounded">Branch</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-gray-600">{{ eval.policy_name }}</span>
|
||||
<span class="text-xs text-gray-400 ml-auto">{{ eval.reason }}</span>
|
||||
</div>
|
||||
|
||||
{# ── Approval UI ──────────────────────────────── #}
|
||||
{% if eval.policy_type == "approval" and eval.approval_state %}
|
||||
<div class="mt-3 ml-7">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<span class="font-medium">{{ eval.approval_state.current_approvals }}/{{ eval.approval_state.required_approvals }} approvals</span>
|
||||
</div>
|
||||
|
||||
{% if eval.approval_state.decisions | length > 0 %}
|
||||
<div class="space-y-1 mb-3">
|
||||
{% for d in eval.approval_state.decisions %}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
{% if d.decision == "approved" %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
<span class="text-green-700 font-medium">{{ d.username }}</span>
|
||||
<span class="text-gray-400">approved</span>
|
||||
{% else %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
<span class="text-red-700 font-medium">{{ d.username }}</span>
|
||||
<span class="text-gray-400">rejected</span>
|
||||
{% endif %}
|
||||
{% if d.comment %}<span class="text-gray-400">— {{ d.comment }}</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not eval.passed %}
|
||||
{% if is_release_author and not is_admin %}
|
||||
<p class="text-xs text-gray-500 italic">You cannot approve your own release.</p>
|
||||
{% else %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if not is_release_author %}
|
||||
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
|
||||
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">Approve</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_release_author and is_admin %}
|
||||
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline" onsubmit="return confirm('You are the release author. This is an admin bypass — are you sure?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
|
||||
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
|
||||
<input type="hidden" name="force_bypass" value="true">
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors">Bypass (Admin)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/reject" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
|
||||
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded-md border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Reject</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destinations with status ──────────────────────────────── #}
|
||||
{% if destinations | length > 0 or configured_destinations | length > 0 %}
|
||||
<div class="mb-8">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="text-2xl font-bold">Deployment Policies</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
|
||||
· Gate deployments with soak times and branch restrictions
|
||||
· Gate deployments with soak times, branch restrictions, and approvals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,6 +39,11 @@
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.branch_pattern }}</code>
|
||||
<span class="text-gray-300">→</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
|
||||
{% elif policy.policy_type == "approval" %}
|
||||
<span class="bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded">Approval Required</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span>{{ policy.config.required_approvals }} approval{{ 's' if policy.config.required_approvals != 1 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -68,7 +73,7 @@
|
||||
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
|
||||
<p class="mb-1">No deployment policies configured.</p>
|
||||
{% if is_admin %}
|
||||
<p class="text-sm">Create one below to gate deployments with soak times or branch restrictions.</p>
|
||||
<p class="text-sm">Create one below to gate deployments with soak times, branch restrictions, or approvals.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -91,6 +96,7 @@
|
||||
class="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<option value="soak_time">Soak Time</option>
|
||||
<option value="branch_restriction">Branch Restriction</option>
|
||||
<option value="approval">Approval Required</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1" id="policy-type-desc">
|
||||
Require an artifact to succeed in a source environment for a duration before deploying to target.
|
||||
@@ -144,6 +150,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Approval fields #}
|
||||
<div id="approval-fields" class="hidden">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="target_environment" id="approval-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}">{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Required approvals</label>
|
||||
<input type="number" name="required_approvals" min="1" value="1" placeholder="1"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<p class="text-xs text-gray-500 mt-1">Number of distinct approvals needed before deployment proceeds.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Create Policy
|
||||
</button>
|
||||
@@ -153,21 +179,33 @@
|
||||
<script>
|
||||
(function() {
|
||||
const typeSelect = document.getElementById('policy-type');
|
||||
const soakFields = document.getElementById('soak-time-fields');
|
||||
const branchFields = document.getElementById('branch-restriction-fields');
|
||||
const sections = {
|
||||
soak_time: document.getElementById('soak-time-fields'),
|
||||
branch_restriction: document.getElementById('branch-restriction-fields'),
|
||||
approval: document.getElementById('approval-fields'),
|
||||
};
|
||||
const desc = document.getElementById('policy-type-desc');
|
||||
|
||||
const descriptions = {
|
||||
soak_time: 'Require an artifact to succeed in a source environment for a duration before deploying to target.',
|
||||
branch_restriction: 'Only allow deployments to the target environment from a specific branch pattern.',
|
||||
approval: 'Require one or more team members to approve before deploying to the target environment.',
|
||||
};
|
||||
|
||||
typeSelect.addEventListener('change', () => {
|
||||
const isSoak = typeSelect.value === 'soak_time';
|
||||
soakFields.classList.toggle('hidden', !isSoak);
|
||||
branchFields.classList.toggle('hidden', isSoak);
|
||||
desc.textContent = descriptions[typeSelect.value] || '';
|
||||
});
|
||||
function toggle() {
|
||||
const v = typeSelect.value;
|
||||
for (const [key, el] of Object.entries(sections)) {
|
||||
const active = key === v;
|
||||
el.classList.toggle('hidden', !active);
|
||||
el.querySelectorAll('input, select').forEach(function(inp) {
|
||||
inp.disabled = !active;
|
||||
});
|
||||
}
|
||||
desc.textContent = descriptions[v] || '';
|
||||
}
|
||||
|
||||
typeSelect.addEventListener('change', toggle);
|
||||
toggle();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<release-timeline org="{{ org_name }}" project="{{ project_name }}"></release-timeline>
|
||||
<release-timeline org="{{ org_name }}" project="{{ project_name }}" csrf="{{ csrf_token }}" username="{{ user.username }}" role="{{ current_role }}"></release-timeline>
|
||||
</section>
|
||||
<script src="/static/js/components/forage-components.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user