Skip to content

Commit 6314ee1

Browse files
committed
Delete Container Registry images left after Functions deployment
1 parent 34a173e commit 6314ee1

File tree

5 files changed

+515
-4
lines changed

5 files changed

+515
-4
lines changed

src/api.js

+1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ var api = {
9999
"FIREBASE_CLOUDLOGGING_URL",
100100
"https://logging.googleapis.com"
101101
),
102+
containerRegistryDomain: utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io"),
102103
appDistributionOrigin: utils.envOverride(
103104
"FIREBASE_APP_DISTRIBUTION_URL",
104105
"https://firebaseappdistribution.googleapis.com"
+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// This code is very aggressive about running requests in parallel and does not use
2+
// a task queue, because the quota limits for GCR.io are absurdly high. At the time
3+
// of writing, we can make 50K requests per 10m.
4+
// https://cloud.google.com/container-registry/quotas
5+
6+
import * as clc from "cli-color";
7+
8+
import { logger } from "../../logger";
9+
import * as gcr from "../../gcp/containerregistry";
10+
import * as backend from "./backend";
11+
import * as utils from "../../utils";
12+
13+
// A flattening of container_registry_hosts and
14+
// region_multiregion_map from regionconfig.borg
15+
const SUBDOMAIN_MAPPING: Record<string, string> = {
16+
"us-west2": "us",
17+
"us-west3": "us",
18+
"us-west4": "us",
19+
"us-central1": "us",
20+
"us-central2": "us",
21+
"us-east1": "us",
22+
"us-east4": "us",
23+
"northamerica-northeast1": "us",
24+
"southamerica-east1": "us",
25+
"europe-west1": "eu",
26+
"europe-west2": "eu",
27+
"europe-west3": "eu",
28+
"europe-west5": "eu",
29+
"europe-west6": "eu",
30+
"europe-central2": "eu",
31+
"asia-east1": "asia",
32+
"asia-east2": "asia",
33+
"asia-northeast1": "asia",
34+
"asia-northeast2": "asia",
35+
"asia-northeast3": "asia",
36+
"asia-south1": "asia",
37+
"asia-southeast2": "asia",
38+
"australia-southeast1": "asia",
39+
};
40+
41+
export async function cleanupBuildImages(functions: backend.FunctionSpec[]): Promise<void> {
42+
utils.logBullet(clc.bold.cyan("functions: ") + "cleaning up build files...");
43+
const gcrCleaner = new ContainerRegistryCleaner();
44+
try {
45+
await Promise.all(functions.map((func) => gcrCleaner.cleanupFunction(func)));
46+
} catch (err) {
47+
logger.debug("Failed to delete container registry artifacts with error", err);
48+
utils.logLabeledWarning(
49+
"functions",
50+
"Unhnandled error cleaning up build files. This could result in a small monthly bill if not corrected"
51+
);
52+
}
53+
54+
// TODO: clean up Artifact Registry images as well.
55+
}
56+
57+
export class ContainerRegistryCleaner {
58+
readonly helpers: Record<string, ContainerRegistryHelper> = {};
59+
60+
private helper(location: string): ContainerRegistryHelper {
61+
const subdomain = SUBDOMAIN_MAPPING[location] || "us";
62+
if (!this.helpers[subdomain]) {
63+
this.helpers[subdomain] = new ContainerRegistryHelper(subdomain);
64+
}
65+
return this.helpers[subdomain];
66+
}
67+
68+
// GCFv1 has the directory structure:
69+
// gcf/
70+
// +- <region>/
71+
// +- <uuid>
72+
// +- <hash> (tags: <FuncName>_version-<#>)
73+
// +- cache/ (Only present in first deploy of region)
74+
// | +- <hash> (tags: latest)
75+
// +- worker/ (Only present in first deploy of region)
76+
// +- <hash> (tags: latest)
77+
//
78+
// We'll parallel search for the valid <uuid> and their children
79+
// until we find one with the right tag for the function name.
80+
// The underlying Helper's caching should make this expensive for
81+
// the first function and free for the next functions in the same
82+
// region.
83+
async cleanupFunction(func: backend.FunctionSpec): Promise<void> {
84+
const helper = this.helper(func.region);
85+
const uuids = (await helper.ls(`${func.project}/gcf/${func.region}`)).children;
86+
87+
const uuidTags: Record<string, string[]> = {};
88+
const loadUuidTags: Promise<void>[] = [];
89+
for (const uuid of uuids) {
90+
loadUuidTags.push(
91+
(async () => {
92+
const path = `${func.project}/gcf/${func.region}/${uuid}`;
93+
const tags = (await helper.ls(path)).tags;
94+
uuidTags[path] = tags;
95+
})()
96+
);
97+
}
98+
await Promise.all(loadUuidTags);
99+
100+
const extractFunction = /^(.*)_version-\d+$/;
101+
const entry = Object.entries(uuidTags).find(([, tags]) => {
102+
return tags.find((tag) => {
103+
const match = tag.match(extractFunction);
104+
return match && match[1] === func.id;
105+
});
106+
});
107+
108+
if (!entry) {
109+
logger.debug("Could not find image for function", backend.functionName(func));
110+
return;
111+
}
112+
await helper.rm(entry[0]);
113+
}
114+
}
115+
116+
export interface Stat {
117+
children: string[];
118+
digests: gcr.Digest[];
119+
tags: gcr.Tag[];
120+
}
121+
122+
export interface Node {
123+
// If we haven't actually done an LS on this exact location
124+
sparse: boolean;
125+
126+
// For directories
127+
children: Record<string, Node>;
128+
129+
// For images
130+
digests: gcr.Digest[];
131+
tags: gcr.Tag[];
132+
}
133+
134+
export class ContainerRegistryHelper {
135+
readonly client: gcr.Client;
136+
readonly cache: Node;
137+
138+
constructor(subdomain: string) {
139+
this.client = new gcr.Client(subdomain);
140+
this.cache = {
141+
sparse: true,
142+
children: {},
143+
digests: [],
144+
tags: [],
145+
};
146+
}
147+
148+
private async getNode(path: string): Promise<Node> {
149+
const parts = path.split("/");
150+
let cwd = this.cache;
151+
for (const part of parts) {
152+
if (!cwd.children[part]) {
153+
cwd.children[part] = {
154+
sparse: true,
155+
children: {},
156+
digests: [],
157+
tags: [],
158+
};
159+
}
160+
cwd = cwd.children[part];
161+
}
162+
if (cwd.sparse) {
163+
const raw = await this.client.listTags(path);
164+
cwd.sparse = false;
165+
cwd.tags = raw.tags;
166+
cwd.digests = Object.keys(raw.manifest);
167+
cwd.children = {};
168+
for (const child of raw.child) {
169+
cwd.children[child] = {
170+
sparse: true,
171+
children: {},
172+
digests: [],
173+
tags: [],
174+
};
175+
}
176+
}
177+
return cwd;
178+
}
179+
180+
async ls(path: string): Promise<Stat> {
181+
const node = await this.getNode(path);
182+
return {
183+
children: Object.keys(node.children),
184+
digests: node.digests,
185+
tags: node.tags,
186+
};
187+
}
188+
189+
async rm(path: string): Promise<void> {
190+
const node = await this.getNode(path);
191+
const deleteChildren: Promise<void>[] = [];
192+
const recursive = Object.keys(node.children).map((child) => this.rm(`${path}/${child}`));
193+
// Let children ("directories") be cleaned up in parallel while we clean
194+
// up the "files" in this location.
195+
196+
const deleteTags = node.tags.map((tag) => this.client.deleteTag(path, tag));
197+
await Promise.all(deleteTags);
198+
node.tags = [];
199+
200+
const deleteImages = node.digests.map((digest) => this.client.deleteImage(path, digest));
201+
await Promise.all(deleteImages);
202+
node.digests = [];
203+
204+
await Promise.all(recursive);
205+
node.children = {};
206+
}
207+
}

src/deploy/functions/release.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { getAppEngineLocation } from "../../functionsConfig";
77
import { promptForFunctionDeletion } from "./prompts";
88
import { DeploymentTimer } from "./deploymentTimer";
99
import { ErrorHandler } from "./errorHandler";
10-
import * as utils from "../../utils";
10+
import { Options } from "../../options";
11+
import * as args from "./args";
12+
import * as backend from "./backend";
13+
import * as containerCleaner from "./containerCleaner";
1114
import * as helper from "./functionsDeployHelper";
1215
import * as tasks from "./tasks";
13-
import * as backend from "./backend";
14-
import * as args from "./args";
15-
import { Options } from "../../options";
16+
import * as utils from "../../utils";
1617

1718
export async function release(context: args.Context, options: Options, payload: args.Payload) {
1819
if (!options.config.has("functions")) {
@@ -136,6 +137,7 @@ export async function release(context: args.Context, options: Options, payload:
136137
);
137138
}
138139
helper.logAndTrackDeployStats(cloudFunctionsQueue, errorHandler);
140+
await containerCleaner.cleanupBuildImages(payload.functions!.backend.cloudFunctions);
139141
await helper.printTriggerUrls(context);
140142
errorHandler.printWarnings();
141143
errorHandler.printErrors();

src/gcp/containerregistry.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Note: unlike Google APIs, the documentation for the GCR API is
2+
// actually the Docker REST API. This can be found at
3+
// https://docs.docker.com/registry/spec/api/
4+
// This API is _very_ complex in its entirety and is very subtle (e.g. tags and digests
5+
// are both strings and can both be put in the same route to get completely different
6+
// response document types).
7+
// This file will only implement a minimal subset as needed.
8+
import { FirebaseError } from "../error";
9+
import { containerRegistryDomain } from "../api";
10+
import * as api from "../apiv2";
11+
12+
// A Digest is a string in the format <algorithm>:<hex>. For example:
13+
// sha256:146d8c9dff0344fb01417ef28673ed196e38215f3c94837ae733d3b064ba439e
14+
export type Digest = string;
15+
export type Tag = string;
16+
17+
export interface Tags {
18+
name: string;
19+
tags: string[];
20+
21+
// These fields are not documented in the Docker API but are
22+
// present in the GCR API.
23+
manifest: Record<Digest, ImageInfo>;
24+
child: string[];
25+
}
26+
27+
export interface ImageInfo {
28+
// times are string milliseconds
29+
timeCreatedMs: string;
30+
timeUploadedMs: string;
31+
tag: string[];
32+
mediaType: string;
33+
imageSizeBytes: string;
34+
layerId: string;
35+
}
36+
37+
interface ErrorsResponse {
38+
errors?: {
39+
code: string;
40+
message: string;
41+
details: unknown;
42+
}[];
43+
}
44+
45+
function isErrors(response: unknown): response is ErrorsResponse {
46+
return Object.prototype.hasOwnProperty.bind(response)("errors");
47+
}
48+
49+
const API_VERSION = "v2";
50+
51+
export class Client {
52+
readonly client: api.Client;
53+
54+
constructor(subdomain?: string) {
55+
let origin: string;
56+
if (subdomain) {
57+
origin = `https://${subdomain}.${containerRegistryDomain}`;
58+
} else {
59+
origin = `https://${containerRegistryDomain}`;
60+
}
61+
this.client = new api.Client({
62+
apiVersion: API_VERSION,
63+
auth: true,
64+
urlPrefix: origin,
65+
});
66+
}
67+
68+
async listTags(path: string): Promise<Tags> {
69+
const response = await this.client.get<Tags | ErrorsResponse>(`${path}/tags/list`);
70+
if (isErrors(response.body)) {
71+
throw new FirebaseError(`Failed to list GCR tags at ${path}`, {
72+
children: response.body.errors,
73+
});
74+
}
75+
return response.body;
76+
}
77+
78+
async deleteTag(path: string, tag: Tag): Promise<void> {
79+
const response = await this.client.delete<ErrorsResponse>(`${path}/manifests/${tag}`);
80+
if (response.body.errors?.length != 0) {
81+
throw new FirebaseError(`Failed to delete tag ${tag} at path ${path}`, {
82+
children: response.body.errors,
83+
});
84+
}
85+
}
86+
87+
async deleteImage(path: string, digest: Digest): Promise<void> {
88+
const response = await this.client.delete<ErrorsResponse>(`${path}/manifests/${digest}`);
89+
if (response.body.errors?.length != 0) {
90+
throw new FirebaseError(`Failed to delete image ${digest} at path ${path}`, {
91+
children: response.body.errors,
92+
});
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)