feat: Digital Ocean App connection and App Platform secret sync (#4203)

* fix: save wip

* feat: final impl

* feat: docs

* Update backend/src/services/app-connection/digital-ocean/digital-ocean-connection-service.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: remove empty conflict files

* Update backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/schemas/digital-ocean-app-platform-sync-destination-schema.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/schemas/digital-ocean-app-platform-sync-destination-schema.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/DigitalOceanAppPlatformSyncFields.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: lint

* fix: api client

* fix: lint and types

* fix: typecheck lint

* fix: docs

* fix: docs

* fix: linting

---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Sid
2025-07-23 19:59:29 +05:30
committed by GitHub
co-authored by greptile-apps[bot]
parent 95b997c100
commit ab56a69d59
90 changed files with 1363 additions and 8 deletions
@@ -0,0 +1,3 @@
export enum DigitalOceanConnectionMethod {
ApiToken = "api-token"
}
@@ -0,0 +1,37 @@
/* eslint-disable no-await-in-loop */
import { AxiosError } from "axios";
import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { DigitalOceanConnectionMethod } from "./digital-ocean-connection-constants";
import { DigitalOceanAppPlatformPublicAPI } from "./digital-ocean-connection-public-client";
import { DigitalOceanConnectionListItemSchema } from "./digital-ocean-connection-schemas";
import { TDigitalOceanConnectionConfig } from "./digital-ocean-connection-types";
export const getDigitalOceanConnectionListItem = () => {
return {
name: "Digital Ocean" as z.infer<typeof DigitalOceanConnectionListItemSchema>["name"],
app: AppConnection.DigitalOcean as const,
methods: Object.values(DigitalOceanConnectionMethod)
};
};
export const validateDigitalOceanConnectionCredentials = async (config: TDigitalOceanConnectionConfig) => {
try {
await DigitalOceanAppPlatformPublicAPI.healthcheck(config);
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection - verify credentials"
});
}
return config.credentials;
};
@@ -0,0 +1,105 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable class-methods-use-this */
import { AxiosInstance } from "axios";
import { createRequestClient } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { DigitalOceanConnectionMethod } from "./digital-ocean-connection-constants";
import {
TDigitalOceanApp,
TDigitalOceanConnectionConfig,
TDigitalOceanVariable
} from "./digital-ocean-connection-types";
class DigitalOceanAppPlatformPublicClient {
private readonly client: AxiosInstance;
constructor() {
this.client = createRequestClient({
baseURL: `${IntegrationUrls.DIGITAL_OCEAN_API_URL}/v2`,
headers: {
"Content-Type": "application/json",
Accept: "application/json"
}
});
}
async healthcheck(connection: TDigitalOceanConnectionConfig) {
switch (connection.method) {
case DigitalOceanConnectionMethod.ApiToken:
await this.getApps(connection);
break;
default:
throw new Error(`Unsupported connection method`);
}
}
async getApps(connection: TDigitalOceanConnectionConfig) {
const response = await this.client.get<{ apps: TDigitalOceanApp[] }>(`/apps`, {
headers: {
Authorization: `Bearer ${connection.credentials.apiToken}`
}
});
return response.data.apps;
}
async getApp(connection: TDigitalOceanConnectionConfig, appId: string) {
const response = await this.client.get<{ app: TDigitalOceanApp }>(`/apps/${appId}`, {
headers: {
Authorization: `Bearer ${connection.credentials.apiToken}`
}
});
return response.data.app;
}
async getVariables(connection: TDigitalOceanConnectionConfig, appId: string): Promise<TDigitalOceanVariable[]> {
const app = await this.getApp(connection, appId);
return app.spec.envs || [];
}
async putVariables(connection: TDigitalOceanConnectionConfig, appId: string, ...input: TDigitalOceanVariable[]) {
const response = await this.getApp(connection, appId);
return this.client.put(
`/apps/${appId}`,
{
spec: {
...response.spec,
envs: input
}
},
{
headers: {
Authorization: `Bearer ${connection.credentials.apiToken}`
}
}
);
}
async deleteVariables(connection: TDigitalOceanConnectionConfig, appId: string, ...input: TDigitalOceanVariable[]) {
const response = await this.getApp(connection, appId);
const existing = response.spec.envs || [];
const variables = existing.filter((v) => input.find((i) => i.key === v.key));
return this.client.put(
`/apps/${appId}`,
{
spec: {
...response.spec,
envs: variables
}
},
{
headers: {
Authorization: `Bearer ${connection.credentials.apiToken}`
}
}
);
}
}
export const DigitalOceanAppPlatformPublicAPI = new DigitalOceanAppPlatformPublicClient();
@@ -0,0 +1,67 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { DigitalOceanConnectionMethod } from "./digital-ocean-connection-constants";
export const DigitalOceanConnectionMethodSchema = z
.nativeEnum(DigitalOceanConnectionMethod)
.describe(AppConnections.CREATE(AppConnection.DigitalOcean).method);
export const DigitalOceanConnectionAccessTokenCredentialsSchema = z.object({
apiToken: z
.string()
.trim()
.min(1, "API Token required")
.max(255)
.describe(AppConnections.CREDENTIALS.DIGITAL_OCEAN_APP_PLATFORM.apiToken)
});
const BaseDigitalOceanConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.DigitalOcean)
});
export const DigitalOceanConnectionSchema = BaseDigitalOceanConnectionSchema.extend({
method: DigitalOceanConnectionMethodSchema,
credentials: DigitalOceanConnectionAccessTokenCredentialsSchema
});
export const SanitizedDigitalOceanConnectionSchema = z.discriminatedUnion("method", [
BaseDigitalOceanConnectionSchema.extend({
method: DigitalOceanConnectionMethodSchema,
credentials: DigitalOceanConnectionAccessTokenCredentialsSchema.pick({})
})
]);
export const ValidateDigitalOceanConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: DigitalOceanConnectionMethodSchema,
credentials: DigitalOceanConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.DigitalOcean).credentials
)
})
]);
export const CreateDigitalOceanConnectionSchema = ValidateDigitalOceanConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.DigitalOcean)
);
export const UpdateDigitalOceanConnectionSchema = z
.object({
credentials: DigitalOceanConnectionAccessTokenCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.DigitalOcean).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.DigitalOcean));
export const DigitalOceanConnectionListItemSchema = z.object({
name: z.literal("Digital Ocean"),
app: z.literal(AppConnection.DigitalOcean),
methods: z.nativeEnum(DigitalOceanConnectionMethod).array()
});
@@ -0,0 +1,29 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { DigitalOceanAppPlatformPublicAPI } from "./digital-ocean-connection-public-client";
import { TDigitalOceanConnection } from "./digital-ocean-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TDigitalOceanConnection>;
export const digitalOceanAppPlatformConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listApps = async (connectionId: string, actor: OrgServiceActor) => {
const connection = await getAppConnection(AppConnection.DigitalOcean, connectionId, actor);
try {
const apps = await DigitalOceanAppPlatformPublicAPI.getApps(connection);
return apps;
} catch (error) {
logger.error(error, "Failed to list apps on Digital Ocean");
return [];
}
};
return {
listApps
};
};
@@ -0,0 +1,42 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateDigitalOceanConnectionSchema,
DigitalOceanConnectionSchema,
ValidateDigitalOceanConnectionCredentialsSchema
} from "./digital-ocean-connection-schemas";
export type TDigitalOceanConnection = z.infer<typeof DigitalOceanConnectionSchema>;
export type TDigitalOceanConnectionInput = z.infer<typeof CreateDigitalOceanConnectionSchema> & {
app: AppConnection.DigitalOcean;
};
export type TValidateDigitalOceanCredentialsSchema = typeof ValidateDigitalOceanConnectionCredentialsSchema;
export type TDigitalOceanConnectionConfig = DiscriminativePick<
TDigitalOceanConnection,
"method" | "app" | "credentials"
> & {
orgId: string;
};
export type TDigitalOceanVariable = {
key: string;
value: string;
type: "SECRET" | "GENERAL";
};
export type TDigitalOceanApp = {
id: string;
spec: {
name: string;
services: Array<{
name: string;
}>;
envs?: TDigitalOceanVariable[];
};
};
@@ -0,0 +1,4 @@
export * from "./digital-ocean-connection-constants";
export * from "./digital-ocean-connection-fns";
export * from "./digital-ocean-connection-schemas";
export * from "./digital-ocean-connection-types";