-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathremoteSource.ts
More file actions
221 lines (198 loc) · 7.19 KB
/
remoteSource.ts
File metadata and controls
221 lines (198 loc) · 7.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import * as fs from "fs";
import * as path from "path";
import { spawnSync, SpawnSyncReturns } from "child_process";
import * as tmp from "tmp";
import { FirebaseError, hasMessage } from "../../error";
import { logger } from "../../logger";
import { logLabeledBullet } from "../../utils";
import { resolveWithin } from "../../pathUtils";
export interface GitClient {
clone(repository: string, destination: string): SpawnSyncReturns<string>;
fetch(ref: string, cwd: string): SpawnSyncReturns<string>;
checkout(ref: string, cwd: string): SpawnSyncReturns<string>;
}
export class DefaultGitClient implements GitClient {
clone(repository: string, destination: string): SpawnSyncReturns<string> {
return spawnSync(
"git",
["clone", "--filter=blob:none", "--no-checkout", "--depth=1", repository, destination],
{ encoding: "utf8" },
);
}
fetch(ref: string, cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", ref], {
cwd,
encoding: "utf8",
});
}
checkout(ref: string, cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["checkout", ref], { cwd, encoding: "utf8" });
}
}
export async function cloneRemoteSource(
repository: string,
ref: string,
dir?: string,
gitClient: GitClient = new DefaultGitClient(),
): Promise<string> {
/**
* Clones a Git repo to a temporary directory and returns the absolute path
* to the source directory. Verifies that a `functions.yaml` manifest exists
* before returning.
*
* @param repository Remote Git URL (e.g. https://github.com/org/repo)
* @param ref Git ref to fetch (tag/branch/commit)
* @param dir Optional subdirectory within the repo to use
* @param gitClient Optional Git client for testing/injection
* @returns Absolute path to the checked‑out source directory
*/
logger.debug(`Cloning remote source: ${repository}@${ref} (dir: ${dir || "."})`);
const tmpDir = tmp.dirSync({
prefix: "firebase-functions-remote-",
unsafeCleanup: true,
});
if (!isGitAvailable()) {
throw new FirebaseError(
"Git is required to deploy functions from a remote source. " +
"Please install Git from https://git-scm.com/downloads and try again.",
);
}
try {
logger.debug(`Fetching remote source for ${repository}@${ref}...`);
const cloneResult = await runGitWithRetry(() => gitClient.clone(repository, tmpDir.name));
if (cloneResult.error || cloneResult.status !== 0) {
throw (
cloneResult.error ||
new Error(
cloneResult.stderr ||
cloneResult.stdout ||
`Clone failed with status ${cloneResult.status}`,
)
);
}
const fetchResult = await runGitWithRetry(() => gitClient.fetch(ref, tmpDir.name));
if (fetchResult.error || fetchResult.status !== 0) {
throw (
fetchResult.error ||
new Error(
fetchResult.stderr ||
fetchResult.stdout ||
`Fetch failed with status ${fetchResult.status}`,
)
);
}
const checkoutResult = gitClient.checkout("FETCH_HEAD", tmpDir.name);
if (checkoutResult.error || checkoutResult.status !== 0) {
throw (
checkoutResult.error ||
new Error(
checkoutResult.stderr ||
checkoutResult.stdout ||
`Checkout failed with status ${checkoutResult.status}`,
)
);
}
const sourceDir = dir
? resolveWithin(
tmpDir.name,
dir,
`Subdirectory '${dir}' in remote source must not escape the repository root.`,
)
: tmpDir.name;
if (dir && !fs.existsSync(sourceDir)) {
throw new FirebaseError(`Directory '${dir}' not found in repository ${repository}@${ref}`);
}
requireFunctionsYaml(sourceDir);
const origin = `${repository}@${ref}${dir ? `/${dir}` : ""}`;
logLabeledBullet("functions", `verified functions.yaml in remote source (${origin})`);
return sourceDir;
} catch (error: unknown) {
if (error instanceof FirebaseError) {
throw error;
}
const errorMessage = hasMessage(error) ? error.message : String(error);
if (
errorMessage.includes("Could not resolve host") ||
errorMessage.includes("unable to access")
) {
throw new FirebaseError(
`Unable to access repository '${repository}'. ` +
`Please check the repository URL and your network connection.`,
);
}
if (errorMessage.includes("pathspec") || errorMessage.includes("did not match")) {
throw new FirebaseError(
`Git ref '${ref}' not found in repository '${repository}'. ` +
`Please check that the ref (tag, branch, or commit) exists.`,
);
}
if (
errorMessage.includes("Permission denied") ||
errorMessage.includes("Authentication failed")
) {
throw new FirebaseError(
`Authentication failed for repository '${repository}'. ` +
`For private repositories, please ensure you have configured Git authentication.`,
);
}
throw new FirebaseError(`Failed to clone repository '${repository}@${ref}': ${errorMessage}`);
}
}
/**
* Checks whether the `git` binary is available in the current environment.
* @returns true if `git --version` runs successfully; false otherwise.
*/
export function isGitAvailable(): boolean {
const result = spawnSync("git", ["--version"], { encoding: "utf8" });
return !result.error && result.status === 0;
}
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTransientGitError(message: string): boolean {
const m = message.toLowerCase();
return (
m.includes("could not resolve host") ||
m.includes("unable to access") ||
m.includes("connection reset") ||
m.includes("timed out") ||
m.includes("temporary failure in name resolution") ||
m.includes("ssl_read") ||
m.includes("network is unreachable")
);
}
async function runGitWithRetry(
cmd: () => SpawnSyncReturns<string>,
retries = 1,
backoffMs = 200,
): Promise<SpawnSyncReturns<string>> {
let attempt = 0;
while (true) {
const res = cmd();
if (!res.error && res.status === 0) {
return res;
}
const stderr = (res.stderr || res.stdout || "").toString();
if (attempt < retries && isTransientGitError(stderr)) {
await delay(backoffMs * (attempt + 1));
attempt++;
continue;
}
return res;
}
}
/**
* Verifies that a `functions.yaml` manifest exists at the given directory.
* Throws a FirebaseError with guidance if it is missing.
*/
export function requireFunctionsYaml(codeDir: string): void {
const functionsYamlPath = path.join(codeDir, "functions.yaml");
if (!fs.existsSync(functionsYamlPath)) {
throw new FirebaseError(
`The remote repository is missing a required deployment manifest (functions.yaml).\n\n` +
`For your security, Firebase requires a static manifest to deploy functions from a remote source. ` +
`This prevents the execution of arbitrary code on your machine during the function discovery process.\n\n` +
`To resolve this, clone the repository locally, inspect the code for safety, and deploy it as a local source.`,
);
}
}