Skip to content

Commit 385c7a1

Browse files
committed
Mark images for deletion on release deletion
Change-type: minor
1 parent 509e9de commit 385c7a1

File tree

19 files changed

+702
-1
lines changed

19 files changed

+702
-1
lines changed

config/confd/templates/env.tmpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ ASYNC_TASK_ATTEMPT_LIMIT={{getenv "ASYNC_TASK_ATTEMPT_LIMIT"}}
55
ASYNC_TASK_CREATE_SERVICE_INSTALLS_ENABLED={{getenv "ASYNC_TASK_CREATE_SERVICE_INSTALLS_ENABLED"}}
66
ASYNC_TASK_CREATE_SERVICE_INSTALLS_BATCH_SIZE={{getenv "ASYNC_TASK_CREATE_SERVICE_INSTALLS_BATCH_SIZE"}}
77
ASYNC_TASK_CREATE_SERVICE_INSTALLS_MAX_TIME_MS={{getenv "ASYNC_TASK_CREATE_SERVICE_INSTALLS_MAX_TIME_MS"}}
8+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED={{getenv "ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED"}}
9+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE={{getenv "ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE"}}
10+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS={{getenv "ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS"}}
811
COOKIE_SESSION_SECRET={{getenv "COOKIE_SESSION_SECRET"}}
912
CONTRACTS_PUBLIC_REPO_OWNER={{getenv "CONTRACTS_PUBLIC_REPO_OWNER"}}
1013
CONTRACTS_PUBLIC_REPO_NAME={{getenv "CONTRACTS_PUBLIC_REPO_NAME"}}

docker-compose.test-custom.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ services:
7474
environment: &env
7575
API_HOST: 127.0.0.1
7676
API_VPN_SERVICE_API_KEY: api_vpn_service_api_key
77+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED: true
7778
BLUEBIRD_DEBUG: 1
7879
BLUEBIRD_LONG_STACK_TRACES: 0
7980
COOKIE_SESSION_SECRET: fuschia
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { sbvrUtils, hooks, permissions } from '@balena/pinejs';
2+
import _ from 'lodash';
3+
import type { DeleteRegistryImagesTaskParams } from '../tasks/delete-registry-images.js';
4+
import {
5+
ASYNC_TASK_ATTEMPT_LIMIT,
6+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE,
7+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED,
8+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS,
9+
ASYNC_TASKS_ENABLED,
10+
} from '../../../lib/config.js';
11+
12+
interface DeleteRequestCustomObject {
13+
imagesToCleanup?: DeleteRegistryImagesTaskParams['images'];
14+
}
15+
16+
if (ASYNC_TASKS_ENABLED && ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED) {
17+
hooks.addPureHook('DELETE', 'resin', 'image', {
18+
PRERUN: async (args) => {
19+
const { api, request } = args;
20+
const affectedIds = await sbvrUtils.getAffectedIds(args);
21+
if (affectedIds.length === 0) {
22+
return;
23+
}
24+
25+
// Get information required to mark the image for deletion on
26+
// the registry before the record is deleted from our database.
27+
const images = await api.get({
28+
resource: 'image',
29+
options: {
30+
$select: ['is_stored_at__image_location', 'content_hash'],
31+
$filter: {
32+
id: { $in: affectedIds },
33+
content_hash: { $ne: null },
34+
},
35+
},
36+
});
37+
if (images.length > 0) {
38+
(request.custom as DeleteRequestCustomObject).imagesToCleanup =
39+
images.map((image) => [
40+
image.is_stored_at__image_location,
41+
image.content_hash!,
42+
]);
43+
}
44+
},
45+
POSTRUN: async ({ request, tx }) => {
46+
const { imagesToCleanup } = request.custom as DeleteRequestCustomObject;
47+
if (imagesToCleanup == null || imagesToCleanup.length === 0) {
48+
return;
49+
}
50+
51+
const chunks = _.chunk(
52+
imagesToCleanup,
53+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE,
54+
);
55+
await Promise.all(
56+
chunks.map((chunk) =>
57+
sbvrUtils.api.tasks.post({
58+
resource: 'task',
59+
passthrough: { req: permissions.root, tx },
60+
body: {
61+
is_executed_by__handler: 'delete_registry_images',
62+
is_executed_with__parameter_set: {
63+
images: chunk,
64+
} satisfies DeleteRegistryImagesTaskParams,
65+
is_scheduled_to_execute_on__time: new Date(
66+
Date.now() +
67+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS * 1000,
68+
),
69+
attempt_limit: ASYNC_TASK_ATTEMPT_LIMIT,
70+
},
71+
}),
72+
),
73+
);
74+
},
75+
});
76+
}

src/features/images/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './delete-registry-images.js';
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { permissions, sbvrUtils, tasks } from '@balena/pinejs';
2+
import _ from 'lodash';
3+
import { generateToken } from '../../registry/registry.js';
4+
import {
5+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE,
6+
REGISTRY2_HOST,
7+
} from '../../../lib/config.js';
8+
import { requestAsync } from '../../../infra/request-promise/index.js';
9+
import { setTimeout } from 'node:timers/promises';
10+
11+
const { api } = sbvrUtils;
12+
13+
// For registry API requests
14+
const DELAY = 200;
15+
const RATE_LIMIT_DELAY_BASE = 1000;
16+
const RATE_LIMIT_RETRIES = 5;
17+
18+
const schema = {
19+
type: 'object',
20+
properties: {
21+
images: {
22+
type: 'array',
23+
items: {
24+
type: 'array',
25+
items: {
26+
type: 'string',
27+
},
28+
maxItems: 2,
29+
minItems: 2,
30+
},
31+
},
32+
},
33+
required: ['images'],
34+
};
35+
36+
export type DeleteRegistryImagesTaskParams = {
37+
images: Array<[repo: string, hash: string]>;
38+
};
39+
40+
const handlerName = 'delete_registry_images';
41+
const logHeader = handlerName.replace(/_/g, '-');
42+
tasks.addTaskHandler(
43+
handlerName,
44+
async (options) => {
45+
try {
46+
const images =
47+
(options.params as DeleteRegistryImagesTaskParams).images ?? [];
48+
if (images.length === 0) {
49+
return {
50+
status: 'succeeded',
51+
};
52+
}
53+
54+
// Chunk by batch size in case we need to tune after tasks have been created
55+
for (const chunk of _.chunk(
56+
images,
57+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE,
58+
)) {
59+
// Avoid deleting any blobs that are still referenced by other images
60+
// This shouldn't normally be necessary as is_stored_at__image_location
61+
// should be enforced as unique at the database level, but just in case
62+
const stillReferenced = await api.resin.get({
63+
resource: 'image',
64+
passthrough: { req: permissions.rootRead },
65+
options: {
66+
$select: ['id', 'is_stored_at__image_location', 'content_hash'],
67+
$filter:
68+
chunk.length === 1
69+
? {
70+
is_stored_at__image_location: chunk[0][0],
71+
content_hash: chunk[0][1],
72+
}
73+
: {
74+
$or: chunk.map(([repo, hash]) => ({
75+
is_stored_at__image_location: repo,
76+
content_hash: hash,
77+
})),
78+
},
79+
},
80+
});
81+
const safeToDelete = chunk.filter(
82+
([repo, hash]) =>
83+
!stillReferenced.some(
84+
(image) =>
85+
image.is_stored_at__image_location === repo &&
86+
image.content_hash === hash,
87+
),
88+
);
89+
for (const [repo, hash] of safeToDelete) {
90+
if (repo === '' || hash === '') {
91+
console.warn(
92+
`[${logHeader}] Skipping deletion of image with empty repo or hash: ${repo}/${hash}`,
93+
);
94+
continue;
95+
}
96+
await markForDeletion(repo, hash);
97+
}
98+
}
99+
100+
return {
101+
status: 'succeeded',
102+
};
103+
} catch (e) {
104+
console.error(`[${logHeader}] Error marking images for deletion: ${e}`);
105+
return {
106+
error: `${e}`,
107+
status: 'failed',
108+
};
109+
}
110+
},
111+
schema,
112+
);
113+
114+
// Make an API call to the registry service to mark images for deletion on next garbage collection
115+
async function markForDeletion(repo: string, hash: string) {
116+
// Generate an admin-level token with delete permission
117+
const token = generateToken('admin', REGISTRY2_HOST, [
118+
{
119+
name: repo,
120+
type: 'repository',
121+
actions: ['delete'],
122+
},
123+
]);
124+
125+
// Need to make requests one image at a time, no batch endpoint available
126+
for (let retries = 0; retries < RATE_LIMIT_RETRIES; retries++) {
127+
const [{ statusCode, statusMessage, headers }] = await requestAsync({
128+
url: `https://${REGISTRY2_HOST}/v2/${repo}/manifests/${hash}`,
129+
headers: { Authorization: `Bearer ${token}` },
130+
method: 'DELETE',
131+
});
132+
133+
// Return on success or not found
134+
if (statusCode === 202 || statusCode === 404) {
135+
await setTimeout(DELAY);
136+
return;
137+
} else if (statusCode === 429) {
138+
// Default delay value to exponential backoff
139+
let delay = RATE_LIMIT_DELAY_BASE * Math.pow(2, retries);
140+
141+
// Use the retry-after header value if available
142+
const retryAfterHeader = headers?.['retry-after'];
143+
if (retryAfterHeader) {
144+
const headerDelay = parseInt(retryAfterHeader, 10);
145+
if (!isNaN(headerDelay)) {
146+
delay = headerDelay * 1000;
147+
} else {
148+
const retryDate = Date.parse(retryAfterHeader);
149+
const waitMillis = retryDate - Date.now();
150+
if (waitMillis > 0) {
151+
delay = waitMillis;
152+
}
153+
}
154+
}
155+
156+
// Apply some jitter for good measure
157+
delay += Math.random() * 1000;
158+
159+
console.warn(
160+
`[${logHeader}] Received 429 for ${repo}/${hash}. Retrying in ${delay}ms...`,
161+
);
162+
await setTimeout(delay);
163+
} else {
164+
throw new Error(
165+
`Failed to mark ${repo}/${hash} for deletion: [${statusCode}] ${statusMessage}`,
166+
);
167+
}
168+
169+
// If we get here, it means we ran out of retries due to rate limiting
170+
throw new Error(
171+
`Failed to mark ${repo}/${hash} for deletion: exceeded retry limit due to rate limiting`,
172+
);
173+
}
174+
}

src/features/images/tasks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './delete-registry-images.js';

src/features/registry/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ const authorizeRequest = async (
553553
);
554554
};
555555

556-
const generateToken = (
556+
export const generateToken = (
557557
subject = '',
558558
audience: string,
559559
access: Access[],

src/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import './features/field-size-limits/hooks.js';
1212
import './features/hostapp/hooks/index.js';
1313
import './features/organizations/hooks/index.js';
1414
import './features/releases/hooks/index.js';
15+
import './features/images/hooks/index.js';
1516
import './features/service-install/hooks/index.js';
1617
import './features/supervisor-app/hooks/index.js';
1718
import './features/tags/hooks.js';

src/lib/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,18 @@ export const ASYNC_TASK_CREATE_SERVICE_INSTALLS_MAX_TIME_MS = intVar(
456456
'ASYNC_TASK_CREATE_SERVICE_INSTALLS_MAX_TIME_MS',
457457
30 * 1000,
458458
);
459+
export const ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED = boolVar(
460+
'ASYNC_TASK_DELETE_REGISTRY_IMAGES_ENABLED',
461+
false,
462+
);
463+
export const ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE = intVar(
464+
'ASYNC_TASK_DELETE_REGISTRY_IMAGES_BATCH_SIZE',
465+
1000,
466+
);
467+
export let ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS = intVar(
468+
'ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS',
469+
60 * 60 * 24 * 30, // 30 days
470+
);
459471

460472
export const USERNAME_BLACKLIST = ['root'];
461473

@@ -726,4 +738,10 @@ export const TEST_MOCK_ONLY = {
726738
guardTestMockOnly();
727739
PINEJS_QUEUE_INTERVAL_MS = v;
728740
},
741+
set ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS(
742+
v: typeof ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS,
743+
) {
744+
guardTestMockOnly();
745+
ASYNC_TASK_DELETE_REGISTRY_IMAGES_OFFSET_SECONDS = v;
746+
},
729747
};

src/tasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import './features/ci-cd/tasks/index.js';
2+
import './features/images/tasks/index.js';

0 commit comments

Comments
 (0)