mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
Merge pull request #5375 from aspatari/feat/infisical-path-in-reference
Some checks are pending
Auto PR to main when version changes / create-pr (push) Waiting to run
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Docker Build / sync-version (push) Blocked by required conditions
autofix.ci / format (push) Waiting to run
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Waiting to run
Some checks are pending
Auto PR to main when version changes / create-pr (push) Waiting to run
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Docker Build / sync-version (push) Blocked by required conditions
autofix.ci / format (push) Waiting to run
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Waiting to run
feat(vault): address an Infisical folder from the reference
This commit is contained in:
commit
e99a98e4fe
106
apps/dokploy/__test__/env/vault.test.ts
vendored
106
apps/dokploy/__test__/env/vault.test.ts
vendored
@ -444,13 +444,22 @@ describe("infisical client", () => {
|
||||
};
|
||||
|
||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||
const list = (secrets: Record<string, string>) =>
|
||||
jsonResponse({
|
||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue,
|
||||
})),
|
||||
});
|
||||
const listPathOf = (callIndex: number) => {
|
||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||
return new URL(url).searchParams.get("secretPath");
|
||||
};
|
||||
|
||||
it("asks the list endpoint to expand secret references", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://real" }],
|
||||
}),
|
||||
);
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
@ -480,6 +489,93 @@ describe("infisical client", () => {
|
||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||
).rejects.toThrow("authentication failed (status 401)");
|
||||
});
|
||||
|
||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"shared/sentry:SENTRY_DSN",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||
});
|
||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||
});
|
||||
|
||||
it("treats a leading slash as an absolute path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("keeps the root path clean when the provider sits at /", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||
|
||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||
"external/sentry:KEY",
|
||||
]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("logs in once and fetches each path once", async () => {
|
||||
const byPath: Record<string, Record<string, string>> = {
|
||||
"/frontend": { A: "a", B: "b" },
|
||||
"/frontend/other": { C: "c" },
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||
return list(byPath[path] ?? {});
|
||||
});
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"A",
|
||||
"B",
|
||||
"other:C",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||
|
||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||
expect(
|
||||
urls
|
||||
.filter((u) => u.includes("/secrets/raw?"))
|
||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||
.sort(),
|
||||
).toEqual(["/frontend", "/frontend/other"]);
|
||||
});
|
||||
|
||||
it("names the path when a secret is missing from an explicit one", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({}));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||
).rejects.toThrow(
|
||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref with an empty path or key", async () => {
|
||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||
"expected format <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
|
||||
@ -32,12 +32,47 @@ const login = async (config: InfisicalConfig) => {
|
||||
return body.accessToken;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
const accessToken = await login(config);
|
||||
// A reference may address a folder: `<path>:<KEY>`, mirroring the HashiCorp
|
||||
// client in this directory. Without a colon the whole ref is the secret name
|
||||
// and the provider's own `secretPath` is used, which is the previous
|
||||
// behaviour. Dots cannot serve as the separator here because Infisical allows
|
||||
// them inside secret names, so `a.b.C` is genuinely ambiguous.
|
||||
const parseRef = (ref: string) => {
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return { path: null, key: ref };
|
||||
}
|
||||
const path = ref.slice(0, separatorIndex);
|
||||
const key = ref.slice(separatorIndex + 1);
|
||||
if (!path || !key) {
|
||||
throw new Error(
|
||||
`Invalid Infisical reference "${ref}": expected format <path>:<KEY> (e.g. external/sentry:SENTRY_DSN)`,
|
||||
);
|
||||
}
|
||||
return { path, key };
|
||||
};
|
||||
|
||||
const resolveSecretPath = (config: InfisicalConfig, refPath: string | null) => {
|
||||
if (!refPath) {
|
||||
return config.secretPath;
|
||||
}
|
||||
if (refPath.startsWith("/")) {
|
||||
return refPath;
|
||||
}
|
||||
const base = config.secretPath.replace(/\/+$/, "");
|
||||
return `${base}/${refPath}`;
|
||||
};
|
||||
|
||||
// One login serves every path a batch of refs touches.
|
||||
const readPath = async (
|
||||
config: InfisicalConfig,
|
||||
accessToken: string,
|
||||
secretPath: string,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
workspaceId: config.projectId,
|
||||
environment: config.environmentSlug,
|
||||
secretPath: config.secretPath,
|
||||
secretPath,
|
||||
// Infisical's list endpoint leaves secret references (`${env.folder.KEY}`)
|
||||
// unexpanded unless asked, so without this a referencing secret arrives as
|
||||
// the literal `${...}` string, lands in the generated .env and the deploy
|
||||
@ -52,7 +87,7 @@ const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: failed to fetch secrets (status ${response.status})`,
|
||||
`Infisical: failed to fetch secrets at "${secretPath}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,18 +102,41 @@ const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
return secrets;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (
|
||||
config: InfisicalConfig,
|
||||
secretPath = config.secretPath,
|
||||
) => readPath(config, await login(config), secretPath);
|
||||
|
||||
export const infisicalClient: VaultClient<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
const { path } = parseRef(ref);
|
||||
const secretPath = resolveSecretPath(config, path);
|
||||
byPath.set(secretPath, [...(byPath.get(secretPath) ?? []), ref]);
|
||||
}
|
||||
|
||||
const accessToken = await login(config);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
[...byPath.entries()].map(async ([secretPath, pathRefs]) => {
|
||||
const secrets = await readPath(config, accessToken, secretPath);
|
||||
for (const ref of pathRefs) {
|
||||
const { path, key } = parseRef(ref);
|
||||
if (secrets[key] === undefined) {
|
||||
// The path is only worth naming when the ref asked for one;
|
||||
// for a bare ref the wording stays as it was, so existing
|
||||
// error messages don't change for anyone.
|
||||
throw new Error(
|
||||
path
|
||||
? `Infisical: secret "${key}" not found at "${secretPath}" in environment "${config.environmentSlug}"`
|
||||
: `Infisical: secret "${key}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[key];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user