mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 22:46:24 +00:00
feat: Add Netlify app connection and secrets sync (#4205)
* 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 * feat: Netlify app connection and secrets sync * feat: docs * fix: type check * fix: api client * fix: lint and types * fix: typecheck lint * fix: docs * fix: lint * fix: lint * fix: PR changes * fix: typecheck * fix: PR changes * fix PR changes * fix: PR Change * fix: type error * Small tweaks * fix: support is_secret * fix: revert is_secret * fix: force update existing netlify secret --------- Co-authored-by: sidwebworks <[email protected]> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: x032205 <[email protected]>
This commit is contained in:
co-authored by
greptile-apps[bot]
sidwebworks
x032205
parent
3877fe524d
commit
79de7c5f08
@@ -0,0 +1,4 @@
|
||||
export * from "./netlify-connection-constants";
|
||||
export * from "./netlify-connection-fns";
|
||||
export * from "./netlify-connection-schemas";
|
||||
export * from "./netlify-connection-types";
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum NetlifyConnectionMethod {
|
||||
AccessToken = "access-token"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { NetlifyConnectionMethod } from "./netlify-connection-constants";
|
||||
import { NetlifyPublicAPI } from "./netlify-connection-public-client";
|
||||
import { TNetlifyConnectionConfig } from "./netlify-connection-types";
|
||||
|
||||
export const getNetlifyConnectionListItem = () => {
|
||||
return {
|
||||
name: "Netlify" as const,
|
||||
app: AppConnection.Netlify as const,
|
||||
methods: Object.values(NetlifyConnectionMethod)
|
||||
};
|
||||
};
|
||||
|
||||
export const validateNetlifyConnectionCredentials = async (config: TNetlifyConnectionConfig) => {
|
||||
try {
|
||||
await NetlifyPublicAPI.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,261 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios";
|
||||
|
||||
import { createRequestClient } from "@app/lib/config/request";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
|
||||
import { NetlifyConnectionMethod } from "./netlify-connection-constants";
|
||||
import { TNetlifyAccount, TNetlifyConnectionConfig, TNetlifySite, TNetlifyVariable } from "./netlify-connection-types";
|
||||
|
||||
export function getNetlifyAuthHeaders(connection: TNetlifyConnectionConfig): Record<string, string> {
|
||||
switch (connection.method) {
|
||||
case NetlifyConnectionMethod.AccessToken:
|
||||
return {
|
||||
Authorization: `Bearer ${connection.credentials.accessToken}`
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported Netlify connection method`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getNetlifyRatelimiter(response: AxiosResponse): {
|
||||
maxAttempts: number;
|
||||
isRatelimited: boolean;
|
||||
wait: () => Promise<void>;
|
||||
} {
|
||||
const wait = (seconds: number = 60) => {
|
||||
return new Promise<void>((res) => {
|
||||
setTimeout(res, seconds * 1000);
|
||||
});
|
||||
};
|
||||
|
||||
let remaining = parseInt(response.headers["X-RateLimit-Remaining"] as string, 10);
|
||||
let isRatelimited = response.status === HttpStatusCode.TooManyRequests;
|
||||
|
||||
if (isRatelimited) {
|
||||
if (Math.round(remaining) > 0) {
|
||||
isRatelimited = true;
|
||||
remaining += 1; // Jitter to ensure we wait at least 1 second
|
||||
} else {
|
||||
remaining = 60;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRatelimited,
|
||||
wait: () => wait(remaining),
|
||||
maxAttempts: 3
|
||||
};
|
||||
}
|
||||
|
||||
type NetlifyParams = {
|
||||
account_id: string;
|
||||
context_name?: string;
|
||||
site_id?: string;
|
||||
};
|
||||
|
||||
class NetlifyPublicClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = createRequestClient({
|
||||
baseURL: `${IntegrationUrls.NETLIFY_API_URL}/api/v1`,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async send<T>(connection: TNetlifyConnectionConfig, config: AxiosRequestConfig, retryAttempt = 0): Promise<T> {
|
||||
const response = await this.client.request<T>({
|
||||
...config,
|
||||
timeout: 1000 * 60, // 60 seconds timeout
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests,
|
||||
headers: getNetlifyAuthHeaders(connection)
|
||||
});
|
||||
const limiter = getNetlifyRatelimiter(response);
|
||||
|
||||
if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) {
|
||||
await limiter.wait();
|
||||
return this.send(connection, config, retryAttempt + 1);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
healthcheck(connection: TNetlifyConnectionConfig) {
|
||||
switch (connection.method) {
|
||||
case NetlifyConnectionMethod.AccessToken:
|
||||
return this.getNetlifyAccounts(connection);
|
||||
default:
|
||||
throw new Error(`Unsupported Netlify connection method`);
|
||||
}
|
||||
}
|
||||
|
||||
async getVariables(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
limit: number = 50,
|
||||
page: number = 1
|
||||
) {
|
||||
const res = await this.send<TNetlifyVariable[]>(connection, {
|
||||
method: "GET",
|
||||
url: `/accounts/${account_id}/env`,
|
||||
params: {
|
||||
...params,
|
||||
limit,
|
||||
page
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async createVariable(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
...variables: TNetlifyVariable[]
|
||||
) {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "POST",
|
||||
url: `/accounts/${account_id}/env`,
|
||||
data: variables,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateVariableValue(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
variable: TNetlifyVariable
|
||||
) {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "PATCH",
|
||||
url: `/accounts/${account_id}/env/${variable.key}`,
|
||||
data: variable,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateVariable(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
variable: TNetlifyVariable
|
||||
) {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "PUT",
|
||||
url: `/accounts/${account_id}/env/${variable.key}`,
|
||||
data: variable,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async getVariable(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
variable: Pick<TNetlifyVariable, "key">
|
||||
) {
|
||||
try {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "GET",
|
||||
url: `/accounts/${account_id}/env/${variable.key}`,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertVariable(connection: TNetlifyConnectionConfig, params: NetlifyParams, variable: TNetlifyVariable) {
|
||||
const res = await this.getVariable(connection, params, variable);
|
||||
|
||||
if (!res) {
|
||||
return this.createVariable(connection, params, variable);
|
||||
}
|
||||
|
||||
if (res.is_secret) {
|
||||
await this.deleteVariable(connection, params, variable);
|
||||
return this.createVariable(connection, params, variable);
|
||||
}
|
||||
|
||||
return this.updateVariable(connection, params, variable);
|
||||
}
|
||||
|
||||
async deleteVariable(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, ...params }: NetlifyParams,
|
||||
variable: Pick<TNetlifyVariable, "key">
|
||||
) {
|
||||
try {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "DELETE",
|
||||
url: `/accounts/${account_id}/env/${variable.key}`,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteVariableValue(
|
||||
connection: TNetlifyConnectionConfig,
|
||||
{ account_id, value_id, ...params }: NetlifyParams & { value_id: string },
|
||||
variable: Pick<TNetlifyVariable, "key" | "id">
|
||||
) {
|
||||
try {
|
||||
const res = await this.send<TNetlifyVariable>(connection, {
|
||||
method: "DELETE",
|
||||
url: `/accounts/${account_id}/${variable.key}/value/${value_id}`,
|
||||
params
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSites(connection: TNetlifyConnectionConfig, accountId: string) {
|
||||
const res = await this.send<TNetlifySite[]>(connection, {
|
||||
method: "GET",
|
||||
url: `/${accountId}/sites`
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async getNetlifyAccounts(connection: TNetlifyConnectionConfig) {
|
||||
const res = await this.send<TNetlifyAccount[]>(connection, {
|
||||
method: "GET",
|
||||
url: `/accounts`
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const NetlifyPublicAPI = new NetlifyPublicClient();
|
||||
@@ -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 { NetlifyConnectionMethod } from "./netlify-connection-constants";
|
||||
|
||||
export const NetlifyConnectionMethodSchema = z
|
||||
.nativeEnum(NetlifyConnectionMethod)
|
||||
.describe(AppConnections.CREATE(AppConnection.Netlify).method);
|
||||
|
||||
export const NetlifyConnectionAccessTokenCredentialsSchema = z.object({
|
||||
accessToken: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Access Token required")
|
||||
.max(255)
|
||||
.describe(AppConnections.CREDENTIALS.NETLIFY.accessToken)
|
||||
});
|
||||
|
||||
const BaseNetlifyConnectionSchema = BaseAppConnectionSchema.extend({
|
||||
app: z.literal(AppConnection.Netlify)
|
||||
});
|
||||
|
||||
export const NetlifyConnectionSchema = BaseNetlifyConnectionSchema.extend({
|
||||
method: NetlifyConnectionMethodSchema,
|
||||
credentials: NetlifyConnectionAccessTokenCredentialsSchema
|
||||
});
|
||||
|
||||
export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [
|
||||
BaseNetlifyConnectionSchema.extend({
|
||||
method: NetlifyConnectionMethodSchema,
|
||||
credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({})
|
||||
})
|
||||
]);
|
||||
|
||||
export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: NetlifyConnectionMethodSchema,
|
||||
credentials: NetlifyConnectionAccessTokenCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.Netlify).credentials
|
||||
)
|
||||
})
|
||||
]);
|
||||
|
||||
export const CreateNetlifyConnectionSchema = ValidateNetlifyConnectionCredentialsSchema.and(
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.Netlify)
|
||||
);
|
||||
|
||||
export const UpdateNetlifyConnectionSchema = z
|
||||
.object({
|
||||
credentials: NetlifyConnectionAccessTokenCredentialsSchema.optional().describe(
|
||||
AppConnections.UPDATE(AppConnection.Netlify).credentials
|
||||
)
|
||||
})
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify));
|
||||
|
||||
export const NetlifyConnectionListItemSchema = z.object({
|
||||
name: z.literal("Netlify"),
|
||||
app: z.literal(AppConnection.Netlify),
|
||||
methods: z.nativeEnum(NetlifyConnectionMethod).array()
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { NetlifyPublicAPI } from "./netlify-connection-public-client";
|
||||
import { TNetlifyConnection } from "./netlify-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
app: AppConnection,
|
||||
connectionId: string,
|
||||
actor: OrgServiceActor
|
||||
) => Promise<TNetlifyConnection>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const netlifyConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
||||
const listAccounts = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor);
|
||||
try {
|
||||
const accounts = await NetlifyPublicAPI.getNetlifyAccounts(appConnection);
|
||||
return accounts;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to list accounts on Netlify");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const listSites = async (connectionId: string, actor: OrgServiceActor, accountId: string) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor);
|
||||
try {
|
||||
const sites = await NetlifyPublicAPI.getSites(appConnection, accountId);
|
||||
return sites;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to list sites on Netlify");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listAccounts,
|
||||
listSites
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import z from "zod";
|
||||
|
||||
import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
CreateNetlifyConnectionSchema,
|
||||
NetlifyConnectionSchema,
|
||||
ValidateNetlifyConnectionCredentialsSchema
|
||||
} from "./netlify-connection-schemas";
|
||||
|
||||
export type TNetlifyConnection = z.infer<typeof NetlifyConnectionSchema>;
|
||||
|
||||
export type TNetlifyConnectionInput = z.infer<typeof CreateNetlifyConnectionSchema> & {
|
||||
app: AppConnection.Netlify;
|
||||
};
|
||||
|
||||
export type TValidateNetlifyConnectionCredentialsSchema = typeof ValidateNetlifyConnectionCredentialsSchema;
|
||||
|
||||
export type TNetlifyConnectionConfig = DiscriminativePick<TNetlifyConnection, "method" | "app" | "credentials"> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TNetlifyVariable = {
|
||||
key: string;
|
||||
id?: string; // ID of the variable (present in responses)
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
is_secret?: boolean;
|
||||
scopes?: ("builds" | "functions" | "runtime" | "post_processing")[];
|
||||
values: TNetlifyVariableValue[];
|
||||
};
|
||||
|
||||
export type TNetlifyVariableValue = {
|
||||
id?: string;
|
||||
context?: string; // "all", "dev", "branch-deploy", etc.
|
||||
value?: string; // Omitted in response if `is_secret` is true
|
||||
site_id?: string; // Optional: overrides at site-level
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type TNetlifyAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TNetlifySite = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
Reference in New Issue
Block a user