Compare commits
1 Commits
main
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
| c1351d8381 |
20
.env.example
20
.env.example
@@ -4,21 +4,5 @@ FOREST_SERVER_URL=http://localhost:4040
|
||||
# HTTP port (default: 3000)
|
||||
# PORT=3001
|
||||
|
||||
# PostgreSQL connection (required for integrations, optional for sessions)
|
||||
DATABASE_URL=postgresql://forageuser:foragepassword@localhost:5433/forage
|
||||
|
||||
# Encryption key for integration configs at rest (32+ chars recommended)
|
||||
# INTEGRATION_ENCRYPTION_KEY=your-secret-key-here
|
||||
|
||||
# Slack OAuth (for Slack integration setup)
|
||||
# SLACK_CLIENT_ID=your-slack-client-id
|
||||
# SLACK_CLIENT_SECRET=your-slack-client-secret
|
||||
|
||||
# Service token (PAT) for forest-server notification listener auth
|
||||
# FORAGE_SERVICE_TOKEN=forage-secret
|
||||
|
||||
# NATS JetStream for durable notification delivery (optional, falls back to direct dispatch)
|
||||
# NATS_URL=nats://localhost:4223
|
||||
|
||||
# Base URL for OAuth callbacks (default: http://localhost:3000)
|
||||
# FORAGE_BASE_URL=https://forage.sh
|
||||
# PostgreSQL connection (optional - omit for in-memory sessions)
|
||||
# DATABASE_URL=postgresql://forageuser:foragepassword@localhost:5432/forage
|
||||
|
||||
1
.playwright-mcp/console-2026-03-07T19-59-28-775Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T19-59-28-775Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 71ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||
12
.playwright-mcp/console-2026-03-07T20-32-14-148Z.log
Normal file
12
.playwright-mcp/console-2026-03-07T20-32-14-148Z.log
Normal file
@@ -0,0 +1,12 @@
|
||||
[ 469877ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 473324ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 473751ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 473934ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 474119ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 474291ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 474467ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 474629ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
|
||||
[ 560213ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
|
||||
[ 561436ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
|
||||
[ 561803ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
|
||||
[ 561970ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
|
||||
1
.playwright-mcp/console-2026-03-07T20-35-27-169Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T20-35-27-169Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://rawpotion.io/favicon.ico:0
|
||||
4
.playwright-mcp/console-2026-03-07T21-49-57-898Z.log
Normal file
4
.playwright-mcp/console-2026-03-07T21-49-57-898Z.log
Normal file
@@ -0,0 +1,4 @@
|
||||
[ 6ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
|
||||
[ 1711ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
|
||||
[ 2177ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
|
||||
[ 2346ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
|
||||
1
.playwright-mcp/console-2026-03-07T23-04-16-205Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T23-04-16-205Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 72ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||
1
.playwright-mcp/console-2026-03-07T23-06-48-917Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T23-06-48-917Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 9ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0
|
||||
1
.playwright-mcp/console-2026-03-07T23-08-47-266Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T23-08-47-266Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 7745ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0
|
||||
1
.playwright-mcp/console-2026-03-07T23-28-21-144Z.log
Normal file
1
.playwright-mcp/console-2026-03-07T23-28-21-144Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 243429ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0
|
||||
1
.playwright-mcp/console-2026-03-08T13-09-32-749Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T13-09-32-749Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 11ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/testorg/projects/my-api/policies:0
|
||||
1
.playwright-mcp/console-2026-03-08T14-22-14-670Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T14-22-14-670Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 83695ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/other-example/pipelines:0
|
||||
1
.playwright-mcp/console-2026-03-08T14-24-37-198Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T14-24-37-198Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 27797ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/other-example/pipelines:0
|
||||
10
.playwright-mcp/console-2026-03-08T15-10-54-843Z.log
Normal file
10
.playwright-mcp/console-2026-03-08T15-10-54-843Z.log
Normal file
@@ -0,0 +1,10 @@
|
||||
[ 183938ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 185942ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 189946ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 197960ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 213961ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 243962ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 273963ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 303968ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 333973ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
[ 363977ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
|
||||
1
.playwright-mcp/console-2026-03-08T18-58-10-322Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T18-58-10-322Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 69ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||
4
.playwright-mcp/console-2026-03-08T19-03-18-217Z.log
Normal file
4
.playwright-mcp/console-2026-03-08T19-03-18-217Z.log
Normal file
@@ -0,0 +1,4 @@
|
||||
[ 42748ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 43749ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 49108ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 50109ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
1
.playwright-mcp/console-2026-03-08T19-16-00-412Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T19-16-00-412Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 281704ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/service-example:0
|
||||
10
.playwright-mcp/console-2026-03-08T20-27-09-753Z.log
Normal file
10
.playwright-mcp/console-2026-03-08T20-27-09-753Z.log
Normal file
@@ -0,0 +1,10 @@
|
||||
[ 136576ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 137577ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 139578ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 152714ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 153715ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 601126ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 602127ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 604128ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 608129ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
[ 616130ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
|
||||
5
.playwright-mcp/console-2026-03-08T21-01-30-414Z.log
Normal file
5
.playwright-mcp/console-2026-03-08T21-01-30-414Z.log
Normal file
@@ -0,0 +1,5 @@
|
||||
[ 80067ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
[ 90065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
[ 100065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
[ 110065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
[ 120065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
1
.playwright-mcp/console-2026-03-08T21-05-06-279Z.log
Normal file
1
.playwright-mcp/console-2026-03-08T21-05-06-279Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 1030036ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
BIN
.playwright-mcp/element-2026-03-08T21-50-01-232Z.png
Normal file
BIN
.playwright-mcp/element-2026-03-08T21-50-01-232Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-50-44-447Z.png
Normal file
BIN
.playwright-mcp/element-2026-03-08T21-50-44-447Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-53-14-160Z.png
Normal file
BIN
.playwright-mcp/element-2026-03-08T21-53-14-160Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-54-05-889Z.png
Normal file
BIN
.playwright-mcp/element-2026-03-08T21-54-05-889Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-55-10-800Z.png
Normal file
BIN
.playwright-mcp/element-2026-03-08T21-55-10-800Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
.playwright-mcp/page-2026-03-08T21-56-48-888Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-08T21-56-48-888Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
@@ -90,7 +90,6 @@ 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`)
|
||||
|
||||
582
Cargo.lock
generated
582
Cargo.lock
generated
@@ -100,53 +100,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-nats"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e23419d455dc57d3ae60a2f4278cf561fc74fe866e548e14d2b0ad3e1b8ca0b2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures",
|
||||
"memchr",
|
||||
"nkeys",
|
||||
"nuid",
|
||||
"once_cell",
|
||||
"pin-project",
|
||||
"portable-atomic",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-native-certs",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-webpki 0.102.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_nanos",
|
||||
"serde_repr",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tokio-websockets",
|
||||
"tracing",
|
||||
"tryhard",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -328,9 +281,6 @@ name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
@@ -482,16 +432,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -531,24 +471,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -574,32 +496,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dagger-sdk"
|
||||
version = "0.20.1"
|
||||
@@ -665,12 +561,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -689,7 +579,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -773,28 +662,6 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"sha2",
|
||||
"signature",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -851,16 +718,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -877,12 +734,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@@ -939,14 +790,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -957,7 +805,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"forage-core",
|
||||
"moka",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -981,7 +828,6 @@ name = "forage-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
@@ -990,21 +836,16 @@ dependencies = [
|
||||
"forage-db",
|
||||
"forage-grpc",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"minijinja",
|
||||
"notmad",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -1015,21 +856,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1489,23 +1315,7 @@ dependencies = [
|
||||
"hyper 0.14.32",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1521,22 +1331,6 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1555,11 +1349,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"system-configuration 0.7.0",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1926,74 +1718,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"event-listener",
|
||||
"futures-util",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nkeys"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"ed25519",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notmad"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88f52fa65fdf2dc8bf9e0ba7e95f0966a3d7449f660922cc21d96fe382f5c82e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"rand 0.9.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2003,15 +1727,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nuid"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -2076,56 +1791,6 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.31.0"
|
||||
@@ -2327,12 +1992,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2517,18 +2176,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -2561,7 +2208,7 @@ dependencies = [
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper-rustls 0.24.2",
|
||||
"hyper-rustls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -2570,14 +2217,14 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration 0.5.1",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -2597,31 +2244,23 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.13",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -2665,15 +2304,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -2713,19 +2343,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
|
||||
dependencies = [
|
||||
"openssl-probe 0.1.6",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -2735,15 +2352,6 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -2763,16 +2371,6 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -2796,15 +2394,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2821,42 +2410,6 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.2.2"
|
||||
@@ -2938,15 +2491,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_nanos"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
@@ -2958,17 +2502,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -3028,18 +2561,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signatory"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
@@ -3398,19 +2919,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys 0.6.0",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3423,22 +2933,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
@@ -3596,16 +3090,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
@@ -3616,16 +3100,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.37",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@@ -3650,27 +3124,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-websockets"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.5"
|
||||
@@ -3855,16 +3308,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tryhard"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -4238,17 +3681,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -29,16 +29,9 @@ prost = "0.14"
|
||||
prost-types = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
async-trait = "0.1"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
moka = { version = "0.12", features = ["future"] }
|
||||
notmad = "0.11"
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
rand = "0.9"
|
||||
time = "0.3"
|
||||
opentelemetry = "0.31"
|
||||
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
|
||||
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
|
||||
tracing-opentelemetry = "0.32"
|
||||
async-nats = "0.40"
|
||||
|
||||
@@ -11,9 +11,6 @@ serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
rand.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
@@ -1,930 +0,0 @@
|
||||
pub mod nats;
|
||||
pub mod router;
|
||||
pub mod webhook;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── Integration types ────────────────────────────────────────────────
|
||||
|
||||
/// An org-level notification integration (Slack workspace, webhook URL, etc.).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Integration {
|
||||
pub id: String,
|
||||
pub organisation: String,
|
||||
pub integration_type: IntegrationType,
|
||||
pub name: String,
|
||||
pub config: IntegrationConfig,
|
||||
pub enabled: bool,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// The raw API token, only populated when the integration is first created.
|
||||
/// After creation, this is None (only the hash is stored).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported integration types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntegrationType {
|
||||
Slack,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl IntegrationType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Slack => "slack",
|
||||
Self::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"slack" => Some(Self::Slack),
|
||||
"webhook" => Some(Self::Webhook),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Slack => "Slack",
|
||||
Self::Webhook => "Webhook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-specific configuration for an integration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum IntegrationConfig {
|
||||
Slack {
|
||||
team_id: String,
|
||||
team_name: String,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
access_token: String,
|
||||
webhook_url: String,
|
||||
},
|
||||
Webhook {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
secret: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Notification rules ───────────────────────────────────────────────
|
||||
|
||||
/// Which event types an integration should receive.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationRule {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub notification_type: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Known notification event types.
|
||||
pub const NOTIFICATION_TYPES: &[&str] = &[
|
||||
"release_annotated",
|
||||
"release_started",
|
||||
"release_succeeded",
|
||||
"release_failed",
|
||||
];
|
||||
|
||||
// ── Slack user links ─────────────────────────────────────────────────
|
||||
|
||||
/// Links a Forage user to their Slack identity in a workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlackUserLink {
|
||||
pub id: String,
|
||||
pub user_id: String, // Forage/Forest user ID
|
||||
pub team_id: String, // Slack workspace ID
|
||||
pub team_name: String, // Slack workspace name (display)
|
||||
pub slack_user_id: String, // Slack user ID (U-xxx)
|
||||
pub slack_username: String, // Slack display name
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Per-destination deployment status within a release.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DestinationStatus {
|
||||
pub environment: String,
|
||||
pub status: String, // "started", "succeeded", "failed"
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Tracks a posted Slack message so we can update it in-place.
|
||||
/// One ref per (integration, release_slug) — accumulates all destinations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlackMessageRef {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub release_id: String, // release slug (shared across destinations)
|
||||
pub channel_id: String, // Slack channel where posted
|
||||
pub message_ts: String, // Slack message timestamp (for chat.update)
|
||||
pub last_event_type: String, // Last event that updated this message
|
||||
/// Accumulated per-destination statuses. Key = destination name.
|
||||
#[serde(default)]
|
||||
pub destinations: HashMap<String, DestinationStatus>,
|
||||
/// Cached release title for message rebuilds.
|
||||
#[serde(default)]
|
||||
pub release_title: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// ── Delivery log ─────────────────────────────────────────────────────
|
||||
|
||||
/// Record of a notification delivery attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationDelivery {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub notification_id: String,
|
||||
pub status: DeliveryStatus,
|
||||
pub error_message: Option<String>,
|
||||
pub attempted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeliveryStatus {
|
||||
Delivered,
|
||||
Failed,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl DeliveryStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Delivered => "delivered",
|
||||
Self::Failed => "failed",
|
||||
Self::Pending => "pending",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"delivered" => Some(Self::Delivered),
|
||||
"failed" => Some(Self::Failed),
|
||||
"pending" => Some(Self::Pending),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create/Update inputs ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateIntegrationInput {
|
||||
pub organisation: String,
|
||||
pub integration_type: IntegrationType,
|
||||
pub name: String,
|
||||
pub config: IntegrationConfig,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
// ── Error type ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum IntegrationError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("duplicate: {0}")]
|
||||
Duplicate(String),
|
||||
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
|
||||
#[error("encryption error: {0}")]
|
||||
Encryption(String),
|
||||
}
|
||||
|
||||
// ── Repository trait ─────────────────────────────────────────────────
|
||||
|
||||
/// Persistence trait for integration management. Implemented by forage-db.
|
||||
#[async_trait::async_trait]
|
||||
pub trait IntegrationStore: Send + Sync {
|
||||
/// List all integrations for an organisation.
|
||||
async fn list_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError>;
|
||||
|
||||
/// Get a single integration by ID (must belong to the given org).
|
||||
async fn get_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
/// Create a new integration with default notification rules (all enabled).
|
||||
async fn create_integration(
|
||||
&self,
|
||||
input: &CreateIntegrationInput,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
/// Enable or disable an integration.
|
||||
async fn set_integration_enabled(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Delete an integration and its rules/deliveries (cascading).
|
||||
async fn delete_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List notification rules for an integration.
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
) -> Result<Vec<NotificationRule>, IntegrationError>;
|
||||
|
||||
/// Set whether a specific notification type is enabled for an integration.
|
||||
async fn set_rule_enabled(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Record a delivery attempt.
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_id: &str,
|
||||
status: DeliveryStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List enabled integrations for an org that have a matching rule for the given event type.
|
||||
async fn list_matching_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
notification_type: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError>;
|
||||
|
||||
/// List recent delivery attempts for an integration, newest first.
|
||||
async fn list_deliveries(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError>;
|
||||
|
||||
/// Update the configuration (and optionally the name) of an existing integration.
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Look up an integration by its API token hash. Used for API authentication.
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
// ── Slack user links ──────────────────────────────────────────────
|
||||
|
||||
/// Get the Slack user link for a Forage user in a given workspace, if any.
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError>;
|
||||
|
||||
/// Create or update the Slack user link for a given (user_id, team_id) pair.
|
||||
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Remove the Slack user link for a given (user_id, team_id) pair.
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List all Slack user links for a Forage user (one per connected workspace).
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError>;
|
||||
|
||||
// ── Slack message refs ────────────────────────────────────────────
|
||||
|
||||
/// Get the Slack message ref for a release in a specific integration, if any.
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError>;
|
||||
|
||||
/// Create or update the Slack message ref for a given (integration_id, release_id) pair.
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError>;
|
||||
}
|
||||
|
||||
// ── Token generation ────────────────────────────────────────────────
|
||||
|
||||
/// Generate a crypto-random API token for an integration.
|
||||
/// Format: `fgi_` prefix + 32 bytes hex-encoded.
|
||||
pub fn generate_api_token() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let encoded = hex_encode(&bytes);
|
||||
format!("fgi_{encoded}")
|
||||
}
|
||||
|
||||
/// SHA-256 hash of a token for storage. Only the hash is persisted.
|
||||
pub fn hash_api_token(token: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(token.as_bytes());
|
||||
hex_encode(&hash)
|
||||
}
|
||||
|
||||
fn hex_encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Validate a webhook URL. Must be HTTPS (or localhost for development).
|
||||
pub fn validate_webhook_url(url: &str) -> Result<(), IntegrationError> {
|
||||
if url.starts_with("https://") {
|
||||
return Ok(());
|
||||
}
|
||||
if url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(IntegrationError::InvalidInput(
|
||||
"Webhook URL must use HTTPS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate an integration name (reuse slug rules: lowercase alphanumeric + hyphens, max 64).
|
||||
pub fn validate_integration_name(name: &str) -> Result<(), IntegrationError> {
|
||||
if name.is_empty() {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if name.len() > 64 {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name too long (max 64 characters)".to_string(),
|
||||
));
|
||||
}
|
||||
// Allow more characters than slugs: spaces, #, etc. for human-readable names
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name contains invalid characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── In-memory store (for tests) ──────────────────────────────────────
|
||||
|
||||
/// In-memory integration store for testing. Not for production use.
|
||||
pub struct InMemoryIntegrationStore {
|
||||
integrations: std::sync::Mutex<Vec<Integration>>,
|
||||
rules: std::sync::Mutex<Vec<NotificationRule>>,
|
||||
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
|
||||
/// Stores token_hash -> integration_id for lookup.
|
||||
token_hashes: std::sync::Mutex<HashMap<String, String>>,
|
||||
slack_user_links: std::sync::Mutex<Vec<SlackUserLink>>,
|
||||
slack_message_refs: std::sync::Mutex<Vec<SlackMessageRef>>,
|
||||
}
|
||||
|
||||
impl InMemoryIntegrationStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
integrations: std::sync::Mutex::new(Vec::new()),
|
||||
rules: std::sync::Mutex::new(Vec::new()),
|
||||
deliveries: std::sync::Mutex::new(Vec::new()),
|
||||
token_hashes: std::sync::Mutex::new(HashMap::new()),
|
||||
slack_user_links: std::sync::Mutex::new(Vec::new()),
|
||||
slack_message_refs: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryIntegrationStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix for integration API tokens.
|
||||
pub const TOKEN_PREFIX: &str = "fgi_";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IntegrationStore for InMemoryIntegrationStore {
|
||||
async fn list_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|i| i.organisation == organisation)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
store
|
||||
.iter()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.cloned()
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
async fn create_integration(
|
||||
&self,
|
||||
input: &CreateIntegrationInput,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
if store
|
||||
.iter()
|
||||
.any(|i| i.organisation == input.organisation && i.name == input.name)
|
||||
{
|
||||
return Err(IntegrationError::Duplicate(format!(
|
||||
"Integration '{}' already exists",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let raw_token = generate_api_token();
|
||||
let token_hash = hash_api_token(&raw_token);
|
||||
|
||||
let integration = Integration {
|
||||
id: id.clone(),
|
||||
organisation: input.organisation.clone(),
|
||||
integration_type: input.integration_type,
|
||||
name: input.name.clone(),
|
||||
config: input.config.clone(),
|
||||
enabled: true,
|
||||
created_by: input.created_by.clone(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
api_token: Some(raw_token),
|
||||
};
|
||||
// Store without the raw token
|
||||
let stored = Integration { api_token: None, ..integration.clone() };
|
||||
store.push(stored);
|
||||
|
||||
// Store token hash
|
||||
self.token_hashes.lock().unwrap().insert(token_hash, id.clone());
|
||||
|
||||
// Create default rules
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
for nt in NOTIFICATION_TYPES {
|
||||
rules.push(NotificationRule {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: id.clone(),
|
||||
notification_type: nt.to_string(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(integration)
|
||||
}
|
||||
|
||||
async fn set_integration_enabled(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let integ = store
|
||||
.iter_mut()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
|
||||
integ.enabled = enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let len = store.len();
|
||||
store.retain(|i| !(i.id == id && i.organisation == organisation));
|
||||
if store.len() == len {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
// Cascade delete rules
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
rules.retain(|r| r.integration_id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let integ = store
|
||||
.iter_mut()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
|
||||
integ.name = name.to_string();
|
||||
integ.config = config.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
) -> Result<Vec<NotificationRule>, IntegrationError> {
|
||||
let rules = self.rules.lock().unwrap();
|
||||
Ok(rules
|
||||
.iter()
|
||||
.filter(|r| r.integration_id == integration_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_rule_enabled(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
if let Some(rule) = rules
|
||||
.iter_mut()
|
||||
.find(|r| r.integration_id == integration_id && r.notification_type == notification_type)
|
||||
{
|
||||
rule.enabled = enabled;
|
||||
} else {
|
||||
rules.push(NotificationRule {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
notification_type: notification_type.to_string(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_id: &str,
|
||||
status: DeliveryStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut deliveries = self.deliveries.lock().unwrap();
|
||||
deliveries.push(NotificationDelivery {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
notification_id: notification_id.to_string(),
|
||||
status,
|
||||
error_message: error_message.map(|s| s.to_string()),
|
||||
attempted_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_deliveries(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError> {
|
||||
let deliveries = self.deliveries.lock().unwrap();
|
||||
let mut matching: Vec<_> = deliveries
|
||||
.iter()
|
||||
.filter(|d| d.integration_id == integration_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
// Sort newest first (by attempted_at descending)
|
||||
matching.sort_by(|a, b| b.attempted_at.cmp(&a.attempted_at));
|
||||
matching.truncate(limit);
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
async fn list_matching_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
notification_type: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
let rules = self.rules.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.organisation == organisation
|
||||
&& i.enabled
|
||||
&& rules.iter().any(|r| {
|
||||
r.integration_id == i.id
|
||||
&& r.notification_type == notification_type
|
||||
&& r.enabled
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let hashes = self.token_hashes.lock().unwrap();
|
||||
let id = hashes
|
||||
.get(token_hash)
|
||||
.ok_or_else(|| IntegrationError::NotFound("invalid token".to_string()))?
|
||||
.clone();
|
||||
drop(hashes);
|
||||
|
||||
let store = self.integrations.lock().unwrap();
|
||||
store
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.cloned()
|
||||
.ok_or(IntegrationError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError> {
|
||||
let links = self.slack_user_links.lock().unwrap();
|
||||
Ok(links
|
||||
.iter()
|
||||
.find(|l| l.user_id == user_id && l.team_id == team_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError> {
|
||||
let mut links = self.slack_user_links.lock().unwrap();
|
||||
if let Some(existing) = links
|
||||
.iter_mut()
|
||||
.find(|l| l.user_id == link.user_id && l.team_id == link.team_id)
|
||||
{
|
||||
*existing = link.clone();
|
||||
} else {
|
||||
links.push(link.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut links = self.slack_user_links.lock().unwrap();
|
||||
links.retain(|l| !(l.user_id == user_id && l.team_id == team_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError> {
|
||||
let links = self.slack_user_links.lock().unwrap();
|
||||
Ok(links
|
||||
.iter()
|
||||
.filter(|l| l.user_id == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError> {
|
||||
let refs = self.slack_message_refs.lock().unwrap();
|
||||
Ok(refs
|
||||
.iter()
|
||||
.find(|r| r.integration_id == integration_id && r.release_id == release_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut refs = self.slack_message_refs.lock().unwrap();
|
||||
if let Some(existing) = refs.iter_mut().find(|r| {
|
||||
r.integration_id == msg_ref.integration_id && r.release_id == msg_ref.release_id
|
||||
}) {
|
||||
*existing = msg_ref.clone();
|
||||
} else {
|
||||
refs.push(msg_ref.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn integration_type_roundtrip() {
|
||||
for t in &[IntegrationType::Slack, IntegrationType::Webhook] {
|
||||
let s = t.as_str();
|
||||
assert_eq!(IntegrationType::parse(s), Some(*t));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_type_unknown_returns_none() {
|
||||
assert_eq!(IntegrationType::parse("discord"), None);
|
||||
assert_eq!(IntegrationType::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_status_roundtrip() {
|
||||
for s in &[
|
||||
DeliveryStatus::Delivered,
|
||||
DeliveryStatus::Failed,
|
||||
DeliveryStatus::Pending,
|
||||
] {
|
||||
let str = s.as_str();
|
||||
assert_eq!(DeliveryStatus::parse(str), Some(*s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_https() {
|
||||
assert!(validate_webhook_url("https://example.com/hook").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_localhost() {
|
||||
assert!(validate_webhook_url("http://localhost:8080/hook").is_ok());
|
||||
assert!(validate_webhook_url("http://127.0.0.1:8080/hook").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_http_rejected() {
|
||||
assert!(validate_webhook_url("http://example.com/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_valid() {
|
||||
assert!(validate_integration_name("my-slack").is_ok());
|
||||
assert!(validate_integration_name("#deploys").is_ok());
|
||||
assert!(validate_integration_name("Production alerts").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_empty() {
|
||||
assert!(validate_integration_name("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_too_long() {
|
||||
assert!(validate_integration_name(&"a".repeat(65)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_control_chars() {
|
||||
assert!(validate_integration_name("bad\x00name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_config_slack_serde_roundtrip() {
|
||||
let config = IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-token".into(),
|
||||
webhook_url: "https://hooks.slack.com/...".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
IntegrationConfig::Slack { team_id, .. } => assert_eq!(team_id, "T123"),
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_config_webhook_serde_roundtrip() {
|
||||
let config = IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: Some("s3cret".into()),
|
||||
headers: HashMap::from([("X-Custom".into(), "value".into())]),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
IntegrationConfig::Webhook { url, secret, headers } => {
|
||||
assert_eq!(url, "https://example.com/hook");
|
||||
assert_eq!(secret.as_deref(), Some("s3cret"));
|
||||
assert_eq!(headers.get("X-Custom").map(|s| s.as_str()), Some("value"));
|
||||
}
|
||||
_ => panic!("expected Webhook config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_types_are_known() {
|
||||
assert_eq!(NOTIFICATION_TYPES.len(), 4);
|
||||
assert!(NOTIFICATION_TYPES.contains(&"release_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_token_has_prefix_and_length() {
|
||||
let token = generate_api_token();
|
||||
assert!(token.starts_with("fgi_"));
|
||||
// fgi_ (4) + 64 hex chars (32 bytes) = 68 total
|
||||
assert_eq!(token.len(), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_token_is_unique() {
|
||||
let t1 = generate_api_token();
|
||||
let t2 = generate_api_token();
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_api_token_is_deterministic() {
|
||||
let token = "fgi_abcdef1234567890";
|
||||
let h1 = hash_api_token(token);
|
||||
let h2 = hash_api_token(token);
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_api_token_different_for_different_tokens() {
|
||||
let h1 = hash_api_token("fgi_token_one");
|
||||
let h2 = hash_api_token("fgi_token_two");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_creates_with_api_token() {
|
||||
let store = InMemoryIntegrationStore::new();
|
||||
let created = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "myorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "test-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Token is returned on creation
|
||||
assert!(created.api_token.is_some());
|
||||
let token = created.api_token.unwrap();
|
||||
assert!(token.starts_with("fgi_"));
|
||||
|
||||
// Token lookup works
|
||||
let token_hash = hash_api_token(&token);
|
||||
let found = store.get_integration_by_token_hash(&token_hash).await.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
assert!(found.api_token.is_none()); // not stored in plaintext
|
||||
|
||||
// Stored integration doesn't have the raw token
|
||||
let listed = store.list_integrations("myorg").await.unwrap();
|
||||
assert!(listed[0].api_token.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::router::{NotificationEvent, ReleaseContext};
|
||||
|
||||
/// Wire format for notification events published to NATS JetStream.
|
||||
/// Mirrors `NotificationEvent` with serde support.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationEnvelope {
|
||||
pub id: String,
|
||||
pub notification_type: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub organisation: String,
|
||||
pub project: String,
|
||||
pub timestamp: String,
|
||||
pub release: Option<ReleaseContextEnvelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleaseContextEnvelope {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
#[serde(default)]
|
||||
pub release_intent_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
#[serde(default)]
|
||||
pub source_user_id: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
#[serde(default)]
|
||||
pub context_title: String,
|
||||
#[serde(default)]
|
||||
pub context_web: String,
|
||||
#[serde(default)]
|
||||
pub destination_count: i32,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&NotificationEvent> for NotificationEnvelope {
|
||||
fn from(e: &NotificationEvent) -> Self {
|
||||
Self {
|
||||
id: e.id.clone(),
|
||||
notification_type: e.notification_type.clone(),
|
||||
title: e.title.clone(),
|
||||
body: e.body.clone(),
|
||||
organisation: e.organisation.clone(),
|
||||
project: e.project.clone(),
|
||||
timestamp: e.timestamp.clone(),
|
||||
release: e.release.as_ref().map(|r| ReleaseContextEnvelope {
|
||||
slug: r.slug.clone(),
|
||||
artifact_id: r.artifact_id.clone(),
|
||||
release_intent_id: r.release_intent_id.clone(),
|
||||
destination: r.destination.clone(),
|
||||
environment: r.environment.clone(),
|
||||
source_username: r.source_username.clone(),
|
||||
source_user_id: r.source_user_id.clone(),
|
||||
commit_sha: r.commit_sha.clone(),
|
||||
commit_branch: r.commit_branch.clone(),
|
||||
context_title: r.context_title.clone(),
|
||||
context_web: r.context_web.clone(),
|
||||
destination_count: r.destination_count,
|
||||
error_message: r.error_message.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NotificationEnvelope> for NotificationEvent {
|
||||
fn from(e: NotificationEnvelope) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
notification_type: e.notification_type,
|
||||
title: e.title,
|
||||
body: e.body,
|
||||
organisation: e.organisation,
|
||||
project: e.project,
|
||||
timestamp: e.timestamp,
|
||||
release: e.release.map(|r| ReleaseContext {
|
||||
slug: r.slug,
|
||||
artifact_id: r.artifact_id,
|
||||
release_intent_id: r.release_intent_id,
|
||||
destination: r.destination,
|
||||
environment: r.environment,
|
||||
source_username: r.source_username,
|
||||
source_user_id: r.source_user_id,
|
||||
commit_sha: r.commit_sha,
|
||||
commit_branch: r.commit_branch,
|
||||
context_title: r.context_title,
|
||||
context_web: r.context_web,
|
||||
destination_count: r.destination_count,
|
||||
error_message: r.error_message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the NATS subject for a notification event.
|
||||
/// Format: `forage.notifications.{org}.{type}`
|
||||
pub fn notification_subject(organisation: &str, notification_type: &str) -> String {
|
||||
format!("forage.notifications.{organisation}.{notification_type}")
|
||||
}
|
||||
|
||||
/// The stream name used for notification delivery.
|
||||
pub const STREAM_NAME: &str = "FORAGE_NOTIFICATIONS";
|
||||
|
||||
/// Subject filter for the stream (captures all orgs and types).
|
||||
pub const STREAM_SUBJECTS: &str = "forage.notifications.>";
|
||||
|
||||
/// Durable consumer name for webhook dispatchers.
|
||||
pub const CONSUMER_NAME: &str = "forage-webhook-dispatcher";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_event() -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: "notif-1".into(),
|
||||
notification_type: "release_failed".into(),
|
||||
title: "Release failed".into(),
|
||||
body: "Container timeout".into(),
|
||||
organisation: "acme-corp".into(),
|
||||
project: "my-service".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v1.2.3".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
release_intent_id: "ri_1".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: "alice_id".into(),
|
||||
commit_sha: "abc1234def".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Release failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 3,
|
||||
error_message: Some("health check timeout".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip() {
|
||||
let event = test_event();
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let json = serde_json::to_string(&envelope).unwrap();
|
||||
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
|
||||
let restored: NotificationEvent = parsed.into();
|
||||
|
||||
assert_eq!(restored.id, event.id);
|
||||
assert_eq!(restored.notification_type, event.notification_type);
|
||||
assert_eq!(restored.organisation, event.organisation);
|
||||
assert_eq!(restored.project, event.project);
|
||||
let r = restored.release.unwrap();
|
||||
let orig = event.release.unwrap();
|
||||
assert_eq!(r.slug, orig.slug);
|
||||
assert_eq!(r.error_message, orig.error_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_without_release() {
|
||||
let event = NotificationEvent {
|
||||
id: "n2".into(),
|
||||
notification_type: "release_started".into(),
|
||||
title: "Starting".into(),
|
||||
body: String::new(),
|
||||
organisation: "org".into(),
|
||||
project: "proj".into(),
|
||||
timestamp: "2026-03-09T00:00:00Z".into(),
|
||||
release: None,
|
||||
};
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let json = serde_json::to_string(&envelope).unwrap();
|
||||
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
|
||||
let restored: NotificationEvent = parsed.into();
|
||||
assert!(restored.release.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_subject_format() {
|
||||
assert_eq!(
|
||||
notification_subject("acme-corp", "release_failed"),
|
||||
"forage.notifications.acme-corp.release_failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// The JSON payload delivered to webhook integrations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookPayload {
|
||||
pub event: String,
|
||||
pub timestamp: String,
|
||||
pub organisation: String,
|
||||
pub project: String,
|
||||
pub notification_id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub release: Option<ReleasePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleasePayload {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Compute HMAC-SHA256 signature for a webhook payload.
|
||||
/// Returns hex-encoded signature prefixed with "sha256=".
|
||||
pub fn sign_payload(body: &[u8], secret: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(body);
|
||||
let result = mac.finalize().into_bytes();
|
||||
format!("sha256={}", hex_encode(&result))
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_payload_produces_hex_signature() {
|
||||
let sig = sign_payload(b"hello world", "my-secret");
|
||||
assert!(sig.starts_with("sha256="));
|
||||
assert_eq!(sig.len(), 7 + 64); // "sha256=" + 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_payload_deterministic() {
|
||||
let a = sign_payload(b"test body", "key");
|
||||
let b = sign_payload(b"test body", "key");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_payload_different_keys_differ() {
|
||||
let a = sign_payload(b"body", "key1");
|
||||
let b = sign_payload(b"body", "key2");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_payload_serializes() {
|
||||
let payload = WebhookPayload {
|
||||
event: "release_failed".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
organisation: "test-org".into(),
|
||||
project: "my-project".into(),
|
||||
notification_id: "notif-123".into(),
|
||||
title: "Release failed".into(),
|
||||
body: "Container health check timeout".into(),
|
||||
release: Some(ReleasePayload {
|
||||
slug: "test-release".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
commit_sha: "abc1234".into(),
|
||||
commit_branch: "main".into(),
|
||||
error_message: Some("timeout".into()),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains("release_failed"));
|
||||
assert!(json.contains("prod-eu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_payload_without_release() {
|
||||
let payload = WebhookPayload {
|
||||
event: "release_annotated".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
organisation: "test-org".into(),
|
||||
project: "my-project".into(),
|
||||
notification_id: "notif-456".into(),
|
||||
title: "Annotated".into(),
|
||||
body: "A note".into(),
|
||||
release: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed["release"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
pub mod platform;
|
||||
pub mod integrations;
|
||||
pub mod registry;
|
||||
pub mod deployments;
|
||||
pub mod billing;
|
||||
|
||||
@@ -130,8 +130,8 @@ pub struct DestinationState {
|
||||
pub struct PipelineRunStageState {
|
||||
pub stage_id: String,
|
||||
pub depends_on: Vec<String>,
|
||||
pub stage_type: String, // "deploy", "wait", or "plan"
|
||||
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AWAITING_APPROVAL"
|
||||
pub stage_type: String, // "deploy" or "wait"
|
||||
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"
|
||||
pub environment: Option<String>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
pub queued_at: Option<String>,
|
||||
@@ -141,10 +141,6 @@ pub struct PipelineRunStageState {
|
||||
pub wait_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub release_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub approval_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub auto_approve: Option<bool>,
|
||||
}
|
||||
|
||||
/// Combined response from get_destination_states: destinations only.
|
||||
@@ -251,10 +247,6 @@ pub enum PolicyConfig {
|
||||
target_environment: String,
|
||||
branch_pattern: String,
|
||||
},
|
||||
Approval {
|
||||
target_environment: String,
|
||||
required_approvals: i32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -275,24 +267,6 @@ 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)]
|
||||
@@ -306,7 +280,6 @@ pub struct PipelineStage {
|
||||
pub enum PipelineStageConfig {
|
||||
Deploy { environment: String },
|
||||
Wait { duration_seconds: i64 },
|
||||
Plan { environment: String, auto_approve: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -346,14 +319,6 @@ pub enum PlatformError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// A user's notification preference for a specific event type + channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationPreference {
|
||||
pub notification_type: String,
|
||||
pub channel: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Trait for platform data from forest-server (organisations, projects, artifacts).
|
||||
/// Separate from `ForestAuth` which handles identity.
|
||||
#[async_trait::async_trait]
|
||||
@@ -581,95 +546,6 @@ pub trait ForestPlatform: Send + Sync {
|
||||
access_token: &str,
|
||||
artifact_id: &str,
|
||||
) -> Result<String, PlatformError>;
|
||||
|
||||
async fn get_notification_preferences(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<NotificationPreference>, PlatformError>;
|
||||
|
||||
async fn set_notification_preference(
|
||||
&self,
|
||||
access_token: &str,
|
||||
notification_type: &str,
|
||||
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>;
|
||||
|
||||
async fn approve_plan_stage(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
) -> Result<(), PlatformError>;
|
||||
|
||||
async fn reject_plan_stage(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<(), PlatformError>;
|
||||
|
||||
async fn get_plan_output(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
) -> Result<PlanOutput, PlatformError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanOutput {
|
||||
pub plan_output: String,
|
||||
pub status: String,
|
||||
pub outputs: Vec<PlanDestinationOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanDestinationOutput {
|
||||
pub destination_id: String,
|
||||
pub destination_name: String,
|
||||
pub plan_output: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,4 +13,3 @@ tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
moka.workspace = true
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError,
|
||||
IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef,
|
||||
SlackUserLink, NOTIFICATION_TYPES,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// PostgreSQL-backed integration store.
|
||||
pub struct PgIntegrationStore {
|
||||
pool: PgPool,
|
||||
/// AES-256 key for encrypting/decrypting integration configs.
|
||||
/// In production this comes from INTEGRATION_ENCRYPTION_KEY env var.
|
||||
/// For simplicity, we use a basic XOR-based obfuscation for now
|
||||
/// and will upgrade to proper AES when the `aes-gcm` crate is added.
|
||||
encryption_key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PgIntegrationStore {
|
||||
pub fn new(pool: PgPool, encryption_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
encryption_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_config(&self, config: &IntegrationConfig) -> Result<Vec<u8>, IntegrationError> {
|
||||
let json = serde_json::to_vec(config)
|
||||
.map_err(|e| IntegrationError::Encryption(e.to_string()))?;
|
||||
Ok(xor_bytes(&json, &self.encryption_key))
|
||||
}
|
||||
|
||||
fn decrypt_config(&self, encrypted: &[u8]) -> Result<IntegrationConfig, IntegrationError> {
|
||||
let json = xor_bytes(encrypted, &self.encryption_key);
|
||||
serde_json::from_slice(&json)
|
||||
.map_err(|e| IntegrationError::Encryption(format!("decrypt failed: {e}")))
|
||||
}
|
||||
|
||||
fn row_to_integration(&self, row: IntegrationRow) -> Result<Integration, IntegrationError> {
|
||||
let config = self.decrypt_config(&row.config_encrypted)?;
|
||||
let integration_type = IntegrationType::parse(&row.integration_type)
|
||||
.ok_or_else(|| IntegrationError::Store(format!("unknown type: {}", row.integration_type)))?;
|
||||
Ok(Integration {
|
||||
id: row.id.to_string(),
|
||||
organisation: row.organisation,
|
||||
integration_type,
|
||||
name: row.name,
|
||||
config,
|
||||
enabled: row.enabled,
|
||||
created_by: row.created_by,
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
updated_at: row.updated_at.to_rfc3339(),
|
||||
api_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple XOR obfuscation. This is NOT production-grade encryption.
|
||||
/// TODO: Replace with AES-256-GCM when aes-gcm dependency is added.
|
||||
fn xor_bytes(data: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
if key.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
data.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| b ^ key[i % key.len()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IntegrationStore for PgIntegrationStore {
|
||||
async fn list_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let rows: Vec<IntegrationRow> = sqlx::query_as(
|
||||
"SELECT id, organisation, integration_type, name, config_encrypted, enabled, created_by, created_at, updated_at
|
||||
FROM integrations WHERE organisation = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(organisation)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|r| self.row_to_integration(r)).collect()
|
||||
}
|
||||
|
||||
async fn get_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let uuid: Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
|
||||
|
||||
let row: IntegrationRow = sqlx::query_as(
|
||||
"SELECT id, organisation, integration_type, name, config_encrypted, enabled, created_by, created_at, updated_at
|
||||
FROM integrations WHERE id = $1 AND organisation = $2",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(organisation)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
|
||||
|
||||
self.row_to_integration(row)
|
||||
}
|
||||
|
||||
async fn create_integration(
|
||||
&self,
|
||||
input: &CreateIntegrationInput,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
use forage_core::integrations::{generate_api_token, hash_api_token};
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let encrypted = self.encrypt_config(&input.config)?;
|
||||
let now = chrono::Utc::now();
|
||||
let raw_token = generate_api_token();
|
||||
let token_hash = hash_api_token(&raw_token);
|
||||
|
||||
// Insert integration with token hash
|
||||
sqlx::query(
|
||||
"INSERT INTO integrations (id, organisation, integration_type, name, config_encrypted, enabled, created_by, created_at, updated_at, api_token_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, true, $6, $7, $7, $8)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&input.organisation)
|
||||
.bind(input.integration_type.as_str())
|
||||
.bind(&input.name)
|
||||
.bind(&encrypted)
|
||||
.bind(&input.created_by)
|
||||
.bind(now)
|
||||
.bind(&token_hash)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("duplicate key") || e.to_string().contains("unique") {
|
||||
IntegrationError::Duplicate(format!(
|
||||
"Integration '{}' already exists in org '{}'",
|
||||
input.name, input.organisation
|
||||
))
|
||||
} else {
|
||||
IntegrationError::Store(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
// Create default notification rules (all enabled)
|
||||
for nt in NOTIFICATION_TYPES {
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_rules (id, integration_id, notification_type, enabled)
|
||||
VALUES ($1, $2, $3, true)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(id)
|
||||
.bind(*nt)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Integration {
|
||||
id: id.to_string(),
|
||||
organisation: input.organisation.clone(),
|
||||
integration_type: input.integration_type,
|
||||
name: input.name.clone(),
|
||||
config: input.config.clone(),
|
||||
enabled: true,
|
||||
created_by: input.created_by.clone(),
|
||||
created_at: now.to_rfc3339(),
|
||||
updated_at: now.to_rfc3339(),
|
||||
api_token: Some(raw_token),
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_integration_enabled(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE integrations SET enabled = $1, updated_at = now() WHERE id = $2 AND organisation = $3",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(uuid)
|
||||
.bind(organisation)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM integrations WHERE id = $1 AND organisation = $2")
|
||||
.bind(uuid)
|
||||
.bind(organisation)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_integration_config(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &IntegrationConfig,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(id.to_string()))?;
|
||||
let encrypted = self.encrypt_config(config)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE integrations SET name = $1, config_encrypted = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND organisation = $4",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&encrypted)
|
||||
.bind(uuid)
|
||||
.bind(organisation)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
) -> Result<Vec<NotificationRule>, IntegrationError> {
|
||||
let uuid: Uuid = integration_id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(integration_id.to_string()))?;
|
||||
|
||||
let rows: Vec<RuleRow> = sqlx::query_as(
|
||||
"SELECT id, integration_id, notification_type, enabled
|
||||
FROM notification_rules WHERE integration_id = $1 ORDER BY notification_type",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| NotificationRule {
|
||||
id: r.id.to_string(),
|
||||
integration_id: r.integration_id.to_string(),
|
||||
notification_type: r.notification_type,
|
||||
enabled: r.enabled,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_rule_enabled(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = integration_id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(integration_id.to_string()))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE notification_rules SET enabled = $1
|
||||
WHERE integration_id = $2 AND notification_type = $3",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(uuid)
|
||||
.bind(notification_type)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
// Rule doesn't exist yet — create it
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_rules (id, integration_id, notification_type, enabled)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(uuid)
|
||||
.bind(notification_type)
|
||||
.bind(enabled)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_id: &str,
|
||||
status: DeliveryStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let uuid: Uuid = integration_id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(integration_id.to_string()))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_deliveries (id, integration_id, notification_id, status, error_message, attempted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(uuid)
|
||||
.bind(notification_id)
|
||||
.bind(status.as_str())
|
||||
.bind(error_message)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_deliveries(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError> {
|
||||
let uuid: Uuid = integration_id
|
||||
.parse()
|
||||
.map_err(|_| IntegrationError::NotFound(integration_id.to_string()))?;
|
||||
|
||||
let rows: Vec<DeliveryRow> = sqlx::query_as(
|
||||
"SELECT id, integration_id, notification_id, status, error_message, attempted_at
|
||||
FROM notification_deliveries
|
||||
WHERE integration_id = $1
|
||||
ORDER BY attempted_at DESC
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let status = DeliveryStatus::parse(&r.status).unwrap_or(DeliveryStatus::Pending);
|
||||
NotificationDelivery {
|
||||
id: r.id.to_string(),
|
||||
integration_id: r.integration_id.to_string(),
|
||||
notification_id: r.notification_id,
|
||||
status,
|
||||
error_message: r.error_message,
|
||||
attempted_at: r.attempted_at.to_rfc3339(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_matching_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
notification_type: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let rows: Vec<IntegrationRow> = sqlx::query_as(
|
||||
"SELECT i.id, i.organisation, i.integration_type, i.name, i.config_encrypted, i.enabled, i.created_by, i.created_at, i.updated_at
|
||||
FROM integrations i
|
||||
JOIN notification_rules nr ON nr.integration_id = i.id
|
||||
WHERE i.organisation = $1
|
||||
AND i.enabled = true
|
||||
AND nr.notification_type = $2
|
||||
AND nr.enabled = true
|
||||
ORDER BY i.created_at",
|
||||
)
|
||||
.bind(organisation)
|
||||
.bind(notification_type)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|r| self.row_to_integration(r)).collect()
|
||||
}
|
||||
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let row: IntegrationRow = sqlx::query_as(
|
||||
"SELECT id, organisation, integration_type, name, config_encrypted, enabled, created_by, created_at, updated_at
|
||||
FROM integrations WHERE api_token_hash = $1 AND enabled = true",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?
|
||||
.ok_or_else(|| IntegrationError::NotFound("invalid token".to_string()))?;
|
||||
|
||||
self.row_to_integration(row)
|
||||
}
|
||||
|
||||
// ── Slack user links ─────────────────────────────────────────
|
||||
|
||||
async fn get_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Option<SlackUserLink>, IntegrationError> {
|
||||
let row: Option<SlackUserLinkRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
|
||||
FROM slack_user_links WHERE user_id = $1 AND team_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| SlackUserLink {
|
||||
id: r.id.to_string(),
|
||||
user_id: r.user_id,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
slack_user_id: r.slack_user_id,
|
||||
slack_username: r.slack_username,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_slack_user_link(
|
||||
&self,
|
||||
link: &SlackUserLink,
|
||||
) -> Result<(), IntegrationError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO slack_user_links (id, user_id, team_id, team_name, slack_user_id, slack_username, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (user_id, team_id) DO UPDATE SET
|
||||
slack_user_id = EXCLUDED.slack_user_id,
|
||||
slack_username = EXCLUDED.slack_username,
|
||||
team_name = EXCLUDED.team_name",
|
||||
)
|
||||
.bind(Uuid::parse_str(&link.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
|
||||
.bind(&link.user_id)
|
||||
.bind(&link.team_id)
|
||||
.bind(&link.team_name)
|
||||
.bind(&link.slack_user_id)
|
||||
.bind(&link.slack_username)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_slack_user_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
team_id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
sqlx::query("DELETE FROM slack_user_links WHERE user_id = $1 AND team_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(team_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_slack_user_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<SlackUserLink>, IntegrationError> {
|
||||
let rows: Vec<SlackUserLinkRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at
|
||||
FROM slack_user_links WHERE user_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SlackUserLink {
|
||||
id: r.id.to_string(),
|
||||
user_id: r.user_id,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
slack_user_id: r.slack_user_id,
|
||||
slack_username: r.slack_username,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Slack message refs ───────────────────────────────────────
|
||||
|
||||
async fn get_slack_message_ref(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
release_id: &str,
|
||||
) -> Result<Option<SlackMessageRef>, IntegrationError> {
|
||||
let iid =
|
||||
Uuid::parse_str(integration_id).map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
let row: Option<SlackMessageRefRow> = sqlx::query_as(
|
||||
"SELECT id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at
|
||||
FROM slack_message_refs WHERE integration_id = $1 AND release_id = $2",
|
||||
)
|
||||
.bind(iid)
|
||||
.bind(release_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| SlackMessageRef {
|
||||
id: r.id.to_string(),
|
||||
integration_id: r.integration_id.to_string(),
|
||||
release_id: r.release_id,
|
||||
channel_id: r.channel_id,
|
||||
message_ts: r.message_ts,
|
||||
last_event_type: r.last_event_type,
|
||||
destinations: serde_json::from_value(r.destinations).unwrap_or_default(),
|
||||
release_title: r.release_title,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_slack_message_ref(
|
||||
&self,
|
||||
msg_ref: &SlackMessageRef,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let iid = Uuid::parse_str(&msg_ref.integration_id)
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
let destinations_json = serde_json::to_value(&msg_ref.destinations)
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
sqlx::query(
|
||||
"INSERT INTO slack_message_refs (id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
ON CONFLICT (integration_id, release_id) DO UPDATE SET
|
||||
message_ts = EXCLUDED.message_ts,
|
||||
last_event_type = EXCLUDED.last_event_type,
|
||||
destinations = EXCLUDED.destinations,
|
||||
release_title = EXCLUDED.release_title,
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(Uuid::parse_str(&msg_ref.id).map_err(|e| IntegrationError::Store(e.to_string()))?)
|
||||
.bind(iid)
|
||||
.bind(&msg_ref.release_id)
|
||||
.bind(&msg_ref.channel_id)
|
||||
.bind(&msg_ref.message_ts)
|
||||
.bind(&msg_ref.last_event_type)
|
||||
.bind(destinations_json)
|
||||
.bind(&msg_ref.release_title)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| IntegrationError::Store(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SlackUserLinkRow {
|
||||
id: Uuid,
|
||||
user_id: String,
|
||||
team_id: String,
|
||||
team_name: String,
|
||||
slack_user_id: String,
|
||||
slack_username: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SlackMessageRefRow {
|
||||
id: Uuid,
|
||||
integration_id: Uuid,
|
||||
release_id: String,
|
||||
channel_id: String,
|
||||
message_ts: String,
|
||||
last_event_type: String,
|
||||
destinations: serde_json::Value,
|
||||
release_title: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct IntegrationRow {
|
||||
id: Uuid,
|
||||
organisation: String,
|
||||
integration_type: String,
|
||||
name: String,
|
||||
config_encrypted: Vec<u8>,
|
||||
enabled: bool,
|
||||
created_by: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RuleRow {
|
||||
id: Uuid,
|
||||
integration_id: Uuid,
|
||||
notification_type: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeliveryRow {
|
||||
id: Uuid,
|
||||
integration_id: Uuid,
|
||||
notification_id: String,
|
||||
status: String,
|
||||
error_message: Option<String>,
|
||||
attempted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
mod integrations;
|
||||
mod sessions;
|
||||
|
||||
pub use integrations::PgIntegrationStore;
|
||||
pub use sessions::PgSessionStore;
|
||||
pub use sqlx::PgPool;
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organisation TEXT NOT NULL,
|
||||
integration_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config_encrypted BYTEA NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(organisation, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integrations_org ON integrations(organisation);
|
||||
CREATE INDEX idx_integrations_org_enabled ON integrations(organisation, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
notification_type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
UNIQUE(integration_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_rules_integration ON notification_rules(integration_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
notification_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT,
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deliveries_integration ON notification_deliveries(integration_id, attempted_at DESC);
|
||||
CREATE INDEX idx_deliveries_status ON notification_deliveries(status, attempted_at DESC);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE sessions ADD COLUMN user_orgs JSONB;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE integrations ADD COLUMN api_token_hash TEXT;
|
||||
CREATE UNIQUE INDEX idx_integrations_api_token ON integrations(api_token_hash) WHERE api_token_hash IS NOT NULL;
|
||||
@@ -1,29 +0,0 @@
|
||||
-- Slack user identity links (user-level "Sign in with Slack")
|
||||
CREATE TABLE IF NOT EXISTS slack_user_links (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
team_id TEXT NOT NULL,
|
||||
team_name TEXT NOT NULL DEFAULT '',
|
||||
slack_user_id TEXT NOT NULL,
|
||||
slack_username TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, team_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_slack_user_links_user ON slack_user_links(user_id);
|
||||
CREATE INDEX idx_slack_user_links_team_slack ON slack_user_links(team_id, slack_user_id);
|
||||
|
||||
-- Slack message refs for update-in-place pattern (one message per release)
|
||||
CREATE TABLE IF NOT EXISTS slack_message_refs (
|
||||
id UUID PRIMARY KEY,
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
release_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
message_ts TEXT NOT NULL,
|
||||
last_event_type TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (integration_id, release_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_slack_message_refs_lookup ON slack_message_refs(integration_id, release_id);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add per-destination status tracking and release title to slack_message_refs
|
||||
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS destinations JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS release_title TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,26 +1,16 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use forage_core::auth::UserEmail;
|
||||
use forage_core::session::{CachedOrg, CachedUser, SessionData, SessionError, SessionId, SessionStore};
|
||||
use moka::future::Cache;
|
||||
use forage_core::session::{CachedUser, SessionData, SessionError, SessionId, SessionStore};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// PostgreSQL-backed session store with a Moka write-through cache.
|
||||
/// Reads check the cache first, falling back to Postgres on miss.
|
||||
/// Writes update both cache and Postgres atomically.
|
||||
/// PostgreSQL-backed session store for horizontal scaling.
|
||||
pub struct PgSessionStore {
|
||||
pool: PgPool,
|
||||
cache: Cache<String, SessionData>,
|
||||
}
|
||||
|
||||
impl PgSessionStore {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_idle(Duration::from_secs(30 * 60)) // evict after 30min idle
|
||||
.build();
|
||||
Self { pool, cache }
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Remove sessions inactive for longer than `max_inactive_days`.
|
||||
@@ -31,10 +21,6 @@ impl PgSessionStore {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
// Moka handles its own TTL eviction, but force a sync for reaped sessions
|
||||
self.cache.run_pending_tasks().await;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -43,11 +29,21 @@ impl PgSessionStore {
|
||||
impl SessionStore for PgSessionStore {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
|
||||
let id = SessionId::generate();
|
||||
let (user_id, username, emails_json, orgs_json) = extract_user_fields(&data)?;
|
||||
let (user_id, username, emails_json) = match &data.user {
|
||||
Some(u) => (
|
||||
Some(u.user_id.clone()),
|
||||
Some(u.username.clone()),
|
||||
Some(
|
||||
serde_json::to_value(&u.emails)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (session_id, access_token, refresh_token, access_expires_at, user_id, username, user_emails, user_orgs, csrf_token, created_at, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||
"INSERT INTO sessions (session_id, access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(id.as_str())
|
||||
.bind(&data.access_token)
|
||||
@@ -56,7 +52,6 @@ impl SessionStore for PgSessionStore {
|
||||
.bind(&user_id)
|
||||
.bind(&username)
|
||||
.bind(&emails_json)
|
||||
.bind(&orgs_json)
|
||||
.bind(&data.csrf_token)
|
||||
.bind(data.created_at)
|
||||
.bind(data.last_seen_at)
|
||||
@@ -64,21 +59,12 @@ impl SessionStore for PgSessionStore {
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
// Populate cache
|
||||
self.cache.insert(id.as_str().to_string(), data).await;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
|
||||
// Check cache first
|
||||
if let Some(data) = self.cache.get(id.as_str()).await {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
// Cache miss — fall back to Postgres
|
||||
let row: Option<SessionRow> = sqlx::query_as(
|
||||
"SELECT access_token, refresh_token, access_expires_at, user_id, username, user_emails, user_orgs, csrf_token, created_at, last_seen_at
|
||||
"SELECT access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at
|
||||
FROM sessions WHERE session_id = $1",
|
||||
)
|
||||
.bind(id.as_str())
|
||||
@@ -86,22 +72,25 @@ impl SessionStore for PgSessionStore {
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let data = row.into_session_data();
|
||||
// Backfill cache
|
||||
self.cache.insert(id.as_str().to_string(), data.clone()).await;
|
||||
Ok(Some(data))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
Ok(row.map(|r| r.into_session_data()))
|
||||
}
|
||||
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
|
||||
let (user_id, username, emails_json, orgs_json) = extract_user_fields(&data)?;
|
||||
let (user_id, username, emails_json) = match &data.user {
|
||||
Some(u) => (
|
||||
Some(u.user_id.clone()),
|
||||
Some(u.username.clone()),
|
||||
Some(
|
||||
serde_json::to_value(&u.emails)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET access_token = $1, refresh_token = $2, access_expires_at = $3, user_id = $4, username = $5, user_emails = $6, user_orgs = $7, csrf_token = $8, last_seen_at = $9
|
||||
WHERE session_id = $10",
|
||||
"UPDATE sessions SET access_token = $1, refresh_token = $2, access_expires_at = $3, user_id = $4, username = $5, user_emails = $6, csrf_token = $7, last_seen_at = $8
|
||||
WHERE session_id = $9",
|
||||
)
|
||||
.bind(&data.access_token)
|
||||
.bind(&data.refresh_token)
|
||||
@@ -109,7 +98,6 @@ impl SessionStore for PgSessionStore {
|
||||
.bind(&user_id)
|
||||
.bind(&username)
|
||||
.bind(&emails_json)
|
||||
.bind(&orgs_json)
|
||||
.bind(&data.csrf_token)
|
||||
.bind(data.last_seen_at)
|
||||
.bind(id.as_str())
|
||||
@@ -117,9 +105,6 @@ impl SessionStore for PgSessionStore {
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
// Update cache
|
||||
self.cache.insert(id.as_str().to_string(), data).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -130,42 +115,10 @@ impl SessionStore for PgSessionStore {
|
||||
.await
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?;
|
||||
|
||||
// Evict from cache
|
||||
self.cache.invalidate(id.as_str()).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract user fields for SQL binding, shared by create and update.
|
||||
fn extract_user_fields(
|
||||
data: &SessionData,
|
||||
) -> Result<
|
||||
(
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
SessionError,
|
||||
> {
|
||||
match &data.user {
|
||||
Some(u) => Ok((
|
||||
Some(u.user_id.clone()),
|
||||
Some(u.username.clone()),
|
||||
Some(
|
||||
serde_json::to_value(&u.emails)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
Some(
|
||||
serde_json::to_value(&u.orgs)
|
||||
.map_err(|e| SessionError::Store(e.to_string()))?,
|
||||
),
|
||||
)),
|
||||
None => Ok((None, None, None, None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SessionRow {
|
||||
access_token: String,
|
||||
@@ -174,7 +127,6 @@ struct SessionRow {
|
||||
user_id: Option<String>,
|
||||
username: Option<String>,
|
||||
user_emails: Option<serde_json::Value>,
|
||||
user_orgs: Option<serde_json::Value>,
|
||||
csrf_token: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_seen_at: DateTime<Utc>,
|
||||
@@ -188,15 +140,11 @@ impl SessionRow {
|
||||
.user_emails
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
.unwrap_or_default();
|
||||
let orgs: Vec<CachedOrg> = self
|
||||
.user_orgs
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
.unwrap_or_default();
|
||||
Some(CachedUser {
|
||||
user_id,
|
||||
username,
|
||||
emails,
|
||||
orgs,
|
||||
orgs: vec![],
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,3 @@ opentelemetry-otlp.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
futures-util = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
reqwest.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
notmad.workspace = true
|
||||
tokio-util.workspace = true
|
||||
async-nats.workspace = true
|
||||
|
||||
@@ -69,28 +69,19 @@ impl FromRequestParts<AppState> for Session {
|
||||
.get_user(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
// Preserve existing orgs on failure — a transient gRPC error
|
||||
// should not wipe the cached org list.
|
||||
let previous_orgs = session_data
|
||||
.user
|
||||
.as_ref()
|
||||
.map(|u| u.orgs.clone())
|
||||
.unwrap_or_default();
|
||||
let orgs = match state
|
||||
let orgs = state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(fresh) => fresh
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => previous_orgs,
|
||||
};
|
||||
.collect();
|
||||
session_data.user = Some(CachedUser {
|
||||
user_id: user.user_id.clone(),
|
||||
username: user.username.clone(),
|
||||
@@ -107,44 +98,6 @@ impl FromRequestParts<AppState> for Session {
|
||||
return Err(axum::response::Redirect::to("/login"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 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_refresh {
|
||||
if let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&session_data.access_token)
|
||||
.await
|
||||
{
|
||||
if !orgs.is_empty() {
|
||||
if let Some(ref mut user) = session_data.user {
|
||||
tracing::info!(
|
||||
user_id = %user.user_id,
|
||||
org_count = orgs.len(),
|
||||
was_empty = orgs_empty,
|
||||
"refreshed org list"
|
||||
);
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
session_data.last_seen_at = chrono::Utc::now();
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Throttle last_seen_at writes: only update if older than 5 minutes
|
||||
let now = chrono::Utc::now();
|
||||
@@ -153,7 +106,6 @@ impl FromRequestParts<AppState> for Session {
|
||||
let _ = state.sessions.update(&session_id, session_data.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let user = session_data
|
||||
.user
|
||||
|
||||
@@ -3,12 +3,11 @@ use forage_core::auth::{
|
||||
UserProfile,
|
||||
};
|
||||
use forage_core::platform::{
|
||||
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
|
||||
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
|
||||
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
|
||||
OrgMember, PipelineStage, PipelineStageConfig, PlanOutput, PlatformError, Policy, PolicyConfig,
|
||||
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
|
||||
UpdateTriggerInput,
|
||||
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput,
|
||||
CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment,
|
||||
ForestPlatform, Organisation, OrgMember, PipelineStage, PipelineStageConfig, PlatformError,
|
||||
Policy, PolicyConfig, ReleasePipeline, Trigger, UpdatePolicyInput,
|
||||
UpdateReleasePipelineInput, UpdateTriggerInput,
|
||||
};
|
||||
use forage_grpc::policy_service_client::PolicyServiceClient;
|
||||
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
|
||||
@@ -88,56 +87,9 @@ impl GrpcForestClient {
|
||||
forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn notification_client(
|
||||
&self,
|
||||
) -> forage_grpc::notification_service_client::NotificationServiceClient<Channel> {
|
||||
forage_grpc::notification_service_client::NotificationServiceClient::new(
|
||||
self.channel.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
|
||||
bearer_request(access_token, msg).map_err(AuthError::Other)
|
||||
}
|
||||
|
||||
/// Fetch release intent states using a service token (for background workers).
|
||||
pub async fn get_release_intent_states_with_token(
|
||||
&self,
|
||||
service_token: &str,
|
||||
organisation: &str,
|
||||
project: Option<&str>,
|
||||
include_completed: bool,
|
||||
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
|
||||
let req = bearer_request(
|
||||
service_token,
|
||||
forage_grpc::GetReleaseIntentStatesRequest {
|
||||
organisation: organisation.into(),
|
||||
project: project.map(|p| p.into()),
|
||||
include_completed,
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("invalid token: {e}"))?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_release_intent_states(req)
|
||||
.await
|
||||
.map_err(|e| format!("gRPC: {e}"))?;
|
||||
|
||||
Ok(resp
|
||||
.into_inner()
|
||||
.release_intents
|
||||
.into_iter()
|
||||
.map(|ri| forage_core::platform::ReleaseIntentState {
|
||||
release_intent_id: ri.release_intent_id,
|
||||
artifact_id: ri.artifact_id,
|
||||
project: ri.project,
|
||||
created_at: ri.created_at,
|
||||
stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
|
||||
steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(status: tonic::Status) -> AuthError {
|
||||
@@ -583,9 +535,6 @@ 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(p)) => {
|
||||
PipelineStageConfig::Plan { environment: p.environment, auto_approve: p.auto_approve }
|
||||
}
|
||||
None => PipelineStageConfig::Deploy { environment: String::new() },
|
||||
};
|
||||
PipelineStage {
|
||||
@@ -603,7 +552,6 @@ fn convert_pipeline_stage_state(
|
||||
let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
|
||||
Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
|
||||
Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
|
||||
Ok(forage_grpc::PipelineRunStageType::Plan) => "plan",
|
||||
_ => "unknown",
|
||||
};
|
||||
let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
|
||||
@@ -612,7 +560,6 @@ fn convert_pipeline_stage_state(
|
||||
Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
|
||||
Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
|
||||
Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
|
||||
Ok(forage_grpc::PipelineRunStageStatus::AwaitingApproval) => "AWAITING_APPROVAL",
|
||||
_ => "PENDING",
|
||||
};
|
||||
forage_core::platform::PipelineRunStageState {
|
||||
@@ -628,8 +575,6 @@ fn convert_pipeline_stage_state(
|
||||
error_message: s.error_message,
|
||||
wait_until: s.wait_until,
|
||||
release_ids: s.release_ids,
|
||||
approval_status: s.approval_status,
|
||||
auto_approve: s.auto_approve,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,12 +612,6 @@ fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::Pipeline
|
||||
duration_seconds: *duration_seconds,
|
||||
})
|
||||
}
|
||||
PipelineStageConfig::Plan { environment, auto_approve } => {
|
||||
forage_grpc::pipeline_stage::Config::Plan(forage_grpc::PlanStageConfig {
|
||||
environment: environment.clone(),
|
||||
auto_approve: *auto_approve,
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
@@ -712,7 +651,6 @@ 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 {
|
||||
@@ -727,10 +665,6 @@ 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(),
|
||||
@@ -780,20 +714,6 @@ 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,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,9 +728,8 @@ fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
|
||||
|
||||
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated => PlatformError::NotAuthenticated,
|
||||
tonic::Code::PermissionDenied => {
|
||||
PlatformError::Other(status.message().into())
|
||||
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
|
||||
PlatformError::NotAuthenticated
|
||||
}
|
||||
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
|
||||
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
|
||||
@@ -1304,7 +1223,6 @@ impl ForestPlatform for GrpcForestClient {
|
||||
environments: environments.to_vec(),
|
||||
force: false,
|
||||
use_pipeline,
|
||||
prepare_only: false,
|
||||
},
|
||||
)
|
||||
.map_err(|e| PlatformError::Other(e.to_string()))?;
|
||||
@@ -1516,9 +1434,6 @@ 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(),
|
||||
),
|
||||
@@ -1705,300 +1620,6 @@ impl ForestPlatform for GrpcForestClient {
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(resp.into_inner().content)
|
||||
}
|
||||
|
||||
async fn get_notification_preferences(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<NotificationPreference>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetNotificationPreferencesRequest {},
|
||||
)?;
|
||||
let resp = self
|
||||
.notification_client()
|
||||
.get_notification_preferences(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(resp
|
||||
.into_inner()
|
||||
.preferences
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let nt = forage_grpc::NotificationType::try_from(p.notification_type)
|
||||
.unwrap_or(forage_grpc::NotificationType::Unspecified);
|
||||
let ch = forage_grpc::NotificationChannel::try_from(p.channel)
|
||||
.unwrap_or(forage_grpc::NotificationChannel::Unspecified);
|
||||
NotificationPreference {
|
||||
notification_type: nt.as_str_name().to_string(),
|
||||
channel: ch.as_str_name().to_string(),
|
||||
enabled: p.enabled,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_notification_preference(
|
||||
&self,
|
||||
access_token: &str,
|
||||
notification_type: &str,
|
||||
channel: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), PlatformError> {
|
||||
let nt = forage_grpc::NotificationType::from_str_name(notification_type)
|
||||
.unwrap_or(forage_grpc::NotificationType::Unspecified) as i32;
|
||||
let ch = forage_grpc::NotificationChannel::from_str_name(channel)
|
||||
.unwrap_or(forage_grpc::NotificationChannel::Unspecified) as i32;
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::SetNotificationPreferenceRequest {
|
||||
notification_type: nt,
|
||||
channel: ch,
|
||||
enabled,
|
||||
},
|
||||
)?;
|
||||
self.notification_client()
|
||||
.set_notification_preference(req)
|
||||
.await
|
||||
.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))
|
||||
}
|
||||
|
||||
async fn approve_plan_stage(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
) -> Result<(), PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ApprovePlanStageRequest {
|
||||
release_intent_id: release_intent_id.into(),
|
||||
stage_id: stage_id.into(),
|
||||
},
|
||||
)?;
|
||||
self.release_client()
|
||||
.approve_plan_stage(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_plan_stage(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<(), PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::RejectPlanStageRequest {
|
||||
release_intent_id: release_intent_id.into(),
|
||||
stage_id: stage_id.into(),
|
||||
reason: reason.map(|s| s.into()),
|
||||
},
|
||||
)?;
|
||||
self.release_client()
|
||||
.reject_plan_stage(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_plan_output(
|
||||
&self,
|
||||
access_token: &str,
|
||||
release_intent_id: &str,
|
||||
stage_id: &str,
|
||||
) -> Result<PlanOutput, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetPlanOutputRequest {
|
||||
release_intent_id: release_intent_id.into(),
|
||||
stage_id: stage_id.into(),
|
||||
},
|
||||
)?;
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_plan_output(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
let inner = resp.into_inner();
|
||||
Ok(PlanOutput {
|
||||
plan_output: inner.plan_output,
|
||||
status: inner.status,
|
||||
outputs: inner.outputs.into_iter().map(|o| {
|
||||
forage_core::platform::PlanDestinationOutput {
|
||||
destination_id: o.destination_id,
|
||||
destination_name: o.destination_name,
|
||||
plan_output: o.plan_output,
|
||||
status: o.status,
|
||||
}
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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.external_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)]
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
mod auth;
|
||||
mod forest_client;
|
||||
mod notification_consumer;
|
||||
mod notification_ingester;
|
||||
mod notification_worker;
|
||||
mod routes;
|
||||
mod serve_http;
|
||||
mod session_reaper;
|
||||
mod state;
|
||||
mod templates;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use forage_core::session::{FileSessionStore, SessionStore};
|
||||
use forage_db::PgSessionStore;
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use forage_core::session::{FileSessionStore, SessionStore};
|
||||
use forage_db::PgSessionStore;
|
||||
use minijinja::context;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::state::AppState;
|
||||
@@ -38,6 +31,7 @@ fn init_telemetry() {
|
||||
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||
|
||||
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() {
|
||||
// OTLP exporter configured — send spans + logs to collector
|
||||
let tracer = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_tonic()
|
||||
.build()
|
||||
@@ -55,8 +49,8 @@ fn init_telemetry() {
|
||||
)
|
||||
.build();
|
||||
|
||||
let otel_layer =
|
||||
tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("forage-server"));
|
||||
let otel_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer_provider.tracer("forage-server"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
@@ -110,154 +104,61 @@ async fn main() -> anyhow::Result<()> {
|
||||
let forest_client = GrpcForestClient::connect_lazy(&forest_endpoint)?;
|
||||
let template_engine = TemplateEngine::new()?;
|
||||
|
||||
// Session store: PostgreSQL if DATABASE_URL is set, otherwise in-memory
|
||||
let sessions: Arc<dyn SessionStore> = if let Ok(database_url) = std::env::var("DATABASE_URL") {
|
||||
tracing::info!("using PostgreSQL session store");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await?;
|
||||
forage_db::migrate(&pool).await?;
|
||||
|
||||
let pg_store = Arc::new(PgSessionStore::new(pool));
|
||||
|
||||
// Session reaper for PostgreSQL
|
||||
let reaper = pg_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match reaper.reap_expired(30).await {
|
||||
Ok(n) if n > 0 => tracing::info!("session reaper: removed {n} expired sessions"),
|
||||
Err(e) => tracing::warn!("session reaper error: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pg_store
|
||||
} else {
|
||||
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
|
||||
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
|
||||
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
|
||||
|
||||
let reaper = file_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
reaper.reap_expired();
|
||||
tracing::debug!("session reaper: {} active sessions", reaper.session_count());
|
||||
}
|
||||
});
|
||||
|
||||
file_store
|
||||
};
|
||||
|
||||
let forest_client = Arc::new(forest_client);
|
||||
let state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
|
||||
.with_grpc_client(forest_client);
|
||||
let app = build_router(state);
|
||||
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
// Build components based on available configuration
|
||||
let mut mad = notmad::Mad::builder();
|
||||
|
||||
// Session store + integration store: PostgreSQL if DATABASE_URL is set
|
||||
let (sessions, integration_store): (
|
||||
Arc<dyn SessionStore>,
|
||||
Option<Arc<dyn forage_core::integrations::IntegrationStore>>,
|
||||
);
|
||||
|
||||
if let Ok(database_url) = std::env::var("DATABASE_URL") {
|
||||
tracing::info!("using PostgreSQL session store");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await?;
|
||||
forage_db::migrate(&pool).await?;
|
||||
|
||||
let pg_store = Arc::new(PgSessionStore::new(pool.clone()));
|
||||
|
||||
// Integration store (uses same pool)
|
||||
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY").unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
"INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)"
|
||||
);
|
||||
"forage-dev-key-not-for-production!!".to_string()
|
||||
});
|
||||
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(
|
||||
pool,
|
||||
encryption_key.into_bytes(),
|
||||
));
|
||||
|
||||
// Session reaper component
|
||||
mad.add(session_reaper::PgSessionReaper {
|
||||
store: pg_store.clone(),
|
||||
max_inactive_days: 30,
|
||||
});
|
||||
|
||||
sessions = pg_store;
|
||||
integration_store =
|
||||
Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
|
||||
} else {
|
||||
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
|
||||
tracing::info!(
|
||||
"using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)"
|
||||
);
|
||||
let file_store =
|
||||
Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
|
||||
|
||||
// File session reaper component
|
||||
mad.add(session_reaper::FileSessionReaper {
|
||||
store: file_store.clone(),
|
||||
});
|
||||
|
||||
sessions = file_store as Arc<dyn SessionStore>;
|
||||
integration_store = None;
|
||||
};
|
||||
|
||||
let forest_client = Arc::new(forest_client);
|
||||
let mut state = AppState::new(
|
||||
template_engine,
|
||||
forest_client.clone(),
|
||||
forest_client.clone(),
|
||||
sessions,
|
||||
)
|
||||
.with_grpc_client(forest_client.clone());
|
||||
|
||||
// Slack OAuth config (optional, enables "Add to Slack" button)
|
||||
if let (Ok(client_id), Ok(client_secret)) = (
|
||||
std::env::var("SLACK_CLIENT_ID"),
|
||||
std::env::var("SLACK_CLIENT_SECRET"),
|
||||
) {
|
||||
let redirect_host = std::env::var("SLACK_REDIRECT_HOST")
|
||||
.unwrap_or_else(|_| format!("http://localhost:{port}"));
|
||||
tracing::info!("Slack OAuth enabled");
|
||||
state = state.with_slack_config(crate::state::SlackConfig {
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_host,
|
||||
});
|
||||
}
|
||||
|
||||
// NATS JetStream connection (optional, enables durable notification delivery)
|
||||
let nats_jetstream = if let Ok(nats_url) = std::env::var("NATS_URL") {
|
||||
match async_nats::connect(&nats_url).await {
|
||||
Ok(client) => {
|
||||
tracing::info!("connected to NATS at {nats_url}");
|
||||
Some(async_nats::jetstream::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to connect to NATS — falling back to direct dispatch");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref store) = integration_store {
|
||||
state = state.with_integration_store(store.clone());
|
||||
|
||||
if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") {
|
||||
let forage_url = std::env::var("FORAGE_URL")
|
||||
.or_else(|_| std::env::var("SLACK_REDIRECT_HOST"))
|
||||
.unwrap_or_else(|_| format!("http://localhost:{port}"));
|
||||
|
||||
if let Some(ref js) = nats_jetstream {
|
||||
// JetStream mode: ingester publishes, consumer dispatches
|
||||
tracing::info!("starting notification pipeline (JetStream)");
|
||||
let grpc_for_consumer = forest_client.clone();
|
||||
let token_for_consumer = service_token.clone();
|
||||
mad.add(notification_ingester::NotificationIngester {
|
||||
grpc: forest_client,
|
||||
jetstream: js.clone(),
|
||||
service_token,
|
||||
});
|
||||
mad.add(notification_consumer::NotificationConsumer {
|
||||
jetstream: js.clone(),
|
||||
store: store.clone(),
|
||||
forage_url,
|
||||
grpc: grpc_for_consumer,
|
||||
service_token: token_for_consumer,
|
||||
});
|
||||
} else {
|
||||
// Fallback: direct dispatch (no durability)
|
||||
tracing::warn!(
|
||||
"NATS_URL not set — using direct notification dispatch (no durability)"
|
||||
);
|
||||
mad.add(notification_worker::NotificationListener {
|
||||
grpc: forest_client,
|
||||
store: store.clone(),
|
||||
service_token,
|
||||
forage_url,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("FORAGE_SERVICE_TOKEN not set — notification listener disabled");
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP server component
|
||||
mad.add(serve_http::ServeHttp { addr, state });
|
||||
|
||||
mad.cancellation(Some(Duration::from_secs(10)))
|
||||
.run()
|
||||
.await?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_nats::jetstream;
|
||||
use async_nats::jetstream::consumer::PullConsumer;
|
||||
use forage_core::integrations::nats::{
|
||||
NotificationEnvelope, CONSUMER_NAME, STREAM_NAME,
|
||||
};
|
||||
use forage_core::integrations::IntegrationStore;
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::notification_worker::NotificationDispatcher;
|
||||
|
||||
/// Background component that pulls notification events from NATS JetStream
|
||||
/// and dispatches webhooks to matching integrations.
|
||||
pub struct NotificationConsumer {
|
||||
pub jetstream: jetstream::Context,
|
||||
pub store: Arc<dyn IntegrationStore>,
|
||||
pub forage_url: String,
|
||||
pub grpc: Arc<GrpcForestClient>,
|
||||
pub service_token: String,
|
||||
}
|
||||
|
||||
impl Component for NotificationConsumer {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/notification-consumer".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let dispatcher = Arc::new(
|
||||
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
|
||||
.with_grpc(self.grpc.clone(), self.service_token.clone()),
|
||||
);
|
||||
|
||||
let mut backoff = 1u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
tracing::info!("notification consumer shutting down");
|
||||
break;
|
||||
}
|
||||
result = self.consume_loop(&dispatcher, &cancellation_token) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tracing::info!("consumer loop ended cleanly");
|
||||
backoff = 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, backoff_secs = backoff, "consumer error, reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(backoff)) => {}
|
||||
}
|
||||
backoff = (backoff * 2).min(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationConsumer {
|
||||
async fn get_or_create_consumer(&self) -> Result<PullConsumer, String> {
|
||||
use async_nats::jetstream::consumer;
|
||||
|
||||
let stream = self
|
||||
.jetstream
|
||||
.get_stream(STREAM_NAME)
|
||||
.await
|
||||
.map_err(|e| format!("get stream: {e}"))?;
|
||||
|
||||
stream
|
||||
.get_or_create_consumer(
|
||||
CONSUMER_NAME,
|
||||
consumer::pull::Config {
|
||||
durable_name: Some(CONSUMER_NAME.to_string()),
|
||||
ack_wait: Duration::from_secs(120),
|
||||
max_deliver: 5,
|
||||
max_ack_pending: 100,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("create consumer: {e}"))
|
||||
}
|
||||
|
||||
async fn consume_loop(
|
||||
&self,
|
||||
dispatcher: &Arc<NotificationDispatcher>,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let consumer = self.get_or_create_consumer().await?;
|
||||
let mut messages = consumer
|
||||
.messages()
|
||||
.await
|
||||
.map_err(|e| format!("consumer messages: {e}"))?;
|
||||
|
||||
tracing::info!(consumer = CONSUMER_NAME, "pulling from JetStream");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
return Ok(());
|
||||
}
|
||||
msg = messages.next() => {
|
||||
let Some(msg) = msg else {
|
||||
return Ok(()); // Stream closed
|
||||
};
|
||||
let msg = msg.map_err(|e| format!("message error: {e}"))?;
|
||||
|
||||
match self.handle_message(&msg, dispatcher).await {
|
||||
Ok(()) => {
|
||||
if let Err(e) = msg.ack().await {
|
||||
tracing::warn!(error = %e, "failed to ack message");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to handle message, nacking");
|
||||
if let Err(e) = msg.ack_with(async_nats::jetstream::AckKind::Nak(Some(Duration::from_secs(30)))).await {
|
||||
tracing::warn!(error = %e, "failed to nak message");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
&self,
|
||||
msg: &async_nats::jetstream::Message,
|
||||
dispatcher: &Arc<NotificationDispatcher>,
|
||||
) -> Result<(), String> {
|
||||
Self::process_payload(&msg.payload, self.store.as_ref(), dispatcher).await
|
||||
}
|
||||
|
||||
/// Process a raw notification payload. Extracted for testability without NATS.
|
||||
pub async fn process_payload(
|
||||
payload: &[u8],
|
||||
store: &dyn IntegrationStore,
|
||||
dispatcher: &NotificationDispatcher,
|
||||
) -> Result<(), String> {
|
||||
let envelope: NotificationEnvelope = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("deserialize envelope: {e}"))?;
|
||||
|
||||
let event: forage_core::integrations::router::NotificationEvent = envelope.into();
|
||||
|
||||
tracing::info!(
|
||||
org = %event.organisation,
|
||||
event_type = %event.notification_type,
|
||||
notification_id = %event.id,
|
||||
"processing notification from JetStream"
|
||||
);
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification_for_org(
|
||||
store,
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
|
||||
if tasks.is_empty() {
|
||||
tracing::debug!(
|
||||
org = %event.organisation,
|
||||
"no matching integrations, skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dispatch all tasks sequentially within this message.
|
||||
// JetStream provides parallelism across messages.
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_nats::jetstream;
|
||||
use forage_core::integrations::nats::{
|
||||
notification_subject, NotificationEnvelope, STREAM_NAME, STREAM_SUBJECTS,
|
||||
};
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::notification_worker::proto_to_event;
|
||||
|
||||
/// Background component that listens to Forest's notification stream
|
||||
/// and publishes events to NATS JetStream for durable processing.
|
||||
pub struct NotificationIngester {
|
||||
pub grpc: Arc<GrpcForestClient>,
|
||||
pub jetstream: jetstream::Context,
|
||||
pub service_token: String,
|
||||
}
|
||||
|
||||
impl Component for NotificationIngester {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/notification-ingester".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
// Ensure the JetStream stream exists
|
||||
self.ensure_stream().await.map_err(|e| {
|
||||
MadError::Inner(anyhow::anyhow!("failed to create JetStream stream: {e}"))
|
||||
})?;
|
||||
|
||||
let mut backoff = 1u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
tracing::info!("notification ingester shutting down");
|
||||
break;
|
||||
}
|
||||
result = self.ingest_once() => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tracing::info!("notification stream ended cleanly");
|
||||
backoff = 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, backoff_secs = backoff, "notification stream error, reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(backoff)) => {}
|
||||
}
|
||||
backoff = (backoff * 2).min(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationIngester {
|
||||
async fn ensure_stream(&self) -> Result<(), String> {
|
||||
use async_nats::jetstream::stream;
|
||||
|
||||
self.jetstream
|
||||
.get_or_create_stream(stream::Config {
|
||||
name: STREAM_NAME.to_string(),
|
||||
subjects: vec![STREAM_SUBJECTS.to_string()],
|
||||
retention: stream::RetentionPolicy::WorkQueue,
|
||||
max_age: Duration::from_secs(7 * 24 * 3600), // 7 days
|
||||
max_bytes: 1_073_741_824, // 1 GB
|
||||
discard: stream::DiscardPolicy::Old,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("create stream: {e}"))?;
|
||||
|
||||
tracing::info!(stream = STREAM_NAME, "JetStream stream ready");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ingest_once(&self) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut client = self.grpc.notification_client();
|
||||
|
||||
let mut req = tonic::Request::new(forage_grpc::ListenNotificationsRequest {
|
||||
organisation: None,
|
||||
project: None,
|
||||
});
|
||||
req.metadata_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {}", self.service_token)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid service token: {e}"))?,
|
||||
);
|
||||
|
||||
let response = client
|
||||
.listen_notifications(req)
|
||||
.await
|
||||
.map_err(|e| format!("gRPC connect: {e}"))?;
|
||||
|
||||
let mut stream = response.into_inner();
|
||||
|
||||
tracing::info!("connected to notification stream (JetStream mode)");
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(notification) => {
|
||||
let event = proto_to_event(¬ification);
|
||||
tracing::info!(
|
||||
org = %event.organisation,
|
||||
event_type = %event.notification_type,
|
||||
notification_id = %event.id,
|
||||
"received notification, publishing to JetStream"
|
||||
);
|
||||
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let subject =
|
||||
notification_subject(&event.organisation, &event.notification_type);
|
||||
let payload = serde_json::to_vec(&envelope)
|
||||
.map_err(|e| format!("serialize envelope: {e}"))?;
|
||||
|
||||
// Publish with ack — JetStream confirms persistence
|
||||
if let Err(e) = self
|
||||
.jetstream
|
||||
.publish(subject, payload.into())
|
||||
.await
|
||||
.map_err(|e| format!("publish: {e}"))
|
||||
.and_then(|ack_future| {
|
||||
// We don't block on the ack to keep the stream flowing,
|
||||
// but we log failures. In practice, JetStream will buffer.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = ack_future.await {
|
||||
tracing::warn!(error = %e, "JetStream publish ack failed");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
{
|
||||
tracing::error!(error = %e, "failed to publish to JetStream");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("stream error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use forage_core::integrations::router::{DispatchTask, NotificationEvent, ReleaseContext};
|
||||
use forage_core::integrations::webhook::sign_payload;
|
||||
use forage_core::integrations::{DeliveryStatus, IntegrationStore};
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────────────
|
||||
|
||||
/// HTTP client for dispatching webhooks and Slack messages.
|
||||
pub struct NotificationDispatcher {
|
||||
http: reqwest::Client,
|
||||
store: Arc<dyn IntegrationStore>,
|
||||
forage_url: String,
|
||||
/// gRPC client for querying pipeline state (optional — absent in tests).
|
||||
grpc: Option<Arc<GrpcForestClient>>,
|
||||
/// Service token for authenticating gRPC calls to fetch pipeline state.
|
||||
service_token: String,
|
||||
}
|
||||
|
||||
impl NotificationDispatcher {
|
||||
pub fn new(store: Arc<dyn IntegrationStore>, forage_url: String) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
Self { http, store, forage_url, grpc: None, service_token: String::new() }
|
||||
}
|
||||
|
||||
pub fn with_grpc(mut self, grpc: Arc<GrpcForestClient>, service_token: String) -> Self {
|
||||
self.grpc = Some(grpc);
|
||||
self.service_token = service_token;
|
||||
self
|
||||
}
|
||||
|
||||
/// Execute a dispatch task with retry (3 attempts, exponential backoff).
|
||||
pub async fn dispatch(&self, task: &DispatchTask) {
|
||||
let (integration_id, notification_id) = match task {
|
||||
DispatchTask::Webhook {
|
||||
integration_id,
|
||||
payload,
|
||||
..
|
||||
} => (integration_id.clone(), payload.notification_id.clone()),
|
||||
DispatchTask::Slack {
|
||||
integration_id,
|
||||
notification_id,
|
||||
..
|
||||
} => (integration_id.clone(), notification_id.clone()),
|
||||
DispatchTask::SlackDm {
|
||||
integration_id,
|
||||
notification_id,
|
||||
..
|
||||
} => (integration_id.clone(), notification_id.clone()),
|
||||
};
|
||||
|
||||
let delays = [1, 5, 25]; // seconds
|
||||
for (attempt, delay) in delays.iter().enumerate() {
|
||||
match self.try_dispatch(task).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
integration_id = %integration_id,
|
||||
attempt = attempt + 1,
|
||||
"notification delivered"
|
||||
);
|
||||
let _ = self
|
||||
.store
|
||||
.record_delivery(&integration_id, ¬ification_id, DeliveryStatus::Delivered, None)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't retry errors that will never succeed
|
||||
let non_retryable = is_non_retryable_error(&e);
|
||||
if non_retryable {
|
||||
tracing::error!(
|
||||
integration_id = %integration_id,
|
||||
error = %e,
|
||||
"non-retryable delivery error"
|
||||
);
|
||||
let _ = self
|
||||
.store
|
||||
.record_delivery(
|
||||
&integration_id,
|
||||
¬ification_id,
|
||||
DeliveryStatus::Failed,
|
||||
Some(&e),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
integration_id = %integration_id,
|
||||
attempt = attempt + 1,
|
||||
error = %e,
|
||||
"delivery attempt failed"
|
||||
);
|
||||
if attempt < delays.len() - 1 {
|
||||
tokio::time::sleep(Duration::from_secs(*delay)).await;
|
||||
} else {
|
||||
tracing::error!(
|
||||
integration_id = %integration_id,
|
||||
"all delivery attempts exhausted"
|
||||
);
|
||||
let _ = self
|
||||
.store
|
||||
.record_delivery(
|
||||
&integration_id,
|
||||
¬ification_id,
|
||||
DeliveryStatus::Failed,
|
||||
Some(&e),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_dispatch(&self, task: &DispatchTask) -> Result<(), String> {
|
||||
match task {
|
||||
DispatchTask::Webhook {
|
||||
url,
|
||||
secret,
|
||||
headers,
|
||||
payload,
|
||||
..
|
||||
} => {
|
||||
let body =
|
||||
serde_json::to_vec(payload).map_err(|e| format!("serialize: {e}"))?;
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", "Forage/1.0");
|
||||
|
||||
if let Some(secret) = secret {
|
||||
let sig = sign_payload(&body, secret);
|
||||
req = req.header("X-Forage-Signature", sig);
|
||||
}
|
||||
|
||||
for (k, v) in headers {
|
||||
req = req.header(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("http: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!("HTTP {status}: {body}"))
|
||||
}
|
||||
}
|
||||
DispatchTask::Slack {
|
||||
integration_id,
|
||||
webhook_url,
|
||||
access_token,
|
||||
channel_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
// If we have a bot token, use chat.postMessage/chat.update for update-in-place
|
||||
if !access_token.is_empty() && !channel_id.is_empty() && !release_id.is_empty() {
|
||||
self.dispatch_slack_bot(
|
||||
integration_id,
|
||||
access_token,
|
||||
channel_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
// Fallback: webhook URL (no update-in-place possible)
|
||||
self.dispatch_slack_webhook(webhook_url, message).await
|
||||
}
|
||||
}
|
||||
DispatchTask::SlackDm {
|
||||
integration_id,
|
||||
access_token,
|
||||
slack_user_id,
|
||||
release_id,
|
||||
event_type,
|
||||
event,
|
||||
message: _,
|
||||
..
|
||||
} => {
|
||||
// DM uses the same bot token post/update pattern, but channel = user ID.
|
||||
// Prefix release_id so the message ref is distinct from channel messages.
|
||||
let dm_release_id = format!("dm:{slack_user_id}:{release_id}");
|
||||
self.dispatch_slack_bot(
|
||||
integration_id,
|
||||
access_token,
|
||||
slack_user_id, // Slack accepts user ID as channel for DMs
|
||||
&dm_release_id,
|
||||
event_type,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post or update a Slack message via the bot token API.
|
||||
/// Merges per-destination status into the message ref and rebuilds the message.
|
||||
async fn dispatch_slack_bot(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
access_token: &str,
|
||||
channel: &str,
|
||||
release_id: &str,
|
||||
event_type: &str,
|
||||
event: &forage_core::integrations::router::NotificationEvent,
|
||||
) -> Result<(), String> {
|
||||
use forage_core::integrations::{DestinationStatus, SlackMessageRef};
|
||||
use forage_core::integrations::router::format_slack_message;
|
||||
|
||||
// Get existing ref (with accumulated destinations) if we already posted
|
||||
let existing_ref = self
|
||||
.store
|
||||
.get_slack_message_ref(integration_id, release_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
// Merge this notification's destination into the accumulated map
|
||||
let mut destinations = existing_ref
|
||||
.as_ref()
|
||||
.map(|r| r.destinations.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(ref r) = event.release {
|
||||
if !r.destination.is_empty() {
|
||||
let status = match event_type {
|
||||
"release_started" => "started",
|
||||
"release_succeeded" => "succeeded",
|
||||
"release_failed" => "failed",
|
||||
_ => "started",
|
||||
};
|
||||
destinations.insert(
|
||||
r.destination.clone(),
|
||||
DestinationStatus {
|
||||
environment: r.environment.clone(),
|
||||
status: status.to_string(),
|
||||
error: r.error_message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the message with the full accumulated state
|
||||
let mut message = format_slack_message(event, &destinations, &self.forage_url);
|
||||
|
||||
// Query pipeline stages and insert before destinations
|
||||
if let Some(ref r) = event.release {
|
||||
if !r.release_intent_id.is_empty() {
|
||||
if let Some(stages) = self
|
||||
.fetch_pipeline_stages(&event.organisation, &event.project, &r.release_intent_id)
|
||||
.await
|
||||
{
|
||||
let pipeline_blocks =
|
||||
forage_core::integrations::router::format_pipeline_blocks(&stages);
|
||||
if !pipeline_blocks.is_empty() {
|
||||
// Insert pipeline before the destination section.
|
||||
// Find the last "context" block (metadata); pipeline goes right after it,
|
||||
// pushing destinations and errors down.
|
||||
let insert_at = message
|
||||
.blocks
|
||||
.iter()
|
||||
.rposition(|b| b["type"] == "context")
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(message.blocks.len());
|
||||
for (i, block) in pipeline_blocks.into_iter().enumerate() {
|
||||
message.blocks.insert(insert_at + i, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let release_title = event
|
||||
.release
|
||||
.as_ref()
|
||||
.filter(|r| !r.context_title.is_empty())
|
||||
.map(|r| r.context_title.clone())
|
||||
.or_else(|| existing_ref.as_ref().map(|r| r.release_title.clone()))
|
||||
.unwrap_or_else(|| event.title.clone());
|
||||
|
||||
let blocks_payload = serde_json::json!([{
|
||||
"color": message.color,
|
||||
"blocks": message.blocks,
|
||||
}]);
|
||||
|
||||
// The `text` field is a fallback for notifications/accessibility only.
|
||||
// Slack renders it above attachments in some clients, causing duplication.
|
||||
// Use a minimal fallback; the attachment blocks carry the rich content.
|
||||
let fallback_text = format!("Release update: {}/{}", event.organisation, event.project);
|
||||
|
||||
if let Some(ref msg_ref) = existing_ref {
|
||||
// Update existing message
|
||||
let payload = serde_json::json!({
|
||||
"channel": msg_ref.channel_id,
|
||||
"ts": msg_ref.message_ts,
|
||||
"text": fallback_text,
|
||||
"attachments": blocks_payload,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/chat.update")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack chat.update http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value =
|
||||
resp.json().await.map_err(|e| format!("slack chat.update parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
if err == "message_not_found" {
|
||||
tracing::warn!(
|
||||
integration_id = %integration_id,
|
||||
release_id = %release_id,
|
||||
"slack message not found, posting new one"
|
||||
);
|
||||
// Fall through to post a new one
|
||||
} else {
|
||||
return Err(format!("slack chat.update: {err}"));
|
||||
}
|
||||
} else {
|
||||
// Update the ref with merged destinations
|
||||
let updated = SlackMessageRef {
|
||||
id: msg_ref.id.clone(),
|
||||
integration_id: integration_id.to_string(),
|
||||
release_id: release_id.to_string(),
|
||||
channel_id: msg_ref.channel_id.clone(),
|
||||
message_ts: msg_ref.message_ts.clone(),
|
||||
last_event_type: event_type.to_string(),
|
||||
destinations,
|
||||
release_title,
|
||||
created_at: msg_ref.created_at.clone(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let _ = self.store.upsert_slack_message_ref(&updated).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Try to join the channel first
|
||||
let _ = self.slack_join_channel(access_token, channel).await;
|
||||
|
||||
// Post new message
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel,
|
||||
"text": fallback_text,
|
||||
"attachments": blocks_payload,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/chat.postMessage")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack chat.postMessage http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value =
|
||||
resp.json().await.map_err(|e| format!("slack chat.postMessage parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
return Err(format!("slack chat.postMessage: {err}"));
|
||||
}
|
||||
|
||||
// Store the message ref with initial destinations
|
||||
let ts = body["ts"].as_str().unwrap_or_default();
|
||||
let posted_channel = body["channel"].as_str().unwrap_or(channel);
|
||||
|
||||
if !ts.is_empty() && !release_id.is_empty() {
|
||||
let msg_ref = SlackMessageRef {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
release_id: release_id.to_string(),
|
||||
channel_id: posted_channel.to_string(),
|
||||
message_ts: ts.to_string(),
|
||||
last_event_type: event_type.to_string(),
|
||||
destinations,
|
||||
release_title,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let _ = self.store.upsert_slack_message_ref(&msg_ref).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to join a Slack channel. Silently succeeds if already a member or channel is private.
|
||||
async fn slack_join_channel(&self, access_token: &str, channel: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({ "channel": channel });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://slack.com/api/conversations.join")
|
||||
.bearer_auth(access_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack conversations.join http: {e}"))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("slack conversations.join parse: {e}"))?;
|
||||
|
||||
if body["ok"].as_bool() == Some(true) {
|
||||
tracing::info!(channel = %channel, "bot joined slack channel");
|
||||
} else {
|
||||
let err = body["error"].as_str().unwrap_or("unknown");
|
||||
// channel_not_found, method_not_supported_for_channel_type (private), already_in_channel
|
||||
// These are all acceptable — we tried our best
|
||||
tracing::debug!(channel = %channel, error = %err, "conversations.join failed (may be private channel)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch pipeline stages for a release intent via gRPC.
|
||||
/// Returns None if gRPC is not configured or the call fails.
|
||||
async fn fetch_pipeline_stages(
|
||||
&self,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
release_intent_id: &str,
|
||||
) -> Option<Vec<forage_core::platform::PipelineRunStageState>> {
|
||||
let grpc = self.grpc.as_ref()?;
|
||||
if self.service_token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match grpc
|
||||
.get_release_intent_states_with_token(
|
||||
&self.service_token,
|
||||
organisation,
|
||||
Some(project),
|
||||
true, // include_completed so we get the current intent
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(intents) => {
|
||||
// Find the matching release intent
|
||||
intents
|
||||
.into_iter()
|
||||
.find(|i| i.release_intent_id == release_intent_id)
|
||||
.map(|i| i.stages)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
release_intent_id = %release_intent_id,
|
||||
error = %e,
|
||||
"failed to fetch pipeline stages"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: post via incoming webhook URL (no update-in-place).
|
||||
async fn dispatch_slack_webhook(
|
||||
&self,
|
||||
webhook_url: &str,
|
||||
message: &forage_core::integrations::router::SlackMessage,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"text": message.text,
|
||||
"attachments": [{
|
||||
"color": message.color,
|
||||
"blocks": message.blocks,
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(webhook_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("slack http: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!("Slack HTTP {status}: {body}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slack API errors that will never succeed on retry.
|
||||
fn is_non_retryable_error(err: &str) -> bool {
|
||||
const NON_RETRYABLE: &[&str] = &[
|
||||
"channel_not_found",
|
||||
"not_in_channel",
|
||||
"is_archived",
|
||||
"invalid_auth",
|
||||
"token_revoked",
|
||||
"account_inactive",
|
||||
"no_permission",
|
||||
"missing_scope",
|
||||
"not_authed",
|
||||
"invalid_arguments",
|
||||
];
|
||||
NON_RETRYABLE.iter().any(|code| err.contains(code))
|
||||
}
|
||||
|
||||
// ── Proto conversion ────────────────────────────────────────────────
|
||||
|
||||
/// Convert a proto Notification to our domain NotificationEvent.
|
||||
pub fn proto_to_event(n: &forage_grpc::Notification) -> NotificationEvent {
|
||||
let notification_type = match n.notification_type() {
|
||||
forage_grpc::NotificationType::ReleaseAnnotated => "release_annotated",
|
||||
forage_grpc::NotificationType::ReleaseStarted => "release_started",
|
||||
forage_grpc::NotificationType::ReleaseSucceeded => "release_succeeded",
|
||||
forage_grpc::NotificationType::ReleaseFailed => "release_failed",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
let release = n.release_context.as_ref().map(|r| ReleaseContext {
|
||||
slug: r.slug.clone(),
|
||||
artifact_id: r.artifact_id.clone(),
|
||||
release_intent_id: r.release_intent_id.clone(),
|
||||
destination: r.destination.clone(),
|
||||
environment: r.environment.clone(),
|
||||
source_username: r.source_username.clone(),
|
||||
source_user_id: r.source_user_id.clone(),
|
||||
commit_sha: r.commit_sha.clone(),
|
||||
commit_branch: r.commit_branch.clone(),
|
||||
context_title: r.context_title.clone(),
|
||||
context_web: r.context_web.clone(),
|
||||
destination_count: r.destination_count,
|
||||
error_message: if r.error_message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.error_message.clone())
|
||||
},
|
||||
});
|
||||
|
||||
NotificationEvent {
|
||||
id: n.id.clone(),
|
||||
notification_type: notification_type.to_string(),
|
||||
title: n.title.clone(),
|
||||
body: n.body.clone(),
|
||||
organisation: n.organisation.clone(),
|
||||
project: n.project.clone(),
|
||||
timestamp: n.created_at.clone(),
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listener component ──────────────────────────────────────────────
|
||||
|
||||
/// Background component that listens to Forest's notification stream
|
||||
/// for all orgs with active integrations, and dispatches to configured channels.
|
||||
pub struct NotificationListener {
|
||||
pub grpc: Arc<GrpcForestClient>,
|
||||
pub store: Arc<dyn IntegrationStore>,
|
||||
/// Service token (PAT) for authenticating with forest-server's NotificationService.
|
||||
pub service_token: String,
|
||||
/// Base URL of the Forage web UI for deep links (e.g. "https://forage.example.com").
|
||||
pub forage_url: String,
|
||||
}
|
||||
|
||||
impl Component for NotificationListener {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/notification-listener".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let dispatcher = Arc::new(
|
||||
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
|
||||
.with_grpc(self.grpc.clone(), self.service_token.clone()),
|
||||
);
|
||||
|
||||
// For now, listen on the global stream (no org filter).
|
||||
// Forest's ListenNotifications with no org filter returns all notifications
|
||||
// the authenticated user has access to.
|
||||
let mut backoff = 1u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
tracing::info!("notification listener shutting down");
|
||||
break;
|
||||
}
|
||||
result = self.listen_once(&dispatcher) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tracing::info!("notification stream ended cleanly");
|
||||
backoff = 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, backoff_secs = backoff, "notification stream error, reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before reconnecting, but respect cancellation
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(backoff)) => {}
|
||||
}
|
||||
backoff = (backoff * 2).min(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationListener {
|
||||
async fn listen_once(&self, dispatcher: &Arc<NotificationDispatcher>) -> Result<(), String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut client = self.grpc.notification_client();
|
||||
|
||||
let mut req = tonic::Request::new(forage_grpc::ListenNotificationsRequest {
|
||||
organisation: None,
|
||||
project: None,
|
||||
});
|
||||
req.metadata_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {}", self.service_token)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid service token: {e}"))?,
|
||||
);
|
||||
|
||||
let response = client
|
||||
.listen_notifications(req)
|
||||
.await
|
||||
.map_err(|e| format!("gRPC connect: {e}"))?;
|
||||
|
||||
let mut stream = response.into_inner();
|
||||
|
||||
tracing::info!("connected to notification stream");
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(notification) => {
|
||||
let event = proto_to_event(¬ification);
|
||||
tracing::info!(
|
||||
org = %event.organisation,
|
||||
event_type = %event.notification_type,
|
||||
notification_id = %event.id,
|
||||
"received notification"
|
||||
);
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification_for_org(
|
||||
self.store.as_ref(),
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
|
||||
for task in &tasks {
|
||||
let dispatcher = dispatcher.clone();
|
||||
let task = task.clone();
|
||||
tokio::spawn(async move {
|
||||
dispatcher.dispatch(&task).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("stream error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Form, Router};
|
||||
use chrono::Utc;
|
||||
use forage_core::integrations::SlackUserLink;
|
||||
use minijinja::context;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -32,22 +31,6 @@ pub fn router() -> Router<AppState> {
|
||||
"/settings/account/emails/remove",
|
||||
post(remove_email_submit),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/notifications",
|
||||
post(update_notification_preference),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/connect",
|
||||
get(slack_connect),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/callback",
|
||||
get(slack_user_callback),
|
||||
)
|
||||
.route(
|
||||
"/settings/account/slack/disconnect",
|
||||
post(slack_disconnect),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Signup ─────────────────────────────────────────────────────────
|
||||
@@ -108,42 +91,34 @@ async fn signup_submit(
|
||||
{
|
||||
Ok(tokens) => {
|
||||
// Fetch user info for the session cache
|
||||
let user_cache = match state
|
||||
let mut user_cache = state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(u) => {
|
||||
let orgs = match state
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(orgs) => orgs
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch orgs during signup");
|
||||
vec![]
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
Some(CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch user during signup");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
@@ -268,42 +243,34 @@ async fn login_submit(
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
let user_cache = match state
|
||||
let mut user_cache = state
|
||||
.forest_client
|
||||
.get_user(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(u) => {
|
||||
let orgs = match state
|
||||
.ok()
|
||||
.map(|u| CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs: vec![],
|
||||
});
|
||||
|
||||
// Cache org memberships in the session
|
||||
if let Some(ref mut user) = user_cache
|
||||
&& let Ok(orgs) = state
|
||||
.platform_client
|
||||
.list_my_organisations(&tokens.access_token)
|
||||
.await
|
||||
{
|
||||
Ok(orgs) => orgs
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch orgs during login");
|
||||
vec![]
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
Some(CachedUser {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u.emails,
|
||||
orgs,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch user during login");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let session_data = SessionData {
|
||||
@@ -519,22 +486,7 @@ async fn account_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
let prefs = state
|
||||
.platform_client
|
||||
.get_notification_preferences(&session.access_token)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let slack_links = if let Some(store) = state.integration_store.as_ref() {
|
||||
store
|
||||
.list_slack_user_links(&session.user.user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
render_account(&state, &session, None, &prefs, &slack_links)
|
||||
render_account(&state, &session, None)
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -542,8 +494,6 @@ fn render_account(
|
||||
state: &AppState,
|
||||
session: &Session,
|
||||
error: Option<&str>,
|
||||
notification_prefs: &[forage_core::platform::NotificationPreference],
|
||||
slack_links: &[SlackUserLink],
|
||||
) -> Result<Response, Response> {
|
||||
let html = state
|
||||
.templates
|
||||
@@ -565,18 +515,6 @@ fn render_account(
|
||||
csrf_token => &session.csrf_token,
|
||||
error => error,
|
||||
active_tab => "account",
|
||||
enabled_prefs => notification_prefs.iter()
|
||||
.filter(|p| p.enabled)
|
||||
.map(|p| format!("{}|{}", p.notification_type, p.channel))
|
||||
.collect::<Vec<_>>(),
|
||||
has_slack_oauth => state.slack_config.is_some(),
|
||||
slack_links => slack_links.iter().map(|l| context! {
|
||||
id => &l.id,
|
||||
team_id => &l.team_id,
|
||||
team_name => &l.team_name,
|
||||
slack_user_id => &l.slack_user_id,
|
||||
slack_username => &l.slack_username,
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -607,7 +545,7 @@ async fn update_username_submit(
|
||||
}
|
||||
|
||||
if let Err(e) = validate_username(&form.username) {
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
return render_account(&state, &session, Some(&e.0));
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -629,11 +567,11 @@ async fn update_username_submit(
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
|
||||
render_account(&state, &session, Some("Username is already taken."), &[], &[])
|
||||
render_account(&state, &session, Some("Username is already taken."))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to update username: {e}");
|
||||
render_account(&state, &session, Some("Could not update username. Please try again."), &[], &[])
|
||||
render_account(&state, &session, Some("Could not update username. Please try again."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,11 +599,11 @@ async fn change_password_submit(
|
||||
}
|
||||
|
||||
if form.new_password != form.new_password_confirm {
|
||||
return render_account(&state, &session, Some("New passwords do not match."), &[], &[]);
|
||||
return render_account(&state, &session, Some("New passwords do not match."));
|
||||
}
|
||||
|
||||
if let Err(e) = validate_password(&form.new_password) {
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
return render_account(&state, &session, Some(&e.0));
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -680,11 +618,11 @@ async fn change_password_submit(
|
||||
{
|
||||
Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
|
||||
Err(forage_core::auth::AuthError::InvalidCredentials) => {
|
||||
render_account(&state, &session, Some("Current password is incorrect."), &[], &[])
|
||||
render_account(&state, &session, Some("Current password is incorrect."))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to change password: {e}");
|
||||
render_account(&state, &session, Some("Could not change password. Please try again."), &[], &[])
|
||||
render_account(&state, &session, Some("Could not change password. Please try again."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,7 +648,7 @@ async fn add_email_submit(
|
||||
}
|
||||
|
||||
if let Err(e) = validate_email(&form.email) {
|
||||
return render_account(&state, &session, Some(&e.0), &[], &[]);
|
||||
return render_account(&state, &session, Some(&e.0));
|
||||
}
|
||||
|
||||
match state
|
||||
@@ -735,11 +673,11 @@ async fn add_email_submit(
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
|
||||
render_account(&state, &session, Some("Email is already registered."), &[], &[])
|
||||
render_account(&state, &session, Some("Email is already registered."))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to add email: {e}");
|
||||
render_account(&state, &session, Some("Could not add email. Please try again."), &[], &[])
|
||||
render_account(&state, &session, Some("Could not add email. Please try again."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,269 +722,7 @@ async fn remove_email_submit(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to remove email: {e}");
|
||||
render_account(&state, &session, Some("Could not remove email. Please try again."), &[], &[])
|
||||
render_account(&state, &session, Some("Could not remove email. Please try again."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notification preferences ────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateNotificationPreferenceForm {
|
||||
_csrf: String,
|
||||
notification_type: String,
|
||||
channel: String,
|
||||
enabled: String,
|
||||
}
|
||||
|
||||
async fn update_notification_preference(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<UpdateNotificationPreferenceForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden",
|
||||
"Invalid CSRF token.",
|
||||
));
|
||||
}
|
||||
|
||||
let enabled = form.enabled == "true";
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.set_notification_preference(
|
||||
&session.access_token,
|
||||
&form.notification_type,
|
||||
&form.channel,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "set notification preference", &e))?;
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
// ─── Slack user enrollment ────────────────────────────────────────────
|
||||
|
||||
async fn slack_connect(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<impl IntoResponse, Response> {
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Slack not configured",
|
||||
"Slack OAuth is not configured on this server.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/settings/account/slack/callback",
|
||||
slack_config.redirect_host
|
||||
);
|
||||
let url = format!(
|
||||
"https://slack.com/oauth/v2/authorize?client_id={}&user_scope=identity.basic&redirect_uri={}&state={}",
|
||||
urlencoding::encode(&slack_config.client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&session.user.user_id),
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&url))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SlackUserCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn slack_user_callback(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<SlackUserCallbackQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
// Handle user-denied case
|
||||
if let Some(err) = query.error {
|
||||
tracing::warn!("Slack user OAuth denied: {err}");
|
||||
return Ok(Redirect::to("/settings/account").into_response());
|
||||
}
|
||||
|
||||
let code = query.code.ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Missing authorization code from Slack.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Verify state matches our user_id to prevent CSRF
|
||||
let state_param = query.state.unwrap_or_default();
|
||||
if state_param != session.user.user_id {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"State parameter mismatch. Please try connecting again.",
|
||||
));
|
||||
}
|
||||
|
||||
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not configured",
|
||||
"Slack OAuth is not configured.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not available",
|
||||
"Slack account linking requires a database. Set DATABASE_URL to enable.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/settings/account/slack/callback",
|
||||
slack_config.redirect_host
|
||||
);
|
||||
|
||||
// Exchange the authorization code for a user access token
|
||||
let http = reqwest::Client::new();
|
||||
let token_resp = http
|
||||
.post("https://slack.com/api/oauth.v2.access")
|
||||
.form(&[
|
||||
("client_id", slack_config.client_id.as_str()),
|
||||
("client_secret", slack_config.client_secret.as_str()),
|
||||
("code", &code),
|
||||
("redirect_uri", &redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&format!("Failed to contact Slack: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&format!("Failed to parse Slack response: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
|
||||
let err_msg = resp_body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
tracing::error!("Slack user OAuth error: {err_msg}");
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Slack error",
|
||||
&format!("Slack returned an error: {err_msg}"),
|
||||
));
|
||||
}
|
||||
|
||||
// For user-scoped OAuth, the user token is nested under authed_user
|
||||
let authed_user = resp_body.get("authed_user").ok_or_else(|| {
|
||||
internal_error(
|
||||
&state,
|
||||
"slack user oauth",
|
||||
&"Missing authed_user in Slack response",
|
||||
)
|
||||
})?;
|
||||
|
||||
let slack_user_id = authed_user["id"].as_str().unwrap_or("").to_string();
|
||||
let user_access_token = authed_user["access_token"].as_str().unwrap_or("").to_string();
|
||||
let team_id = resp_body["team"]["id"].as_str().unwrap_or("").to_string();
|
||||
let team_name = resp_body["team"]["name"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// Fetch display name via users.identity (requires identity.basic user scope)
|
||||
let slack_username = if user_access_token.is_empty() {
|
||||
slack_user_id.clone()
|
||||
} else {
|
||||
let identity_name: Option<String> = async {
|
||||
let r = http
|
||||
.get("https://slack.com/api/users.identity")
|
||||
.bearer_auth(&user_access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = r.json().await.ok()?;
|
||||
let name = body["user"]["name"].as_str()?.to_string();
|
||||
if name.is_empty() { None } else { Some(name) }
|
||||
}
|
||||
.await;
|
||||
|
||||
identity_name.unwrap_or_else(|| slack_user_id.clone())
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let link = SlackUserLink {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
user_id: session.user.user_id.clone(),
|
||||
team_id,
|
||||
team_name,
|
||||
slack_user_id,
|
||||
slack_username,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
integration_store
|
||||
.upsert_slack_user_link(&link)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "upsert slack user link", &e))?;
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SlackDisconnectForm {
|
||||
team_id: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn slack_disconnect(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<SlackDisconnectForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed.",
|
||||
));
|
||||
}
|
||||
|
||||
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Not available",
|
||||
"Slack account linking requires a database.",
|
||||
)
|
||||
})?;
|
||||
|
||||
integration_store
|
||||
.delete_slack_user_link(&session.user.user_id, &form.team_id)
|
||||
.await
|
||||
.map_err(|e| internal_error(&state, "delete slack user link", &e))?;
|
||||
|
||||
Ok(Redirect::to("/settings/account").into_response())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
mod auth;
|
||||
mod events;
|
||||
mod integrations;
|
||||
mod pages;
|
||||
mod platform;
|
||||
|
||||
@@ -17,7 +16,6 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(auth::router())
|
||||
.merge(platform::router())
|
||||
.merge(events::router())
|
||||
.merge(integrations::router())
|
||||
}
|
||||
|
||||
/// Render an error page with the given status code, heading, and message.
|
||||
|
||||
@@ -100,14 +100,6 @@ 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),
|
||||
@@ -125,18 +117,6 @@ pub fn router() -> Router<AppState> {
|
||||
post(delete_pipeline),
|
||||
)
|
||||
.route("/users/{username}", get(user_profile))
|
||||
.route(
|
||||
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve",
|
||||
post(approve_plan_stage_submit),
|
||||
)
|
||||
.route(
|
||||
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject",
|
||||
post(reject_plan_stage_submit),
|
||||
)
|
||||
.route(
|
||||
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output",
|
||||
get(get_plan_output_api),
|
||||
)
|
||||
.route(
|
||||
"/api/orgs/{org}/projects/{project}/timeline",
|
||||
get(timeline_api),
|
||||
@@ -414,12 +394,7 @@ async fn fetch_notifications(
|
||||
if let Some(run_stages) = intent_stages_by_artifact.get(aid) {
|
||||
let sorted = topo_sort_run_stages(run_stages);
|
||||
for rs in sorted {
|
||||
let base_status = deploy_stage_display_status(rs, &matching_states);
|
||||
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
|
||||
"AWAITING_APPROVAL"
|
||||
} else {
|
||||
base_status
|
||||
};
|
||||
let display_status = deploy_stage_display_status(rs, &matching_states);
|
||||
pipeline_stages.push(context! {
|
||||
id => rs.stage_id,
|
||||
stage_type => rs.stage_type,
|
||||
@@ -430,7 +405,6 @@ async fn fetch_notifications(
|
||||
completed_at => rs.completed_at,
|
||||
error_message => rs.error_message,
|
||||
wait_until => rs.wait_until,
|
||||
approval_status => rs.approval_status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -708,8 +682,7 @@ async fn project_detail(
|
||||
Path((org, project)): Path<(String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
let current_role = current_org.role.clone();
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
if !validate_slug(&project) {
|
||||
return Err(error_page(
|
||||
@@ -794,7 +767,6 @@ 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,
|
||||
@@ -913,7 +885,7 @@ async fn artifact_detail(
|
||||
));
|
||||
}
|
||||
|
||||
let (artifact_result, projects, dest_states, release_intents, pipelines, environments) = tokio::join!(
|
||||
let (artifact_result, projects, dest_states, release_intents, pipelines) = tokio::join!(
|
||||
state
|
||||
.platform_client
|
||||
.get_artifact_by_slug(&session.access_token, &slug),
|
||||
@@ -929,12 +901,7 @@ async fn artifact_detail(
|
||||
state
|
||||
.platform_client
|
||||
.list_release_pipelines(&session.access_token, &org, &project),
|
||||
state
|
||||
.platform_client
|
||||
.list_environments(&session.access_token, &org),
|
||||
);
|
||||
// Fetch artifact spec after we have the artifact_id (needs artifact_result first).
|
||||
|
||||
let artifact = artifact_result.map_err(|e| match e {
|
||||
forage_core::platform::PlatformError::NotFound(_) => error_page(
|
||||
&state,
|
||||
@@ -946,14 +913,6 @@ async fn artifact_detail(
|
||||
internal_error(&state, "failed to fetch artifact", &other)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Fetch artifact spec now that we have the artifact_id.
|
||||
let artifact_spec = state
|
||||
.platform_client
|
||||
.get_artifact_spec(&session.access_token, &artifact.artifact_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let projects = warn_default("list_projects", projects);
|
||||
let dest_states = dest_states.unwrap_or_default();
|
||||
let release_intents = release_intents.unwrap_or_default();
|
||||
@@ -975,22 +934,13 @@ async fn artifact_detail(
|
||||
.any(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
|
||||
});
|
||||
|
||||
// Build pipeline stages from the most recent release intent for this artifact.
|
||||
// Build pipeline stages from intent data.
|
||||
let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
|
||||
let latest_intent = release_intents
|
||||
.iter()
|
||||
.filter(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
|
||||
.max_by_key(|ri| &ri.created_at);
|
||||
|
||||
if let Some(ri) = latest_intent {
|
||||
for ri in &release_intents {
|
||||
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
|
||||
let sorted = topo_sort_run_stages(&ri.stages);
|
||||
for rs in sorted {
|
||||
let base_status = deploy_stage_display_status(rs, &matching_states);
|
||||
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
|
||||
"AWAITING_APPROVAL"
|
||||
} else {
|
||||
base_status
|
||||
};
|
||||
let display_status = deploy_stage_display_status(rs, &matching_states);
|
||||
pipeline_stages.push(context! {
|
||||
id => rs.stage_id,
|
||||
stage_type => rs.stage_type,
|
||||
@@ -1001,105 +951,13 @@ async fn artifact_detail(
|
||||
completed_at => rs.completed_at,
|
||||
error_message => rs.error_message,
|
||||
wait_until => rs.wait_until,
|
||||
approval_status => rs.approval_status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;
|
||||
|
||||
// Fetch policy evaluations for active release intents.
|
||||
struct PolicyEvalEntry {
|
||||
policy_name: String,
|
||||
policy_type: String,
|
||||
passed: bool,
|
||||
reason: String,
|
||||
target_environment: String,
|
||||
approval_state: Option<forage_core::platform::ApprovalState>,
|
||||
}
|
||||
|
||||
let mut raw_evals: Vec<PolicyEvalEntry> = Vec::new();
|
||||
let release_intent_id_str = latest_intent
|
||||
.map(|ri| ri.release_intent_id.clone())
|
||||
.unwrap_or_default();
|
||||
let is_release_author = false;
|
||||
if let Some(ri) = latest_intent {
|
||||
{
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let environments: Vec<String> = ri
|
||||
.stages
|
||||
.iter()
|
||||
.filter_map(|s| s.environment.clone())
|
||||
.filter(|e| seen.insert(e.clone()))
|
||||
.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 {
|
||||
raw_evals.push(PolicyEvalEntry {
|
||||
policy_name: eval.policy_name,
|
||||
policy_type: eval.policy_type,
|
||||
passed: eval.passed,
|
||||
reason: eval.reason,
|
||||
target_environment: env.clone(),
|
||||
approval_state: eval.approval_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
raw_evals.sort_by(|a, b| a.policy_type.cmp(&b.policy_type).then(a.policy_name.cmp(&b.policy_name)));
|
||||
|
||||
let policy_evaluations: Vec<minijinja::Value> = raw_evals
|
||||
.iter()
|
||||
.map(|eval| {
|
||||
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,
|
||||
}
|
||||
});
|
||||
context! {
|
||||
policy_name => eval.policy_name,
|
||||
policy_type => eval.policy_type,
|
||||
passed => eval.passed,
|
||||
reason => eval.reason,
|
||||
target_environment => eval.target_environment,
|
||||
approval_state => approval_state_ctx,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
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);
|
||||
|
||||
@@ -1120,8 +978,6 @@ async fn artifact_detail(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let artifact_id_val = artifact.artifact_id.clone();
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
@@ -1178,17 +1034,6 @@ async fn artifact_detail(
|
||||
context! { name => d.name, environment => d.environment }
|
||||
}).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,
|
||||
artifact_id => &artifact_id_val,
|
||||
has_active_pipeline => has_pipeline,
|
||||
environments => warn_default("list_environments", environments)
|
||||
.iter()
|
||||
.map(|e| context! { name => e.name })
|
||||
.collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -2109,8 +1954,6 @@ 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>,
|
||||
@@ -2162,12 +2005,6 @@ 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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_approve: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -2185,7 +2022,6 @@ 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<
|
||||
@@ -2198,17 +2034,14 @@ fn build_timeline_json(
|
||||
}
|
||||
}
|
||||
|
||||
// Index pipeline run stages and intent IDs by artifact_id.
|
||||
// Index pipeline run stages 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2345,22 +2178,6 @@ 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
|
||||
};
|
||||
// For plan stages, use AWAITING_APPROVAL as display status when appropriate
|
||||
let display_status = if rs.stage_type == "plan"
|
||||
&& rs.approval_status.as_deref() == Some("AWAITING_APPROVAL")
|
||||
{
|
||||
"AWAITING_APPROVAL".to_string()
|
||||
} else {
|
||||
display_status
|
||||
};
|
||||
stages.push(ApiPipelineStage {
|
||||
id: rs.stage_id.clone(),
|
||||
stage_type: rs.stage_type.clone(),
|
||||
@@ -2371,9 +2188,6 @@ fn build_timeline_json(
|
||||
completed_at: rs.completed_at.clone(),
|
||||
error_message: rs.error_message.clone(),
|
||||
wait_until: rs.wait_until.clone(),
|
||||
blocked_by,
|
||||
approval_status: rs.approval_status.clone(),
|
||||
auto_approve: rs.auto_approve,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2413,9 +2227,6 @@ 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,
|
||||
@@ -2487,11 +2298,7 @@ fn build_timeline_json(
|
||||
let mut seen_deployed = false;
|
||||
|
||||
for raw in raw_releases {
|
||||
let needs_action = raw.release.pipeline_stages.iter().any(|s| {
|
||||
s.blocked_by.is_some()
|
||||
|| (s.stage_type == "plan" && s.status == "AWAITING_APPROVAL")
|
||||
});
|
||||
if raw.has_dests || needs_action {
|
||||
if raw.has_dests {
|
||||
if !hidden_buf.is_empty() {
|
||||
let count = hidden_buf.len();
|
||||
timeline.push(ApiTimelineItem::Hidden {
|
||||
@@ -2499,9 +2306,7 @@ fn build_timeline_json(
|
||||
releases: std::mem::take(&mut hidden_buf),
|
||||
});
|
||||
}
|
||||
if raw.has_dests {
|
||||
seen_deployed = true;
|
||||
}
|
||||
timeline.push(ApiTimelineItem::Release {
|
||||
release: Box::new(raw.release),
|
||||
});
|
||||
@@ -2542,7 +2347,7 @@ async fn timeline_api(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (artifacts, environments, dest_states, release_intents, project_pipelines, policies) = tokio::join!(
|
||||
let (artifacts, environments, dest_states, release_intents, project_pipelines) = tokio::join!(
|
||||
state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org, &project),
|
||||
@@ -2558,9 +2363,6 @@ 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:#}");
|
||||
@@ -2588,18 +2390,7 @@ async fn timeline_api(
|
||||
pipelines_map.insert(project.clone(), project_pipelines);
|
||||
}
|
||||
|
||||
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);
|
||||
let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map);
|
||||
|
||||
Ok(Json(data).into_response())
|
||||
}
|
||||
@@ -2661,7 +2452,6 @@ 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())
|
||||
@@ -3909,16 +3699,6 @@ 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,
|
||||
@@ -4019,9 +3799,6 @@ struct CreatePolicyForm {
|
||||
// BranchRestriction fields
|
||||
#[serde(default)]
|
||||
branch_pattern: String,
|
||||
// Approval fields
|
||||
#[serde(default)]
|
||||
required_approvals: Option<i32>,
|
||||
}
|
||||
|
||||
async fn create_policy_submit(
|
||||
@@ -4087,28 +3864,12 @@ 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.",
|
||||
"Invalid policy type. Must be 'soak_time' or 'branch_restriction'.",
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -4270,16 +4031,6 @@ 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! {
|
||||
@@ -4702,290 +4453,3 @@ 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())
|
||||
}
|
||||
|
||||
// ── Plan stage approve / reject / output ─────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlanStageForm {
|
||||
csrf_token: String,
|
||||
release_intent_id: String,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
#[serde(default)]
|
||||
redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
async fn approve_plan_stage_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path((org, _project, stage_id)): Path<(String, String, String)>,
|
||||
Form(form): Form<PlanStageForm>,
|
||||
) -> 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.",
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.approve_plan_stage(
|
||||
&session.access_token,
|
||||
&form.release_intent_id,
|
||||
&stage_id,
|
||||
)
|
||||
.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}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
if let Some(redirect) = &form.redirect_to {
|
||||
Ok(Redirect::to(redirect).into_response())
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "ok": true })).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_plan_stage_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path((org, _project, stage_id)): Path<(String, String, String)>,
|
||||
Form(form): Form<PlanStageForm>,
|
||||
) -> 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 reason = form.reason.as_deref().and_then(|s| {
|
||||
let t = s.trim();
|
||||
if t.is_empty() { None } else { Some(t.to_string()) }
|
||||
});
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.reject_plan_stage(
|
||||
&session.access_token,
|
||||
&form.release_intent_id,
|
||||
&stage_id,
|
||||
reason.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}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
if let Some(redirect) = &form.redirect_to {
|
||||
Ok(Redirect::to(redirect).into_response())
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "ok": true })).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlanOutputQuery {
|
||||
release_intent_id: String,
|
||||
}
|
||||
|
||||
async fn get_plan_output_api(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, _project, stage_id)): Path<(String, String, String)>,
|
||||
Query(query): Query<PlanOutputQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let output = state
|
||||
.platform_client
|
||||
.get_plan_output(
|
||||
&session.access_token,
|
||||
&query.release_intent_id,
|
||||
&stage_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
internal_error(&state, "get plan output", &e)
|
||||
})?;
|
||||
|
||||
let outputs: Vec<serde_json::Value> = output.outputs.iter().map(|o| {
|
||||
serde_json::json!({
|
||||
"destination_id": o.destination_id,
|
||||
"destination_name": o.destination_name,
|
||||
"plan_output": o.plan_output,
|
||||
"status": o.status,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"plan_output": output.plan_output,
|
||||
"status": output.status,
|
||||
"outputs": outputs,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct ServeHttp {
|
||||
pub addr: SocketAddr,
|
||||
pub state: AppState,
|
||||
}
|
||||
|
||||
impl Component for ServeHttp {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/http".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let app = crate::build_router(self.state.clone());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(self.addr)
|
||||
.await
|
||||
.context("failed to listen on port")?;
|
||||
|
||||
tracing::info!("listening on {}", self.addr);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
cancellation_token.cancelled().await;
|
||||
})
|
||||
.await
|
||||
.context("failed to run axum server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use forage_core::session::FileSessionStore;
|
||||
use forage_db::PgSessionStore;
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Session reaper for PostgreSQL-backed sessions.
|
||||
pub struct PgSessionReaper {
|
||||
pub store: Arc<PgSessionStore>,
|
||||
pub max_inactive_days: i64,
|
||||
}
|
||||
|
||||
impl Component for PgSessionReaper {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/session-reaper-pg".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
match self.store.reap_expired(self.max_inactive_days).await {
|
||||
Ok(n) if n > 0 => tracing::info!("session reaper: removed {n} expired sessions"),
|
||||
Err(e) => tracing::warn!("session reaper error: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Session reaper for file-backed sessions.
|
||||
pub struct FileSessionReaper {
|
||||
pub store: Arc<FileSessionStore>,
|
||||
}
|
||||
|
||||
impl Component for FileSessionReaper {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/session-reaper-file".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
self.store.reap_expired();
|
||||
tracing::debug!("session reaper: {} active sessions", self.store.session_count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,9 @@ use std::sync::Arc;
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::templates::TemplateEngine;
|
||||
use forage_core::auth::ForestAuth;
|
||||
use forage_core::integrations::IntegrationStore;
|
||||
use forage_core::platform::ForestPlatform;
|
||||
use forage_core::session::SessionStore;
|
||||
|
||||
/// Slack OAuth credentials for the "Add to Slack" flow.
|
||||
#[derive(Clone)]
|
||||
pub struct SlackConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_host: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub templates: TemplateEngine,
|
||||
@@ -22,8 +13,6 @@ pub struct AppState {
|
||||
pub platform_client: Arc<dyn ForestPlatform>,
|
||||
pub sessions: Arc<dyn SessionStore>,
|
||||
pub grpc_client: Option<Arc<GrpcForestClient>>,
|
||||
pub integration_store: Option<Arc<dyn IntegrationStore>>,
|
||||
pub slack_config: Option<SlackConfig>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -39,8 +28,6 @@ impl AppState {
|
||||
platform_client,
|
||||
sessions,
|
||||
grpc_client: None,
|
||||
integration_store: None,
|
||||
slack_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +35,4 @@ impl AppState {
|
||||
self.grpc_client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_integration_store(mut self, store: Arc<dyn IntegrationStore>) -> Self {
|
||||
self.integration_store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_slack_config(mut self, config: SlackConfig) -> Self {
|
||||
self.slack_config = Some(config);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ use chrono::Utc;
|
||||
use forage_core::auth::*;
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
|
||||
Destination, Environment, ForestPlatform, NotificationPreference, Organisation, OrgMember,
|
||||
PlatformError, Policy, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
|
||||
UpdateTriggerInput,
|
||||
Destination, Environment, ForestPlatform, Organisation, OrgMember, PlatformError, Policy,
|
||||
ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput,
|
||||
};
|
||||
use forage_core::integrations::InMemoryIntegrationStore;
|
||||
use forage_core::session::{
|
||||
CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore,
|
||||
};
|
||||
@@ -55,9 +53,6 @@ pub(crate) struct MockPlatformBehavior {
|
||||
pub create_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
|
||||
pub update_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
|
||||
pub delete_release_pipeline_result: Option<Result<(), PlatformError>>,
|
||||
pub get_artifact_spec_result: Option<Result<String, PlatformError>>,
|
||||
pub get_notification_preferences_result: Option<Result<Vec<NotificationPreference>, PlatformError>>,
|
||||
pub set_notification_preference_result: Option<Result<(), PlatformError>>,
|
||||
}
|
||||
|
||||
pub(crate) fn ok_tokens() -> AuthTokens {
|
||||
@@ -680,131 +675,6 @@ impl ForestPlatform for MockPlatformClient {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.delete_release_pipeline_result.clone().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
async fn get_artifact_spec(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_artifact_id: &str,
|
||||
) -> Result<String, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.get_artifact_spec_result
|
||||
.clone()
|
||||
.unwrap_or(Ok(String::new()))
|
||||
}
|
||||
|
||||
async fn get_notification_preferences(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
) -> Result<Vec<NotificationPreference>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.get_notification_preferences_result
|
||||
.clone()
|
||||
.unwrap_or(Ok(Vec::new()))
|
||||
}
|
||||
|
||||
async fn set_notification_preference(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_notification_type: &str,
|
||||
_channel: &str,
|
||||
_enabled: bool,
|
||||
) -> Result<(), PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.set_notification_preference_result
|
||||
.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![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn approve_plan_stage(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_release_intent_id: &str,
|
||||
_stage_id: &str,
|
||||
) -> Result<(), PlatformError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_plan_stage(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_release_intent_id: &str,
|
||||
_stage_id: &str,
|
||||
_reason: Option<&str>,
|
||||
) -> Result<(), PlatformError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_plan_output(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_release_intent_id: &str,
|
||||
_stage_id: &str,
|
||||
) -> Result<forage_core::platform::PlanOutput, PlatformError> {
|
||||
Ok(forage_core::platform::PlanOutput {
|
||||
plan_output: String::new(),
|
||||
status: "RUNNING".into(),
|
||||
outputs: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_templates() -> TemplateEngine {
|
||||
@@ -835,22 +705,6 @@ pub(crate) fn test_state_with(
|
||||
(state, sessions)
|
||||
}
|
||||
|
||||
pub(crate) fn test_state_with_integrations(
|
||||
mock: MockForestClient,
|
||||
platform: MockPlatformClient,
|
||||
) -> (AppState, Arc<InMemorySessionStore>, Arc<InMemoryIntegrationStore>) {
|
||||
let sessions = Arc::new(InMemorySessionStore::new());
|
||||
let integrations = Arc::new(InMemoryIntegrationStore::new());
|
||||
let state = AppState::new(
|
||||
make_templates(),
|
||||
Arc::new(mock),
|
||||
Arc::new(platform),
|
||||
sessions.clone(),
|
||||
)
|
||||
.with_integration_store(integrations.clone());
|
||||
(state, sessions, integrations)
|
||||
}
|
||||
|
||||
pub(crate) fn test_app() -> Router {
|
||||
let (state, _) = test_state();
|
||||
crate::build_router(state)
|
||||
|
||||
@@ -1,645 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, DeliveryStatus, IntegrationConfig, IntegrationStore, IntegrationType,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
fn build_app_with_integrations() -> (
|
||||
axum::Router,
|
||||
std::sync::Arc<forage_core::session::InMemorySessionStore>,
|
||||
std::sync::Arc<forage_core::integrations::InMemoryIntegrationStore>,
|
||||
) {
|
||||
let (state, sessions, integrations) =
|
||||
test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new());
|
||||
let app = crate::build_router(state);
|
||||
(app, sessions, integrations)
|
||||
}
|
||||
|
||||
// ─── List integrations ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_returns_200_for_admin() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("Integrations"));
|
||||
assert!(text.contains("Available integrations"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_returns_403_for_non_admin() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_returns_403_for_non_member() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/otherorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_shows_existing_integrations() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
// Create a webhook integration
|
||||
integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "Production alerts".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("Production alerts"));
|
||||
assert!(text.contains("Webhook"));
|
||||
}
|
||||
|
||||
// ─── Install webhook page ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_webhook_page_returns_200() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/webhook")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("Install Webhook"));
|
||||
assert!(text.contains("Payload URL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_webhook_page_returns_403_for_non_admin() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/webhook")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ─── Create webhook ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_webhook_success_shows_installed_page() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=my-hook&url=https%3A%2F%2Fexample.com%2Fhook&secret=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/webhook")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Renders the "installed" page directly (with API token shown once)
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("installed"));
|
||||
assert!(text.contains("fgi_")); // API token shown
|
||||
assert!(text.contains("my-hook"));
|
||||
|
||||
// Verify it was created
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].name, "my-hook");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_webhook_invalid_csrf_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=wrong-csrf&name=my-hook&url=https%3A%2F%2Fexample.com%2Fhook&secret=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/webhook")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_webhook_rejects_http_url() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=my-hook&url=http%3A%2F%2Fexample.com%2Fhook&secret=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/webhook")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should redirect back to install page with error
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/webhook"));
|
||||
assert!(location.contains("error="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_webhook_non_admin_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=my-hook&url=https%3A%2F%2Fexample.com%2Fhook&secret=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/webhook")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ─── Integration detail ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integration_detail_returns_200() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "test-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: Some("s3cret".into()),
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("test-hook"));
|
||||
assert!(text.contains("Release failed"));
|
||||
assert!(text.contains("HMAC-SHA256 enabled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integration_detail_not_found_returns_404() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/00000000-0000-0000-0000-000000000000")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── Toggle integration ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggle_integration_disables_and_enables() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "toggle-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disable
|
||||
let body = format!("_csrf=test-csrf&enabled=false");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/toggle",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let integ = integrations
|
||||
.get_integration("testorg", &created.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!integ.enabled);
|
||||
}
|
||||
|
||||
// ─── Delete integration ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_integration_removes_it() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "delete-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = "_csrf=test-csrf";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/delete",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
assert!(all.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_integration_invalid_csrf_returns_403() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "csrf-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = "_csrf=wrong-csrf";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/delete",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
// Verify it was NOT deleted
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
}
|
||||
|
||||
// ─── Update notification rules ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_rule_toggles_notification_type() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "rule-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disable release_failed
|
||||
let body = format!(
|
||||
"_csrf=test-csrf¬ification_type=release_failed&enabled=false"
|
||||
);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/rules",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
let rules = integrations.list_rules(&created.id).await.unwrap();
|
||||
let failed_rule = rules
|
||||
.iter()
|
||||
.find(|r| r.notification_type == "release_failed")
|
||||
.unwrap();
|
||||
assert!(!failed_rule.enabled);
|
||||
|
||||
// Other rules should still be enabled
|
||||
let started_rule = rules
|
||||
.iter()
|
||||
.find(|r| r.notification_type == "release_started")
|
||||
.unwrap();
|
||||
assert!(started_rule.enabled);
|
||||
}
|
||||
|
||||
// ─── Delivery log ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn detail_page_shows_delivery_log() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "delivery-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Record a successful and a failed delivery
|
||||
integrations
|
||||
.record_delivery(&created.id, "notif-aaa", DeliveryStatus::Delivered, None)
|
||||
.await
|
||||
.unwrap();
|
||||
integrations
|
||||
.record_delivery(
|
||||
&created.id,
|
||||
"notif-bbb",
|
||||
DeliveryStatus::Failed,
|
||||
Some("HTTP 500: Internal Server Error"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
|
||||
// Should show the deliveries section
|
||||
assert!(text.contains("Recent deliveries"));
|
||||
assert!(text.contains("Delivered"));
|
||||
assert!(text.contains("Failed"));
|
||||
assert!(text.contains("notif-aaa"));
|
||||
assert!(text.contains("notif-bbb"));
|
||||
assert!(text.contains("HTTP 500: Internal Server Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detail_page_shows_empty_deliveries() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "empty-delivery-test".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("No deliveries yet"));
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
mod account_tests;
|
||||
mod auth_tests;
|
||||
mod integration_tests;
|
||||
mod nats_tests;
|
||||
mod pages_tests;
|
||||
mod platform_tests;
|
||||
mod slack_tests;
|
||||
mod token_tests;
|
||||
mod webhook_delivery_tests;
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use forage_core::integrations::nats::NotificationEnvelope;
|
||||
use forage_core::integrations::router::{NotificationEvent, ReleaseContext};
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, DeliveryStatus, IntegrationConfig, IntegrationStore, IntegrationType,
|
||||
InMemoryIntegrationStore,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::notification_consumer::NotificationConsumer;
|
||||
use crate::notification_worker::NotificationDispatcher;
|
||||
|
||||
// ─── Test webhook receiver (same pattern as webhook_delivery_tests) ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReceivedWebhook {
|
||||
body: String,
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReceiverState {
|
||||
deliveries: Arc<Mutex<Vec<ReceivedWebhook>>>,
|
||||
}
|
||||
|
||||
async fn webhook_handler(
|
||||
State(state): State<ReceiverState>,
|
||||
req: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
let sig = req
|
||||
.headers()
|
||||
.get("x-forage-signature")
|
||||
.map(|v| v.to_str().unwrap_or("").to_string());
|
||||
|
||||
let bytes = axum::body::to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
||||
state.deliveries.lock().unwrap().push(ReceivedWebhook {
|
||||
body,
|
||||
signature: sig,
|
||||
});
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn start_receiver() -> (String, ReceiverState) {
|
||||
let state = ReceiverState {
|
||||
deliveries: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/hook", post(webhook_handler))
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}/hook", addr.port());
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(url, state)
|
||||
}
|
||||
|
||||
fn test_event(org: &str) -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: format!("nats-test-{}", uuid::Uuid::new_v4()),
|
||||
notification_type: "release_succeeded".into(),
|
||||
title: "Deploy v3.0 succeeded".into(),
|
||||
body: "All checks passed".into(),
|
||||
organisation: org.into(),
|
||||
project: "my-svc".into(),
|
||||
timestamp: "2026-03-09T16:00:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v3.0".into(),
|
||||
artifact_id: "art_nats".into(),
|
||||
release_intent_id: "ri_nats".into(),
|
||||
destination: "prod".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "aabbccdd".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Deploy v3.0 succeeded".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_event(org: &str) -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: format!("nats-fail-{}", uuid::Uuid::new_v4()),
|
||||
notification_type: "release_failed".into(),
|
||||
title: "Deploy v3.0 failed".into(),
|
||||
body: "OOM killed".into(),
|
||||
organisation: org.into(),
|
||||
project: "my-svc".into(),
|
||||
timestamp: "2026-03-09T16:05:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v3.0".into(),
|
||||
artifact_id: "art_nats".into(),
|
||||
release_intent_id: "ri_nats".into(),
|
||||
destination: "prod".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "bob".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "deadbeef".into(),
|
||||
commit_branch: "hotfix".into(),
|
||||
context_title: "Deploy v3.0 failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: Some("OOM killed".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Unit tests: process_payload without NATS ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_routes_and_dispatches_to_webhook() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "nats-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: Some("nats-secret".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1, "webhook should receive the event");
|
||||
|
||||
let d = &deliveries[0];
|
||||
assert!(d.signature.is_some(), "should be signed");
|
||||
|
||||
let body: serde_json::Value = serde_json::from_str(&d.body).unwrap();
|
||||
assert_eq!(body["event"], "release_succeeded");
|
||||
assert_eq!(body["organisation"], "testorg");
|
||||
assert_eq!(body["project"], "my-svc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_skips_when_no_matching_integrations() {
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
// No integrations created — should skip silently
|
||||
let event = test_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
let result = NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher).await;
|
||||
assert!(result.is_ok(), "should succeed with no matching integrations");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_rejects_invalid_json() {
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let result =
|
||||
NotificationConsumer::process_payload(b"not-json", store.as_ref(), &dispatcher).await;
|
||||
assert!(result.is_err(), "invalid JSON should fail");
|
||||
assert!(
|
||||
result.unwrap_err().contains("deserialize"),
|
||||
"error should mention deserialization"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_respects_disabled_rules() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "rule-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disable release_succeeded
|
||||
store
|
||||
.set_rule_enabled(&integration.id, "release_succeeded", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg"); // release_succeeded
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
receiver.deliveries.lock().unwrap().is_empty(),
|
||||
"disabled rule should prevent delivery"
|
||||
);
|
||||
|
||||
// But release_failed should still work
|
||||
let event = failed_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
receiver.deliveries.lock().unwrap().len(),
|
||||
1,
|
||||
"release_failed should still deliver"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_dispatches_to_multiple_integrations() {
|
||||
let (url1, receiver1) = start_receiver().await;
|
||||
let (url2, receiver2) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "hook-a".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url1,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "hook-b".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url2,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receiver1.deliveries.lock().unwrap().len(), 1);
|
||||
assert_eq!(receiver2.deliveries.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_records_delivery_status() {
|
||||
let (url, _receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "status-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify delivery was recorded
|
||||
let deliveries = store.list_deliveries(&integration.id, 10).await.unwrap();
|
||||
assert_eq!(deliveries.len(), 1);
|
||||
assert_eq!(deliveries[0].status, DeliveryStatus::Delivered);
|
||||
assert!(deliveries[0].error_message.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_payload_records_failed_delivery() {
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "dead-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
// Unreachable port — will fail all retries
|
||||
url: "http://127.0.0.1:1/hook".into(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deliveries = store.list_deliveries(&integration.id, 10).await.unwrap();
|
||||
assert_eq!(deliveries.len(), 1);
|
||||
assert_eq!(deliveries[0].status, DeliveryStatus::Failed);
|
||||
assert!(deliveries[0].error_message.is_some());
|
||||
}
|
||||
|
||||
// ─── Integration tests: full JetStream publish → consume → dispatch ──
|
||||
// These require NATS running on localhost:4223 (docker-compose).
|
||||
|
||||
async fn connect_nats() -> Option<async_nats::jetstream::Context> {
|
||||
let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4223".into());
|
||||
match async_nats::connect(&nats_url).await {
|
||||
Ok(client) => Some(async_nats::jetstream::new(client)),
|
||||
Err(_) => {
|
||||
eprintln!("NATS not available at {nats_url}, skipping integration test");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a unique test stream to avoid interference between tests.
|
||||
async fn create_test_stream(
|
||||
js: &async_nats::jetstream::Context,
|
||||
name: &str,
|
||||
subjects: &[String],
|
||||
) -> async_nats::jetstream::stream::Stream {
|
||||
use async_nats::jetstream::stream;
|
||||
|
||||
// Delete if exists from a previous test run
|
||||
let _ = js.delete_stream(name).await;
|
||||
|
||||
js.create_stream(stream::Config {
|
||||
name: name.to_string(),
|
||||
subjects: subjects.to_vec(),
|
||||
retention: stream::RetentionPolicy::WorkQueue,
|
||||
max_age: Duration::from_secs(60),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("failed to create test stream")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jetstream_publish_and_consume_delivers_webhook() {
|
||||
let Some(js) = connect_nats().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "js-org".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "js-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: Some("js-secret".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a unique stream for this test
|
||||
let stream_name = "TEST_NATS_DELIVER";
|
||||
let subject = "test.notifications.js-org.release_succeeded";
|
||||
let stream = create_test_stream(&js, stream_name, &[format!("test.notifications.>")]).await;
|
||||
|
||||
// Publish an envelope
|
||||
let event = test_event("js-org");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let ack = js
|
||||
.publish(subject, payload.into())
|
||||
.await
|
||||
.expect("publish failed");
|
||||
ack.await.expect("publish ack failed");
|
||||
|
||||
// Create a consumer and pull the message
|
||||
use async_nats::jetstream::consumer;
|
||||
let consumer_name = "test-consumer-deliver";
|
||||
let pull_consumer = stream
|
||||
.create_consumer(consumer::pull::Config {
|
||||
durable_name: Some(consumer_name.to_string()),
|
||||
ack_wait: Duration::from_secs(30),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("create consumer failed");
|
||||
|
||||
use futures_util::StreamExt;
|
||||
let mut messages = pull_consumer.messages().await.expect("messages failed");
|
||||
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
|
||||
.await
|
||||
.expect("timeout waiting for message")
|
||||
.expect("stream ended")
|
||||
.expect("message error");
|
||||
|
||||
// Process through the consumer logic
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
msg.ack().await.expect("ack failed");
|
||||
|
||||
// Verify webhook was delivered
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1, "webhook should receive the event");
|
||||
|
||||
let d = &deliveries[0];
|
||||
assert!(d.signature.is_some(), "should be HMAC signed");
|
||||
|
||||
let body: serde_json::Value = serde_json::from_str(&d.body).unwrap();
|
||||
assert_eq!(body["event"], "release_succeeded");
|
||||
assert_eq!(body["organisation"], "js-org");
|
||||
|
||||
// Cleanup
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jetstream_multiple_messages_all_delivered() {
|
||||
let Some(js) = connect_nats().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "multi-org".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "multi-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stream_name = "TEST_NATS_MULTI";
|
||||
let stream = create_test_stream(&js, stream_name, &["test.multi.>".into()]).await;
|
||||
|
||||
// Publish 3 events
|
||||
for i in 0..3 {
|
||||
let mut event = test_event("multi-org");
|
||||
event.id = format!("multi-{i}");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
let ack = js
|
||||
.publish(
|
||||
format!("test.multi.multi-org.release_succeeded"),
|
||||
payload.into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ack.await.unwrap();
|
||||
}
|
||||
|
||||
// Consume all 3
|
||||
use async_nats::jetstream::consumer;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let pull_consumer = stream
|
||||
.create_consumer(consumer::pull::Config {
|
||||
durable_name: Some("test-consumer-multi".to_string()),
|
||||
ack_wait: Duration::from_secs(30),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut messages = pull_consumer.messages().await.unwrap();
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
for _ in 0..3 {
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("stream ended")
|
||||
.expect("error");
|
||||
|
||||
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
msg.ack().await.unwrap();
|
||||
}
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 3, "all 3 events should be delivered");
|
||||
|
||||
// Verify each has a unique notification_id
|
||||
let ids: Vec<String> = deliveries
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let v: serde_json::Value = serde_json::from_str(&d.body).unwrap();
|
||||
v["notification_id"].as_str().unwrap().to_string()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jetstream_message_for_wrong_org_skips_dispatch() {
|
||||
let Some(js) = connect_nats().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(InMemoryIntegrationStore::new());
|
||||
|
||||
// Integration for "org-a" only
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "org-a".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "org-a-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stream_name = "TEST_NATS_WRONG_ORG";
|
||||
let stream = create_test_stream(&js, stream_name, &["test.wrongorg.>".into()]).await;
|
||||
|
||||
// Publish event for "org-b" (no integration)
|
||||
let event = test_event("org-b");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
let ack = js
|
||||
.publish("test.wrongorg.org-b.release_succeeded", payload.into())
|
||||
.await
|
||||
.unwrap();
|
||||
ack.await.unwrap();
|
||||
|
||||
use async_nats::jetstream::consumer;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let pull_consumer = stream
|
||||
.create_consumer(consumer::pull::Config {
|
||||
durable_name: Some("test-consumer-wrongorg".to_string()),
|
||||
ack_wait: Duration::from_secs(30),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut messages = pull_consumer.messages().await.unwrap();
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
|
||||
.await
|
||||
.unwrap();
|
||||
msg.ack().await.unwrap();
|
||||
|
||||
// org-a's webhook should NOT have been called
|
||||
assert!(
|
||||
receiver.deliveries.lock().unwrap().is_empty(),
|
||||
"wrong org should not trigger delivery"
|
||||
);
|
||||
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jetstream_stream_creation_is_idempotent() {
|
||||
let Some(js) = connect_nats().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
use async_nats::jetstream::stream;
|
||||
|
||||
let stream_name = "TEST_NATS_IDEMPOTENT";
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
|
||||
let config = stream::Config {
|
||||
name: stream_name.to_string(),
|
||||
subjects: vec!["test.idempotent.>".to_string()],
|
||||
retention: stream::RetentionPolicy::WorkQueue,
|
||||
max_age: Duration::from_secs(60),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Create twice — should not error
|
||||
js.get_or_create_stream(config.clone()).await.unwrap();
|
||||
js.get_or_create_stream(config).await.unwrap();
|
||||
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jetstream_envelope_roundtrip_through_nats() {
|
||||
let Some(js) = connect_nats().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let stream_name = "TEST_NATS_ROUNDTRIP";
|
||||
let stream = create_test_stream(&js, stream_name, &["test.roundtrip.>".into()]).await;
|
||||
|
||||
// Publish an event with release context including error_message
|
||||
let event = failed_event("roundtrip-org");
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let payload = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let ack = js
|
||||
.publish("test.roundtrip.roundtrip-org.release_failed", payload.into())
|
||||
.await
|
||||
.unwrap();
|
||||
ack.await.unwrap();
|
||||
|
||||
use async_nats::jetstream::consumer;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let pull_consumer = stream
|
||||
.create_consumer(consumer::pull::Config {
|
||||
durable_name: Some("test-consumer-roundtrip".to_string()),
|
||||
ack_wait: Duration::from_secs(30),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut messages = pull_consumer.messages().await.unwrap();
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Deserialize and verify all fields survived the roundtrip
|
||||
let restored: NotificationEnvelope = serde_json::from_slice(&msg.payload).unwrap();
|
||||
assert_eq!(restored.notification_type, "release_failed");
|
||||
assert_eq!(restored.organisation, "roundtrip-org");
|
||||
assert_eq!(restored.title, "Deploy v3.0 failed");
|
||||
|
||||
let release = restored.release.unwrap();
|
||||
assert_eq!(release.error_message.as_deref(), Some("OOM killed"));
|
||||
assert_eq!(release.source_username, "bob");
|
||||
assert_eq!(release.commit_branch, "hotfix");
|
||||
|
||||
msg.ack().await.unwrap();
|
||||
let _ = js.delete_stream(stream_name).await;
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, IntegrationConfig, IntegrationStore, IntegrationType,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
fn build_app_with_integrations() -> (
|
||||
axum::Router,
|
||||
std::sync::Arc<forage_core::session::InMemorySessionStore>,
|
||||
std::sync::Arc<forage_core::integrations::InMemoryIntegrationStore>,
|
||||
) {
|
||||
let (state, sessions, integrations) =
|
||||
test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new());
|
||||
let app = crate::build_router(state);
|
||||
(app, sessions, integrations)
|
||||
}
|
||||
|
||||
// ─── Install Slack page ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_returns_200() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("Install Slack"));
|
||||
assert!(text.contains("Webhook URL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_returns_403_for_non_admin() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_slack_page_shows_manual_form_without_oauth() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations/install/slack")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
// Should show manual webhook URL form
|
||||
assert!(text.contains("hooks.slack.com"));
|
||||
// Should NOT show "Add to Slack" button (no OAuth configured)
|
||||
assert!(!text.contains("Add to Slack"));
|
||||
}
|
||||
|
||||
// ─── Create Slack (manual webhook URL) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_success_shows_installed_page() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=%23deploys";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("installed"));
|
||||
assert!(text.contains("fgi_")); // API token shown
|
||||
assert!(text.contains("#deploys"));
|
||||
|
||||
// Verify it was created as Slack type
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].name, "#deploys");
|
||||
match &all[0].config {
|
||||
IntegrationConfig::Slack { channel_name, webhook_url, .. } => {
|
||||
assert_eq!(channel_name, "#deploys");
|
||||
assert!(webhook_url.contains("hooks.slack.com"));
|
||||
}
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_defaults_channel_to_general() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=alerts&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let all = integrations.list_integrations("testorg").await.unwrap();
|
||||
match &all[0].config {
|
||||
IntegrationConfig::Slack { channel_name, .. } => {
|
||||
assert_eq!(channel_name, "#general");
|
||||
}
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_invalid_csrf_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=wrong-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_rejects_non_slack_url() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fexample.com%2Fhook&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should redirect back to install page with error
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_non_admin_returns_403() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_slack_rejects_empty_name() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let body = "_csrf=test-csrf&name=&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/integrations/slack")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should redirect back with error
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
}
|
||||
|
||||
// ─── Slack integration detail ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_integration_detail_shows_config() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("#deploys"));
|
||||
assert!(text.contains("My Team"));
|
||||
assert!(text.contains("Slack"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_integration_detail_manual_mode_shows_webhook_url() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
// Manual mode: empty team_name
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "manual-slack".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: String::new(),
|
||||
team_name: String::new(),
|
||||
channel_id: String::new(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: String::new(),
|
||||
webhook_url: "https://hooks.slack.com/services/T123/B456/xyz".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("hooks.slack.com"));
|
||||
}
|
||||
|
||||
// ─── Slack in integrations catalog ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_shows_slack_as_available() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
// Slack should be a clickable link to install page
|
||||
assert!(text.contains("install/slack"));
|
||||
}
|
||||
|
||||
// ─── Slack shows in installed list ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrations_page_shows_installed_slack() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#alerts".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "Test".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#alerts".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/integrations")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("#alerts"));
|
||||
assert!(text.contains("Slack"));
|
||||
}
|
||||
|
||||
// ─── Slack OAuth callback without session ───────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_without_state_returns_error() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?code=test-code")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_with_error_redirects() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?state=testorg&error=access_denied")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
let location = resp.headers().get("location").unwrap().to_str().unwrap();
|
||||
assert!(location.contains("install/slack"));
|
||||
assert!(location.contains("error="));
|
||||
assert!(location.contains("access_denied"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_callback_without_oauth_config_returns_503() {
|
||||
let (app, sessions, _) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/integrations/slack/callback?code=test-code&state=testorg")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No SlackConfig set, so should return 503
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// ─── Reinstall Slack ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_redirects_to_oauth_error_without_slack_config() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=test-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No SlackConfig set → 503
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_non_admin_returns_403() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=test-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstall_slack_invalid_csrf_returns_403() {
|
||||
let (app, sessions, integrations) = build_app_with_integrations();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = format!("_csrf=wrong-csrf");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/reinstall",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use forage_core::integrations::router::{NotificationEvent, ReleaseContext};
|
||||
use forage_core::integrations::webhook::sign_payload;
|
||||
use forage_core::integrations::{
|
||||
CreateIntegrationInput, IntegrationConfig, IntegrationStore, IntegrationType,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::notification_worker::NotificationDispatcher;
|
||||
use crate::test_support::*;
|
||||
|
||||
// ─── Test webhook receiver ──────────────────────────────────────────
|
||||
|
||||
/// A received webhook delivery, captured by the test server.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReceivedWebhook {
|
||||
body: String,
|
||||
signature: Option<String>,
|
||||
content_type: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
/// Shared state for the test webhook receiver.
|
||||
#[derive(Clone)]
|
||||
struct ReceiverState {
|
||||
deliveries: Arc<Mutex<Vec<ReceivedWebhook>>>,
|
||||
/// If set, the receiver returns this status code instead of 200.
|
||||
force_status: Arc<Mutex<Option<StatusCode>>>,
|
||||
}
|
||||
|
||||
/// Handler that captures incoming webhook POSTs.
|
||||
async fn webhook_handler(
|
||||
State(state): State<ReceiverState>,
|
||||
req: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
let sig = req
|
||||
.headers()
|
||||
.get("x-forage-signature")
|
||||
.map(|v| v.to_str().unwrap_or("").to_string());
|
||||
let content_type = req
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.map(|v| v.to_str().unwrap_or("").to_string());
|
||||
let user_agent = req
|
||||
.headers()
|
||||
.get("user-agent")
|
||||
.map(|v| v.to_str().unwrap_or("").to_string());
|
||||
|
||||
let bytes = axum::body::to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
||||
state.deliveries.lock().unwrap().push(ReceivedWebhook {
|
||||
body,
|
||||
signature: sig,
|
||||
content_type,
|
||||
user_agent,
|
||||
});
|
||||
|
||||
let forced = state.force_status.lock().unwrap().take();
|
||||
forced.unwrap_or(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Start a test webhook receiver on a random port. Returns (url, state).
|
||||
async fn start_receiver() -> (String, ReceiverState) {
|
||||
let state = ReceiverState {
|
||||
deliveries: Arc::new(Mutex::new(Vec::new())),
|
||||
force_status: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/hook", post(webhook_handler))
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}/hook", addr.port());
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(url, state)
|
||||
}
|
||||
|
||||
fn test_event(org: &str) -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: "notif-e2e-1".into(),
|
||||
notification_type: "release_succeeded".into(),
|
||||
title: "Deploy v2.0 succeeded".into(),
|
||||
body: "All health checks passed".into(),
|
||||
organisation: org.into(),
|
||||
project: "my-api".into(),
|
||||
timestamp: "2026-03-09T15:00:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "my-api-v2".into(),
|
||||
artifact_id: "art_abc".into(),
|
||||
release_intent_id: "ri_1".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "deadbeef1234567".into(),
|
||||
commit_branch: "main".into(),
|
||||
context_title: "Deploy v2.0 succeeded".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_event(org: &str) -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: "notif-e2e-2".into(),
|
||||
notification_type: "release_failed".into(),
|
||||
title: "Deploy v2.0 failed".into(),
|
||||
body: "Container crashed on startup".into(),
|
||||
organisation: org.into(),
|
||||
project: "my-api".into(),
|
||||
timestamp: "2026-03-09T15:05:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "my-api-v2".into(),
|
||||
artifact_id: "art_abc".into(),
|
||||
release_intent_id: "ri_2".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "bob".into(),
|
||||
source_user_id: String::new(),
|
||||
commit_sha: "cafebabe0000000".into(),
|
||||
commit_branch: "hotfix/fix-crash".into(),
|
||||
context_title: "Deploy v2.0 failed".into(),
|
||||
context_web: String::new(),
|
||||
destination_count: 1,
|
||||
error_message: Some("container exited with code 137".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── End-to-end: dispatch delivers to real HTTP server ──────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_delivers_webhook_to_http_server() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "e2e-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification(&event, &[integration.clone()]);
|
||||
assert_eq!(tasks.len(), 1);
|
||||
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1, "server should have received 1 delivery");
|
||||
|
||||
let d = &deliveries[0];
|
||||
assert_eq!(d.content_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(d.user_agent.as_deref(), Some("Forage/1.0"));
|
||||
assert!(d.signature.is_none(), "no secret = no signature");
|
||||
|
||||
// Parse and verify the payload
|
||||
let payload: serde_json::Value = serde_json::from_str(&d.body).unwrap();
|
||||
assert_eq!(payload["event"], "release_succeeded");
|
||||
assert_eq!(payload["organisation"], "testorg");
|
||||
assert_eq!(payload["project"], "my-api");
|
||||
assert_eq!(payload["title"], "Deploy v2.0 succeeded");
|
||||
assert_eq!(payload["notification_id"], "notif-e2e-1");
|
||||
|
||||
let release = &payload["release"];
|
||||
assert_eq!(release["slug"], "my-api-v2");
|
||||
assert_eq!(release["destination"], "prod-eu");
|
||||
assert_eq!(release["commit_sha"], "deadbeef1234567");
|
||||
assert_eq!(release["commit_branch"], "main");
|
||||
assert_eq!(release["source_username"], "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_signs_webhook_with_hmac() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let secret = "webhook-secret-42";
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "signed-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: Some(secret.into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&event, &[integration]);
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1);
|
||||
|
||||
let d = &deliveries[0];
|
||||
let sig = d.signature.as_ref().expect("signed webhook should have signature");
|
||||
assert!(sig.starts_with("sha256="), "signature should have sha256= prefix");
|
||||
|
||||
// Verify the signature ourselves
|
||||
let expected_sig = sign_payload(d.body.as_bytes(), secret);
|
||||
assert_eq!(
|
||||
sig, &expected_sig,
|
||||
"HMAC signature should match re-computed signature"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_delivers_failed_event_with_error_message() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = failed_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "fail-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&event, &[integration]);
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1);
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_str(&deliveries[0].body).unwrap();
|
||||
assert_eq!(payload["event"], "release_failed");
|
||||
assert_eq!(payload["title"], "Deploy v2.0 failed");
|
||||
assert_eq!(
|
||||
payload["release"]["error_message"],
|
||||
"container exited with code 137"
|
||||
);
|
||||
assert_eq!(payload["release"]["source_username"], "bob");
|
||||
assert_eq!(payload["release"]["commit_branch"], "hotfix/fix-crash");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_records_successful_delivery() {
|
||||
let (url, _receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "status-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&event, &[integration]);
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
// The dispatcher records delivery status via the store.
|
||||
// InMemoryIntegrationStore stores deliveries internally;
|
||||
// we verify it was called by checking the integration is still healthy.
|
||||
// (Delivery recording is best-effort, so we verify the webhook arrived.)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_retries_on_server_error() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
|
||||
// Make the server return 500 for the first 2 calls, then 200.
|
||||
// The dispatcher uses 3 retries with backoff [1s, 5s, 25s] which is too slow
|
||||
// for tests. Instead, we verify the dispatcher reports failure when the server
|
||||
// always returns 500.
|
||||
*receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "retry-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&event, &[integration]);
|
||||
|
||||
// This will attempt 3 retries with backoff — the first attempt gets 500,
|
||||
// then the server returns 200 for subsequent attempts (force_status is taken once).
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
// First attempt gets 500, subsequent attempts (with backoff) get 200
|
||||
// since force_status is consumed on first use.
|
||||
assert!(
|
||||
deliveries.len() >= 2,
|
||||
"dispatcher should retry after 500; got {} deliveries",
|
||||
deliveries.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_handles_unreachable_url() {
|
||||
// Port 1 is almost certainly not listening
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
|
||||
let event = test_event("testorg");
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "dead-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "http://127.0.0.1:1/hook".into(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = forage_core::integrations::router::route_notification(&event, &[integration]);
|
||||
|
||||
// Should not panic, just log errors and exhaust retries.
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
}
|
||||
|
||||
// ─── Full flow: event → route_for_org → dispatch → receiver ────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_flow_event_routes_and_delivers() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
// Create two integrations: one for testorg, one for otherorg
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "testorg-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: Some("org-secret".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "otherorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "other-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-2".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fire an event for testorg only
|
||||
let event = test_event("testorg");
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
|
||||
// Should only match testorg's integration (not otherorg's)
|
||||
assert_eq!(tasks.len(), 1);
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1, "only testorg's hook should fire");
|
||||
|
||||
// Verify it was signed with testorg's secret
|
||||
let d = &deliveries[0];
|
||||
let sig = d.signature.as_ref().expect("should be signed");
|
||||
let expected = sign_payload(d.body.as_bytes(), "org-secret");
|
||||
assert_eq!(sig, &expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_integration_does_not_receive_events() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "disabled-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disable the integration
|
||||
store
|
||||
.set_integration_enabled("testorg", &integration.id, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
|
||||
assert!(tasks.is_empty(), "disabled integration should not produce tasks");
|
||||
assert!(
|
||||
receiver.deliveries.lock().unwrap().is_empty(),
|
||||
"nothing should be delivered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_rule_filters_event_type() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
let integration = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "filtered-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url.clone(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disable the release_succeeded rule
|
||||
store
|
||||
.set_rule_enabled(&integration.id, "release_succeeded", false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fire a release_succeeded event — should be filtered out
|
||||
let event = test_event("testorg"); // release_succeeded
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
|
||||
assert!(
|
||||
tasks.is_empty(),
|
||||
"disabled rule should filter out release_succeeded events"
|
||||
);
|
||||
|
||||
// Fire a release_failed event — should still be delivered
|
||||
let event = failed_event("testorg"); // release_failed
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
|
||||
assert_eq!(tasks.len(), 1, "release_failed should still match");
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
dispatcher.dispatch(&tasks[0]).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(deliveries.len(), 1);
|
||||
let payload: serde_json::Value = serde_json::from_str(&deliveries[0].body).unwrap();
|
||||
assert_eq!(payload["event"], "release_failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_integrations_all_receive_same_event() {
|
||||
let (url1, receiver1) = start_receiver().await;
|
||||
let (url2, receiver2) = start_receiver().await;
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "hook-1".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url1,
|
||||
secret: Some("secret-1".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "hook-2".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: url2,
|
||||
secret: Some("secret-2".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = test_event("testorg");
|
||||
let tasks =
|
||||
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
|
||||
assert_eq!(tasks.len(), 2);
|
||||
|
||||
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
|
||||
for task in &tasks {
|
||||
dispatcher.dispatch(task).await;
|
||||
}
|
||||
|
||||
let d1 = receiver1.deliveries.lock().unwrap();
|
||||
let d2 = receiver2.deliveries.lock().unwrap();
|
||||
assert_eq!(d1.len(), 1, "hook-1 should receive the event");
|
||||
assert_eq!(d2.len(), 1, "hook-2 should receive the event");
|
||||
|
||||
// Verify each has different HMAC signatures (different secrets)
|
||||
let sig1 = d1[0].signature.as_ref().unwrap();
|
||||
let sig2 = d2[0].signature.as_ref().unwrap();
|
||||
assert_ne!(sig1, sig2, "different secrets produce different signatures");
|
||||
|
||||
// Both payloads should be identical
|
||||
let p1: serde_json::Value = serde_json::from_str(&d1[0].body).unwrap();
|
||||
let p2: serde_json::Value = serde_json::from_str(&d2[0].body).unwrap();
|
||||
assert_eq!(p1, p2, "same event produces same payload body");
|
||||
}
|
||||
|
||||
// ─── API token tests ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_token_lookup_works_after_install() {
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
let created = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "token-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let raw_token = created.api_token.expect("new integration should have api_token");
|
||||
assert!(raw_token.starts_with("fgi_"));
|
||||
|
||||
// Look up by hash
|
||||
let token_hash = forage_core::integrations::hash_api_token(&raw_token);
|
||||
let found = store
|
||||
.get_integration_by_token_hash(&token_hash)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
assert_eq!(found.organisation, "testorg");
|
||||
assert_eq!(found.name, "token-hook");
|
||||
assert!(found.api_token.is_none(), "stored integration should not have raw token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_token_lookup_fails_for_invalid_token() {
|
||||
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
|
||||
|
||||
let bogus_hash = forage_core::integrations::hash_api_token("fgi_bogus");
|
||||
let result = store.get_integration_by_token_hash(&bogus_hash).await;
|
||||
assert!(result.is_err(), "invalid token should fail lookup");
|
||||
}
|
||||
|
||||
// ─── "Send test notification" via the web UI route ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_button_dispatches_to_webhook() {
|
||||
let (url, receiver) = start_receiver().await;
|
||||
|
||||
let (state, sessions, integrations) =
|
||||
test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new());
|
||||
|
||||
// Create a webhook pointing at our test receiver
|
||||
let created = integrations
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "testorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "ui-test-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret: Some("ui-test-secret".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-123".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = crate::build_router(state);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
|
||||
// Hit the "Send test notification" endpoint
|
||||
let body = "_csrf=test-csrf";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!(
|
||||
"/orgs/testorg/settings/integrations/{}/test",
|
||||
created.id
|
||||
))
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
// Give the async dispatch a moment to complete
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let deliveries = receiver.deliveries.lock().unwrap();
|
||||
assert_eq!(
|
||||
deliveries.len(),
|
||||
1,
|
||||
"test notification should have been delivered"
|
||||
);
|
||||
|
||||
let d = &deliveries[0];
|
||||
|
||||
// Verify HMAC signature
|
||||
let sig = d.signature.as_ref().expect("should be signed");
|
||||
let expected = sign_payload(d.body.as_bytes(), "ui-test-secret");
|
||||
assert_eq!(sig, &expected, "HMAC signature should be verifiable");
|
||||
|
||||
// Verify payload is a test event
|
||||
let payload: serde_json::Value = serde_json::from_str(&d.body).unwrap();
|
||||
assert_eq!(payload["event"], "release_succeeded");
|
||||
assert_eq!(payload["organisation"], "testorg");
|
||||
assert!(
|
||||
payload["notification_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("test-"),
|
||||
"test notification should have test- prefix"
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,11 @@
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { fetchTimeline, connectSSE, formatElapsed, timeAgo } from "./lib/api.js";
|
||||
import { envColors, envLaneColor, envBadgeClasses, statusDotColor } from "./lib/colors.js";
|
||||
import { pipelineSummary, deployStageLabel, waitStageLabel, planStageLabel, STATUS_CONFIG } from "./lib/status.js";
|
||||
import { pipelineSummary, deployStageLabel, waitStageLabel, STATUS_CONFIG } from "./lib/status.js";
|
||||
|
||||
// Props from attributes
|
||||
export let org = "";
|
||||
export let project = "";
|
||||
export let csrf = "";
|
||||
export let username = "";
|
||||
export let role = "";
|
||||
|
||||
// Reactive state
|
||||
let timeline = [];
|
||||
@@ -32,152 +29,6 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan stage actions ──────────────────────────────────────────
|
||||
|
||||
let planOutputs = {}; // keyed by "intentId:stageId"
|
||||
let planOutputLoading = new Set();
|
||||
|
||||
async function approvePlanStage(release, stage, reject = false) {
|
||||
const key = `plan:${release.release_intent_id}:${stage.id}`;
|
||||
if (approving.has(key)) return;
|
||||
approving.add(key);
|
||||
approving = approving;
|
||||
approvalError = null;
|
||||
|
||||
try {
|
||||
const action = reject ? "reject" : "approve";
|
||||
const formData = new URLSearchParams();
|
||||
formData.set("csrf_token", csrf);
|
||||
formData.set("release_intent_id", release.release_intent_id);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/${action}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
await refreshData();
|
||||
} else {
|
||||
const text = await res.text().catch(() => "");
|
||||
let msg;
|
||||
try { msg = JSON.parse(text).error; } catch {}
|
||||
approvalError = msg || `Plan ${action} failed (${res.status})`;
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
approvalError = err.message || "Plan action failed";
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
} finally {
|
||||
approving.delete(key);
|
||||
approving = approving;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewPlanOutput(release, stage) {
|
||||
const key = `${release.release_intent_id}:${stage.id}`;
|
||||
if (planOutputLoading.has(key)) return;
|
||||
if (planOutputs[key]) {
|
||||
// Toggle off
|
||||
delete planOutputs[key];
|
||||
planOutputs = planOutputs;
|
||||
return;
|
||||
}
|
||||
planOutputLoading.add(key);
|
||||
planOutputLoading = planOutputLoading;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/output?release_intent_id=${encodeURIComponent(release.release_intent_id)}`,
|
||||
{ credentials: "same-origin", headers: { "Accept": "application/json" } }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
planOutputs[key] = data;
|
||||
planOutputs = planOutputs;
|
||||
} else {
|
||||
approvalError = `Failed to load plan output (${res.status})`;
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
approvalError = err.message || "Failed to load plan output";
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
} finally {
|
||||
planOutputLoading.delete(key);
|
||||
planOutputLoading = planOutputLoading;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────
|
||||
|
||||
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
|
||||
@@ -510,37 +361,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize plan stage status: the API returns status="RUNNING" with
|
||||
// approval_status="AWAITINGAPPROVAL" (no underscore, Debug format from Rust).
|
||||
// Map this to a single effective status for template rendering.
|
||||
function effectiveStatus(stage) {
|
||||
if (stage.stage_type === "plan" && stage.approval_status &&
|
||||
(stage.approval_status === "AWAITINGAPPROVAL" || stage.approval_status === "AWAITING_APPROVAL")) {
|
||||
return "AWAITING_APPROVAL";
|
||||
}
|
||||
return stage.status;
|
||||
}
|
||||
|
||||
function isPlanAwaiting(stage) {
|
||||
return stage.stage_type === "plan" && effectiveStatus(stage) === "AWAITING_APPROVAL";
|
||||
}
|
||||
|
||||
$: laneCount = lanes.length;
|
||||
$: gutterWidth = laneCount * (BAR_WIDTH + BAR_GAP) + 8;
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -640,8 +466,6 @@
|
||||
<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}
|
||||
@@ -656,33 +480,6 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if stage.stage_type === "plan" && isPlanAwaiting(stage) && release.release_intent_id && csrf}
|
||||
{@const planBadge = envBadgeClasses(stage.environment || "")}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-purple-100">
|
||||
{stage.environment} plan
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
</span>
|
||||
<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(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => approvePlanStage(release, stage)}
|
||||
>Approve plan</button>
|
||||
{/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>
|
||||
@@ -739,18 +536,15 @@
|
||||
{#if release.has_pipeline}
|
||||
<div class="border-t border-gray-100">
|
||||
{#each release.pipeline_stages as stage, i (stage.id || `${stage.stage_type}-${stage.environment}-${i}`)}
|
||||
{@const stageStatus = effectiveStatus(stage)}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stageStatus === 'PENDING' ? 'opacity-50' : ''}">
|
||||
{#if stageStatus === "SUCCEEDED"}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stage.status === 'PENDING' ? 'opacity-50' : ''}">
|
||||
{#if stage.status === "SUCCEEDED"}
|
||||
<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 if stageStatus === "RUNNING"}
|
||||
{:else if stage.status === "RUNNING"}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{:else if stageStatus === "QUEUED"}
|
||||
{:else if stage.status === "QUEUED"}
|
||||
<svg class="w-4 h-4 text-blue-400 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 stageStatus === "FAILED"}
|
||||
{:else if stage.status === "FAILED"}
|
||||
<svg class="w-4 h-4 text-red-500 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 stageStatus === "AWAITING_APPROVAL"}
|
||||
<svg class="w-4 h-4 text-purple-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-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}
|
||||
@@ -768,37 +562,9 @@
|
||||
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : 'text-gray-400'}">
|
||||
{waitStageLabel(stage.status)} {stage.duration_seconds}s
|
||||
</span>
|
||||
{:else if stage.stage_type === "plan"}
|
||||
<span class="text-sm {stageStatus === 'AWAITING_APPROVAL' ? 'text-purple-700' : stageStatus === 'SUCCEEDED' ? 'text-gray-700' : stageStatus === 'RUNNING' ? 'text-yellow-700' : stageStatus === 'FAILED' ? 'text-red-700' : 'text-gray-400'}">
|
||||
{planStageLabel(stageStatus)}
|
||||
</span>
|
||||
{@const planBadge = envBadgeClasses(stage.environment || "")}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {planBadge.bg}">
|
||||
{stage.environment}
|
||||
<span class="w-1.5 h-1.5 rounded-full {planBadge.dot}"></span>
|
||||
</span>
|
||||
{#if stageStatus === "AWAITING_APPROVAL" && release.release_intent_id && csrf}
|
||||
<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(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => approvePlanStage(release, stage)}
|
||||
>Approve plan</button>
|
||||
<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(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => { if (confirm('Reject this plan?')) approvePlanStage(release, stage, true); }}
|
||||
>Reject</button>
|
||||
{/if}
|
||||
{#if (stageStatus === "AWAITING_APPROVAL" || stageStatus === "SUCCEEDED" || stageStatus === "FAILED") && release.release_intent_id}
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
disabled={planOutputLoading.has(`${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => viewPlanOutput(release, stage)}
|
||||
>{planOutputs[`${release.release_intent_id}:${stage.id}`] ? "Hide plan" : "View plan"}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if stage.started_at && (stageStatus === "RUNNING" || stageStatus === "QUEUED" || stageStatus === "AWAITING_APPROVAL" || stage.completed_at)}
|
||||
{#if stage.started_at && (stage.status === "RUNNING" || stage.status === "QUEUED" || stage.completed_at)}
|
||||
<span class="text-xs text-gray-400 tabular-nums">{elapsedStr(stage.started_at, stage.completed_at, stage.status)}</span>
|
||||
{/if}
|
||||
|
||||
@@ -807,28 +573,6 @@
|
||||
pipeline
|
||||
</span>
|
||||
</div>
|
||||
{#if stage.stage_type === "plan" && planOutputs[`${release.release_intent_id}:${stage.id}`]}
|
||||
{@const planData = planOutputs[`${release.release_intent_id}:${stage.id}`]}
|
||||
<div class="px-4 py-3 bg-gray-50 border-t border-gray-100 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500">Plan output</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700">{planData.status}</span>
|
||||
</div>
|
||||
{#if planData.outputs && planData.outputs.length > 0}
|
||||
{#each planData.outputs as destOutput (destOutput.destination_id)}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-600">{destOutput.destination_name}</span>
|
||||
<span class="text-xs text-gray-400">{destOutput.status}</span>
|
||||
</div>
|
||||
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-48 overflow-auto">{destOutput.plan_output || "(no output)"}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-64 overflow-auto">{planData.plan_output || "(no output)"}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<svelte:options customElement="spec-viewer" />
|
||||
|
||||
<script>
|
||||
let { content = "", filename = "forest.cue" } = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let highlighted = $state("");
|
||||
|
||||
// Simple CUE syntax highlighter
|
||||
function highlightCue(src) {
|
||||
// Escape HTML first
|
||||
let html = src
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// Process tokens via regex replacement
|
||||
// Order matters: comments first, then strings, then keywords/numbers
|
||||
html = html
|
||||
// Line comments
|
||||
.replace(/(\/\/.*)/g, '<span class="hl-comment">$1</span>')
|
||||
// Strings (double-quoted, with escapes)
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '<span class="hl-string">$&</span>')
|
||||
// Keywords
|
||||
.replace(
|
||||
/\b(package|import|let|if|for|in|true|false|null|enabled|path)\b/g,
|
||||
'<span class="hl-keyword">$1</span>'
|
||||
)
|
||||
// Numbers
|
||||
.replace(/\b(\d+)\b/g, '<span class="hl-number">$1</span>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (expanded && content && !highlighted) {
|
||||
highlighted = highlightCue(content);
|
||||
}
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
// Count lines for display
|
||||
let lineCount = $derived(content ? content.split("\n").length : 0);
|
||||
</script>
|
||||
|
||||
<div class="spec-root" class:expanded>
|
||||
<button class="spec-header" onclick={toggle}>
|
||||
<div class="spec-header-left">
|
||||
<svg
|
||||
class="spec-chevron"
|
||||
class:rotated={expanded}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="spec-filename">{filename}</span>
|
||||
</div>
|
||||
<span class="spec-meta">{lineCount} lines</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="spec-code">
|
||||
<pre><code>{@html highlighted}</code></pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spec-root {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.spec-root.expanded {
|
||||
max-height: 36rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spec-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.spec-root.expanded .spec-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.spec-header:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.spec-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.spec-chevron {
|
||||
color: #6b7280;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.spec-filename {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.spec-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.spec-code {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.spec-root.expanded::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.spec-root.expanded::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.spec-root.expanded::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.spec-code pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.625;
|
||||
color: #e5e7eb;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.spec-code code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Syntax highlighting tokens */
|
||||
.spec-code :global(.hl-comment) {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.spec-code :global(.hl-string) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.spec-code :global(.hl-keyword) {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.spec-code :global(.hl-number) {
|
||||
color: #79c0ff;
|
||||
}
|
||||
</style>
|
||||
@@ -24,13 +24,8 @@ export function pipelineSummary(stages) {
|
||||
if (s.stage_type === "wait" && s.status === "RUNNING") anyWaiting = true;
|
||||
}
|
||||
|
||||
let anyApprovalBlocked = stages.some(s => s.blocked_by);
|
||||
let anyPlanAwaiting = stages.some(s => s.stage_type === "plan" && (s.status === "AWAITING_APPROVAL" || s.approval_status === "AWAITINGAPPROVAL" || s.approval_status === "AWAITING_APPROVAL"));
|
||||
|
||||
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 (anyPlanAwaiting) return { label: "Awaiting plan approval", color: "text-purple-700", icon: "shield", iconColor: "text-purple-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 };
|
||||
@@ -66,14 +61,3 @@ export function deployStageLabel(status) {
|
||||
default: return "Deploy to";
|
||||
}
|
||||
}
|
||||
|
||||
export function planStageLabel(status) {
|
||||
switch (status) {
|
||||
case "SUCCEEDED": return "Plan approved";
|
||||
case "RUNNING": return "Planning";
|
||||
case "AWAITING_APPROVAL": return "Awaiting plan approval";
|
||||
case "FAILED": return "Plan failed";
|
||||
case "CANCELLED": return "Plan cancelled";
|
||||
default: return "Plan";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Register all Svelte web components
|
||||
import "./ReleaseTimeline.svelte";
|
||||
import "./ReleaseLogs.svelte";
|
||||
import "./SpecViewer.svelte";
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,62 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,864 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package forest.v1;
|
||||
|
||||
service StatusService {
|
||||
rpc Status(GetStatusRequest) returns (GetStatusResponse) {}
|
||||
}
|
||||
|
||||
message GetStatusRequest {}
|
||||
message GetStatusResponse {}
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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 external_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);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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,7 +10,6 @@ enum StageType {
|
||||
STAGE_TYPE_UNSPECIFIED = 0;
|
||||
STAGE_TYPE_DEPLOY = 1;
|
||||
STAGE_TYPE_WAIT = 2;
|
||||
STAGE_TYPE_PLAN = 3;
|
||||
}
|
||||
|
||||
// ── Per-type config messages ─────────────────────────────────────────
|
||||
@@ -23,11 +22,6 @@ message WaitStageConfig {
|
||||
int64 duration_seconds = 1;
|
||||
}
|
||||
|
||||
message PlanStageConfig {
|
||||
string environment = 1;
|
||||
bool auto_approve = 2;
|
||||
}
|
||||
|
||||
// ── A single pipeline stage ──────────────────────────────────────────
|
||||
|
||||
message PipelineStage {
|
||||
@@ -37,7 +31,6 @@ message PipelineStage {
|
||||
oneof config {
|
||||
DeployStageConfig deploy = 10;
|
||||
WaitStageConfig wait = 11;
|
||||
PlanStageConfig plan = 12;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +43,6 @@ 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,8 +35,6 @@ 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)
|
||||
@@ -57,23 +55,9 @@ 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;
|
||||
@@ -106,13 +90,6 @@ message GetProjectsResponse {
|
||||
repeated string projects = 1;
|
||||
}
|
||||
|
||||
message CreateProjectRequest {
|
||||
string organisation = 1;
|
||||
string project = 2;
|
||||
}
|
||||
message CreateProjectResponse {
|
||||
Project project = 1;
|
||||
}
|
||||
|
||||
|
||||
message GetReleasesByActorRequest {
|
||||
@@ -148,67 +125,6 @@ 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 {
|
||||
@@ -222,91 +138,6 @@ 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; // deprecated: use outputs
|
||||
string status = 2; // RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
|
||||
repeated PlanDestinationOutput outputs = 3;
|
||||
}
|
||||
|
||||
message PlanDestinationOutput {
|
||||
string destination_id = 1;
|
||||
string destination_name = 2;
|
||||
string plan_output = 3;
|
||||
string status = 4; // SUCCEEDED, FAILED, RUNNING, etc.
|
||||
}
|
||||
|
||||
service ReleaseService {
|
||||
@@ -319,13 +150,7 @@ 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 {
|
||||
@@ -333,8 +158,6 @@ 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 {
|
||||
@@ -354,7 +177,6 @@ message Artifact {
|
||||
Project project = 7;
|
||||
repeated ArtifactDestination destinations = 8;
|
||||
string created_at = 9;
|
||||
Ref ref = 10;
|
||||
}
|
||||
|
||||
message ArtifactDestination {
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Execution mode for a work assignment.
|
||||
enum ReleaseMode {
|
||||
RELEASE_MODE_UNSPECIFIED = 0;
|
||||
// Normal deployment execution.
|
||||
RELEASE_MODE_DEPLOY = 1;
|
||||
// Dry-run / plan only (e.g. terraform plan). Runner should capture
|
||||
// plan output and include it in CompleteRelease.plan_output.
|
||||
RELEASE_MODE_PLAN = 2;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Execution mode. Defaults to DEPLOY if unset.
|
||||
ReleaseMode mode = 7;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Plan output text when mode was "plan" and outcome is SUCCESS.
|
||||
// Stored in release_states.plan_output for UI review.
|
||||
optional string plan_output = 4;
|
||||
}
|
||||
|
||||
enum ReleaseOutcome {
|
||||
RELEASE_OUTCOME_UNSPECIFIED = 0;
|
||||
RELEASE_OUTCOME_SUCCESS = 1;
|
||||
RELEASE_OUTCOME_FAILURE = 2;
|
||||
}
|
||||
|
||||
message CompleteReleaseResponse {}
|
||||
@@ -1,79 +0,0 @@
|
||||
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);
|
||||
}
|
||||
12
mise.toml
12
mise.toml
@@ -8,8 +8,8 @@ _.file = ".env"
|
||||
|
||||
[tasks.develop]
|
||||
alias = ["d", "dev"]
|
||||
description = "Start the forage development server with postgres"
|
||||
depends = ["tailwind:build", "local:up"]
|
||||
description = "Start the forage development server"
|
||||
depends = ["tailwind:build"]
|
||||
run = "cargo run -p forage-server"
|
||||
|
||||
[tasks.build]
|
||||
@@ -87,7 +87,7 @@ run = "docker compose -f templates/docker-compose.yaml logs -f"
|
||||
|
||||
[tasks."db:shell"]
|
||||
description = "Connect to local postgres"
|
||||
run = "psql postgresql://forageuser:foragepassword@localhost:5433/forage"
|
||||
run = "psql postgresql://forageuser:foragepassword@localhost:5432/forage"
|
||||
|
||||
[tasks."db:migrate"]
|
||||
description = "Run database migrations"
|
||||
@@ -109,12 +109,6 @@ run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --mi
|
||||
description = "Watch and rebuild tailwind CSS"
|
||||
run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --watch"
|
||||
|
||||
# ─── Testing Tools ────────────────────────────────────────────────
|
||||
|
||||
[tasks."test:webhook"]
|
||||
description = "Start a webhook test server on port 9876"
|
||||
run = "python3 tools/webhook-test-server.py"
|
||||
|
||||
# ─── Forest Commands ───────────────────────────────────────────────
|
||||
|
||||
[tasks."forest:run"]
|
||||
|
||||
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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."
|
||||
@@ -1,418 +0,0 @@
|
||||
# 006 - Notification Integrations
|
||||
|
||||
**Status**: Phase 1 - Spec Crystallisation
|
||||
**Depends on**: 005 (Dashboard Enhancement)
|
||||
|
||||
## Problem
|
||||
|
||||
Users can toggle notification preferences (event type × channel) on their account page, but:
|
||||
|
||||
1. **No delivery**: Forest fires events via `ListenNotifications` gRPC stream, but Forage doesn't consume them or route them anywhere.
|
||||
2. **Fixed channels**: The current toggle matrix (CLI, Slack columns) doesn't scale beyond 2 channels. Adding Discord, webhooks, PagerDuty, email, etc. makes the table too wide.
|
||||
3. **No integration config**: There's no way to connect a Slack workspace, set a webhook URL, or configure any third-party channel.
|
||||
4. **Wrong ownership**: The current proto has `NotificationChannel` as a fixed enum on forest-server. But channel routing is a Forage premium feature — Forest should only fire events, Forage decides where to route them.
|
||||
|
||||
## Separation of Concerns
|
||||
|
||||
**Forest** (upstream gRPC server):
|
||||
- Fires notification events when releases are annotated, started, succeed, or fail
|
||||
- Exposes `ListenNotifications` (server-streaming) and `ListNotifications` (paginated) RPCs
|
||||
- Knows nothing about Slack, Discord, webhooks, or any delivery channel
|
||||
- Stores/returns notification preferences as opaque data (channel is just a string/enum from Forage's perspective)
|
||||
|
||||
**Forage** (this codebase — the BFF):
|
||||
- Subscribes to Forest's `ListenNotifications` stream for each connected org
|
||||
- Maintains its own integration registry: which org has which channels configured
|
||||
- Routes notifications to the appropriate channels based on org integrations + user preferences
|
||||
- Manages third-party OAuth flows (Slack), webhook URLs, API keys
|
||||
- Gates channel availability behind org plan/premium features
|
||||
- Displays notification history to users via web UI and CLI API
|
||||
|
||||
This means Forage needs its own persistence for integrations — not stored in Forest.
|
||||
|
||||
## Scope
|
||||
|
||||
This spec covers:
|
||||
- **Integration model**: org-level integrations stored in Forage's database
|
||||
- **Notification listener**: background service consuming Forest's `ListenNotifications` stream
|
||||
- **Notification routing**: dispatching notifications to configured integrations
|
||||
- **Slack integration**: OAuth setup, message formatting, delivery
|
||||
- **Webhook integration**: generic outbound webhook with configurable URL
|
||||
- **Redesigned preferences UI**: per-integration notification rules (not a fixed matrix)
|
||||
- **Notification history page**: paginated list using `ListNotifications` RPC
|
||||
- **CLI notification API**: JSON endpoint for CLI consumption
|
||||
|
||||
Out of scope:
|
||||
- Discord, PagerDuty, email (future integrations — the model supports them)
|
||||
- Per-project notification filtering (future enhancement)
|
||||
- Billing/plan gating logic (assumes all orgs have access for now)
|
||||
- Real-time browser push (SSE/WebSocket to browser — future enhancement)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Integration Model
|
||||
|
||||
Integrations are org-scoped resources stored in Forage's PostgreSQL database.
|
||||
|
||||
```rust
|
||||
/// An org-level notification integration (e.g., a Slack workspace, a webhook URL).
|
||||
pub struct Integration {
|
||||
pub id: String, // UUID
|
||||
pub organisation: String, // org name
|
||||
pub integration_type: String, // "slack", "webhook", "cli"
|
||||
pub name: String, // user-given label, e.g. "#deploys"
|
||||
pub config: IntegrationConfig, // type-specific config (encrypted at rest)
|
||||
pub enabled: bool,
|
||||
pub created_by: String, // user_id
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub enum IntegrationConfig {
|
||||
Slack {
|
||||
team_id: String,
|
||||
team_name: String,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
access_token: String, // encrypted, from OAuth
|
||||
webhook_url: String, // incoming webhook URL
|
||||
},
|
||||
Webhook {
|
||||
url: String,
|
||||
secret: Option<String>, // HMAC signing secret
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**CLI is special**: CLI notifications use Forest's `ListNotifications` RPC directly — there's no org-level integration for CLI. Users just call the API and get their notifications. CLI preference toggles remain per-user on Forest's side.
|
||||
|
||||
### Notification Rules
|
||||
|
||||
Each integration has notification rules that control which event types trigger it:
|
||||
|
||||
```rust
|
||||
/// Which event types an integration should receive.
|
||||
pub struct NotificationRule {
|
||||
pub integration_id: String,
|
||||
pub notification_type: String, // e.g., "release_failed", "release_succeeded"
|
||||
pub enabled: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Default: new integrations receive all event types. Users can disable specific types per integration.
|
||||
|
||||
### Notification Listener (Background Service)
|
||||
|
||||
A background tokio task in Forage that:
|
||||
|
||||
1. On startup, connects to Forest's `ListenNotifications` for each org with active integrations
|
||||
2. When a notification arrives, looks up the org's enabled integrations
|
||||
3. For each integration with a matching notification rule, dispatches via the appropriate channel
|
||||
4. Handles reconnection on stream failure (exponential backoff)
|
||||
5. Logs delivery success/failure for audit
|
||||
|
||||
```
|
||||
Forest gRPC stream ──► Forage Listener ──► Integration Router ──► Slack API
|
||||
──► Webhook POST
|
||||
──► (future channels)
|
||||
```
|
||||
|
||||
The listener runs as part of the Forage server process (not a separate service). It uses the org's admin access token (or a service token) to authenticate with Forest.
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE integrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organisation TEXT NOT NULL,
|
||||
integration_type TEXT NOT NULL, -- 'slack', 'webhook'
|
||||
name TEXT NOT NULL,
|
||||
config_encrypted BYTEA NOT NULL, -- JSON config, encrypted with app key
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by TEXT NOT NULL, -- user_id
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(organisation, name)
|
||||
);
|
||||
|
||||
CREATE TABLE notification_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
notification_type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
UNIQUE(integration_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
notification_id TEXT NOT NULL, -- from Forest
|
||||
status TEXT NOT NULL, -- 'delivered', 'failed', 'pending'
|
||||
error_message TEXT,
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integrations_org ON integrations(organisation);
|
||||
CREATE INDEX idx_deliveries_integration ON notification_deliveries(integration_id, attempted_at DESC);
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Auth | Description |
|
||||
|-------|--------|------|-------------|
|
||||
| `GET /orgs/{org}/settings/integrations` | GET | Required + admin | List integrations for org |
|
||||
| `POST /orgs/{org}/settings/integrations/slack` | POST | Required + admin + CSRF | Start Slack OAuth flow |
|
||||
| `GET /orgs/{org}/settings/integrations/slack/callback` | GET | Required | Slack OAuth callback |
|
||||
| `POST /orgs/{org}/settings/integrations/webhook` | POST | Required + admin + CSRF | Create webhook integration |
|
||||
| `GET /orgs/{org}/settings/integrations/{id}` | GET | Required + admin | Integration detail + rules |
|
||||
| `POST /orgs/{org}/settings/integrations/{id}/rules` | POST | Required + admin + CSRF | Update notification rules |
|
||||
| `POST /orgs/{org}/settings/integrations/{id}/test` | POST | Required + admin + CSRF | Send test notification |
|
||||
| `POST /orgs/{org}/settings/integrations/{id}/toggle` | POST | Required + admin + CSRF | Enable/disable integration |
|
||||
| `POST /orgs/{org}/settings/integrations/{id}/delete` | POST | Required + admin + CSRF | Delete integration |
|
||||
| `GET /notifications` | GET | Required | Notification history (paginated) |
|
||||
| `GET /api/notifications` | GET | Bearer token | JSON notification list for CLI |
|
||||
|
||||
### Templates
|
||||
|
||||
| Template | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| `pages/integrations.html.jinja` | New | Integration list: cards per integration, "Add" buttons |
|
||||
| `pages/integration_detail.html.jinja` | New | Single integration: status, notification rules toggles, test/delete |
|
||||
| `pages/integration_slack_setup.html.jinja` | New | Slack OAuth success/error result page |
|
||||
| `pages/integration_webhook_form.html.jinja` | New | Webhook URL + secret + headers form |
|
||||
| `pages/notifications.html.jinja` | Rewrite | Use `ListNotifications` RPC instead of manual assembly |
|
||||
| `pages/account.html.jinja` | Update | Replace channel matrix with CLI-only toggles + link to org integrations |
|
||||
| `base.html.jinja` | Update | Add "Integrations" tab under org-level nav |
|
||||
|
||||
### Account Settings Redesign
|
||||
|
||||
The current 4×2 toggle matrix becomes:
|
||||
|
||||
**Personal notifications (CLI)**
|
||||
A single column of toggles for CLI event types (these are stored on Forest via the existing preference RPCs):
|
||||
|
||||
| Event | CLI |
|
||||
|-------|-----|
|
||||
| Release annotated | toggle |
|
||||
| Release started | toggle |
|
||||
| Release succeeded | toggle |
|
||||
| Release failed | toggle |
|
||||
|
||||
Below: a link to `/orgs/{org}/settings/integrations` — "Configure Slack, webhooks, and other channels for your organisation."
|
||||
|
||||
### Integrations Page Layout
|
||||
|
||||
```
|
||||
Integrations
|
||||
Configure where your organisation receives deployment notifications.
|
||||
|
||||
[+ Add Slack] [+ Add Webhook]
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 🔵 Slack · #deploys · rawpotion workspace │
|
||||
│ Receives: all events [Manage] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 🟢 Webhook · Production alerts │
|
||||
│ Receives: release_failed only [Manage] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Integration Detail Page
|
||||
|
||||
```
|
||||
Slack · #deploys
|
||||
Status: Active ✓
|
||||
|
||||
Notification rules:
|
||||
Release annotated [on]
|
||||
Release started [on]
|
||||
Release succeeded [on]
|
||||
Release failed [on]
|
||||
|
||||
[Send test notification] [Disable] [Delete]
|
||||
```
|
||||
|
||||
### Slack OAuth Flow
|
||||
|
||||
1. Admin clicks "Add Slack" → `POST /orgs/{org}/settings/integrations/slack` with CSRF
|
||||
2. Server generates OAuth state (CSRF + org), stores in session, redirects to:
|
||||
`https://slack.com/oauth/v2/authorize?client_id=...&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri=...&state=...`
|
||||
3. User authorizes in Slack
|
||||
4. Slack redirects to `GET /orgs/{org}/settings/integrations/slack/callback?code=...&state=...`
|
||||
5. Server validates state, exchanges code for access token via Slack API
|
||||
6. Stores integration in database (token encrypted at rest)
|
||||
7. Redirects to integration detail page
|
||||
|
||||
**Environment variables:**
|
||||
- `SLACK_CLIENT_ID` — Slack app client ID
|
||||
- `SLACK_CLIENT_SECRET` — Slack app client secret (encrypted/from secrets manager)
|
||||
- `FORAGE_BASE_URL` — Base URL for OAuth callbacks (e.g., `https://forage.sh`)
|
||||
- `INTEGRATION_ENCRYPTION_KEY` — AES-256 key for encrypting integration configs at rest
|
||||
|
||||
### Webhook Delivery Format
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "release_failed",
|
||||
"timestamp": "2026-03-09T14:30:00Z",
|
||||
"organisation": "rawpotion",
|
||||
"project": "service-example",
|
||||
"release": {
|
||||
"slug": "evidently-assisting-ladybeetle",
|
||||
"artifact_id": "art_123",
|
||||
"title": "fix: resolve OOM on large payload deserialization (#603)",
|
||||
"destination": "prod-eu",
|
||||
"environment": "production",
|
||||
"source_username": "hermansen",
|
||||
"commit_sha": "abc1234",
|
||||
"commit_branch": "main",
|
||||
"error_message": "container health check timeout after 120s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Webhooks include `X-Forage-Signature` header (HMAC-SHA256 of body with the webhook's secret) for verification.
|
||||
|
||||
### Slack Message Format
|
||||
|
||||
Slack messages use Block Kit for rich formatting:
|
||||
|
||||
- **Release succeeded**: green sidebar, title, commit, destination, link to release page
|
||||
- **Release failed**: red sidebar, title, error message, commit, link to release page
|
||||
- **Release started**: neutral, title, destination, link to release page
|
||||
- **Release annotated**: neutral, title, description, link to release page
|
||||
|
||||
## Behavioral Contract
|
||||
|
||||
### Integrations page
|
||||
- Only org admins/owners can view and manage integrations
|
||||
- Non-admin members get 403
|
||||
- Non-members get 403
|
||||
- Lists all integrations for the org with status badges
|
||||
|
||||
### Slack integration setup
|
||||
- CSRF protection on the initiation POST
|
||||
- OAuth state validated on callback (prevents CSRF via Slack redirect)
|
||||
- If Slack returns error, show error page with "Try again" button
|
||||
- Duplicate channel detection: warn if same channel already configured
|
||||
|
||||
### Webhook integration
|
||||
- URL must be HTTPS (except localhost for development)
|
||||
- Secret is optional but recommended
|
||||
- Test delivery on creation to validate the URL responds
|
||||
|
||||
### Notification routing
|
||||
- Only enabled integrations with matching rules receive notifications
|
||||
- Delivery failures are logged but don't block other integrations
|
||||
- Retry: 3 attempts with exponential backoff (1s, 5s, 25s)
|
||||
- After 3 failures, log error but don't disable integration
|
||||
|
||||
### Notification history
|
||||
- Paginated, newest first, 20 per page
|
||||
- Filterable by org and project (optional)
|
||||
- Accessible to all authenticated users (scoped to their orgs)
|
||||
|
||||
### CLI API
|
||||
- Authenticates via `Authorization: Bearer <personal_access_token>`
|
||||
- Returns JSON `{ notifications: [...], next_page_token: "..." }`
|
||||
- Token auth bypasses session — direct proxy to Forest's `ListNotifications` RPC
|
||||
|
||||
### Account settings
|
||||
- CLI toggles remain per-user, stored on Forest
|
||||
- Link to org integrations page for channel configuration
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase A: Database + Integration Model
|
||||
1. Add `integrations`, `notification_rules`, `notification_deliveries` tables to `forage-db`
|
||||
2. Add domain types to `forage-core` (`Integration`, `IntegrationConfig`, `NotificationRule`)
|
||||
3. Add repository trait + Postgres implementation for CRUD operations
|
||||
4. Unit tests for model validation
|
||||
|
||||
### Phase B: Integrations CRUD Routes + UI
|
||||
1. Add `/orgs/{org}/settings/integrations` routes (list, detail, toggle, delete)
|
||||
2. Add webhook creation form + route
|
||||
3. Templates: integrations list, detail, webhook form
|
||||
4. Update `base.html.jinja` nav with "Integrations" tab
|
||||
5. Tests: CRUD operations, auth checks, CSRF validation
|
||||
|
||||
### Phase C: Slack OAuth
|
||||
1. Add Slack OAuth initiation + callback routes
|
||||
2. Slack API token exchange (reqwest call to `slack.com/api/oauth.v2.access`)
|
||||
3. Store encrypted config in database
|
||||
4. Template: success/error pages
|
||||
5. Tests: mock OAuth flow, state validation
|
||||
|
||||
### Phase D: Notification Listener + Router
|
||||
1. Background task: subscribe to Forest `ListenNotifications` for active orgs
|
||||
2. Notification router: match notification to integrations + rules
|
||||
3. Slack dispatcher: format Block Kit message, POST to Slack API
|
||||
4. Webhook dispatcher: POST JSON payload with HMAC signature
|
||||
5. Delivery logging to `notification_deliveries` table
|
||||
6. Tests: routing logic, retry behavior, delivery recording
|
||||
|
||||
### Phase E: Notification History + CLI API
|
||||
1. Rewrite `/notifications` to use `ListNotifications` RPC
|
||||
2. Add `GET /api/notifications` JSON endpoint with bearer auth
|
||||
3. Template: paginated notification list with filters
|
||||
4. Tests: pagination, auth, JSON response shape
|
||||
|
||||
### Phase F: Account Settings Redesign
|
||||
1. Simplify notification prefs to CLI-only toggles
|
||||
2. Add link to org integrations page
|
||||
3. Update tests for new layout
|
||||
|
||||
## Test Strategy
|
||||
|
||||
~35 new tests:
|
||||
|
||||
**Integration CRUD (10)**:
|
||||
- List integrations returns 200 for admin
|
||||
- List integrations returns 403 for non-admin member
|
||||
- List integrations returns 403 for non-member
|
||||
- Create webhook integration with valid URL
|
||||
- Create webhook rejects HTTP URL (non-HTTPS)
|
||||
- Create webhook validates CSRF
|
||||
- Toggle integration on/off
|
||||
- Delete integration with CSRF
|
||||
- Update notification rules for integration
|
||||
- Integration detail returns 404 for wrong org
|
||||
|
||||
**Slack OAuth (5)**:
|
||||
- Slack initiation redirects to slack.com with correct params
|
||||
- Slack callback with valid state creates integration
|
||||
- Slack callback with invalid state returns 403
|
||||
- Slack callback with error param shows error page
|
||||
- Duplicate Slack channel shows warning
|
||||
|
||||
**Notification routing (8)**:
|
||||
- Router dispatches to enabled integration with matching rule
|
||||
- Router skips disabled integration
|
||||
- Router skips integration with disabled rule for event type
|
||||
- Router handles delivery failure gracefully (doesn't panic)
|
||||
- Webhook dispatcher includes HMAC signature
|
||||
- Slack dispatcher formats Block Kit correctly
|
||||
- Retry logic attempts 3 times on failure
|
||||
- Delivery logged to database
|
||||
|
||||
**Notification history (5)**:
|
||||
- Notification page returns 200 with entries
|
||||
- Notification page supports pagination
|
||||
- CLI API returns JSON with bearer auth
|
||||
- CLI API rejects unauthenticated request
|
||||
- CLI API returns empty list gracefully
|
||||
|
||||
**Account settings (3)**:
|
||||
- Account page shows CLI-only toggles
|
||||
- Account page links to org integrations
|
||||
- CLI toggle round-trip works
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo test` — all existing + new tests pass
|
||||
- `cargo clippy` — clean
|
||||
- `sqlx migrate` — new tables created without error
|
||||
- Manual: create webhook integration, trigger release, verify delivery
|
||||
- Manual: Slack OAuth flow end-to-end (requires Slack app credentials)
|
||||
@@ -1,28 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../templates";
|
||||
|
||||
/* ── Scrollbar-hidden utility for tab navigation ──────────────────────── */
|
||||
@utility scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile/Desktop visibility ────────────────────────────────────────── */
|
||||
@utility mobile-only {
|
||||
display: none;
|
||||
@media (max-width: 39.999rem) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@utility desktop-only {
|
||||
@media (max-width: 39.999rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark mode (system preference) ──────────────────────────────────────── */
|
||||
/* Remap Tailwind's color variables so all existing utilities adapt automatically. */
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -42,7 +42,6 @@ class PipelineBuilder extends HTMLElement {
|
||||
if (!config) return "deploy";
|
||||
if (config.Deploy !== undefined) return "deploy";
|
||||
if (config.Wait !== undefined) return "wait";
|
||||
if (config.Plan !== undefined) return "plan";
|
||||
return "deploy";
|
||||
}
|
||||
|
||||
@@ -51,7 +50,6 @@ class PipelineBuilder extends HTMLElement {
|
||||
if (!config) return "";
|
||||
if (config.Deploy) return config.Deploy.environment || "";
|
||||
if (config.Wait) return config.Wait.duration_seconds ? `${config.Wait.duration_seconds}s` : "";
|
||||
if (config.Plan) return config.Plan.environment || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -369,7 +367,7 @@ class PipelineBuilder extends HTMLElement {
|
||||
|
||||
// Type select (deploy / wait)
|
||||
const typeSelect = el("select", "border border-gray-200 rounded px-2 py-1 text-xs bg-white shrink-0");
|
||||
for (const t of ["deploy", "wait", "plan"]) {
|
||||
for (const t of ["deploy", "wait"]) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
@@ -381,8 +379,6 @@ class PipelineBuilder extends HTMLElement {
|
||||
clearTimeout(this._blurTimer);
|
||||
if (typeSelect.value === "wait") {
|
||||
this.stages[index].config = { Wait: { duration_seconds: 0 } };
|
||||
} else if (typeSelect.value === "plan") {
|
||||
this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
|
||||
} else {
|
||||
this.stages[index].config = { Deploy: { environment: "" } };
|
||||
}
|
||||
@@ -444,33 +440,6 @@ class PipelineBuilder extends HTMLElement {
|
||||
};
|
||||
const secLabel = el("span", "text-xs text-gray-400", "seconds");
|
||||
configRow.append(durLabel, durInput, secLabel);
|
||||
} else if (type === "plan") {
|
||||
const envLabel = el("span", "text-xs text-gray-500 shrink-0", "env:");
|
||||
const envInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-32 focus:outline-none focus:ring-1 focus:ring-gray-400");
|
||||
envInput.type = "text";
|
||||
envInput.value = (stage.config.Plan && stage.config.Plan.environment) || "";
|
||||
envInput.placeholder = "environment";
|
||||
envInput.onmousedown = (e) => e.stopPropagation();
|
||||
envInput.oninput = () => {
|
||||
if (!this.stages[index].config.Plan) this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
|
||||
this.stages[index].config.Plan.environment = envInput.value.trim();
|
||||
this._sync();
|
||||
};
|
||||
envInput.onblur = () => {
|
||||
this._blurTimer = setTimeout(() => this._render(), 150);
|
||||
};
|
||||
const autoLabel = el("label", "text-xs text-gray-500 flex items-center gap-1 ml-2 shrink-0");
|
||||
const autoCheck = el("input", "");
|
||||
autoCheck.type = "checkbox";
|
||||
autoCheck.checked = !!(stage.config.Plan && stage.config.Plan.auto_approve);
|
||||
autoCheck.onmousedown = (e) => e.stopPropagation();
|
||||
autoCheck.onchange = () => {
|
||||
if (!this.stages[index].config.Plan) this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
|
||||
this.stages[index].config.Plan.auto_approve = autoCheck.checked;
|
||||
this._sync();
|
||||
};
|
||||
autoLabel.append(autoCheck, document.createTextNode("auto-approve"));
|
||||
configRow.append(envLabel, envInput, autoLabel);
|
||||
}
|
||||
card.append(configRow);
|
||||
|
||||
@@ -599,7 +568,6 @@ class PipelineBuilder extends HTMLElement {
|
||||
const TYPE_COLORS = {
|
||||
deploy: { bg: "#dbeafe", border: "#93c5fd", text: "#1e40af" },
|
||||
wait: { bg: "#fef3c7", border: "#fcd34d", text: "#92400e" },
|
||||
plan: { bg: "#ede9fe", border: "#c4b5fd", text: "#5b21b6" },
|
||||
};
|
||||
|
||||
for (const s of named) {
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## Plan Stage Support (Prepare-Before-Deploy)
|
||||
|
||||
### Overview
|
||||
|
||||
Added support for "plan" pipeline stages — destinations that run a prepare/dry-run (e.g. terraform plan) and require approval of the output before the actual deploy proceeds. Forest already had full infrastructure for this; this work surfaces it in the Forage UI.
|
||||
|
||||
### Changes
|
||||
|
||||
#### forage-core (`crates/forage-core/src/platform/mod.rs`)
|
||||
- Added `PipelineStageConfig::Plan { environment, auto_approve }` variant
|
||||
- Added `approval_status: Option<String>` and `auto_approve: Option<bool>` to `PipelineRunStageState`
|
||||
- Added 3 new `ForestPlatform` trait methods: `approve_plan_stage`, `reject_plan_stage`, `get_plan_output`
|
||||
- Added `PlanOutput` struct (`plan_output: String`, `status: String`)
|
||||
|
||||
#### forage-server gRPC client (`forest_client.rs`)
|
||||
- `convert_pipeline_stage`: handles `Plan` config variant (was previously mapped to empty Deploy)
|
||||
- `convert_pipeline_stage_state`: recognizes `Plan` stage type + `AwaitingApproval` status + new fields
|
||||
- `convert_stages_to_grpc`: handles `PipelineStageConfig::Plan` → `PlanStageConfig`
|
||||
- Implemented `approve_plan_stage`, `reject_plan_stage`, `get_plan_output` calling forest's RPCs
|
||||
|
||||
#### forage-server routes (`routes/platform.rs`)
|
||||
- Added 3 API routes:
|
||||
- `POST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve`
|
||||
- `POST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject`
|
||||
- `GET /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output`
|
||||
- `ApiPipelineStage` now includes `approval_status` and `auto_approve`
|
||||
- `build_timeline_json`: plan stages with `AWAITING_APPROVAL` status are shown with that status; releases with plan stages awaiting approval are treated as `needs_action` (not hidden)
|
||||
|
||||
#### Pipeline builder (`static/js/pipeline-builder.js`)
|
||||
- Added "plan" as third stage type in dropdown
|
||||
- Plan stage config: environment + auto-approve checkbox
|
||||
- Purple color scheme for plan nodes in DAG visualization
|
||||
|
||||
#### Svelte timeline (`frontend/src/ReleaseTimeline.svelte`)
|
||||
- `approvePlanStage(release, stage, reject)` function for approve/reject via API
|
||||
- `viewPlanOutput(release, stage)` function for on-demand plan output fetching (toggle)
|
||||
- Plan stages render with purple shield icon when `AWAITING_APPROVAL`
|
||||
- "Approve plan" / "Reject" buttons on plan stages awaiting approval
|
||||
- "View plan" / "Hide plan" button to toggle plan output display
|
||||
- Plan output shown in collapsible `<pre>` block (monospace, max-height 256px with scroll)
|
||||
- Summary line shows plan stage badge + approve button when plan awaiting approval
|
||||
|
||||
#### Status helpers (`frontend/src/lib/status.js`)
|
||||
- Added `planStageLabel(status)` function
|
||||
- `pipelineSummary`: detects `AWAITING_APPROVAL` plan stages → "Awaiting plan approval" (purple)
|
||||
|
||||
#### Slack notifications (`forage-core/src/integrations/router.rs`)
|
||||
- Plan stage rendering in Slack blocks: "Planning", "Awaiting plan approval", "Plan approved", "Plan failed"
|
||||
- Shield emoji for AWAITING_APPROVAL status
|
||||
|
||||
#### Test support (`test_support.rs`)
|
||||
- Added default mock implementations for the 3 new trait methods
|
||||
|
||||
### Forest Runner Infrastructure
|
||||
|
||||
#### Proto (`runner.proto`)
|
||||
- Added `ReleaseMode` enum: `RELEASE_MODE_UNSPECIFIED`, `RELEASE_MODE_DEPLOY`, `RELEASE_MODE_PLAN`
|
||||
- Added `mode` field (type `ReleaseMode`) to `WorkAssignment` — tells remote runners whether to deploy or plan
|
||||
- Added `plan_output` field (optional string) to `CompleteReleaseRequest` — runners send plan output back
|
||||
|
||||
#### Scheduler (`scheduler.rs`)
|
||||
- Reads `release_state.mode` and maps to `ReleaseMode::Plan` / `ReleaseMode::Deploy`
|
||||
- Includes `mode` in `WorkAssignment` when dispatching to remote runners
|
||||
|
||||
#### Runner gRPC handler (`grpc/runner.rs`)
|
||||
- `complete_release`: stores `plan_output` from `CompleteReleaseRequest` to `release_states.plan_output` in DB
|
||||
|
||||
#### Terraform destination (`destinations/terraformv1.rs`)
|
||||
- `plan()`: now captures actual terraform plan stdout (not just a marker)
|
||||
- Added `run_capture()` method — same as `run()` but captures stdout into a String
|
||||
- Added `run_command_capture()` — like `run_command()` but returns captured stdout while still logging
|
||||
|
||||
#### Runner crate (`forest-runner`)
|
||||
- `RunnerDestination` trait: added `plan()` method (default returns None)
|
||||
- `Executor`: checks `ReleaseMode` from `WorkAssignment`, calls `plan()` instead of `release()` for plan mode
|
||||
- `RunnerSession::complete_release`: accepts optional `plan_output` parameter
|
||||
- `run_destination_plan()` function: prepare + plan, returns `Option<String>`
|
||||
@@ -5,7 +5,6 @@
|
||||
<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">
|
||||
@@ -34,8 +33,8 @@
|
||||
<a href="/orgs/{{ current_org }}/projects" class="font-medium text-gray-900 hover:text-black">{{ current_org }}</a>
|
||||
{% endif %}
|
||||
{% if projects is defined and projects | length > 0 %}
|
||||
<span class="text-gray-300 desktop-only">/</span>
|
||||
<details class="relative desktop-only">
|
||||
<span class="text-gray-300">/</span>
|
||||
<details class="relative">
|
||||
<summary class="font-medium text-gray-900 hover:text-black cursor-pointer list-none truncate">
|
||||
{% if project_name is defined and project_name %}{{ project_name }}{% else %}Select project{% endif %}
|
||||
<svg class="inline w-3 h-3 ml-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
@@ -47,67 +46,45 @@
|
||||
</div>
|
||||
</details>
|
||||
{% elif project_name is defined and project_name %}
|
||||
<span class="text-gray-300 desktop-only">/</span>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="font-medium text-gray-900 hover:text-black truncate desktop-only">{{ project_name }}</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="font-medium text-gray-900 hover:text-black truncate">{{ project_name }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Right-side actions #}
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<a href="/notifications" class="text-gray-400 hover:text-gray-900 relative" title="Notifications">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
</a>
|
||||
{# Desktop-only: username + sign out #}
|
||||
<a href="/settings/account" class="text-sm text-gray-500 hover:text-gray-900 desktop-only">{{ user.username }}</a>
|
||||
<form method="POST" action="/logout" class="inline desktop-only">
|
||||
<a href="/settings/account" class="text-sm text-gray-500 hover:text-gray-900">{{ user.username }}</a>
|
||||
<form method="POST" action="/logout" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-900">Sign out</button>
|
||||
</form>
|
||||
{# Mobile-only: hamburger #}
|
||||
<button type="button" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="text-gray-500 hover:text-gray-900 p-1 mobile-only">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{# Mobile dropdown menu #}
|
||||
<div id="mobile-menu" class="hidden border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-2">
|
||||
{% if project_name is defined and project_name %}
|
||||
<div class="text-xs text-gray-500">
|
||||
{% if current_org is defined %}{{ current_org }} /{% endif %} {{ project_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="/settings/account" class="block text-sm text-gray-700 hover:text-gray-900 py-1">{{ user.username }}</a>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-900 py-1">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
{# Tab navigation — scrollable on mobile #}
|
||||
{# Tab navigation #}
|
||||
<div class="max-w-6xl mx-auto px-4 mt-2">
|
||||
<div class="flex gap-1 -mb-px overflow-x-auto scrollbar-none">
|
||||
<div class="flex gap-1 -mb-px overflow-x-auto">
|
||||
{% if project_name is defined and project_name %}
|
||||
{# ── Project-level tabs ─────────────────────────────── #}
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_overview' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}/releases" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_overview' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}/releases" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
|
||||
{% elif current_org is defined and current_org %}
|
||||
{# ── Org-level tabs ─────────────────────────────────── #}
|
||||
<a href="/dashboard" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'projects' %} text-gray-900 border-gray-900{% endif %}">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'members' %} text-gray-900 border-gray-900{% endif %}">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/destinations" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'destinations' %} text-gray-900 border-gray-900{% endif %}">Destinations</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'integrations' %} text-gray-900 border-gray-900{% endif %}">Integrations</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'usage' %} text-gray-900 border-gray-900{% endif %}">Usage</a>
|
||||
<a href="/settings/tokens" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'projects' %} text-gray-900 border-gray-900{% endif %}">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'members' %} text-gray-900 border-gray-900{% endif %}">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/destinations" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'destinations' %} text-gray-900 border-gray-900{% endif %}">Destinations</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'usage' %} text-gray-900 border-gray-900{% endif %}">Usage</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
{% else %}
|
||||
{# ── Global tabs (no org context) ───────────────────── #}
|
||||
<a href="/dashboard" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/settings/tokens" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,24 +95,15 @@
|
||||
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold tracking-tight">forage</a>
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900 desktop-only">Pricing</a>
|
||||
<a href="/components" class="text-sm text-gray-600 hover:text-gray-900 desktop-only">Components</a>
|
||||
<a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/components" class="text-sm text-gray-600 hover:text-gray-900">Components</a>
|
||||
<a href="/login" class="text-sm font-medium px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800">Sign in</a>
|
||||
<button type="button" onclick="document.getElementById('marketing-mobile-menu').classList.toggle('hidden')" class="text-gray-500 hover:text-gray-900 p-1 mobile-only">
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="marketing-mobile-menu" class="hidden border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-2">
|
||||
<a href="/pricing" class="block text-sm text-gray-600 hover:text-gray-900 py-1">Pricing</a>
|
||||
<a href="/components" class="block text-sm text-gray-600 hover:text-gray-900 py-1">Components</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main class="flex-1 pb-16">
|
||||
<main class="flex-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
name: forage
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: forage
|
||||
POSTGRES_USER: forageuser
|
||||
POSTGRES_PASSWORD: foragepassword
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- forage-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -17,20 +15,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
nats:
|
||||
image: nats:2-alpine
|
||||
command: ["--jetstream", "--store_dir", "/data", "--http_port", "8222"]
|
||||
ports:
|
||||
- "4223:4222"
|
||||
- "8223:8222"
|
||||
volumes:
|
||||
- forage-nats:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
forage-pgdata:
|
||||
forage-nats:
|
||||
|
||||
@@ -83,95 +83,5 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Linked Slack accounts #}
|
||||
{% if has_slack_oauth %}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Linked Slack accounts</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">Link your Slack identity to receive personal DMs about your releases.</p>
|
||||
|
||||
{% if slack_links %}
|
||||
<div class="space-y-2 mb-4">
|
||||
{% for link in slack_links %}
|
||||
<div class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-[#4A154B] rounded flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ link.team_name }}</p>
|
||||
<p class="text-xs text-gray-500">@{{ link.slack_username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/settings/account/slack/disconnect">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="team_id" value="{{ link.team_id }}">
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Disconnect</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="/settings/account/slack/connect"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 text-gray-700">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="#4A154B" aria-hidden="true">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
Add to Slack
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Notification preferences #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Notification preferences</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">Choose which events trigger notifications on each channel.</p>
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Event</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700 w-24">CLI</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700 w-24">Slack</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% set event_types = [
|
||||
{"key": "NOTIFICATION_TYPE_RELEASE_ANNOTATED", "label": "Release annotated"},
|
||||
{"key": "NOTIFICATION_TYPE_RELEASE_STARTED", "label": "Release started"},
|
||||
{"key": "NOTIFICATION_TYPE_RELEASE_SUCCEEDED", "label": "Release succeeded"},
|
||||
{"key": "NOTIFICATION_TYPE_RELEASE_FAILED", "label": "Release failed"},
|
||||
] %}
|
||||
{% set channels = [
|
||||
{"key": "NOTIFICATION_CHANNEL_CLI", "label": "CLI"},
|
||||
{"key": "NOTIFICATION_CHANNEL_SLACK", "label": "Slack"},
|
||||
] %}
|
||||
{% for event in event_types %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-gray-700">{{ event.label }}</td>
|
||||
{% for ch in channels %}
|
||||
{% set pref_key = event.key ~ "|" ~ ch.key %}
|
||||
{% set is_enabled = pref_key in enabled_prefs %}
|
||||
<td class="text-center px-4 py-3">
|
||||
<form method="POST" action="/settings/account/notifications" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="notification_type" value="{{ event.key }}">
|
||||
<input type="hidden" name="channel" value="{{ ch.key }}">
|
||||
<input type="hidden" name="enabled" value="{{ 'false' if is_enabled else 'true' }}">
|
||||
<button type="submit" class="w-8 h-5 rounded-full relative transition-colors {{ 'bg-gray-900' if is_enabled else 'bg-gray-300' }}" title="{{ 'Disable' if is_enabled else 'Enable' }} {{ event.label | lower }} via {{ ch.label }}">
|
||||
<span class="block w-3.5 h-3.5 rounded-full bg-white shadow-sm absolute top-0.5 transition-all {{ 'left-[calc(100%-1.125rem)]' if is_enabled else 'left-0.5' }}"></span>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -79,49 +79,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Deploy action ─────────────────────────────────────────── #}
|
||||
{% if is_admin %}
|
||||
<div class="mb-8">
|
||||
<details class="border border-gray-200 rounded-lg group">
|
||||
<summary class="px-4 py-3 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span class="font-medium text-gray-700">Deploy this release</span>
|
||||
<svg class="w-3 h-3 text-gray-400 ml-auto transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</summary>
|
||||
<div class="px-4 py-4 border-t border-gray-100">
|
||||
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/deploy" class="space-y-4">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="artifact_id" value="{{ artifact_id }}">
|
||||
|
||||
{% if has_active_pipeline %}
|
||||
<div class="flex items-center gap-3 p-3 bg-purple-50 border border-purple-200 rounded-md">
|
||||
<input type="checkbox" id="use-pipeline" name="use_pipeline" value="true" checked class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<label for="use-pipeline" class="text-sm text-purple-800">
|
||||
Use pipeline <span class="text-purple-600 text-xs">(follows the configured multi-stage deployment pipeline)</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="env-select">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="environment" 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">
|
||||
<option value="">All environments (pipeline decides)</option>
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}">{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">Leave empty when using a pipeline — it will deploy to all configured stages.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Deploy
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Pipeline stages ───────────────────────────────────────── #}
|
||||
{% if has_pipeline and pipeline_stages | length > 0 %}
|
||||
<div class="mb-8">
|
||||
@@ -138,8 +95,6 @@
|
||||
<svg class="w-4 h-4 text-blue-400 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>
|
||||
{% elif stage.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 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>
|
||||
{% elif stage.status == "AWAITING_APPROVAL" or (stage.stage_type == "plan" and (stage.approval_status == "AWAITINGAPPROVAL" or stage.approval_status == "AWAITING_APPROVAL")) %}
|
||||
<svg class="w-4 h-4 text-purple-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-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>
|
||||
{% endif %}
|
||||
@@ -157,30 +112,6 @@
|
||||
{% if stage.status == "SUCCEEDED" %}Waited{% elif stage.status == "RUNNING" %}Waiting{% elif stage.status == "FAILED" %}Wait failed{% elif stage.status == "CANCELLED" %}Wait cancelled{% else %}Wait{% endif %}
|
||||
{% if stage.duration_seconds %}{{ stage.duration_seconds }}s{% endif %}
|
||||
</span>
|
||||
{% elif stage.stage_type == "plan" %}
|
||||
{% set plan_awaiting = stage.approval_status == "AWAITINGAPPROVAL" or stage.approval_status == "AWAITING_APPROVAL" or stage.status == "AWAITING_APPROVAL" %}
|
||||
<span class="text-sm {{ 'text-purple-700' if plan_awaiting else 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-red-700' if stage.status == 'FAILED' else 'text-gray-400' }}">
|
||||
{% if stage.status == "SUCCEEDED" %}Plan approved{% elif plan_awaiting %}Awaiting plan approval{% elif stage.status == "RUNNING" %}Planning{% elif stage.status == "FAILED" %}Plan failed{% elif stage.status == "CANCELLED" %}Plan cancelled{% else %}Plan{% endif %}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in stage.environment and 'preprod' not in stage.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in stage.environment or 'pre-prod' in stage.environment %}bg-orange-100 text-orange-800{% elif 'stag' in stage.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in stage.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ stage.environment }}
|
||||
</span>
|
||||
{% if plan_awaiting and release_intent_id %}
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<form method="post" action="/api/orgs/{{ org_name }}/projects/{{ project_name }}/plan-stages/{{ stage.id }}/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="redirect_to" value="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}">
|
||||
<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 plan</button>
|
||||
</form>
|
||||
<form method="post" action="/api/orgs/{{ org_name }}/projects/{{ project_name }}/plan-stages/{{ stage.id }}/reject" class="inline" onsubmit="return confirm('Reject this plan?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
|
||||
<input type="hidden" name="redirect_to" value="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}">
|
||||
<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 %}
|
||||
|
||||
{# Elapsed time #}
|
||||
@@ -213,150 +144,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Policy evaluations (approval, soak, branch) ──────────── #}
|
||||
{% if policy_evaluations | length > 0 %}
|
||||
{% set pns = namespace(passed=0, total=0, pending=0) %}
|
||||
{% for eval in policy_evaluations %}
|
||||
{% set pns.total = pns.total + 1 %}
|
||||
{% if eval.passed %}
|
||||
{% set pns.passed = pns.passed + 1 %}
|
||||
{% else %}
|
||||
{% set pns.pending = pns.pending + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Policy Requirements</h2>
|
||||
{% if pns.passed == pns.total %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
<svg class="w-3 h-3" 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>
|
||||
{{ pns.passed }}/{{ pns.total }} passed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
<svg class="w-3 h-3" 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>
|
||||
{{ pns.passed }}/{{ pns.total }} passed
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Pending / failed policies (expanded) ──────────────── #}
|
||||
{% if pns.pending > 0 %}
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100 mb-3">
|
||||
{% for eval in policy_evaluations %}
|
||||
{% if not eval.passed %}
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<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>
|
||||
|
||||
{% 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 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_admin %}
|
||||
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline" onsubmit="return confirm('Admin bypass — skip remaining approvals?')">
|
||||
<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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Passed policies (collapsed) ───────────────────────── #}
|
||||
{% if pns.passed > 0 %}
|
||||
<details class="border border-gray-200 rounded-lg group">
|
||||
<summary class="px-4 py-2.5 flex items-center gap-2 text-sm text-gray-500 cursor-pointer list-none hover:bg-gray-50">
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
<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>
|
||||
{{ pns.passed }} passed polic{{ "y" if pns.passed == 1 else "ies" }}
|
||||
</summary>
|
||||
<div class="divide-y divide-gray-100 border-t border-gray-100">
|
||||
{% for eval in policy_evaluations %}
|
||||
{% if eval.passed %}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm">
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destinations with status ──────────────────────────────── #}
|
||||
{% if destinations | length > 0 or configured_destinations | length > 0 %}
|
||||
<div class="mb-8">
|
||||
@@ -440,14 +227,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Spec (forest.cue) ──────────────────────────────────────── #}
|
||||
{% if artifact_spec %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Spec</h2>
|
||||
<spec-viewer content="{{ artifact_spec | e }}" filename="forest.cue"></spec-viewer>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Details ───────────────────────────────────────────────── #}
|
||||
{% if artifact.commit_message or artifact.repo_url or artifact.source_email or artifact.run_url %}
|
||||
<div class="mb-8">
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">← All integrations</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 mb-8">
|
||||
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center shrink-0">
|
||||
<svg class="w-7 h-7 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Install Slack</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Post deployment notifications directly to Slack channels from <strong>{{ current_org }}</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error is defined and error %}
|
||||
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── How it works ──────────────────────────────────────────── #}
|
||||
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">How it works</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1.5">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">1.</span>
|
||||
<span>Rich Block Kit messages with release metadata, status badges, and color-coded sidebars</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">2.</span>
|
||||
<span>Notifications include organisation, project, destination, commit, branch, and author</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">3.</span>
|
||||
<span>Configure which events trigger notifications (releases started, succeeded, failed, annotated)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">4.</span>
|
||||
<span>Failed deliveries are retried up to 3 times with exponential backoff</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if has_slack_oauth %}
|
||||
{# ── OAuth "Add to Slack" flow ─────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Connect with Slack</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Click the button below to authorize Forage to post to a Slack channel. You'll choose which channel during the Slack authorization flow.</p>
|
||||
<a href="{{ slack_oauth_url }}" class="inline-flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium text-sm">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
Add to Slack
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="relative my-8">
|
||||
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-200"></div></div>
|
||||
<div class="relative flex justify-center text-sm"><span class="px-3 bg-white text-gray-400">or use a webhook URL</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Manual webhook URL form ───────────────────────────────── #}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/slack" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
|
||||
{% if has_slack_oauth %}
|
||||
<p class="text-sm text-gray-500 mb-2">Alternatively, paste a Slack Incoming Webhook URL directly. Create one in your <a href="https://api.slack.com/apps" target="_blank" rel="noopener" class="text-gray-700 underline hover:text-gray-900">Slack App settings</a>.</p>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500 mb-2">Paste a Slack Incoming Webhook URL. Create one in your <a href="https://api.slack.com/apps" target="_blank" rel="noopener" class="text-gray-700 underline hover:text-gray-900">Slack App settings</a> under <strong>Incoming Webhooks</strong>.</p>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g. #deploys" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">A friendly name to identify this integration</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="webhook_url" class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
|
||||
<input type="url" id="webhook_url" name="webhook_url" placeholder="https://hooks.slack.com/services/T.../B.../..." required
|
||||
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">Must be a <code class="bg-gray-200 px-1 py-0.5 rounded">https://hooks.slack.com/</code> URL</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="channel_name" class="block text-sm font-medium text-gray-700 mb-1">Channel name <span class="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="text" id="channel_name" name="channel_name" placeholder="#deploys"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">For display purposes only (defaults to #general)</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
|
||||
Install Slack integration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,80 +0,0 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">← All integrations</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4 mb-8">
|
||||
<div class="w-14 h-14 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
<svg class="w-7 h-7 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Install Webhook</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Send HTTP POST requests to your endpoint when deployment events occur in <strong>{{ current_org }}</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error is defined and error %}
|
||||
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── How it works ──────────────────────────────────────────── #}
|
||||
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">How it works</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1.5">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">1.</span>
|
||||
<span>Forage sends a <code class="text-xs bg-gray-200 px-1 py-0.5 rounded">POST</code> request with a JSON payload to your URL</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">2.</span>
|
||||
<span>Payloads include event type, release metadata, project, and organisation</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">3.</span>
|
||||
<span>Optional HMAC-SHA256 signing via <code class="text-xs bg-gray-200 px-1 py-0.5 rounded">X-Forage-Signature</code> header</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">4.</span>
|
||||
<span>Failed deliveries are retried up to 3 times with exponential backoff</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# ── Setup form ────────────────────────────────────────────── #}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/webhook" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g. Production alerts" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">A friendly name to identify this webhook</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">Payload URL</label>
|
||||
<input type="url" id="url" name="url" placeholder="https://example.com/webhooks/forage" required
|
||||
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">Must use HTTPS (HTTP allowed for localhost only)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="secret" class="block text-sm font-medium text-gray-700 mb-1">Secret <span class="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="text" id="secret" name="secret" placeholder="whsec_..."
|
||||
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<p class="text-xs text-gray-400 mt-1">Used to compute <code class="bg-gray-200 px-1 py-0.5 rounded">X-Forage-Signature</code> (HMAC-SHA256) so you can verify payloads are from Forage</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
|
||||
Install webhook
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,165 +0,0 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="text-sm text-gray-500 hover:text-gray-700">← All integrations</a>
|
||||
</div>
|
||||
|
||||
{# ── Header ───────────────────────────────────────────────── #}
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
{% if integration.integration_type == "webhook" %}
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{% elif integration.integration_type == "slack" %}
|
||||
<svg class="w-6 h-6 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ integration.name }}</h1>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-sm text-gray-500">{{ integration.type_display }}</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ 'bg-green-50 text-green-700' if integration.enabled else 'bg-gray-100 text-gray-500' }}">
|
||||
{{ "Active" if integration.enabled else "Paused" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% if integration.integration_type == "slack" and has_slack_oauth is defined and has_slack_oauth %}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/reinstall" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
|
||||
Reinstall
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/toggle" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="enabled" value="{{ 'false' if integration.enabled else 'true' }}">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
|
||||
{{ "Pause" if integration.enabled else "Resume" }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/delete" class="inline" onsubmit="return confirm('Uninstall this integration? This cannot be undone.')">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 transition-colors">
|
||||
Uninstall
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if test_sent is defined and test_sent %}
|
||||
<div class="mb-6 px-4 py-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg">
|
||||
Test notification sent. Check your endpoint for delivery.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Configuration ────────────────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Configuration</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% if config.type_name == "Webhook" %}
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Payload URL</span>
|
||||
<span class="text-sm text-gray-900 font-mono">{{ config.detail }}</span>
|
||||
</div>
|
||||
{% if config.has_secret is defined and config.has_secret %}
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Signing</span>
|
||||
<span class="text-sm text-gray-900">HMAC-SHA256 enabled</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="px-4 py-3">
|
||||
<span class="text-sm text-gray-900">{{ config.detail }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Events ───────────────────────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Events</h2>
|
||||
<p class="text-sm text-gray-500 mb-3">Choose which deployment events trigger this integration.</p>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for rule in rules %}
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">{{ rule.label }}</span>
|
||||
</div>
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/rules" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="notification_type" value="{{ rule.notification_type }}">
|
||||
<input type="hidden" name="enabled" value="{{ 'false' if rule.enabled else 'true' }}">
|
||||
<button type="submit" class="w-9 h-5 rounded-full relative transition-colors {{ 'bg-gray-900' if rule.enabled else 'bg-gray-300' }}" title="{{ 'Disable' if rule.enabled else 'Enable' }} {{ rule.label | lower }}">
|
||||
<span class="block w-3.5 h-3.5 rounded-full bg-white shadow-sm absolute top-[3px] transition-all {{ 'left-[calc(100%-1.125rem)]' if rule.enabled else 'left-[3px]' }}"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Recent deliveries ────────────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Recent deliveries</h2>
|
||||
{% if deliveries | length > 0 %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Notification</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{% for d in deliveries %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
{% if d.status == "delivered" %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-green-700">Delivered</span>
|
||||
{% elif d.status == "failed" %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-50 text-red-700">Failed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-50 text-yellow-700">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-700 font-mono truncate max-w-[200px]" title="{{ d.notification_id }}">{{ d.notification_id[:12] }}{% if d.notification_id | length > 12 %}…{% endif %}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 whitespace-nowrap">{{ d.attempted_at[:19] | replace("T", " ") }} UTC</td>
|
||||
<td class="px-4 py-2 text-sm text-red-600 truncate max-w-[250px]" title="{{ d.error_message }}">{{ d.error_message | default("—", true) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-gray-200 rounded-lg p-6 text-center">
|
||||
<p class="text-sm text-gray-500">No deliveries yet. Send a test event or wait for a deployment notification.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Test ─────────────────────────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Testing</h2>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500 mb-3">Send a test <code class="text-xs bg-gray-100 px-1 py-0.5 rounded">release_succeeded</code> event to verify your endpoint is receiving payloads correctly.</p>
|
||||
<form method="POST" action="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}/test">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
|
||||
Send test event
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,75 +0,0 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-50 mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">{{ integration.type_display }} installed</h1>
|
||||
<p class="text-sm text-gray-500 mt-1"><strong>{{ integration.name }}</strong> is now active in {{ current_org }}.</p>
|
||||
</div>
|
||||
|
||||
{# ── API Token (shown once) ───────────────────────────────── #}
|
||||
{% if api_token %}
|
||||
<div class="mb-8 p-5 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-amber-800 mb-1">API Token</h3>
|
||||
<p class="text-sm text-amber-700 mb-3">This token allows the integration to query the Forage API. Copy it now — it won't be shown again.</p>
|
||||
<div class="relative">
|
||||
<code id="api-token" class="block w-full px-3 py-2 text-sm font-mono bg-white border border-amber-300 rounded-md text-gray-900 break-all select-all">{{ api_token }}</code>
|
||||
<button type="button" onclick="copyToken()" id="copy-btn" class="absolute top-1.5 right-1.5 px-2 py-1 text-xs bg-amber-100 text-amber-700 rounded hover:bg-amber-200 transition-colors">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── What's next ──────────────────────────────────────────── #}
|
||||
<div class="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">What's next</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-1.5">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">1.</span>
|
||||
<span>Configure which events trigger notifications on the integration settings page</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">2.</span>
|
||||
<span>Use the API token to query releases, projects, and notifications from your service</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-gray-400 mt-0.5">3.</span>
|
||||
<span>Send a test event to verify your endpoint receives payloads correctly</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations/{{ integration.id }}" class="flex-1 text-center px-4 py-2.5 text-sm font-medium bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors">
|
||||
Configure integration
|
||||
</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="flex-1 text-center px-4 py-2.5 text-sm font-medium border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
|
||||
Back to integrations
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function copyToken() {
|
||||
var token = document.getElementById('api-token').textContent;
|
||||
navigator.clipboard.writeText(token).then(function() {
|
||||
var btn = document.getElementById('copy-btn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,139 +0,0 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold">Integrations</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Connect tools and services to receive deployment notifications from {{ current_org }}.</p>
|
||||
</div>
|
||||
|
||||
{% if error is defined and error %}
|
||||
<div class="mb-6 px-4 py-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Installed integrations ─────────────────────────────────── #}
|
||||
{% if integrations | length > 0 %}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Installed</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for integ in integrations %}
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations/{{ integ.id }}" class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors first:rounded-t-lg last:rounded-b-lg">
|
||||
<div class="flex items-center gap-4">
|
||||
{# Icon #}
|
||||
<div class="w-10 h-10 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
{% if integ.integration_type == "webhook" %}
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{% elif integ.integration_type == "slack" %}
|
||||
<svg class="w-5 h-5 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ integ.name }}</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ 'bg-green-50 text-green-700' if integ.enabled else 'bg-gray-100 text-gray-500' }}">
|
||||
{{ "Active" if integ.enabled else "Paused" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-0.5">
|
||||
{{ integ.type_display }}
|
||||
·
|
||||
{% if integ.enabled_rules == integ.total_rules %}
|
||||
All events
|
||||
{% elif integ.enabled_rules == 0 %}
|
||||
No events
|
||||
{% else %}
|
||||
{{ integ.enabled_rules }}/{{ integ.total_rules }} events
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Available integrations (marketplace) ─────────────────── #}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Available integrations</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{# Webhook #}
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations/install/webhook" class="group border border-gray-200 rounded-lg p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0 group-hover:border-gray-300">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">Webhook</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">Send HTTP POST notifications to any URL when deployments happen. Supports HMAC-SHA256 payload signing and custom headers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# Slack #}
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations/install/slack" class="group border border-gray-200 rounded-lg p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center shrink-0 group-hover:border-gray-300">
|
||||
<svg class="w-6 h-6 text-gray-600" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">Slack</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">Post deployment notifications directly to Slack channels. Rich formatting with release details, status, and quick links.</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# Discord #}
|
||||
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-500">Discord</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-1">Send deployment updates to Discord channels via webhook. Includes embeds with release metadata and status.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Email #}
|
||||
<div class="border border-gray-200 rounded-lg p-5 opacity-60">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-lg border border-gray-200 flex items-center justify-center bg-white shrink-0">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-500">Email</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">Coming soon</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-1">Email notifications for deployment events. Configure recipients and digest frequency per project.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user