@backstage/backend-defaults
Version:
Backend defaults used by Backstage backend apps
306 lines (300 loc) • 12.1 kB
JavaScript
;
var fetch = require('node-fetch');
var errors = require('@backstage/errors');
var integration = require('@backstage/integration');
var parseGitUrl = require('git-url-parse');
var lodash = require('lodash');
var minimatch = require('minimatch');
var stream = require('stream');
var ReadUrlResponseFactory = require('./ReadUrlResponseFactory.cjs.js');
var util = require('./util.cjs.js');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
var fetch__default = /*#__PURE__*/_interopDefaultCompat(fetch);
var parseGitUrl__default = /*#__PURE__*/_interopDefaultCompat(parseGitUrl);
class GitlabUrlReader {
static factory = ({ config, treeResponseFactory }) => {
const integrations = integration.ScmIntegrations.fromConfig(config);
return integrations.gitlab.list().map((integration) => {
const reader = new GitlabUrlReader(integration, {
treeResponseFactory
});
const predicate = (url) => url.host === integration.config.host;
return { reader, predicate };
});
};
integration;
deps;
constructor(integration, deps) {
this.integration = integration;
this.deps = deps;
}
async read(url) {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(url, options) {
const { etag, lastModifiedAfter, signal, token } = options ?? {};
const isArtifact = url.includes("/-/jobs/artifacts/");
const builtUrl = await this.getGitlabFetchUrl(url, token);
let response;
try {
response = await fetch__default.default(builtUrl, {
headers: {
...integration.getGitLabRequestOptions(this.integration.config, token).headers,
...etag && !isArtifact && { "If-None-Match": etag },
...lastModifiedAfter && !isArtifact && {
"If-Modified-Since": lastModifiedAfter.toUTCString()
}
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
// removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...signal && { signal }
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new errors.NotModifiedError();
}
if (response.ok) {
return ReadUrlResponseFactory.ReadUrlResponseFactory.fromNodeJSReadable(response.body, {
etag: response.headers.get("ETag") ?? void 0,
lastModifiedAt: util.parseLastModified(
response.headers.get("Last-Modified")
)
});
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new errors.NotFoundError(message);
}
throw new Error(message);
}
async readTree(url, options) {
const { etag, signal, token } = options ?? {};
const { ref, full_name, filepath } = parseGitUrl__default.default(url);
let repoFullName = full_name;
const relativePath = integration.getGitLabIntegrationRelativePath(
this.integration.config
);
if (relativePath) {
const rectifiedRelativePath = `${lodash.trimStart(relativePath, "/")}/`;
repoFullName = full_name.replace(rectifiedRelativePath, "");
}
const projectGitlabResponse = await fetch__default.default(
new URL(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName
)}`
).toString(),
integration.getGitLabRequestOptions(this.integration.config, token)
);
if (!projectGitlabResponse.ok) {
const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`;
if (projectGitlabResponse.status === 404) {
throw new errors.NotFoundError(msg);
}
throw new Error(msg);
}
const projectGitlabResponseJson = await projectGitlabResponse.json();
const branch = ref || projectGitlabResponseJson.default_branch;
const commitsReqParams = new URLSearchParams();
commitsReqParams.set("ref_name", branch);
if (!!filepath) {
commitsReqParams.set("path", filepath);
}
const commitsGitlabResponse = await fetch__default.default(
new URL(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName
)}/repository/commits?${commitsReqParams.toString()}`
).toString(),
{
...integration.getGitLabRequestOptions(this.integration.config, token),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...signal && { signal }
}
);
if (!commitsGitlabResponse.ok) {
const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`;
if (commitsGitlabResponse.status === 404) {
throw new errors.NotFoundError(message);
}
throw new Error(message);
}
const commitSha = (await commitsGitlabResponse.json())[0]?.id ?? "";
if (etag && etag === commitSha) {
throw new errors.NotModifiedError();
}
const archiveReqParams = new URLSearchParams();
archiveReqParams.set("sha", branch);
if (!!filepath) {
archiveReqParams.set("path", filepath);
}
const archiveGitLabResponse = await fetch__default.default(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
repoFullName
)}/repository/archive?${archiveReqParams.toString()}`,
{
...integration.getGitLabRequestOptions(this.integration.config, token),
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...signal && { signal }
}
);
if (!archiveGitLabResponse.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
if (archiveGitLabResponse.status === 404) {
throw new errors.NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
stream: stream.Readable.from(archiveGitLabResponse.body),
subpath: filepath,
etag: commitSha,
filter: options?.filter
});
}
async search(url, options) {
const { filepath } = parseGitUrl__default.default(url);
if (!filepath?.match(/[*?]/)) {
try {
const data = await this.readUrl(url, options);
return {
files: [
{
url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt
}
],
etag: data.etag ?? ""
};
} catch (error) {
errors.assertError(error);
if (error.name === "NotFoundError") {
return {
files: [],
etag: ""
};
}
throw error;
}
}
const staticPart = this.getStaticPart(filepath);
const matcher = new minimatch.Minimatch(filepath);
const treeUrl = lodash.trimEnd(url.replace(filepath, staticPart), `/`);
const pathPrefix = staticPart ? `${staticPart}/` : "";
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
signal: options?.signal,
filter: (path) => matcher.match(`${pathPrefix}${path}`)
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map((file) => ({
url: this.integration.resolveUrl({
url: `/${pathPrefix}${file.path}`,
base: url
}),
content: file.content,
lastModifiedAt: file.lastModifiedAt
}))
};
}
/**
* This function splits the input globPattern string into segments using the path separator /. It then iterates over
* the segments from the end of the array towards the beginning, checking if the concatenated string up to that
* segment matches the original globPattern using the minimatch function. If a match is found, it continues iterating.
* If no match is found, it returns the concatenated string up to the current segment, which is the static part of the
* glob pattern.
*
* E.g. `catalog/foo/*.yaml` will return `catalog/foo`.
*
* @param globPattern - the glob pattern
*/
getStaticPart(globPattern) {
const segments = globPattern.split("/");
const globIndex = segments.findIndex((segment) => segment.match(/[*?]/));
return globIndex === -1 ? globPattern : segments.slice(0, globIndex).join("/");
}
toString() {
const { host, token } = this.integration.config;
return `gitlab{host=${host},authed=${Boolean(token)}}`;
}
async getGitlabFetchUrl(target, token) {
const targetUrl = new URL(target);
if (targetUrl.pathname.includes("/-/jobs/artifacts/")) {
return this.getGitlabArtifactFetchUrl(targetUrl, token).then(
(value) => value.toString()
);
}
return integration.getGitLabFileFetchUrl(target, this.integration.config, token);
}
// convert urls of the form:
// https://example.com/<namespace>/<project>/-/jobs/artifacts/<ref>/raw/<path_to_file>?job=<job_name>
// to urls of the form:
// https://example.com/api/v4/projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=<job_name>
async getGitlabArtifactFetchUrl(target, token) {
if (!target.pathname.includes("/-/jobs/artifacts/")) {
throw new Error("Unable to process url as an GitLab artifact");
}
try {
const [namespaceAndProject, ref] = target.pathname.split("/-/jobs/artifacts/");
const projectPath = new URL(target);
projectPath.pathname = namespaceAndProject;
const projectId = await this.resolveProjectToId(projectPath, token);
const relativePath = integration.getGitLabIntegrationRelativePath(
this.integration.config
);
const newUrl = new URL(target);
newUrl.pathname = `${relativePath}/api/v4/projects/${projectId}/jobs/artifacts/${ref}`;
return newUrl;
} catch (e) {
throw new Error(
`Unable to translate GitLab artifact URL: ${target}, ${e}`
);
}
}
async resolveProjectToId(pathToProject, token) {
let project = pathToProject.pathname;
const relativePath = integration.getGitLabIntegrationRelativePath(
this.integration.config
);
if (relativePath) {
project = project.replace(relativePath, "");
}
project = project.replace(/^\//, "");
const result = await fetch__default.default(
`${pathToProject.origin}${relativePath}/api/v4/projects/${encodeURIComponent(project)}`,
integration.getGitLabRequestOptions(this.integration.config, token)
);
const data = await result.json();
if (!result.ok) {
if (result.status === 401) {
throw new Error(
"GitLab Error: 401 - Unauthorized. The access token used is either expired, or does not have permission to read the project"
);
}
throw new Error(`Gitlab error: ${data.error}, ${data.error_description}`);
}
return Number(data.id);
}
}
exports.GitlabUrlReader = GitlabUrlReader;
//# sourceMappingURL=GitlabUrlReader.cjs.js.map