-
Notifications
You must be signed in to change notification settings - Fork 85
feat(deployment): add shell-exec endpoint for synchronous command execution #3097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| import { Err, Ok } from "ts-results"; | ||
| import { container } from "tsyringe"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { mock } from "vitest-mock-extended"; | ||
|
|
||
| import { AuthService } from "@src/auth/services/auth.service"; | ||
| import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; | ||
| import type { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service"; | ||
| import type { ShellExecService } from "@src/deployment/services/shell-exec/shell-exec.service"; | ||
| import type { ProviderService } from "@src/provider/services/provider/provider.service"; | ||
| import { ShellExecController } from "./shell-exec.controller"; | ||
|
|
||
| import { createUser } from "@test/seeders/user.seeder"; | ||
|
|
||
| describe(ShellExecController.name, () => { | ||
| it("throws 404 when deployment not found", async () => { | ||
| const { controller, deploymentReaderService } = setup(); | ||
| deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(undefined as never); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(404); | ||
| expect(error.message).toBe("Deployment not found"); | ||
| }); | ||
|
|
||
| it("throws 404 when no lease matches the provided gseq and oseq", async () => { | ||
| const { controller } = setup(); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 99, oseq: 99, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(404); | ||
| expect(error.message).toBe("Lease not found"); | ||
| }); | ||
|
|
||
| it("throws 500 when lease provider address is an empty string", async () => { | ||
| const { controller } = setup({ provider: "" }); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(500); | ||
| expect(error.message).toBe("Lease provider address not found"); | ||
| }); | ||
|
|
||
| it("throws 400 when lease state is not active", async () => { | ||
| const { controller } = setup({ state: "closed" }); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(400); | ||
| expect(error.message).toBe("Lease is not active"); | ||
| }); | ||
|
|
||
| it("throws 502 when shell exec service returns an error result", async () => { | ||
| const { controller, shellExecService } = setup(); | ||
| shellExecService.execute.mockResolvedValue(Err("Command timed out")); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(502); | ||
| expect(error.message).toBe("Command execution timed out"); | ||
| }); | ||
|
|
||
| it("returns the shell exec result on successful execution", async () => { | ||
| const { controller, shellExecService } = setup(); | ||
|
|
||
| const result = await controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }); | ||
|
|
||
| expect(result).toEqual({ stdout: "output", stderr: "", exitCode: 0, truncated: false }); | ||
| expect(shellExecService.execute).toHaveBeenCalledWith({ | ||
| providerBaseUrl: "https://provider.example.com", | ||
| providerAddress: "akash1provider", | ||
| dseq: "1234", | ||
| gseq: 1, | ||
| oseq: 1, | ||
| service: "web", | ||
| command: "ls", | ||
| timeout: 60, | ||
| jwtToken: "test-token" | ||
| }); | ||
| }); | ||
|
|
||
| it("throws 404 when provider info lookup returns null", async () => { | ||
| const { controller, providerService } = setup(); | ||
| providerService.getProvider.mockResolvedValue(null as never); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(404); | ||
| expect(error.message).toBe("Provider not found"); | ||
| }); | ||
|
|
||
| it("throws 404 when deployment has an empty leases array", async () => { | ||
| const { controller, deploymentReaderService, deployment } = setup(); | ||
| deploymentReaderService.findByUserIdAndDseq.mockResolvedValue({ ...deployment, leases: [] } as never); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(404); | ||
| expect(error.message).toBe("Lease not found"); | ||
| }); | ||
|
|
||
| it("finds the correct lease among multiple leases by gseq and oseq", async () => { | ||
| const { controller, deploymentReaderService, deployment, shellExecService } = setup(); | ||
|
|
||
| const multiLeaseDeployment = { | ||
| ...deployment, | ||
| leases: [ | ||
| { ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 1, oseq: 1 }, state: "active" }, | ||
| { ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 2, oseq: 1, provider: "akash1provider2" }, state: "active" }, | ||
| { ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 1, oseq: 2, provider: "akash1provider3" }, state: "active" } | ||
| ] | ||
| }; | ||
| deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(multiLeaseDeployment as never); | ||
|
|
||
| const result = await controller.exec({ dseq: "1234", gseq: 2, oseq: 1, command: "ls", service: "web", timeout: 60 }); | ||
|
|
||
| expect(result).toEqual({ stdout: "output", stderr: "", exitCode: 0, truncated: false }); | ||
| expect(shellExecService.execute).toHaveBeenCalledWith(expect.objectContaining({ providerAddress: "akash1provider2" })); | ||
| }); | ||
|
|
||
| it("throws 400 when deployment state is closed", async () => { | ||
| const { controller, deploymentReaderService, deployment } = setup(); | ||
| deploymentReaderService.findByUserIdAndDseq.mockResolvedValue({ ...deployment, deployment: { ...deployment.deployment, state: "closed" } } as never); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(400); | ||
| expect(error.message).toBe("Deployment is not active"); | ||
| }); | ||
|
|
||
| it("throws 502 with stable message when WS connection fails", async () => { | ||
| const { controller, shellExecService } = setup(); | ||
| shellExecService.execute.mockResolvedValue(Err("WebSocket connection failed: ECONNREFUSED")); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(502); | ||
| expect(error.message).toBe("Failed to connect to provider"); | ||
| }); | ||
|
|
||
| it("throws 502 with stable message when provider returns error", async () => { | ||
| const { controller, shellExecService } = setup(); | ||
| shellExecService.execute.mockResolvedValue(Err("Provider error: internal error")); | ||
|
|
||
| const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 })); | ||
|
|
||
| expect(error.status).toBe(502); | ||
| expect(error.message).toBe("Provider returned an error"); | ||
| }); | ||
|
|
||
| async function captureError(fn: () => Promise<any>): Promise<any> { | ||
| try { | ||
| await fn(); | ||
| throw new Error("Expected function to throw"); | ||
| } catch (error) { | ||
| return error; | ||
| } | ||
| } | ||
|
|
||
| function setup(overrides?: { provider?: string; state?: string }) { | ||
| const user = createUser(); | ||
| const deploymentReaderService = mock<DeploymentReaderService>(); | ||
| const providerService = mock<ProviderService>(); | ||
| const shellExecService = mock<ShellExecService>(); | ||
| const authService = mock<AuthService>({ currentUser: user }); | ||
| const walletReaderService = mock<WalletReaderService>(); | ||
|
|
||
| container.register(AuthService, { useValue: authService }); | ||
|
|
||
| const controller = new ShellExecController(deploymentReaderService, providerService, shellExecService, authService, walletReaderService); | ||
|
|
||
| const provider = overrides?.provider ?? "akash1provider"; | ||
| const state = overrides?.state ?? "active"; | ||
|
|
||
| const deployment = { | ||
| deployment: { | ||
| id: { owner: "akash1owner", dseq: "1234" }, | ||
| state: "active", | ||
| hash: "abc123", | ||
| created_at: "12345" | ||
| }, | ||
| leases: [ | ||
| { | ||
| id: { owner: "akash1owner", dseq: "1234", gseq: 1, oseq: 1, provider, bseq: 0 }, | ||
| state, | ||
| price: { denom: "uakt", amount: "100" }, | ||
| created_at: "12345", | ||
| closed_on: "0", | ||
| status: null | ||
| } | ||
| ], | ||
| escrow_account: { | ||
| id: { scope: "deployment", xid: "1234" }, | ||
| state: { | ||
| owner: "akash1owner", | ||
| state: "open", | ||
| transferred: [], | ||
| settled_at: "12345", | ||
| funds: [{ denom: "uakt", amount: "1000" }], | ||
| deposits: [] | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(deployment as never); | ||
| walletReaderService.getWalletByUserId.mockResolvedValue({ id: 1, address: "akash1wallet" } as never); | ||
| providerService.toProviderAuth.mockResolvedValue({ type: "jwt" as const, token: "test-token" }); | ||
| providerService.getProvider.mockResolvedValue({ hostUri: "https://provider.example.com" } as never); | ||
| shellExecService.execute.mockResolvedValue(new Ok({ stdout: "output", stderr: "", exitCode: 0, truncated: false })); | ||
|
|
||
| return { controller, deploymentReaderService, providerService, shellExecService, authService, walletReaderService, user, deployment }; | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,71 @@ | ||||||||||||||||||
| import assert from "http-assert"; | ||||||||||||||||||
| import { singleton } from "tsyringe"; | ||||||||||||||||||
|
|
||||||||||||||||||
| import { AuthService, Protected } from "@src/auth/services/auth.service"; | ||||||||||||||||||
| import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; | ||||||||||||||||||
| import { ShellExecRequest, ShellExecResponse } from "@src/deployment/http-schemas/shell-exec.schema"; | ||||||||||||||||||
| import { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service"; | ||||||||||||||||||
| import { ShellExecService } from "@src/deployment/services/shell-exec/shell-exec.service"; | ||||||||||||||||||
| import { ProviderService } from "@src/provider/services/provider/provider.service"; | ||||||||||||||||||
|
|
||||||||||||||||||
| @singleton() | ||||||||||||||||||
| export class ShellExecController { | ||||||||||||||||||
| constructor( | ||||||||||||||||||
| private readonly deploymentReaderService: DeploymentReaderService, | ||||||||||||||||||
| private readonly providerService: ProviderService, | ||||||||||||||||||
| private readonly shellExecService: ShellExecService, | ||||||||||||||||||
| private readonly authService: AuthService, | ||||||||||||||||||
| private readonly walletReaderService: WalletReaderService | ||||||||||||||||||
| ) {} | ||||||||||||||||||
|
|
||||||||||||||||||
| @Protected([{ action: "read", subject: "Lease" }]) | ||||||||||||||||||
| async exec(input: ShellExecRequest & { dseq: string; gseq: number; oseq: number }): Promise<ShellExecResponse> { | ||||||||||||||||||
| const userId = this.authService.currentUser.id; | ||||||||||||||||||
|
|
||||||||||||||||||
| const deployment = await this.deploymentReaderService.findByUserIdAndDseq(userId, input.dseq); | ||||||||||||||||||
|
|
||||||||||||||||||
| assert(deployment, 404, "Deployment not found"); | ||||||||||||||||||
| assert(deployment.deployment.state === "active", 400, "Deployment is not active"); | ||||||||||||||||||
|
|
||||||||||||||||||
| const lease = deployment.leases.find(l => l.id.gseq === input.gseq && l.id.oseq === input.oseq); | ||||||||||||||||||
|
|
||||||||||||||||||
| assert(lease, 404, "Lease not found"); | ||||||||||||||||||
| assert(lease.id.provider, 500, "Lease provider address not found"); | ||||||||||||||||||
| assert(lease.state === "active", 400, "Lease is not active"); | ||||||||||||||||||
|
|
||||||||||||||||||
| const providerAddress = lease.id.provider; | ||||||||||||||||||
|
|
||||||||||||||||||
| const wallet = await this.walletReaderService.getWalletByUserId(userId); | ||||||||||||||||||
|
|
||||||||||||||||||
| const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]); | ||||||||||||||||||
|
|
||||||||||||||||||
| const providerInfo = await this.providerService.getProvider(providerAddress); | ||||||||||||||||||
|
|
||||||||||||||||||
| assert(providerInfo, 404, "Provider not found"); | ||||||||||||||||||
|
Comment on lines
+30
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing deployment-state check + wasted JWT minting before provider lookup.
Proposed diff- assert(lease, 404, "Lease not found");
- assert(lease.id.provider, 500, "Lease provider address not found");
- assert(lease.state === "active", 400, "Lease is not active");
-
- const providerAddress = lease.id.provider;
-
- const wallet = await this.walletReaderService.getWalletByUserId(userId);
-
- const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]);
-
- const providerInfo = await this.providerService.getProvider(providerAddress);
-
- assert(providerInfo, 404, "Provider not found");
+ assert(lease, 404, "Lease not found");
+ assert(lease.id.provider, 500, "Lease provider address not found");
+ assert(lease.state === "active", 400, "Lease is not active");
+
+ const providerAddress = lease.id.provider;
+ const providerInfo = await this.providerService.getProvider(providerAddress);
+ assert(providerInfo, 404, "Provider not found");
+
+ const wallet = await this.walletReaderService.getWalletByUserId(userId);
+ const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]);🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| const result = await this.shellExecService.execute({ | ||||||||||||||||||
| providerBaseUrl: providerInfo.hostUri, | ||||||||||||||||||
| providerAddress: providerAddress, | ||||||||||||||||||
| dseq: input.dseq, | ||||||||||||||||||
| gseq: input.gseq, | ||||||||||||||||||
| oseq: input.oseq, | ||||||||||||||||||
| service: input.service, | ||||||||||||||||||
| command: input.command, | ||||||||||||||||||
| timeout: input.timeout, | ||||||||||||||||||
| jwtToken: auth.token | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!result.ok) { | ||||||||||||||||||
| const message = result.val.startsWith("Command timed out") | ||||||||||||||||||
| ? "Command execution timed out" | ||||||||||||||||||
| : result.val.startsWith("WebSocket connection failed") | ||||||||||||||||||
| ? "Failed to connect to provider" | ||||||||||||||||||
| : result.val.startsWith("Provider error") | ||||||||||||||||||
| ? "Provider returned an error" | ||||||||||||||||||
| : "Shell execution failed"; | ||||||||||||||||||
| assert(false, 502, message); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+58
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Proposed diff- if (!result.ok) {
- assert(false, 502, result.val);
- }
+ if (!result.ok) {
+ this.logger.warn({ event: "shell-exec-failed", reason: result.val, providerAddress, dseq: input.dseq });
+ const publicMessage = result.val === "Command timed out" ? "Command timed out" : "Shell execution failed";
+ assert(false, 502, publicMessage);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| return result.val; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export const ShellExecParamsSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||
| dseq: z.string(), | ||||||||||||||||||||||||||||||||||||||||||||||
| gseq: z.coerce.number().int().nonnegative(), | ||||||||||||||||||||||||||||||||||||||||||||||
| oseq: z.coerce.number().int().nonnegative() | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export const ShellExecRequestSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||
| command: z.string().min(1).max(4096), | ||||||||||||||||||||||||||||||||||||||||||||||
| service: z.string().min(1).max(253), | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout: z.number().int().min(1).max(120).default(60) | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Citations:
🏁 Script executed: # Find where ShellExecParamsSchema is used
fd -t f "\.ts$" apps/api | xargs rg "ShellExecParamsSchema" -lRepository: akash-network/console Length of output: 185 🏁 Script executed: # Search for route definitions using ShellExecParamsSchema
rg "ShellExecParamsSchema" -A 5 -B 5Repository: akash-network/console Length of output: 3782 🏁 Script executed: # Find shell-exec related service code and buildShellUrl
rg "buildShellUrl|ShellExec" -t ts --max-count 20Repository: akash-network/console Length of output: 6628 🏁 Script executed: # Check test files for shell-exec
fd -t f -i "shell.*exec.*test\|test.*shell.*exec" apps/Repository: akash-network/console Length of output: 47 🏁 Script executed: # Look for schema patterns in the codebase for similar validation
rg "z\.string\(\)\.regex" apps/api/src --max-count 10Repository: akash-network/console Length of output: 399 🏁 Script executed: # Read the shell-exec service test file to find references to dseq: "foo/bar/baz"
cat -n apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.ts | head -150Repository: akash-network/console Length of output: 6273 🏁 Script executed: # Read the buildShellUrl implementation in the service
cat -n apps/api/src/deployment/services/shell-exec/shell-exec.service.tsRepository: akash-network/console Length of output: 8112 🏁 Script executed: # Check how dseq, gseq, oseq are used in the controller
cat -n apps/api/src/deployment/controllers/shell-exec/shell-exec.controller.tsRepository: akash-network/console Length of output: 2984 🏁 Script executed: # Check if there's a test with dseq: "foo/bar/baz" (continuation of spec file)
tail -100 apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.tsRepository: akash-network/console Length of output: 3490 🏁 Script executed: # Look for the createShellExecInput helper function
rg "createShellExecInput" -A 10 apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.tsRepository: akash-network/console Length of output: 23237 🏁 Script executed: # Verify how the router passes validated params to controller
cat -n apps/api/src/deployment/routes/shell-exec/shell-exec.router.ts | grep -A 3 "routeShellExec"Repository: akash-network/console Length of output: 334 Path parameters must use
Proposed diff export const ShellExecParamsSchema = z.object({
- dseq: z.string(),
- gseq: z.number(),
- oseq: z.number()
+ dseq: z.string().regex(/^\d+$/, "dseq must be numeric"),
+ gseq: z.coerce.number().int().nonnegative(),
+ oseq: z.coerce.number().int().nonnegative()
});
export const ShellExecRequestSchema = z.object({
- command: z.string().min(1),
+ command: z.string().min(1).max(4096),
- service: z.string().min(1),
+ service: z.string().min(1).max(253),
- timeout: z.number().min(1).max(120).default(60)
+ timeout: z.number().int().min(1).max(120).default(60)
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export const ShellExecResponseSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||
| stdout: z.string(), | ||||||||||||||||||||||||||||||||||||||||||||||
| stderr: z.string(), | ||||||||||||||||||||||||||||||||||||||||||||||
| exitCode: z.number(), | ||||||||||||||||||||||||||||||||||||||||||||||
| truncated: z.boolean() | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export type ShellExecParams = z.infer<typeof ShellExecParamsSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||
| export type ShellExecRequest = z.infer<typeof ShellExecRequestSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||
| export type ShellExecResponse = z.infer<typeof ShellExecResponseSchema>; | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { container } from "tsyringe"; | ||
|
|
||
| import { createRoute } from "@src/core/lib/create-route/create-route"; | ||
| import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; | ||
| import { SECURITY_BEARER_OR_API_KEY } from "@src/core/services/openapi-docs/openapi-security"; | ||
| import { ShellExecController } from "@src/deployment/controllers/shell-exec/shell-exec.controller"; | ||
| import { ShellExecParamsSchema, ShellExecRequestSchema, ShellExecResponseSchema } from "@src/deployment/http-schemas/shell-exec.schema"; | ||
|
|
||
| export const shellExecRouter = new OpenApiHonoHandler(); | ||
|
|
||
| const shellExecRoute = createRoute({ | ||
| method: "post", | ||
| path: "/v1/deployments/{dseq}/leases/{gseq}/{oseq}/shell-exec", | ||
| summary: "Execute a shell command in a deployment container", | ||
| tags: ["Shell Exec"], | ||
| security: SECURITY_BEARER_OR_API_KEY, | ||
| request: { | ||
| params: ShellExecParamsSchema, | ||
| body: { | ||
| content: { | ||
| "application/json": { | ||
| schema: ShellExecRequestSchema | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| responses: { | ||
| 200: { | ||
| description: "Command executed successfully", | ||
| content: { | ||
| "application/json": { | ||
| schema: ShellExecResponseSchema | ||
| } | ||
| } | ||
| }, | ||
| 400: { | ||
| description: "Invalid request (e.g., lease not active)" | ||
| }, | ||
| 401: { | ||
| description: "Unauthorized" | ||
| }, | ||
| 403: { | ||
| description: "Forbidden - user does not own this deployment" | ||
| }, | ||
| 404: { | ||
| description: "Deployment or lease not found" | ||
| }, | ||
| 502: { | ||
| description: "Provider proxy error" | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| shellExecRouter.openapi(shellExecRoute, async function routeShellExec(c) { | ||
| const params = c.req.valid("param"); | ||
| const body = c.req.valid("json"); | ||
| const result = await container.resolve(ShellExecController).exec({ ...params, ...body }); | ||
| return c.json(result, 200); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Avoid
as never/as anycasts in mocks — breaks the type safety contract.The
mockResolvedValue(... as never)pattern hides real shape mismatches. Prefer typed factory helpers (e.g., acreateDeployment()seeder returning the correctGetDeploymentResponse["data"]shape) so a future change inDeploymentReaderService.findByUserIdAndDseqreturn type fails the test loudly. Same forwalletReaderService.getWalletByUserIdandproviderService.getProvider.As per coding guidelines: "Never use type
anyor cast to typeany. Always define the proper TypeScript types."Also applies to: 94-94, 175-179