Skip to content
Merged

init #208

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/jobs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ COPY packages/validators/package.json ./packages/validators/package.json
COPY packages/logger/package.json ./packages/logger/package.json
COPY packages/job-dispatch/package.json ./packages/job-dispatch/package.json
COPY packages/secrets/package.json ./packages/secrets/package.json
COPY packages/events/package.json ./packages/events/package.json

COPY apps/jobs/package.json ./apps/jobs/package.json

Expand Down
1 change: 1 addition & 0 deletions apps/jobs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@ctrlplane/db": "workspace:*",
"@ctrlplane/events": "workspace:*",
"@ctrlplane/job-dispatch": "workspace:*",
"@ctrlplane/logger": "workspace:*",
"@ctrlplane/validators": "workspace:*",
Expand Down
52 changes: 11 additions & 41 deletions apps/jobs/src/expired-env-checker/index.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,23 @@
import _ from "lodash";
import { isPresent } from "ts-is-present";

import { eq, inArray, lte } from "@ctrlplane/db";
import { inArray, lte } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import * as SCHEMA from "@ctrlplane/db/schema";
import { logger } from "@ctrlplane/logger";

type QueryRow = {
environment: SCHEMA.Environment;
deployment: SCHEMA.Deployment;
};

const groupByEnvironment = (rows: QueryRow[]) =>
_.chain(rows)
.groupBy((e) => e.environment.id)
.map((env) => ({
...env[0]!.environment,
deployments: env.map((e) => e.deployment),
}))
.value();
import { getEventsForEnvironmentDeleted, handleEvent } from "@ctrlplane/events";

export const run = async () => {
const expiredEnvironments = await db
.select()
.from(SCHEMA.environment)
.innerJoin(
SCHEMA.deployment,
eq(SCHEMA.deployment.systemId, SCHEMA.environment.systemId),
)
.where(lte(SCHEMA.environment.expiresAt, new Date()))
.then(groupByEnvironment);
.where(lte(SCHEMA.environment.expiresAt, new Date()));
if (expiredEnvironments.length === 0) return;
Comment on lines 8 to 11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for database operations.

The database query could fail due to various reasons (connection issues, permissions, etc.). Consider wrapping it in a try-catch block to handle potential errors gracefully.

 export const run = async () => {
+  try {
     const expiredEnvironments = await db
       .select()
       .from(SCHEMA.environment)
       .where(lte(SCHEMA.environment.expiresAt, new Date()));
     if (expiredEnvironments.length === 0) return;
+  } catch (error) {
+    console.error('Failed to fetch expired environments:', error);
+    throw error;
+  }

Committable suggestion skipped: line range outside the PR's diff.


const targetPromises = expiredEnvironments
.filter((env) => isPresent(env.targetFilter))
.map(async (env) => {
const targets = await db
.select()
.from(SCHEMA.target)
.where(SCHEMA.targetMatchesMetadata(db, env.targetFilter));

return { environmentId: env.id, targets };
});
const associatedTargets = await Promise.all(targetPromises);

for (const { environmentId, targets } of associatedTargets)
logger.info(
`[${targets.length}] targets are associated with expired environment [${environmentId}]`,
);
const eventPromises = expiredEnvironments.flatMap(
getEventsForEnvironmentDeleted,
);
const events = (await Promise.allSettled(eventPromises))
.filter((result) => result.status === "fulfilled")
.flatMap((result) => result.value);
const handleEventsPromises = events.map(handleEvent);
await Promise.allSettled(handleEventsPromises);

const envIds = expiredEnvironments.map((env) => env.id);
await db
Expand Down
1 change: 1 addition & 0 deletions apps/webservice/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/logger/package.json ./packages/logger/package.json
COPY packages/secrets/package.json ./packages/secrets/package.json
COPY packages/events/package.json ./packages/events/package.json

COPY apps/webservice/package.json ./apps/webservice/package.json

Expand All @@ -58,7 +59,7 @@
EXPOSE 3000

ENV PORT=3000
ENV AUTH_TRUST_HOST=true

Check warning on line 62 in apps/webservice/Dockerfile

View workflow job for this annotation

GitHub Actions / build (linux/amd64)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_TRUST_HOST") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0

Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"@ctrlplane/auth": "workspace:*",
"@ctrlplane/db": "workspace:*",
"@ctrlplane/events": "workspace:*",
"@ctrlplane/job-dispatch": "workspace:*",
"@ctrlplane/logger": "workspace:*",
"@ctrlplane/secrets": "workspace:*",
Expand Down
16 changes: 12 additions & 4 deletions packages/api/src/router/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
updateReleaseChannel,
workspace,
} from "@ctrlplane/db/schema";
import { getEventsForDeploymentDeleted, handleEvent } from "@ctrlplane/events";
import { Permission } from "@ctrlplane/validators/auth";
import { JobStatus } from "@ctrlplane/validators/jobs";

Expand Down Expand Up @@ -260,13 +261,20 @@ export const deploymentRouter = createTRPCRouter({
.on({ type: "deployment", id: input }),
})
.input(z.string().uuid())
.mutation(({ ctx, input }) =>
ctx.db
.mutation(async ({ ctx, input }) => {
const dep = await ctx.db
.select()
.from(deployment)
.where(eq(deployment.id, input))
.then(takeFirst);
const events = await getEventsForDeploymentDeleted(dep);
await Promise.allSettled(events.map(handleEvent));
return ctx.db
.delete(deployment)
.where(eq(deployment.id, input))
.returning()
.then(takeFirst),
),
.then(takeFirst);
}),
Comment on lines +264 to +277
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add transaction handling to ensure data consistency

The current implementation could lead to inconsistent state if event processing fails after fetching the deployment but before deletion. Consider wrapping the operations in a transaction.

Here's a suggested implementation:

 .mutation(async ({ ctx, input }) => {
+  return ctx.db.transaction(async (tx) => {
     const dep = await ctx.db
       .select()
       .from(deployment)
       .where(eq(deployment.id, input))
       .then(takeFirst);
     const events = await getEventsForDeploymentDeleted(dep);
     await Promise.allSettled(events.map(handleEvent));
     return ctx.db
       .delete(deployment)
       .where(eq(deployment.id, input))
       .returning()
       .then(takeFirst);
+  });
 }),

Committable suggestion skipped: line range outside the PR's diff.


byId: protectedProcedure
.input(z.string().uuid())
Expand Down
19 changes: 12 additions & 7 deletions packages/api/src/router/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
targetMatchesMetadata,
updateEnvironment,
} from "@ctrlplane/db/schema";
import { getEventsForEnvironmentDeleted, handleEvent } from "@ctrlplane/events";
import { dispatchJobsForNewTargets } from "@ctrlplane/job-dispatch";
import { Permission } from "@ctrlplane/validators/auth";

Expand Down Expand Up @@ -322,12 +323,16 @@ export const environmentRouter = createTRPCRouter({
})
.input(z.string().uuid())
.mutation(({ ctx, input }) =>
ctx.db.transaction((db) =>
db
.delete(environment)
.where(eq(environment.id, input))
.returning()
.then(takeFirst),
),
ctx.db
.delete(environment)
.where(eq(environment.id, input))
.returning()
.then(takeFirst)
.then(async (env) => {
const events = await getEventsForEnvironmentDeleted(env);
const handleEventPromises = events.map(handleEvent);
await Promise.allSettled(handleEventPromises);
return env;
}),
Comment on lines +326 to +336
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider improving error handling and transaction safety.

The current implementation has several areas for improvement:

  1. Error handling is not explicit for failed event processing. While Promise.allSettled ensures all promises complete, it silently handles failures.
  2. Removing the transaction wrapper could lead to inconsistent state if event handling fails after the deletion succeeds.
  3. The sequential nature of operations could impact performance.

Consider refactoring to:

 delete: protectedProcedure
   .meta({
     authorizationCheck: ({ canUser, input }) =>
       canUser
         .perform(Permission.SystemDelete)
         .on({ type: "environment", id: input }),
   })
   .input(z.string().uuid())
   .mutation(({ ctx, input }) =>
-    ctx.db
-      .delete(environment)
-      .where(eq(environment.id, input))
-      .returning()
-      .then(takeFirst)
-      .then(async (env) => {
-        const events = await getEventsForEnvironmentDeleted(env);
-        const handleEventPromises = events.map(handleEvent);
-        await Promise.allSettled(handleEventPromises);
-        return env;
-      }),
+    ctx.db.transaction(async (tx) => {
+      const env = await tx
+        .delete(environment)
+        .where(eq(environment.id, input))
+        .returning()
+        .then(takeFirst);
+      
+      const events = await getEventsForEnvironmentDeleted(env);
+      const results = await Promise.allSettled(events.map(handleEvent));
+      
+      // Check for any failed event handling
+      const failures = results
+        .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
+        .map(r => r.reason);
+      
+      if (failures.length > 0) {
+        console.error('Failed to process some events:', failures);
+        throw new Error('Failed to process all events after environment deletion');
+      }
+      
+      return env;
+    }),

This refactoring:

  1. Restores transaction safety
  2. Adds explicit error handling
  3. Logs failed events for debugging
  4. Maintains atomicity of the operation
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ctx.db
.delete(environment)
.where(eq(environment.id, input))
.returning()
.then(takeFirst)
.then(async (env) => {
const events = await getEventsForEnvironmentDeleted(env);
const handleEventPromises = events.map(handleEvent);
await Promise.allSettled(handleEventPromises);
return env;
}),
ctx.db.transaction(async (tx) => {
const env = await tx
.delete(environment)
.where(eq(environment.id, input))
.returning()
.then(takeFirst);
const events = await getEventsForEnvironmentDeleted(env);
const results = await Promise.allSettled(events.map(handleEvent));
// Check for any failed event handling
const failures = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => r.reason);
if (failures.length > 0) {
console.error('Failed to process some events:', failures);
throw new Error('Failed to process all events after environment deletion');
}
return env;
}),

),
});
17 changes: 13 additions & 4 deletions packages/api/src/router/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
takeFirstOrNull,
} from "@ctrlplane/db";
import * as schema from "@ctrlplane/db/schema";
import { getEventsForTargetDeleted, handleEvent } from "@ctrlplane/events";
import {
cancelOldReleaseJobTriggersOnJobDispatch,
createJobApprovals,
Expand Down Expand Up @@ -477,12 +478,20 @@ export const targetRouter = createTRPCRouter({
),
})
.input(z.array(z.string().uuid()))
.mutation(({ ctx, input }) =>
ctx.db
.mutation(async ({ ctx, input }) => {
const targets = await ctx.db.query.target.findMany({
where: inArray(schema.target.id, input),
});
const events = (
await Promise.allSettled(targets.map(getEventsForTargetDeleted))
).flatMap((r) => (r.status === "fulfilled" ? r.value : []));
await Promise.allSettled(events.map(handleEvent));

return ctx.db
.delete(schema.target)
.where(inArray(schema.target.id, input))
.returning(),
),
.returning();
}),
Comment on lines +481 to +494
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure proper error handling when processing events during target deletion

The current implementation uses Promise.allSettled and ignores rejected promises when generating and handling events for target deletion. This could lead to silent failures, causing inconsistent state or missed critical actions.

Consider explicitly handling rejected promises to ensure failures are not silently ignored. Here's how you might modify the code:

        .mutation(async ({ ctx, input }) => {
          const targets = await ctx.db.query.target.findMany({
            where: inArray(schema.target.id, input),
          });
-         const events = (
-           await Promise.allSettled(targets.map(getEventsForTargetDeleted))
-         ).flatMap((r) => (r.status === "fulfilled" ? r.value : []));
+         const events = await Promise.all(
+           targets.map(async (target) => {
+             try {
+               return await getEventsForTargetDeleted(target);
+             } catch (error) {
+               // Handle or log the error appropriately
+               console.error(`Failed to get events for target ${target.id}:`, error);
+               throw error;
+             }
+           })
+         );

-         await Promise.allSettled(events.map(handleEvent));
+         await Promise.all(
+           events.map(async (event) => {
+             try {
+               await handleEvent(event);
+             } catch (error) {
+               // Handle or log the error appropriately
+               console.error(`Failed to handle event for target deletion:`, error);
+               throw error;
+             }
+           })
+         );

          return ctx.db
            .delete(schema.target)
            .where(inArray(schema.target.id, input))
            .returning();
        }),

This approach ensures that any errors during event generation or handling are caught and can be handled appropriately, possibly aborting the deletion if necessary.

Committable suggestion skipped: line range outside the PR's diff.


🛠️ Refactor suggestion

Wrap database operations in a transaction for consistency

The deletion process includes multiple database operations: fetching targets, handling events, and deleting targets. To maintain data consistency, especially in cases where an error might occur during event handling, it's advisable to wrap these operations in a transaction.

Consider modifying the code to use a transaction:

        .mutation(async ({ ctx, input }) => {
+         return ctx.db.transaction(async (tx) => {
            const targets = await tx.query.target.findMany({
              where: inArray(schema.target.id, input),
            });
            const events = await Promise.all(
              targets.map(async (target) => {
                // Error handling as discussed previously
                return await getEventsForTargetDeleted(target);
              })
            );
            await Promise.all(
              events.map(async (event) => {
                // Error handling as discussed previously
                await handleEvent(event);
              })
            );

            return tx
              .delete(schema.target)
              .where(inArray(schema.target.id, input))
              .returning();
+         });
        }),

Wrapping these operations in a transaction ensures that if any part of the process fails, all changes can be rolled back, maintaining the integrity of your data.

Committable suggestion skipped: line range outside the PR's diff.


metadataKeys: protectedProcedure
.meta({
Expand Down
32 changes: 32 additions & 0 deletions packages/db/drizzle/0035_moaning_supernaut.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS "event" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"action" text NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "hook" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"action" text NOT NULL,
"name" text NOT NULL,
"scope_type" text NOT NULL,
"scope_id" uuid NOT NULL
);
Comment on lines +8 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance data integrity and query performance

Consider the following improvements:

  1. Add a composite index for scope-based queries
  2. Add a CHECK constraint for valid scope_type values
  3. Consider adding a unique constraint on (name, scope_type, scope_id)
CREATE INDEX IF NOT EXISTS idx_hook_scope ON hook(scope_type, scope_id);
ALTER TABLE hook ADD CONSTRAINT chk_hook_scope_type 
  CHECK (scope_type IN ('environment', 'deployment', 'target'));
ALTER TABLE hook ADD CONSTRAINT unq_hook_name_scope 
  UNIQUE (name, scope_type, scope_id);

--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "runhook" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"hook_id" uuid NOT NULL,
"runbook_id" uuid NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "runhook" ADD CONSTRAINT "runhook_hook_id_hook_id_fk" FOREIGN KEY ("hook_id") REFERENCES "public"."hook"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "runhook" ADD CONSTRAINT "runhook_runbook_id_runbook_id_fk" FOREIGN KEY ("runbook_id") REFERENCES "public"."runbook"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
Loading
Loading