Skip to content

Commit e0f5911

Browse files
authored
Merge pull request #71 from bcgov/pr/plg-monitoring-stack
PR 3/4: PLG Monitoring Stack - Metrics, Helm Charts & Promtail Sidecars
2 parents 420830e + 891c8e2 commit e0f5911

94 files changed

Lines changed: 3787 additions & 92 deletions

File tree

Some content is hidden

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

.claude/skills/requirements-refiner/SKILL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ description: "Requirements Refiner: Iteratively questions the user to clarify va
1010
2. **Iterative Elicitation**:
1111
- Identify gaps, ambiguities, or assumptions.
1212
- Ask a set of numbered clarifying questions.
13+
- For **each question**, provide 2–4 concrete answer options labeled (a), (b), (c), etc., and mark one as **"Recommended"** with a brief rationale. The user can pick an option, combine options, or provide their own answer.
1314
- Wait for the user's response.
1415
- Repeat this step until the requirements are clear and complete.
1516
3. **Consolidate**:
@@ -25,5 +26,22 @@ description: "Requirements Refiner: Iteratively questions the user to clarify va
2526
## Key Behaviors
2627
- **Iterative Approach**: Do not rush to the final output. Prioritize clarity over speed.
2728
- **Probe Deeply**: Ask about edge cases, error states, and user roles.
29+
- **Suggest, Don't Just Ask**: Every clarifying question must include concrete options with a recommended choice. Base recommendations on the project context, industry best practices, and the elicitation standards. This helps the user make faster decisions and reduces back-and-forth.
2830
- **Datetime-stamped Folders**: Use the current UTC time in `YYYYMMDDHHmmss` format as the folder prefix.
2931
- **Output Format**: The final output must be saved as `feature-docs/{YYYYMMDDHHmmss}-{feature-slug}/REQUIREMENTS.md`.
32+
33+
## Question Format Example
34+
35+
```
36+
1. **Who should be able to trigger this workflow?**
37+
(a) Only admin users
38+
(b) Any authenticated user
39+
(c) Both authenticated users and external API consumers
40+
→ **Recommended: (b)** — Most workflows in this system are user-initiated; restricting to admins adds friction without clear security benefit.
41+
42+
2. **How should the system handle partial failures?**
43+
(a) Fail the entire operation and roll back
44+
(b) Continue processing remaining items and report failures at the end
45+
(c) Retry failed items up to N times, then report
46+
→ **Recommended: (c)** — Retries with a cap balance reliability with predictable completion times.
47+
```

.github/workflows/build-apps.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,58 @@ jobs:
162162
rm -rf /tmp/.buildx-cache
163163
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
164164
165+
deploy-plg:
166+
name: Deploy PLG Stack
167+
needs: [build-apps, get-environment]
168+
if: ${{ always() && (needs.build-apps.result == 'success' || needs.build-apps.result == 'skipped') }}
169+
runs-on: ubuntu-latest
170+
environment:
171+
name: ${{ needs.get-environment.outputs.environment }}
172+
env:
173+
OPENSHIFT_SERVER: ${{ secrets.OPENSHIFT_SERVER }}
174+
OPENSHIFT_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }}
175+
OPENSHIFT_NAMESPACE: ${{ secrets.OPENSHIFT_NAMESPACE }}
176+
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
177+
steps:
178+
- name: Checkout
179+
uses: actions/checkout@v4
180+
with:
181+
ref: ${{ github.ref }}
182+
183+
- name: Install Helm
184+
uses: azure/setup-helm@v4
185+
with:
186+
version: v3.17.0
187+
188+
- name: Install oc CLI
189+
uses: redhat-actions/openshift-tools-installer@v1
190+
with:
191+
oc: "4"
192+
193+
- name: Login to OpenShift
194+
run: |
195+
oc login "${{ env.OPENSHIFT_SERVER }}" \
196+
--token="${{ env.OPENSHIFT_TOKEN }}" \
197+
--insecure-skip-tls-verify=true
198+
199+
- name: Deploy PLG Helm Chart
200+
run: |
201+
CHART_DIR="deployments/openshift/helm/plg"
202+
203+
# Use environment-specific defaults; secrets override via GitHub environment
204+
GRAFANA_PWD="${{ env.GRAFANA_ADMIN_PASSWORD }}"
205+
if [ -z "${GRAFANA_PWD}" ]; then
206+
GRAFANA_PWD="admin"
207+
fi
208+
209+
helm upgrade --install plg "${CHART_DIR}" \
210+
--namespace "${{ env.OPENSHIFT_NAMESPACE }}" \
211+
-f "${CHART_DIR}/values-openshift.yaml" \
212+
--set "grafana.adminPassword=${GRAFANA_PWD}" \
213+
--wait --timeout 120s
214+
215+
echo "PLG stack deployed successfully."
216+
165217
# trigger_migration:
166218
# needs: [metadata, build-apps]
167219
# if: ${{ always() && needs.build-apps.result == 'success' && needs.metadata.outputs.changed-apps != '[]' && needs.metadata.outputs.changed-apps != '' }}

.vscode/tasks.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,30 @@
149149
"runOn": "folderOpen"
150150
},
151151
"problemMatcher": []
152+
},
153+
{
154+
"label": "monitoring: docker up",
155+
"type": "shell",
156+
"command": "npm",
157+
"args": ["run", "dev:monitoring"],
158+
"options": {
159+
"cwd": "${workspaceFolder}"
160+
},
161+
"problemMatcher": [],
162+
"presentation": {
163+
"panel": "new",
164+
"reveal": "always"
165+
}
166+
},
167+
{
168+
"label": "Dev: all + monitoring",
169+
"dependsOn": [
170+
"Dev: prerequisites",
171+
"monitoring: docker up",
172+
"Dev: runtime"
173+
],
174+
"dependsOrder": "sequence",
175+
"problemMatcher": []
152176
}
153177
]
154178
}

apps/backend-services/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"passport-jwt": "^4.0.1",
6262
"pg": "^8.16.3",
6363
"prisma": "7.2.0",
64+
"prom-client": "^15.1.3",
6465
"rxjs": "^7.8.2",
6566
"uuid": "13.0.0"
6667
},

apps/backend-services/src/api-key/api-key.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ describe("ApiKeyService", () => {
190190

191191
const result = await service.validateApiKey(validKey);
192192

193-
expect(result).toEqual({ groupId: "group-test" });
193+
expect(result).toEqual({ groupId: "group-test", keyPrefix: "testkey1" });
194194
expect(mockApiKeyDbService.findApiKeysByPrefix).toHaveBeenCalledWith(
195195
"testkey1",
196196
);

apps/backend-services/src/api-key/api-key.service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ApiKeyInfoDto,
66
GeneratedApiKeyDto,
77
} from "@/api-key/dto/api-key-info.dto";
8+
import type { ValidatedApiKey } from "@/auth/types";
89
import { AppLoggerService } from "@/logging/app-logger.service";
910
import { ApiKeyDbService } from "./api-key-db.service";
1011

@@ -95,7 +96,7 @@ export class ApiKeyService {
9596
return this.generateApiKey(userId, groupId);
9697
}
9798

98-
async validateApiKey(key: string): Promise<{ groupId: string } | null> {
99+
async validateApiKey(key: string): Promise<ValidatedApiKey | null> {
99100
// Extract prefix from the incoming key for indexed lookup
100101
const prefix = key.substring(0, 8);
101102

@@ -109,7 +110,7 @@ export class ApiKeyService {
109110
// Update last_used timestamp
110111
await this.apiKeyDb.updateApiKeyLastUsed(apiKey.id);
111112

112-
return { groupId: apiKey.group_id };
113+
return { groupId: apiKey.group_id, keyPrefix: apiKey.key_prefix };
113114
}
114115
}
115116

apps/backend-services/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { GroupModule } from "./group/group.module";
1616
import { HitlModule } from "./hitl/hitl.module";
1717
import { LabelingModule } from "./labeling/labeling.module";
1818
import { LoggingModule } from "./logging/logging.module";
19+
import { MetricsModule } from "./metrics/metrics.module";
1920
import { OcrModule } from "./ocr/ocr.module";
2021
import { QueueModule } from "./queue/queue.module";
2122
import { TemporalModule } from "./temporal/temporal.module";
@@ -63,6 +64,7 @@ import { WorkflowModule } from "./workflow/workflow.module";
6364
AzureModule,
6465
BootstrapModule,
6566
GroupModule,
67+
MetricsModule,
6668
],
6769
providers: [
6870
{

apps/backend-services/src/auth/api-key-auth.guard.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Reflector } from "@nestjs/core";
88
import { Test, TestingModule } from "@nestjs/testing";
99
import { ApiKeyService } from "../api-key/api-key.service";
1010
import { ApiKeyAuthGuard } from "./api-key-auth.guard";
11-
import { IdentityOptions } from "./identity.decorator";
1211

1312
describe("ApiKeyAuthGuard", () => {
1413
let guard: ApiKeyAuthGuard;
@@ -80,7 +79,7 @@ describe("ApiKeyAuthGuard", () => {
8079
it("should return true if user is already authenticated", async () => {
8180
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
8281
allowApiKey: true,
83-
} as IdentityOptions);
82+
});
8483

8584
const context = createMockExecutionContext({}, { sub: "testuser" });
8685
const result = await guard.canActivate(context);
@@ -92,7 +91,7 @@ describe("ApiKeyAuthGuard", () => {
9291
it("should throw UnauthorizedException if no API key header and no authenticated user", async () => {
9392
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
9493
allowApiKey: true,
95-
} as IdentityOptions);
94+
});
9695

9796
const context = createMockExecutionContext({});
9897

@@ -105,7 +104,7 @@ describe("ApiKeyAuthGuard", () => {
105104
it("should return true if no API key header but user is already authenticated", async () => {
106105
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
107106
allowApiKey: true,
108-
} as IdentityOptions);
107+
});
109108

110109
const context = createMockExecutionContext({}, { sub: "testuser" });
111110
const result = await guard.canActivate(context);
@@ -117,7 +116,7 @@ describe("ApiKeyAuthGuard", () => {
117116
it("should throw UnauthorizedException for invalid API key", async () => {
118117
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
119118
allowApiKey: true,
120-
} as IdentityOptions);
119+
});
121120
mockApiKeyService.validateApiKey.mockResolvedValue(null);
122121

123122
const context = createMockExecutionContext({ "x-api-key": "invalidkey" });
@@ -128,12 +127,13 @@ describe("ApiKeyAuthGuard", () => {
128127
expect(apiKeyService.validateApiKey).toHaveBeenCalledWith("invalidkey");
129128
});
130129

131-
it("should set apiKeyGroupId for valid API key", async () => {
130+
it("should set apiKey for valid API key", async () => {
132131
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
133132
allowApiKey: true,
134-
} as IdentityOptions);
133+
});
135134
mockApiKeyService.validateApiKey.mockResolvedValue({
136135
groupId: "group-abc",
136+
keyPrefix: "aBcDeFgH",
137137
});
138138

139139
const mockRequest: Record<string, unknown> = {
@@ -152,14 +152,17 @@ describe("ApiKeyAuthGuard", () => {
152152

153153
expect(result).toBe(true);
154154
expect(mockRequest.user).toBeUndefined();
155-
expect(mockRequest.apiKeyGroupId).toBe("group-abc");
155+
expect(mockRequest.apiKey).toEqual({
156+
groupId: "group-abc",
157+
keyPrefix: "aBcDeFgH",
158+
});
156159
});
157160

158161
describe("failed-attempt throttling", () => {
159162
beforeEach(() => {
160163
(reflector.getAllAndOverride as jest.Mock).mockReturnValue({
161164
allowApiKey: true,
162-
} as IdentityOptions);
165+
});
163166
mockApiKeyService.validateApiKey.mockResolvedValue(null);
164167
});
165168

@@ -249,6 +252,7 @@ describe("ApiKeyAuthGuard", () => {
249252
// Successful validation should reset the counter
250253
mockApiKeyService.validateApiKey.mockResolvedValueOnce({
251254
groupId: "group-reset",
255+
keyPrefix: "validkey",
252256
});
253257

254258
const mockRequest = {

apps/backend-services/src/auth/api-key-auth.guard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ export class ApiKeyAuthGuard implements CanActivate, OnModuleDestroy {
9393
// Successful validation — reset failure counter for this IP
9494
this.failedAttempts.delete(clientIp);
9595

96-
// Attach the API key's group_id for use by IdentityGuard and downstream
96+
// Attach the validated API key for use by IdentityGuard and downstream
9797
// service-layer authorization helpers. The key is group-scoped; there is
9898
// no user identity to apply.
99-
request.apiKeyGroupId = keyInfo.groupId;
99+
request.apiKey = keyInfo;
100100

101101
return true;
102102
}

apps/backend-services/src/auth/guard-composition.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ const JWT_ADMIN = {
4747
email: "admin@example.com",
4848
};
4949

50-
const API_KEY_USER = { groupId: "group-user" };
51-
const API_KEY_ADMIN = { groupId: "group-admin" };
50+
const API_KEY_USER = { groupId: "group-user", keyPrefix: "test-pre" };
51+
const API_KEY_ADMIN = { groupId: "group-admin", keyPrefix: "test-pre" };
5252

5353
// ---------------------------------------------------------------------------
5454
// Stub: replaces Passport JWT validation with a simple token check.
@@ -170,7 +170,7 @@ describe("Guard Composition Integration", () => {
170170
jest.clearAllMocks();
171171

172172
mockApiKeyService.validateApiKey.mockImplementation(
173-
(key: string): Promise<{ groupId: string } | null> => {
173+
(key: string): Promise<{ groupId: string; keyPrefix: string } | null> => {
174174
if (key === VALID_API_KEY) return Promise.resolve(API_KEY_USER);
175175
if (key === "valid-admin-api-key")
176176
return Promise.resolve(API_KEY_ADMIN);

0 commit comments

Comments
 (0)