diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 740fd05b5..ff07940bc 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1808,6 +1808,10 @@ export const AppConnections = { CAMUNDA: { clientId: "The client ID used to authenticate with Camunda.", clientSecret: "The client secret used to authenticate with Camunda." + }, + WINDMILL: { + instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).", + accessToken: "The access token to use to connect with Windmill." } } }; @@ -1943,6 +1947,10 @@ export const SecretSyncs = { env: "The ID of the Vercel environment to sync secrets to.", branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." + }, + WINDMILL: { + workspace: "The Windmill workspace to sync secrets to.", + path: "The Windmill workspace path to sync secrets to." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index c773acc16..c8d2a976d 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -37,6 +37,10 @@ import { TerraformCloudConnectionListItemSchema } from "@app/services/app-connection/terraform-cloud"; import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; +import { + SanitizedWindmillConnectionSchema, + WindmillConnectionListItemSchema +} from "@app/services/app-connection/windmill"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -53,6 +57,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options ]); @@ -69,6 +74,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, + WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 22bd5f717..a833b6882 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -13,6 +13,7 @@ import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; +import { registerWindmillConnectionRouter } from "./windmill-connection-router"; export * from "./app-connection-router"; @@ -30,5 +31,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Windmill, + server, + sanitizedResponseSchema: SanitizedWindmillConnectionSchema, + createSchema: CreateWindmillConnectionSchema, + updateSchema: UpdateWindmillConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/workspaces`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const workspaces = await server.services.appConnection.windmill.listWorkspaces(connectionId, req.permission); + + return workspaces; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 07567124d..ee407cee3 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -11,6 +11,7 @@ import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; +import { registerWindmillSyncRouter } from "./windmill-sync-router"; export * from "./secret-sync-router"; @@ -25,5 +26,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts new file mode 100644 index 000000000..d40a21ef3 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/windmill-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateWindmillSyncSchema, + UpdateWindmillSyncSchema, + WindmillSyncSchema +} from "@app/services/secret-sync/windmill"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerWindmillSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Windmill, + server, + responseSchema: WindmillSyncSchema, + createSchema: CreateWindmillSyncSchema, + updateSchema: UpdateWindmillSyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 2c5a32c85..6b6048f2a 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -11,6 +11,7 @@ export enum AppConnection { Postgres = "postgres", MsSql = "mssql", Camunda = "camunda", + Windmill = "windmill", Auth0 = "auth0" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index de329faaa..7e08a92b4 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -50,6 +50,11 @@ import { } from "./terraform-cloud"; import { VercelConnectionMethod } from "./vercel"; import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; +import { + getWindmillConnectionListItem, + validateWindmillConnectionCredentials, + WindmillConnectionMethod +} from "./windmill"; export const listAppConnectionOptions = () => { return [ @@ -65,6 +70,7 @@ export const listAppConnectionOptions = () => { getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem(), + getWindmillConnectionListItem(), getAuth0ConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -128,7 +134,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -159,6 +166,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return "Username & Password"; + case WindmillConnectionMethod.AccessToken: + return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; default: @@ -204,5 +213,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, + [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 2e3f91dd6..762a9bcf2 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -13,5 +13,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", [AppConnection.Camunda]: "Camunda", + [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 6d1d53e93..5293761a8 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -49,6 +49,8 @@ import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-c import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; import { vercelConnectionService } from "./vercel/vercel-connection-service"; +import { ValidateWindmillConnectionCredentialsSchema } from "./windmill"; +import { windmillConnectionService } from "./windmill/windmill-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -71,6 +73,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + const instanceUrl = config.credentials.instanceUrl + ? removeTrailingSlash(config.credentials.instanceUrl) + : "https://app.windmill.dev"; + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getWindmillConnectionListItem = () => { + return { + name: "Windmill" as const, + app: AppConnection.Windmill as const, + methods: Object.values(WindmillConnectionMethod) as [WindmillConnectionMethod.AccessToken] + }; +}; + +export const validateWindmillConnectionCredentials = async (config: TWindmillConnectionConfig) => { + const instanceUrl = await getWindmillInstanceUrl(config); + const { accessToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/api/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + } 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; +}; + +export const listWindmillWorkspaces = async (appConnection: TWindmillConnection) => { + const instanceUrl = await getWindmillInstanceUrl(appConnection); + const { accessToken } = appConnection.credentials; + + const resp = await request.get(`${instanceUrl}/api/workspaces/list`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + return resp.data.filter((workspace) => !workspace.deleted); +}; diff --git a/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts new file mode 100644 index 000000000..eb7f74ecd --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts @@ -0,0 +1,70 @@ +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 { WindmillConnectionMethod } from "./windmill-connection-enums"; + +export const WindmillConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.WINDMILL.accessToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.WINDMILL.instanceUrl) +}); + +const BaseWindmillConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Windmill) }); + +export const WindmillConnectionSchema = BaseWindmillConnectionSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: WindmillConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedWindmillConnectionSchema = z.discriminatedUnion("method", [ + BaseWindmillConnectionSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: WindmillConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateWindmillConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(WindmillConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.Windmill).method), + credentials: WindmillConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Windmill).credentials + ) + }) +]); + +export const CreateWindmillConnectionSchema = ValidateWindmillConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Windmill) +); + +export const UpdateWindmillConnectionSchema = z + .object({ + credentials: WindmillConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Windmill).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Windmill)); + +export const WindmillConnectionListItemSchema = z.object({ + name: z.literal("Windmill"), + app: z.literal(AppConnection.Windmill), + methods: z.nativeEnum(WindmillConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/windmill/windmill-connection-service.ts b/backend/src/services/app-connection/windmill/windmill-connection-service.ts new file mode 100644 index 000000000..89306985f --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-service.ts @@ -0,0 +1,28 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listWindmillWorkspaces } from "./windmill-connection-fns"; +import { TWindmillConnection } from "./windmill-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const windmillConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Windmill, connectionId, actor); + + try { + const workspaces = await listWindmillWorkspaces(appConnection); + return workspaces; + } catch (error) { + return []; + } + }; + + return { + listWorkspaces + }; +}; diff --git a/backend/src/services/app-connection/windmill/windmill-connection-types.ts b/backend/src/services/app-connection/windmill/windmill-connection-types.ts new file mode 100644 index 000000000..9747ce14b --- /dev/null +++ b/backend/src/services/app-connection/windmill/windmill-connection-types.ts @@ -0,0 +1,27 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateWindmillConnectionSchema, + ValidateWindmillConnectionCredentialsSchema, + WindmillConnectionSchema +} from "./windmill-connection-schemas"; + +export type TWindmillConnection = z.infer; + +export type TWindmillConnectionInput = z.infer & { + app: AppConnection.Windmill; +}; + +export type TValidateWindmillConnectionCredentialsSchema = typeof ValidateWindmillConnectionCredentialsSchema; + +export type TWindmillConnectionConfig = DiscriminativePick< + TWindmillConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TWindmillWorkspace = { id: string; name: string; deleted: boolean }; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 9349e2197..86273a4ff 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -9,7 +9,8 @@ export enum SecretSync { Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Camunda = "camunda", - Vercel = "vercel" + Vercel = "vercel", + Windmill = "windmill" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a3efa4bc1..0b821b593 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -29,6 +29,7 @@ import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; +import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -41,7 +42,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, - [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, + [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -136,6 +138,8 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Vercel: return VercelSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.Windmill: + return WindmillSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -192,6 +196,9 @@ export const SecretSyncFns = { case SecretSync.Vercel: secretMap = await VercelSyncFns.getSecrets(secretSync); break; + case SecretSync.Windmill: + secretMap = await WindmillSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -243,6 +250,8 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Vercel: return VercelSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.Windmill: + return WindmillSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 661815814..a9099543d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -12,7 +12,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Humanitec]: "Humanitec", [SecretSync.TerraformCloud]: "Terraform Cloud", [SecretSync.Camunda]: "Camunda", - [SecretSync.Vercel]: "Vercel" + [SecretSync.Vercel]: "Vercel", + [SecretSync.Windmill]: "Windmill" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -26,5 +27,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, - [SecretSync.Vercel]: AppConnection.Vercel + [SecretSync.Vercel]: AppConnection.Vercel, + [SecretSync.Windmill]: AppConnection.Windmill }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 17257b8e2..9f4e283a9 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -76,6 +76,7 @@ type TSecretSyncQueueFactoryDep = { | "findBySecretKeys" | "bulkUpdate" | "deleteMany" + | "invalidateSecretCacheByProjectId" >; secretImportDAL: Pick; secretSyncDAL: Pick; @@ -382,6 +383,9 @@ export const secretSyncQueueFactory = ({ }); } + if (secretsToUpdate.length || secretsToCreate.length) + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); + return importedSecretMap; }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index d3207918c..03e92a57a 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -29,6 +29,12 @@ import { } from "@app/services/secret-sync/github"; import { TSecretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; +import { + TWindmillSync, + TWindmillSyncInput, + TWindmillSyncListItem, + TWindmillSyncWithCredentials +} from "@app/services/secret-sync/windmill"; import { TAwsParameterStoreSync, @@ -74,7 +80,8 @@ export type TSecretSync = | THumanitecSync | TTerraformCloudSync | TCamundaSync - | TVercelSync; + | TVercelSync + | TWindmillSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -87,7 +94,8 @@ export type TSecretSyncWithCredentials = | THumanitecSyncWithCredentials | TTerraformCloudSyncWithCredentials | TCamundaSyncWithCredentials - | TVercelSyncWithCredentials; + | TVercelSyncWithCredentials + | TWindmillSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -100,7 +108,8 @@ export type TSecretSyncInput = | THumanitecSyncInput | TTerraformCloudSyncInput | TCamundaSyncInput - | TVercelSyncInput; + | TVercelSyncInput + | TWindmillSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -113,7 +122,8 @@ export type TSecretSyncListItem = | THumanitecSyncListItem | TTerraformCloudSyncListItem | TCamundaSyncListItem - | TVercelSyncListItem; + | TVercelSyncListItem + | TWindmillSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/windmill/index.ts b/backend/src/services/secret-sync/windmill/index.ts new file mode 100644 index 000000000..94897da0f --- /dev/null +++ b/backend/src/services/secret-sync/windmill/index.ts @@ -0,0 +1,4 @@ +export * from "./windmill-sync-constants"; +export * from "./windmill-sync-fns"; +export * from "./windmill-sync-schemas"; +export * from "./windmill-sync-types"; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-constants.ts b/backend/src/services/secret-sync/windmill/windmill-sync-constants.ts new file mode 100644 index 000000000..d52c704b7 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const WINDMILL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Windmill", + destination: SecretSync.Windmill, + connection: AppConnection.Windmill, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts new file mode 100644 index 000000000..2e2c36740 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts @@ -0,0 +1,241 @@ +import { request } from "@app/lib/config/request"; +import { getWindmillInstanceUrl } from "@app/services/app-connection/windmill"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { + TDeleteWindmillVariable, + TPostWindmillVariable, + TWindmillListVariables, + TWindmillListVariablesResponse, + TWindmillSyncWithCredentials, + TWindmillVariable +} from "@app/services/secret-sync/windmill/windmill-sync-types"; + +import { TSecretMap } from "../secret-sync-types"; + +const PAGE_LIMIT = 100; + +const listWindmillVariables = async ({ instanceUrl, workspace, accessToken, path }: TWindmillListVariables) => { + const variables: Record = {}; + + // windmill paginates but doesn't return if there's more pages so we need to check if page size full + let page: number | null = 1; + + while (page) { + // eslint-disable-next-line no-await-in-loop + const { data: variablesPage } = await request.get( + `${instanceUrl}/api/w/${workspace}/variables/list`, + { + headers: { + Authorization: `Bearer ${accessToken}` + }, + params: { + page, + limit: PAGE_LIMIT, + path_start: path + } + } + ); + + for (const variable of variablesPage) { + const variableName = variable.path.replace(path, ""); + + if (variable.is_secret) { + // eslint-disable-next-line no-await-in-loop + const { data: variableValue } = await request.get( + `${instanceUrl}/api/w/${workspace}/variables/get_value/${variable.path}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + variables[variableName] = { + ...variable, + value: variableValue + }; + } else { + variables[variableName] = variable; + } + } + + if (variablesPage.length >= PAGE_LIMIT) { + page += 1; + } else { + page = null; + } + } + + return variables; +}; + +const createWindmillVariable = async ({ + path, + value, + instanceUrl, + accessToken, + workspace, + description +}: TPostWindmillVariable) => + request.post( + `${instanceUrl}/api/w/${workspace}/variables/create`, + { + path, + value, + is_secret: true, + description + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + +const updateWindmillVariable = async ({ + path, + value, + instanceUrl, + accessToken, + workspace, + description +}: TPostWindmillVariable) => + request.post( + `${instanceUrl}/api/w/${workspace}/variables/update/${path}`, + { + value, + is_secret: true, + description + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + +const deleteWindmillVariable = async ({ path, instanceUrl, accessToken, workspace }: TDeleteWindmillVariable) => + request.delete(`${instanceUrl}/api/w/${workspace}/variables/delete/${path}`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + +export const WindmillSyncFns = { + syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { path }, + syncOptions: { disableSecretDeletion } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value, comment = "" }] = entry; + + try { + const payload = { + instanceUrl, + workspace, + path: path + key, + value, + accessToken, + description: comment + }; + if (key in variables) { + if (variables[key].value !== value || variables[key].description !== comment) + await updateWindmillVariable(payload); + } else { + await createWindmillVariable(payload); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const [key, variable] of Object.entries(variables)) { + if (!(key in secretMap)) { + try { + await deleteWindmillVariable({ + instanceUrl, + workspace, + path: variable.path, + accessToken + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { path } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + for await (const [key, variable] of Object.entries(variables)) { + if (key in secretMap) { + try { + await deleteWindmillVariable({ + path: variable.path, + instanceUrl, + workspace, + accessToken + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TWindmillSyncWithCredentials) => { + const { + connection, + destinationConfig: { path } + } = secretSync; + + // url needs to be lowercase + const workspace = secretSync.destinationConfig.workspace.toLowerCase(); + + const instanceUrl = await getWindmillInstanceUrl(connection); + + const { accessToken } = connection.credentials; + + const variables = await listWindmillVariables({ instanceUrl, accessToken, workspace, path }); + + return Object.fromEntries( + Object.entries(variables).map(([key, variable]) => [key, { value: variable.value ?? "" }]) + ); + } +}; diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts new file mode 100644 index 000000000..5740e21c9 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const pathCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen +]); + +const WindmillSyncDestinationConfigSchema = z.object({ + workspace: z.string().trim().min(1, "Workspace required").describe(SecretSyncs.DESTINATION_CONFIG.WINDMILL.workspace), + path: z + .string() + .trim() + .min(1, "Path required") + .refine( + (val) => + (val.startsWith("u/") || val.startsWith("f/")) && + val.endsWith("/") && + val.split("/").length >= 3 && + val + .split("/") + .slice(0, -1) // Remove last empty segment from trailing slash + .every((segment) => segment && pathCharacterValidator(segment)), + 'Invalid path - must follow Windmill path format. ex: "f/folder/path/"' + ) + .describe(SecretSyncs.DESTINATION_CONFIG.WINDMILL.path) +}); + +const WindmillSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const WindmillSyncSchema = BaseSecretSyncSchema(SecretSync.Windmill, WindmillSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Windmill), + destinationConfig: WindmillSyncDestinationConfigSchema +}); + +export const CreateWindmillSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Windmill, + WindmillSyncOptionsConfig +).extend({ + destinationConfig: WindmillSyncDestinationConfigSchema +}); + +export const UpdateWindmillSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Windmill, + WindmillSyncOptionsConfig +).extend({ + destinationConfig: WindmillSyncDestinationConfigSchema.optional() +}); + +export const WindmillSyncListItemSchema = z.object({ + name: z.literal("Windmill"), + connection: z.literal(AppConnection.Windmill), + destination: z.literal(SecretSync.Windmill), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-types.ts b/backend/src/services/secret-sync/windmill/windmill-sync-types.ts new file mode 100644 index 000000000..9837599d7 --- /dev/null +++ b/backend/src/services/secret-sync/windmill/windmill-sync-types.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { TWindmillConnection } from "@app/services/app-connection/windmill"; + +import { CreateWindmillSyncSchema, WindmillSyncListItemSchema, WindmillSyncSchema } from "./windmill-sync-schemas"; + +export type TWindmillSync = z.infer; + +export type TWindmillSyncInput = z.infer; + +export type TWindmillSyncListItem = z.infer; + +export type TWindmillSyncWithCredentials = TWindmillSync & { + connection: TWindmillConnection; +}; + +export type TWindmillVariable = { + path: string; + value: string; + is_secret: boolean; + is_oauth: boolean; + description: string; +}; + +export type TWindmillListVariablesResponse = TWindmillVariable[]; + +export type TWindmillListVariables = { + accessToken: string; + instanceUrl: string; + path: string; + workspace: string; + description?: string; +}; + +export type TPostWindmillVariable = TWindmillListVariables & { + value: string; +}; + +export type TDeleteWindmillVariable = TWindmillListVariables; diff --git a/docs/api-reference/endpoints/app-connections/windmill/available.mdx b/docs/api-reference/endpoints/app-connections/windmill/available.mdx new file mode 100644 index 000000000..c202bf368 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/windmill/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/create.mdx b/docs/api-reference/endpoints/app-connections/windmill/create.mdx new file mode 100644 index 000000000..0894dad27 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/windmill" +--- + + + Check out the configuration docs for [Windmill Connections](/integrations/app-connections/windmill) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/windmill/delete.mdx b/docs/api-reference/endpoints/app-connections/windmill/delete.mdx new file mode 100644 index 000000000..7a966c338 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/windmill/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx new file mode 100644 index 000000000..95ae6dc5c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/windmill/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx new file mode 100644 index 000000000..fdfdbcb48 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/windmill/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/list.mdx b/docs/api-reference/endpoints/app-connections/windmill/list.mdx new file mode 100644 index 000000000..a69d46451 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/windmill" +--- diff --git a/docs/api-reference/endpoints/app-connections/windmill/update.mdx b/docs/api-reference/endpoints/app-connections/windmill/update.mdx new file mode 100644 index 000000000..a700ffea9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/windmill/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/windmill/{connectionId}" +--- + + + Check out the configuration docs for [Windmill Connections](/integrations/app-connections/windmill) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx new file mode 100644 index 000000000..422cab9c1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/windmill" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx new file mode 100644 index 000000000..05d039cb8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx new file mode 100644 index 000000000..25f040de5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx new file mode 100644 index 000000000..cf10c1c6e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/windmill/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx new file mode 100644 index 000000000..c2cf0cdc4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx new file mode 100644 index 000000000..175a72f9c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/windmill" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx new file mode 100644 index 000000000..8f6bfb02e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx new file mode 100644 index 000000000..040345641 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/windmill/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx b/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx new file mode 100644 index 000000000..2b846691e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/windmill/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/windmill/{syncId}" +--- diff --git a/docs/images/app-connections/windmill/create-windmill-access-token.png b/docs/images/app-connections/windmill/create-windmill-access-token.png new file mode 100644 index 000000000..0c4ae06f9 Binary files /dev/null and b/docs/images/app-connections/windmill/create-windmill-access-token.png differ diff --git a/docs/images/app-connections/windmill/select-windmill-connection.png b/docs/images/app-connections/windmill/select-windmill-connection.png new file mode 100644 index 000000000..dabead13e Binary files /dev/null and b/docs/images/app-connections/windmill/select-windmill-connection.png differ diff --git a/docs/images/app-connections/windmill/windmill-access-token-created.png b/docs/images/app-connections/windmill/windmill-access-token-created.png new file mode 100644 index 000000000..6035bf0ad Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-access-token-created.png differ diff --git a/docs/images/app-connections/windmill/windmill-account-settings.png b/docs/images/app-connections/windmill/windmill-account-settings.png new file mode 100644 index 000000000..b2a63d997 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-account-settings.png differ diff --git a/docs/images/app-connections/windmill/windmill-copy-token.png b/docs/images/app-connections/windmill/windmill-copy-token.png new file mode 100644 index 000000000..4c5a5e8ca Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-copy-token.png differ diff --git a/docs/images/app-connections/windmill/windmill-create-token.png b/docs/images/app-connections/windmill/windmill-create-token.png new file mode 100644 index 000000000..c5a4ac004 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-create-token.png differ diff --git a/docs/images/app-connections/windmill/windmill-new-token.png b/docs/images/app-connections/windmill/windmill-new-token.png new file mode 100644 index 000000000..9f103ad41 Binary files /dev/null and b/docs/images/app-connections/windmill/windmill-new-token.png differ diff --git a/docs/images/secret-syncs/windmill/select-windmill-option.png b/docs/images/secret-syncs/windmill/select-windmill-option.png new file mode 100644 index 000000000..0108dd2da Binary files /dev/null and b/docs/images/secret-syncs/windmill/select-windmill-option.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-created.png b/docs/images/secret-syncs/windmill/windmill-sync-created.png new file mode 100644 index 000000000..37abbba49 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-created.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-destination.png b/docs/images/secret-syncs/windmill/windmill-sync-destination.png new file mode 100644 index 000000000..48b54ad79 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-destination.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-details.png b/docs/images/secret-syncs/windmill/windmill-sync-details.png new file mode 100644 index 000000000..9871cc57d Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-details.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-options.png b/docs/images/secret-syncs/windmill/windmill-sync-options.png new file mode 100644 index 000000000..37ca24cc4 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-options.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-review.png b/docs/images/secret-syncs/windmill/windmill-sync-review.png new file mode 100644 index 000000000..2a8a35e9c Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-review.png differ diff --git a/docs/images/secret-syncs/windmill/windmill-sync-source.png b/docs/images/secret-syncs/windmill/windmill-sync-source.png new file mode 100644 index 000000000..b7d94c048 Binary files /dev/null and b/docs/images/secret-syncs/windmill/windmill-sync-source.png differ diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx new file mode 100644 index 000000000..ca4aa7da4 --- /dev/null +++ b/docs/integrations/app-connections/windmill.mdx @@ -0,0 +1,112 @@ +--- +title: "Windmill Connection" +description: "Learn how to configure a Windmill Connection for Infisical." +--- + +Infisical supports connecting to Windmill using an **Access Token** to securely sync your secrets to Windmill. + +## Get a Windmill Access Token + +Ensure the user generating the access token has the required role and permissions based on your use-case: + + + + The user generating the access token should be at least a `Developer` in the configured workspace and have `write` permissions for the workspace path secrets will be synced to. + + + + + + + + In Windmill, click on your user in the sidebar and select **Account Settings**. + ![Windmill Account Settings](/images/app-connections/windmill/windmill-account-settings.png) + + + In the **Tokens** section on the drawer, click **Create token**. + ![Windmill Create Token](/images/app-connections/windmill/windmill-create-token.png) + + + Give your token a name and click **New token**. + + If you configure an expiry date for your access token, you must manually rotate to a new token before the expiration date to prevent service interruption. + + ![Windmill New Token](/images/app-connections/windmill/windmill-new-token.png) + + + Copy your new access token and save it for the steps below. + ![Windmill Copy Token](/images/app-connections/windmill/windmill-copy-token.png) + + + + +## Setup Windmill Connection in Infisical + + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Windmill Connection** option. + ![Select Windmill Connection](/images/app-connections/windmill/select-windmill-connection.png) + + + Configure your Windmill Connection using the access token generated in the steps above. Then click **Connect to Windmill**. + ![Windmill Configure Connection](/images/app-connections/windmill/create-windmill-access-token.png) + + - **Name**: The name of the connection to be created. Must be slug-friendly. + - **Description**: An optional description to provide details about this connection. + - **Instance URL**: The URL of your Windmill instance. If you are not self-hosting Windmill you can leave this field blank. + - **Access Token**: The access token generated in the steps above. + + + Your Windmill Connection is now available for use. + ![Windmill Connection Created](/images/app-connections/windmill/windmill-access-token-created.png) + + + + + To create a Windmill Connection, make an API request to the [Create Windmill + Connection](/api-reference/endpoints/app-connections/windmill/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/windmill \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-windmill-connection", + "method": "access-token", + "credentials": { + "token": "...", + "instanceUrl": "https://app.windmill.dev" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-windmill-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "windmill", + "method": "access-token", + "credentials": { + "instanceUrl": "https://app.windmill.dev" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/windmill.mdx b/docs/integrations/secret-syncs/windmill.mdx new file mode 100644 index 000000000..90d35f8b8 --- /dev/null +++ b/docs/integrations/secret-syncs/windmill.mdx @@ -0,0 +1,147 @@ +--- +title: "Windmill Sync" +description: "Learn how to configure a Windmill Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Windmill Connection](/integrations/app-connections/windmill) with the required **Secret Sync** permissions + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Windmill** option. + ![Select Windmill](/images/secret-syncs/windmill/select-windmill-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/windmill/windmill-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/windmill/windmill-sync-destination.png) + + - **Windmill Connection**: The Windmill Connection to authenticate with. + - **Workspace**: The Windmill workspace to sync secrets to. + - **Path**: The workspace path to sync secrets to. + + + Workspace path must conform to Windmill's [owner path convention](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path). + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/windmill/windmill-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Windmill when keys conflict. + - **Import Secrets (Prioritize Windmill)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Windmill over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Windmill Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/windmill/windmill-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Windmill Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/windmill/windmill-sync-review.png) + + 8. If enabled, your Windmill Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/windmill/windmill-sync-created.png) + + + + To create an **Windmill Sync**, make an API request to the [Create Windmill Sync](/api-reference/endpoints/secret-syncs/windmill/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/windmill \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-windmill-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "workspace": "my-workspace", + "path": "f/folder/path/" + } + }' + ``` + + + Workspace path must conform to Windmill's [owner path convention](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path). + + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-windmill-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "windmill", + "name": "my-windmill-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "windmill", + "destinationConfig": { + "workspace": "my-workspace", + "path": "f/folder/path/" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index c224ee23c..ee07fb048 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -427,7 +427,8 @@ "integrations/app-connections/mssql", "integrations/app-connections/postgres", "integrations/app-connections/terraform-cloud", - "integrations/app-connections/vercel" + "integrations/app-connections/vercel", + "integrations/app-connections/windmill" ] } ] @@ -449,7 +450,8 @@ "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/terraform-cloud", - "integrations/secret-syncs/vercel" + "integrations/secret-syncs/vercel", + "integrations/secret-syncs/windmill" ] } ] @@ -1058,6 +1060,18 @@ "api-reference/endpoints/app-connections/vercel/update", "api-reference/endpoints/app-connections/vercel/delete" ] + }, + { + "group": "Windmill", + "pages": [ + "api-reference/endpoints/app-connections/windmill/list", + "api-reference/endpoints/app-connections/windmill/available", + "api-reference/endpoints/app-connections/windmill/get-by-id", + "api-reference/endpoints/app-connections/windmill/get-by-name", + "api-reference/endpoints/app-connections/windmill/create", + "api-reference/endpoints/app-connections/windmill/update", + "api-reference/endpoints/app-connections/windmill/delete" + ] } ] }, @@ -1211,8 +1225,22 @@ "api-reference/endpoints/secret-syncs/vercel/update", "api-reference/endpoints/secret-syncs/vercel/delete", "api-reference/endpoints/secret-syncs/vercel/sync-secrets", - "api-reference/endpoints/secret-syncs/vercel/remove-secrets", - "api-reference/endpoints/secret-syncs/vercel/import-secrets" + "api-reference/endpoints/secret-syncs/vercel/import-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets" + ] + }, + { + "group": "Windmill", + "pages": [ + "api-reference/endpoints/secret-syncs/windmill/list", + "api-reference/endpoints/secret-syncs/windmill/get-by-id", + "api-reference/endpoints/secret-syncs/windmill/get-by-name", + "api-reference/endpoints/secret-syncs/windmill/create", + "api-reference/endpoints/secret-syncs/windmill/update", + "api-reference/endpoints/secret-syncs/windmill/delete", + "api-reference/endpoints/secret-syncs/windmill/sync-secrets", + "api-reference/endpoints/secret-syncs/windmill/import-secrets", + "api-reference/endpoints/secret-syncs/windmill/remove-secrets" ] } ] diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 854c841dd..b2008b4f6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -14,6 +14,7 @@ import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; +import { WindmillSyncFields } from "./WindmillSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -43,6 +44,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Vercel: return ; + case SecretSync.Windmill: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx new file mode 100644 index 000000000..8578fa63c --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/WindmillSyncFields.tsx @@ -0,0 +1,105 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2"; +import { + TWindmillWorkspace, + useWindmillConnectionListWorkspaces +} from "@app/hooks/api/appConnections/windmill"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const WindmillSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Windmill } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: workspaces, isLoading: isWorkspacesLoading } = useWindmillConnectionListWorkspaces( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.workspace", ""); + }} + /> + + ( + +
+ Don't see the workspace you're looking for?{" "} + +
+ + } + > + workspace.name === value) ?? null} + onChange={(option) => + onChange((option as SingleValue)?.name ?? null) + } + options={workspaces} + placeholder="Select a workspace..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + ( + + The workspace path where secrets should be synced to. Path must follow Windmill{" "} + + + owner path convention + + . + + + } + isError={Boolean(error)} + errorText={error?.message} + label="Path" + > + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 5e8ff01d1..79c437d27 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -42,6 +42,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.TerraformCloud: case SecretSync.Camunda: case SecretSync.Vercel: + case SecretSync.Windmill: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 4e9e89427..99182f207 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -24,6 +24,7 @@ import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; +import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -84,6 +85,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Vercel: DestinationFieldsComponent = ; break; + case SecretSync.Windmill: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx new file mode 100644 index 000000000..4d7f9e3d1 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/WindmillSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const WindmillSyncReviewFields = () => { + const { watch } = useFormContext(); + const workspace = watch("destinationConfig.workspace"); + const path = watch("destinationConfig.path"); + + return ( + <> + {workspace} + {path} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 4eb1094ae..d58e60b19 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -1,17 +1,17 @@ import { z } from "zod"; -import { AwsSecretsManagerSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema"; -import { DatabricksSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/databricks-sync-destination-schema"; -import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema"; - import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; +import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; +import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; +import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; +import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -24,7 +24,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ HumanitecSyncDestinationSchema, TerraformCloudSyncDestinationSchema, CamundaSyncDestinationSchema, - VercelSyncDestinationSchema + VercelSyncDestinationSchema, + WindmillSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts new file mode 100644 index 000000000..242f77140 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/windmill-sync-destination-schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const WindmillSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Windmill), + destinationConfig: z.object({ + workspace: z.string().trim().min(1, "Workspace required"), + path: z + .string() + .trim() + .min(1, "Path required") + .regex( + /^([uf])\/([a-zA-Z0-9_-]+)(\/[a-zA-Z0-9_-]+)*\/$/, + 'Invalid path - must follow Windmill path format. ex: "f/folder/path/"' + ) + }) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 666784b54..c94bd6648 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -16,7 +16,8 @@ import { PostgresConnectionMethod, TAppConnection, TerraformCloudConnectionMethod, - VercelConnectionMethod + VercelConnectionMethod, + WindmillConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record< @@ -41,6 +42,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, + [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 } }; @@ -69,6 +71,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; + case WindmillConnectionMethod.AccessToken: + return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; default: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index fd7159c02..291b6f80a 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -35,6 +35,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, - [SecretSync.Vercel]: AppConnection.Vercel + [SecretSync.Vercel]: AppConnection.Vercel, + [SecretSync.Windmill]: AppConnection.Windmill }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 823583d59..704b200bb 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -11,5 +11,6 @@ export enum AppConnection { Postgres = "postgres", MsSql = "mssql", Camunda = "camunda", + Windmill = "windmill", Auth0 = "auth0" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 48cee7b81..ae57fbda2 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -59,6 +59,10 @@ export type TCamundaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Camunda; }; +export type TWindmillConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Windmill; +}; + export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; @@ -76,6 +80,7 @@ export type TAppConnectionOption = | TPostgresConnectionOption | TMsSqlConnectionOption | TCamundaConnectionOption + | TWindmillConnectionOption | TAuth0ConnectionOption; export type TAppConnectionOptionMap = { @@ -91,5 +96,6 @@ export type TAppConnectionOptionMap = { [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; [AppConnection.Camunda]: TCamundaConnectionOption; + [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index c227ae381..29e82bff6 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -13,6 +13,7 @@ import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; +import { TWindmillConnection } from "./windmill-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; @@ -27,6 +28,7 @@ export * from "./mssql-connection"; export * from "./postgres-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; +export * from "./windmill-connection"; export type TAppConnection = | TAwsConnection @@ -41,6 +43,7 @@ export type TAppConnection = | TPostgresConnection | TMsSqlConnection | TCamundaConnection + | TWindmillConnection | TAuth0Connection; export type TAvailableAppConnection = Pick; @@ -81,5 +84,6 @@ export type TAppConnectionMap = { [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; [AppConnection.Camunda]: TCamundaConnection; + [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; }; diff --git a/frontend/src/hooks/api/appConnections/types/windmill-connection.ts b/frontend/src/hooks/api/appConnections/types/windmill-connection.ts new file mode 100644 index 000000000..85de7dd7e --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/windmill-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum WindmillConnectionMethod { + AccessToken = "access-token" +} + +export type TWindmillConnection = TRootAppConnection & { app: AppConnection.Windmill } & { + method: WindmillConnectionMethod.AccessToken; + credentials: { + accessToken: string; + instanceUrl?: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/windmill/index.ts b/frontend/src/hooks/api/appConnections/windmill/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/windmill/queries.tsx b/frontend/src/hooks/api/appConnections/windmill/queries.tsx new file mode 100644 index 000000000..40cc061d3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TWindmillWorkspace } from "./types"; + +const windmillConnectionKeys = { + all: [...appConnectionKeys.all, "windmill"] as const, + listWorkspaces: (connectionId: string) => + [...windmillConnectionKeys.all, "workspaces", connectionId] as const +}; + +export const useWindmillConnectionListWorkspaces = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TWindmillWorkspace[], + unknown, + TWindmillWorkspace[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: windmillConnectionKeys.listWorkspaces(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/windmill/${connectionId}/workspaces` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/windmill/types.ts b/frontend/src/hooks/api/appConnections/windmill/types.ts new file mode 100644 index 000000000..b08a40856 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/windmill/types.ts @@ -0,0 +1,4 @@ +export type TWindmillWorkspace = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 449ddf7e0..b0d3fd7bb 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -9,7 +9,8 @@ export enum SecretSync { Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Camunda = "camunda", - Vercel = "vercel" + Vercel = "vercel", + Windmill = "windmill" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index c6e5f2762..21fda56c7 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,17 +1,18 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; -import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; -import { TDatabricksSync } from "@app/hooks/api/secretSyncs/types/databricks-sync"; -import { TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync"; import { DiscriminativePick } from "@app/types"; +import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; +import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; +import { TGitHubSync } from "./github-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; +import { TWindmillSync } from "./windmill-sync"; export type TSecretSyncOption = { name: string; @@ -30,7 +31,8 @@ export type TSecretSync = | THumanitecSync | TTerraformCloudSync | TCamundaSync - | TVercelSync; + | TVercelSync + | TWindmillSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts b/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts new file mode 100644 index 000000000..220a7c165 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/windmill-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TWindmillSync = TRootSecretSync & { + destination: SecretSync.Windmill; + destinationConfig: { + workspace: string; + path: string; + }; + connection: { + app: AppConnection.Windmill; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 907e388f2..05a7eebbc 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -22,6 +22,7 @@ import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; +import { WindmillConnectionForm } from "./WindmillConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -84,6 +85,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.Windmill: + return ; case AppConnection.Auth0: return ; default: @@ -146,6 +149,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.Windmill: + return ; case AppConnection.Auth0: return ; default: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx new file mode 100644 index 000000000..a1cc2209c --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/WindmillConnectionForm.tsx @@ -0,0 +1,158 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TWindmillConnection, WindmillConnectionMethod } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TWindmillConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Windmill) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(WindmillConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z.string().trim().min(1, "Access Token required"), + instanceUrl: z + .string() + .trim() + .transform((value) => value || undefined) + .refine((value) => (!value ? true : z.string().url().safeParse(value).success), { + message: "Invalid instance URL" + }) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const WindmillConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Windmill, + method: WindmillConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 700bc5ce3..03371c8ce 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -11,6 +11,7 @@ import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol"; +import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -40,6 +41,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Vercel: return ; + case SecretSync.Windmill: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx new file mode 100644 index 000000000..6da83ec6b --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/WindmillSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TWindmillSync } from "@app/hooks/api/secretSyncs/types/windmill-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TWindmillSync; +}; + +export const WindmillSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 13e921e4b..7ef5644ed 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -90,6 +90,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.appName || destinationConfig.app; secondaryText = destinationConfig.env; break; + case SecretSync.Windmill: + primaryText = destinationConfig.workspace; + secondaryText = destinationConfig.path; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index d2ef25975..a3c948b94 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -9,18 +9,19 @@ import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; -import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection"; -import { AwsSecretsManagerSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection"; -import { DatabricksSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection"; -import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection"; +import { AwsParameterStoreSyncDestinationSection } from "./AwsParameterStoreSyncDestinationSection"; +import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSyncDestinationSection"; import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; +import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; +import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; +import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -69,6 +70,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Vercel: DestinationComponents = ; break; + case SecretSync.Windmill: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx new file mode 100644 index 000000000..65149b90e --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/WindmillSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TWindmillSync } from "@app/hooks/api/secretSyncs/types/windmill-sync"; + +type Props = { + secretSync: TWindmillSync; +}; + +export const WindmillSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { path, workspace } + } = secretSync; + + return ( + <> + {workspace} + {path} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index e37294ed8..4fe701c8a 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -51,6 +51,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.TerraformCloud: case SecretSync.Camunda: case SecretSync.Vercel: + case SecretSync.Windmill: AdditionalSyncOptionsComponent = null; break; default: