ghcr-manager 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/LICENSE +1 -1
- package/README.md +45 -63
- package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +0 -1
- package/dist/cleanup-summary/_cleanup-summary-markdown.js +149 -39
- package/dist/cleanup-summary/_cleanup-summary.d.ts +22 -10
- package/dist/cleanup-summary/_cleanup-summary.js +26 -11
- package/dist/cleanup-summary/index.d.ts +1 -1
- package/dist/cli/_cleanup-command.js +82 -23
- package/dist/cli/_json-output.d.ts +1 -0
- package/dist/cli/_json-output.js +11 -0
- package/dist/cli/_tag-selector-resolver.js +36 -13
- package/dist/cli/index.js +1 -5
- package/dist/core/_types.d.ts +9 -6
- package/dist/core/_types.js +8 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/db/_cleanup-run-writer.d.ts +1 -1
- package/dist/db/_cleanup-run-writer.js +28 -7
- package/dist/db/_db-merge-cleanup-copy.js +4 -2
- package/dist/db/_db-merge-scan-copy.js +3 -2
- package/dist/db/_manifest-reachability.js +47 -8
- package/dist/db/_scan-writer.js +0 -9
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +1 -0
- package/dist/db/planner/_planner-direct-target-root-options.d.ts +11 -0
- package/dist/db/planner/_planner-direct-target-root-options.js +1 -0
- package/dist/db/planner/_planner-direct-target-root-tag-filters.d.ts +9 -0
- package/dist/db/planner/_planner-direct-target-root-tag-filters.js +42 -0
- package/dist/db/planner/_planner-direct-target-roots-combined-sql.d.ts +7 -0
- package/dist/db/planner/_planner-direct-target-roots-combined-sql.js +198 -0
- package/dist/db/planner/_planner-direct-target-roots-combined.d.ts +4 -0
- package/dist/db/planner/_planner-direct-target-roots-combined.js +10 -0
- package/dist/db/planner/_planner-direct-target-roots-tagged.d.ts +4 -0
- package/dist/db/planner/_planner-direct-target-roots-tagged.js +125 -0
- package/dist/db/planner/_planner-direct-target-roots.d.ts +2 -11
- package/dist/db/planner/_planner-direct-target-roots.js +8 -192
- package/dist/db/planner/_planner-direct-target-tags.d.ts +1 -1
- package/dist/db/planner/_planner-direct-target-tags.js +7 -6
- package/dist/db/planner/_planner-output.js +34 -13
- package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.d.ts +1 -0
- package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.js +65 -0
- package/dist/db/planner/_planner-plan-artifacts-closure-sql.d.ts +1 -0
- package/dist/db/planner/_planner-plan-artifacts-closure-sql.js +195 -0
- package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.d.ts +1 -0
- package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.js +86 -0
- package/dist/db/planner/_planner-plan-artifacts.js +26 -128
- package/dist/db/planner/_planner-repository.d.ts +2 -1
- package/dist/db/planner/_planner-repository.js +3 -1
- package/dist/db/planner/_planner-sql.js +13 -2
- package/dist/db/planner/_planner-types.d.ts +23 -8
- package/dist/db/planner/_planner-types.js +14 -3
- package/dist/db/planner/index.d.ts +2 -1
- package/dist/db/planner/index.js +1 -0
- package/dist/execute/_plan-executor.d.ts +1 -1
- package/dist/execute/_plan-executor.js +38 -16
- package/dist/execute/_types.d.ts +2 -19
- package/dist/execute/_untag-client.d.ts +2 -2
- package/dist/execute/_untag-client.js +1 -42
- package/dist/execute/index.d.ts +1 -1
- package/dist/ingest/github/_manifest-kind.d.ts +7 -1
- package/dist/ingest/github/_manifest-kind.js +21 -6
- package/package.json +16 -10
- package/resources/sql/schema/001_schema.sql +17 -4
- package/dist/cli/_untag-command.d.ts +0 -1
- package/dist/cli/_untag-command.js +0 -57
- package/resources/sql/views/002_v_missing_digests.sql +0 -32
- package/resources/sql/views/003_v_scan_root_manifests.sql +0 -44
- package/resources/sql/views/004_v_digest_tag_relations.sql +0 -50
- package/resources/sql/views/005_v_cleanup_root_closure_members.sql +0 -101
- package/resources/sql/views/006_v_cleanup_blocking_overlaps.sql +0 -42
- package/resources/sql/views/007_v_cleanup_root_decision_readable.sql +0 -67
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { DeletePlanValidationStatuses } from "../db/index.js";
|
|
1
2
|
export function buildCleanupSummary(plan, options) {
|
|
2
3
|
const directTargetTagSet = new Set(plan.directTargetTags);
|
|
3
|
-
const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.
|
|
4
|
-
const fullyDeletableRoots = roots.filter((root) => root.validationStatus ===
|
|
5
|
-
const blockedRoots = roots.filter((root) => root.validationStatus ===
|
|
6
|
-
const untagOnlyRoots = roots.filter((root) => root.validationStatus ===
|
|
7
|
-
const
|
|
4
|
+
const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.rootTagsByVersionId));
|
|
5
|
+
const fullyDeletableRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.fullyDeletable);
|
|
6
|
+
const blockedRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.blocked);
|
|
7
|
+
const untagOnlyRoots = roots.filter((root) => root.validationStatus === DeletePlanValidationStatuses.untagOnly);
|
|
8
|
+
const affectedManifests = _listAffectedManifests(plan, fullyDeletableRoots.map((root) => root.digest));
|
|
8
9
|
return {
|
|
9
10
|
command: "cleanup",
|
|
10
11
|
owner: plan.owner,
|
|
@@ -17,14 +18,14 @@ export function buildCleanupSummary(plan, options) {
|
|
|
17
18
|
fullyDeletableRoots,
|
|
18
19
|
untagOnlyRoots,
|
|
19
20
|
blockedRoots,
|
|
20
|
-
affectedManifests
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
affectedManifests,
|
|
22
|
+
changes: options.changes,
|
|
23
|
+
deletedPackageVersionCount: options.executionSummary?.deletedPackageVersionCount ?? 0,
|
|
24
|
+
detachedTagCount: options.executionSummary?.detachedTagCount ?? 0
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
|
-
function _mapRootDecision(decision, directTargetTagSet,
|
|
27
|
-
const rootTags =
|
|
27
|
+
function _mapRootDecision(decision, directTargetTagSet, rootTagsByVersionId) {
|
|
28
|
+
const rootTags = rootTagsByVersionId.get(decision.versionId) ?? [];
|
|
28
29
|
return {
|
|
29
30
|
versionId: decision.versionId,
|
|
30
31
|
digest: decision.digest,
|
|
@@ -42,3 +43,17 @@ function _mapRootDecision(decision, directTargetTagSet, listRootTags) {
|
|
|
42
43
|
overlapManifestKind: decision.overlapManifestKind
|
|
43
44
|
};
|
|
44
45
|
}
|
|
46
|
+
function _listAffectedManifests(plan, fullyDeletableRootDigests) {
|
|
47
|
+
const fullyDeletableRootDigestSet = new Set(fullyDeletableRootDigests);
|
|
48
|
+
const manifestsByDigest = new Map();
|
|
49
|
+
for (const manifest of plan.closureManifests) {
|
|
50
|
+
if (!fullyDeletableRootDigestSet.has(manifest.sourceDigest)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
manifestsByDigest.set(manifest.memberDigest, {
|
|
54
|
+
digest: manifest.memberDigest,
|
|
55
|
+
manifestKind: manifest.memberManifestKind
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return [...manifestsByDigest.values()].sort((left, right) => left.digest.localeCompare(right.digest));
|
|
59
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryAffectedManifest, type CleanupSummaryRoot } from "./_cleanup-summary.js";
|
|
1
|
+
export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryAffectedManifest, type CleanupSummaryChanges, type CleanupSummaryRoot } from "./_cleanup-summary.js";
|
|
2
2
|
export { renderCleanupSummaryMarkdown } from "./_cleanup-summary-markdown.js";
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { ManifestKinds } from "../core/index.js";
|
|
1
2
|
import { buildCleanupSummary } from "../cleanup-summary/index.js";
|
|
2
3
|
import { CleanupRunWriter, openDatabase, PlannerRepository } from "../db/index.js";
|
|
3
4
|
import { executeDeletePlan } from "../execute/index.js";
|
|
4
5
|
import { hasFlag, resolveLogLevel, resolveToken } from "./_args.js";
|
|
6
|
+
import { writeJsonOutput } from "./_json-output.js";
|
|
5
7
|
import { createLogger } from "./_logger.js";
|
|
6
8
|
import { loadDeletePlan, resolvePlanCommandInputs } from "./_planner-options.js";
|
|
7
9
|
import { resolveTagSelectors } from "./_tag-selector-resolver.js";
|
|
@@ -17,18 +19,19 @@ export async function handleCleanup(args) {
|
|
|
17
19
|
const scanId = repository.getLatestCompletedScanId(inputs.owner, inputs.packageName);
|
|
18
20
|
logger.debug(`Starting cleanup for ${inputs.owner}/${inputs.packageName}`);
|
|
19
21
|
const plan = loadDeletePlan(repository, resolveTagSelectors(database, inputs));
|
|
20
|
-
|
|
22
|
+
const rootTagsByVersionId = _loadRootTagsByVersionId(database, inputs.owner, inputs.packageName, plan.rootDecisions.map((decision) => decision.versionId));
|
|
23
|
+
const cleanupRunId = cleanupRunWriter.persistCleanupRun(scanId, plan, {
|
|
21
24
|
dryRun,
|
|
22
25
|
cleanupStartedAt: new Date().toISOString()
|
|
23
26
|
});
|
|
24
27
|
if (dryRun) {
|
|
25
28
|
const summary = buildCleanupSummary(plan, {
|
|
26
29
|
dryRun: true,
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
rootTagsByVersionId,
|
|
31
|
+
changes: _loadSummaryChanges(database, cleanupRunId)
|
|
29
32
|
});
|
|
30
33
|
logger.debug(`Completed dry-run cleanup for ${inputs.owner}/${inputs.packageName}`);
|
|
31
|
-
|
|
34
|
+
writeJsonOutput(args, "--summary-json-path", summary);
|
|
32
35
|
return 0;
|
|
33
36
|
}
|
|
34
37
|
const executionSummary = await executeDeletePlan(plan, {
|
|
@@ -38,34 +41,18 @@ export async function handleCleanup(args) {
|
|
|
38
41
|
});
|
|
39
42
|
const summary = buildCleanupSummary(plan, {
|
|
40
43
|
dryRun: false,
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
rootTagsByVersionId,
|
|
45
|
+
changes: _loadSummaryChanges(database, cleanupRunId),
|
|
43
46
|
executionSummary
|
|
44
47
|
});
|
|
45
48
|
logger.debug(`Completed cleanup for ${inputs.owner}/${inputs.packageName}`);
|
|
46
|
-
|
|
49
|
+
writeJsonOutput(args, "--summary-json-path", summary);
|
|
47
50
|
return 0;
|
|
48
51
|
}
|
|
49
52
|
finally {
|
|
50
53
|
database.close();
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
function _listAffectedManifestDigests(database, scanId, rootDigests) {
|
|
54
|
-
if (rootDigests.length === 0) {
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
const placeholders = rootDigests.map(() => "?").join(", ");
|
|
58
|
-
const rows = database
|
|
59
|
-
.prepare(`
|
|
60
|
-
SELECT DISTINCT descendant_digest AS digest
|
|
61
|
-
FROM manifest_reachability
|
|
62
|
-
WHERE scan_id = ?
|
|
63
|
-
AND ancestor_digest IN (${placeholders})
|
|
64
|
-
ORDER BY descendant_digest
|
|
65
|
-
`)
|
|
66
|
-
.all(scanId, ...rootDigests);
|
|
67
|
-
return rows.map((row) => row.digest);
|
|
68
|
-
}
|
|
69
56
|
function _listRootTags(database, owner, packageName, versionId) {
|
|
70
57
|
const rows = database
|
|
71
58
|
.prepare(`
|
|
@@ -81,3 +68,75 @@ function _listRootTags(database, owner, packageName, versionId) {
|
|
|
81
68
|
.all(owner, packageName, versionId);
|
|
82
69
|
return rows.map((row) => row.tag);
|
|
83
70
|
}
|
|
71
|
+
function _loadRootTagsByVersionId(database, owner, packageName, versionIds) {
|
|
72
|
+
const requestedVersionIds = new Set(versionIds);
|
|
73
|
+
const tagsByVersionId = new Map();
|
|
74
|
+
for (const versionId of requestedVersionIds) {
|
|
75
|
+
tagsByVersionId.set(versionId, []);
|
|
76
|
+
}
|
|
77
|
+
if (requestedVersionIds.size === 0) {
|
|
78
|
+
return tagsByVersionId;
|
|
79
|
+
}
|
|
80
|
+
const rows = database
|
|
81
|
+
.prepare(`
|
|
82
|
+
SELECT tags.version_id, tags.tag
|
|
83
|
+
FROM tags
|
|
84
|
+
INNER JOIN v_latest_scan_per_package latest_scan ON latest_scan.scan_id = tags.scan_id
|
|
85
|
+
WHERE latest_scan.owner = ?
|
|
86
|
+
AND latest_scan.package_name = ?
|
|
87
|
+
AND tags.is_digest_tag = 0
|
|
88
|
+
ORDER BY tags.version_id, tags.tag
|
|
89
|
+
`)
|
|
90
|
+
.all(owner, packageName);
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
if (!requestedVersionIds.has(row.version_id)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
tagsByVersionId.get(row.version_id)?.push(row.tag);
|
|
96
|
+
}
|
|
97
|
+
return tagsByVersionId;
|
|
98
|
+
}
|
|
99
|
+
function _loadSummaryChanges(database, cleanupRunId) {
|
|
100
|
+
const deletedTags = database
|
|
101
|
+
.prepare(`
|
|
102
|
+
SELECT COUNT(*) AS count
|
|
103
|
+
FROM cleanup_selected_tags
|
|
104
|
+
WHERE cleanup_run_id = ?
|
|
105
|
+
AND is_deleted = 1
|
|
106
|
+
`)
|
|
107
|
+
.get(cleanupRunId).count;
|
|
108
|
+
const manifestCounts = database
|
|
109
|
+
.prepare(`
|
|
110
|
+
WITH fully_deletable_manifests AS (
|
|
111
|
+
SELECT DISTINCT
|
|
112
|
+
reachable.descendant_digest AS digest,
|
|
113
|
+
manifest.manifest_kind
|
|
114
|
+
FROM cleanup_root_decisions decision
|
|
115
|
+
JOIN manifest_reachability reachable
|
|
116
|
+
ON reachable.scan_id = decision.scan_id
|
|
117
|
+
AND reachable.ancestor_digest = decision.digest
|
|
118
|
+
JOIN manifests manifest
|
|
119
|
+
ON manifest.scan_id = reachable.scan_id
|
|
120
|
+
AND manifest.digest = reachable.descendant_digest
|
|
121
|
+
WHERE decision.cleanup_run_id = ?
|
|
122
|
+
AND decision.validation_status = 'fully-deletable'
|
|
123
|
+
)
|
|
124
|
+
SELECT
|
|
125
|
+
manifest_kind,
|
|
126
|
+
COUNT(*) AS count
|
|
127
|
+
FROM fully_deletable_manifests
|
|
128
|
+
GROUP BY manifest_kind
|
|
129
|
+
`)
|
|
130
|
+
.all(cleanupRunId);
|
|
131
|
+
const countsByKind = new Map(manifestCounts.map((row) => [row.manifest_kind ?? "", row.count]));
|
|
132
|
+
return {
|
|
133
|
+
deletedTags,
|
|
134
|
+
deletedImages: countsByKind.get(ManifestKinds.imageManifest) ?? 0,
|
|
135
|
+
deletedIndexes: countsByKind.get(ManifestKinds.indexManifest) ?? 0,
|
|
136
|
+
deletedMultiArchManifests: countsByKind.get(ManifestKinds.multiArchManifest) ?? 0,
|
|
137
|
+
deletedArtifactManifests: countsByKind.get(ManifestKinds.artifactManifest) ?? 0,
|
|
138
|
+
deletedAttestations: countsByKind.get(ManifestKinds.attestationManifest) ?? 0,
|
|
139
|
+
deletedSignatures: countsByKind.get(ManifestKinds.signatureManifest) ?? 0,
|
|
140
|
+
deletedTotal: manifestCounts.reduce((total, row) => total + row.count, 0)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeJsonOutput(args: string[], optionName: string, payload: unknown): void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { findOption } from "./_args.js";
|
|
3
|
+
export function writeJsonOutput(args, optionName, payload) {
|
|
4
|
+
const json = JSON.stringify(payload);
|
|
5
|
+
const outputPath = findOption(args, optionName);
|
|
6
|
+
if (outputPath) {
|
|
7
|
+
writeFileSync(outputPath, `${json}\n`, "utf8");
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
console.log(json);
|
|
11
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
const BrokenIndexModes = {
|
|
2
|
+
allMissing: "all-missing",
|
|
3
|
+
someMissing: "some-missing"
|
|
4
|
+
};
|
|
1
5
|
export function resolveTagSelectors(database, inputs) {
|
|
2
6
|
if (!inputs.deleteGhostImages && !inputs.deletePartialImages && !inputs.deleteOrphanedImages) {
|
|
3
7
|
return inputs;
|
|
@@ -14,13 +18,13 @@ export function resolveTagSelectors(database, inputs) {
|
|
|
14
18
|
};
|
|
15
19
|
}
|
|
16
20
|
function _listLatestGhostTags(database, owner, packageName, cutoffTimestamp) {
|
|
17
|
-
return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp,
|
|
21
|
+
return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, BrokenIndexModes.allMissing);
|
|
18
22
|
}
|
|
19
23
|
function _listLatestPartialTags(database, owner, packageName, cutoffTimestamp) {
|
|
20
|
-
return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp,
|
|
24
|
+
return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, BrokenIndexModes.someMissing);
|
|
21
25
|
}
|
|
22
26
|
function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, mode) {
|
|
23
|
-
const havingClause = mode ===
|
|
27
|
+
const havingClause = mode === BrokenIndexModes.allMissing
|
|
24
28
|
? "COUNT(*) > 0 AND COUNT(child.digest) = 0"
|
|
25
29
|
: "COUNT(child.digest) > 0 AND COUNT(child.digest) < COUNT(*)";
|
|
26
30
|
const rows = database
|
|
@@ -30,7 +34,6 @@ function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestam
|
|
|
30
34
|
FROM v_latest_scan_per_package
|
|
31
35
|
WHERE owner = ?
|
|
32
36
|
AND package_name = ?
|
|
33
|
-
LIMIT 1
|
|
34
37
|
),
|
|
35
38
|
ghost_roots AS (
|
|
36
39
|
SELECT
|
|
@@ -93,16 +96,36 @@ function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestam
|
|
|
93
96
|
function _listLatestOrphanedTags(database, owner, packageName, cutoffTimestamp) {
|
|
94
97
|
const rows = database
|
|
95
98
|
.prepare(`
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
WITH latest_scan AS (
|
|
100
|
+
SELECT scan_id
|
|
101
|
+
FROM v_latest_scan_per_package
|
|
102
|
+
WHERE owner = ?
|
|
103
|
+
AND package_name = ?
|
|
104
|
+
),
|
|
105
|
+
digest_tag_artifacts AS (
|
|
106
|
+
SELECT
|
|
107
|
+
t.tag,
|
|
108
|
+
t.scan_id,
|
|
109
|
+
t.version_id AS artifact_version_id,
|
|
110
|
+
'sha256:' || SUBSTR(t.tag, 8, 64) AS parent_digest
|
|
111
|
+
FROM latest_scan ls
|
|
112
|
+
JOIN tags t
|
|
113
|
+
ON t.scan_id = ls.scan_id
|
|
114
|
+
WHERE t.is_digest_tag = 1
|
|
115
|
+
)
|
|
116
|
+
SELECT DISTINCT dta.tag
|
|
117
|
+
FROM digest_tag_artifacts dta
|
|
118
|
+
JOIN package_versions pv
|
|
119
|
+
ON pv.scan_id = dta.scan_id
|
|
120
|
+
AND pv.version_id = dta.artifact_version_id
|
|
121
|
+
WHERE NOT EXISTS (
|
|
122
|
+
SELECT 1
|
|
123
|
+
FROM manifests parent
|
|
124
|
+
WHERE parent.scan_id = dta.scan_id
|
|
125
|
+
AND parent.digest = dta.parent_digest
|
|
126
|
+
)
|
|
104
127
|
AND (? IS NULL OR pv.created_at < ?)
|
|
105
|
-
ORDER BY
|
|
128
|
+
ORDER BY dta.tag
|
|
106
129
|
`)
|
|
107
130
|
.all(owner, packageName, cutoffTimestamp ?? null, cutoffTimestamp ?? null);
|
|
108
131
|
return rows.map((row) => row.tag);
|
package/dist/cli/index.js
CHANGED
|
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { handleCleanup } from "./_cleanup-command.js";
|
|
5
5
|
import { handleDbMerge } from "./_db-merge-command.js";
|
|
6
6
|
import { handleScan } from "./_scan-command.js";
|
|
7
|
-
import { handleUntag } from "./_untag-command.js";
|
|
8
7
|
export async function main(argv) {
|
|
9
8
|
const [command, ...rest] = argv;
|
|
10
9
|
if (!command) {
|
|
@@ -18,18 +17,15 @@ export async function main(argv) {
|
|
|
18
17
|
return handleDbMerge(rest);
|
|
19
18
|
case "scan":
|
|
20
19
|
return handleScan(rest);
|
|
21
|
-
case "untag":
|
|
22
|
-
return handleUntag(rest);
|
|
23
20
|
default:
|
|
24
21
|
throw new Error(`unknown command: ${command}`);
|
|
25
22
|
}
|
|
26
23
|
}
|
|
27
24
|
function printUsage() {
|
|
28
25
|
console.error(`Usage:
|
|
29
|
-
ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> [--token <token>] <cleanup selectors...> [--exclude-tag <tag> ...] [--use-regex] [--older-than <interval>]
|
|
26
|
+
ghcr-manager cleanup --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] [--summary-json-path <path>] --owner <org> --package <name> [--token <token>] <cleanup selectors...> [--exclude-tag <tag> ...] [--use-regex] [--older-than <interval>]
|
|
30
27
|
ghcr-manager db-merge --db <target-path> --source-db <path> [--source-db <path> ...]
|
|
31
28
|
ghcr-manager scan --db <path> [--log-level <trace|debug|info|warn|error|silent>] [--github-output <path>] --owner <org> --package <name> --token <token>
|
|
32
|
-
ghcr-manager untag [--log-level <trace|debug|info|warn|error|silent>] [--dry-run] --owner <org> --package <name> --token <token> --tag <tag> [--tag <tag> ...]
|
|
33
29
|
|
|
34
30
|
Cleanup selectors:
|
|
35
31
|
--delete-untagged
|
package/dist/core/_types.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare const ManifestKinds: {
|
|
2
|
+
readonly indexManifest: "index_manifest";
|
|
3
|
+
readonly multiArchManifest: "multi_arch_manifest";
|
|
4
|
+
readonly imageManifest: "image_manifest";
|
|
5
|
+
readonly artifactManifest: "artifact_manifest";
|
|
6
|
+
readonly attestationManifest: "attestation_manifest";
|
|
7
|
+
readonly signatureManifest: "signature_manifest";
|
|
8
|
+
};
|
|
9
|
+
export type ManifestKind = (typeof ManifestKinds)[keyof typeof ManifestKinds];
|
|
2
10
|
export type ManifestEdgeKind = "image-child" | "referrer" | "digest-tag-referrer";
|
|
3
11
|
export interface PackageVersionRecord {
|
|
4
12
|
versionId: number;
|
|
@@ -19,11 +27,6 @@ export interface ManifestRecord {
|
|
|
19
27
|
subjectDigest?: string;
|
|
20
28
|
annotations?: Record<string, unknown>;
|
|
21
29
|
manifestKind?: ManifestKind;
|
|
22
|
-
platform?: {
|
|
23
|
-
architecture?: string;
|
|
24
|
-
os?: string;
|
|
25
|
-
variant?: string;
|
|
26
|
-
};
|
|
27
30
|
}
|
|
28
31
|
export interface ManifestDescriptorRecord {
|
|
29
32
|
parentDigest: string;
|
package/dist/core/_types.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export const ManifestKinds = {
|
|
2
|
+
indexManifest: "index_manifest",
|
|
3
|
+
multiArchManifest: "multi_arch_manifest",
|
|
4
|
+
imageManifest: "image_manifest",
|
|
5
|
+
artifactManifest: "artifact_manifest",
|
|
6
|
+
attestationManifest: "attestation_manifest",
|
|
7
|
+
signatureManifest: "signature_manifest"
|
|
8
|
+
};
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type { ManifestEdgeKind, ManifestEdgeRecord, ManifestDescriptorRecord, ManifestKind, ManifestRecord, PackageSnapshot, PackageVersionRecord, TagRecord } from "./_types.js";
|
|
2
|
+
export { ManifestKinds } from "./_types.js";
|
|
2
3
|
export type { HttpErrorResponse } from "./_http-error.js";
|
|
3
4
|
export { buildHttpErrorMessage } from "./_http-error.js";
|
|
4
5
|
export { getOwnerURIComponent } from "./_github-package-owner.js";
|
package/dist/core/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { resolveGitHubActionsRunUrl } from "./_github-actions-run-url.js";
|
|
3
|
+
import { DeletePlanValidationStatuses } from "./planner/index.js";
|
|
3
4
|
export class CleanupRunWriter {
|
|
4
5
|
#database;
|
|
5
6
|
#insertSelectedTagStatement;
|
|
7
|
+
#updateSelectedTagsDeletedStatement;
|
|
6
8
|
#insertRootDecisionStatement;
|
|
7
9
|
#insertProtectedRootBlockStatement;
|
|
8
10
|
#insertCleanupRunStatement;
|
|
@@ -12,9 +14,27 @@ export class CleanupRunWriter {
|
|
|
12
14
|
INSERT INTO cleanup_selected_tags(
|
|
13
15
|
cleanup_run_id,
|
|
14
16
|
scan_id,
|
|
15
|
-
tag
|
|
17
|
+
tag,
|
|
18
|
+
is_deleted
|
|
16
19
|
)
|
|
17
|
-
VALUES(?, ?,
|
|
20
|
+
VALUES(?, ?, ?, 0)
|
|
21
|
+
`);
|
|
22
|
+
this.#updateSelectedTagsDeletedStatement = this.#database.prepare(`
|
|
23
|
+
UPDATE cleanup_selected_tags
|
|
24
|
+
SET is_deleted = 1
|
|
25
|
+
FROM cleanup_root_decisions decision
|
|
26
|
+
JOIN manifests manifest
|
|
27
|
+
ON manifest.scan_id = decision.scan_id
|
|
28
|
+
AND manifest.digest = decision.digest
|
|
29
|
+
JOIN tags
|
|
30
|
+
ON tags.scan_id = manifest.scan_id
|
|
31
|
+
AND tags.version_id = manifest.version_id
|
|
32
|
+
WHERE cleanup_selected_tags.cleanup_run_id = ?
|
|
33
|
+
AND cleanup_selected_tags.scan_id = ?
|
|
34
|
+
AND decision.cleanup_run_id = cleanup_selected_tags.cleanup_run_id
|
|
35
|
+
AND decision.scan_id = cleanup_selected_tags.scan_id
|
|
36
|
+
AND decision.validation_status != 'blocked'
|
|
37
|
+
AND tags.tag = cleanup_selected_tags.tag
|
|
18
38
|
`);
|
|
19
39
|
this.#insertRootDecisionStatement = this.#database.prepare(`
|
|
20
40
|
INSERT INTO cleanup_root_decisions(
|
|
@@ -64,9 +84,6 @@ export class CleanupRunWriter {
|
|
|
64
84
|
persistCleanupRun(scanId, plan, options) {
|
|
65
85
|
return this.#database.transaction(() => {
|
|
66
86
|
const cleanupRunId = this.#insertCleanupRun(scanId, plan, options);
|
|
67
|
-
for (const tag of plan.directTargetTags) {
|
|
68
|
-
this.#insertSelectedTagStatement.run(cleanupRunId, scanId, tag);
|
|
69
|
-
}
|
|
70
87
|
for (const rootDecision of plan.rootDecisions) {
|
|
71
88
|
this.#insertRootDecisionStatement.run(cleanupRunId, scanId, rootDecision.digest, rootDecision.selectionMode, rootDecision.selectionReason, rootDecision.validationStatus, rootDecision.validationReasonCode, rootDecision.validationReason, rootDecision.blockingDigest ?? null, rootDecision.overlapDigest ?? null);
|
|
72
89
|
}
|
|
@@ -75,6 +92,10 @@ export class CleanupRunWriter {
|
|
|
75
92
|
this.#insertProtectedRootBlockStatement.run(cleanupRunId, scanId, protectedRoot.digest, block.blockedDigest, block.blockReasonCode, block.overlapDigest);
|
|
76
93
|
}
|
|
77
94
|
}
|
|
95
|
+
for (const tag of plan.directTargetTags) {
|
|
96
|
+
this.#insertSelectedTagStatement.run(cleanupRunId, scanId, tag);
|
|
97
|
+
}
|
|
98
|
+
this.#updateSelectedTagsDeletedStatement.run(cleanupRunId, scanId);
|
|
78
99
|
return cleanupRunId;
|
|
79
100
|
})();
|
|
80
101
|
}
|
|
@@ -82,9 +103,9 @@ export class CleanupRunWriter {
|
|
|
82
103
|
const directTargetTagCount = plan.directTargetTags.length;
|
|
83
104
|
const directTargetRootCount = plan.directTargetRoots.length;
|
|
84
105
|
const deleteRootCandidateCount = plan.directTargetRoots.filter((root) => root.selectionMode === "delete-root").length;
|
|
85
|
-
const untagOnlyRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus ===
|
|
106
|
+
const untagOnlyRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus === DeletePlanValidationStatuses.untagOnly).length;
|
|
86
107
|
const fullyDeletableRootCount = plan.fullyDeletableRoots.length;
|
|
87
|
-
const blockedDeleteRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus ===
|
|
108
|
+
const blockedDeleteRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus === DeletePlanValidationStatuses.blocked).length;
|
|
88
109
|
const protectedRootCount = plan.protectedRoots.length;
|
|
89
110
|
const result = this.#insertCleanupRunStatement.run(scanId, randomUUID(), options.cleanupStartedAt, resolveGitHubActionsRunUrl(), options.dryRun ? 1 : 0, JSON.stringify(plan.plannerInputs), directTargetTagCount, directTargetRootCount, deleteRootCandidateCount, untagOnlyRootCount, fullyDeletableRootCount, blockedDeleteRootCount, protectedRootCount);
|
|
90
111
|
return Number(result.lastInsertRowid);
|
|
@@ -112,12 +112,14 @@ export class DbMergeCleanupCopy {
|
|
|
112
112
|
INSERT INTO cleanup_selected_tags(
|
|
113
113
|
cleanup_run_id,
|
|
114
114
|
scan_id,
|
|
115
|
-
tag
|
|
115
|
+
tag,
|
|
116
|
+
is_deleted
|
|
116
117
|
)
|
|
117
118
|
SELECT
|
|
118
119
|
?,
|
|
119
120
|
?,
|
|
120
|
-
tag
|
|
121
|
+
tag,
|
|
122
|
+
is_deleted
|
|
121
123
|
FROM ${attachName}.cleanup_selected_tags
|
|
122
124
|
WHERE cleanup_run_id = ?
|
|
123
125
|
AND scan_id = ?
|
|
@@ -26,11 +26,12 @@ export class DbMergeScanCopy {
|
|
|
26
26
|
"package_versions(scan_id, version_id, created_at, updated_at)",
|
|
27
27
|
"package_version_payloads(scan_id, version_id, raw_json)",
|
|
28
28
|
"tags(scan_id, tag, version_id, is_digest_tag)",
|
|
29
|
-
"manifests(scan_id, version_id, digest, media_type, artifact_type, config_media_type, subject_digest, annotations_json,
|
|
29
|
+
"manifests(scan_id, version_id, digest, media_type, artifact_type, config_media_type, subject_digest, annotations_json, manifest_kind)",
|
|
30
30
|
"manifest_descriptors(scan_id, parent_digest, child_digest, media_type, artifact_type, platform_os, platform_architecture, platform_variant)",
|
|
31
31
|
"manifest_payloads(scan_id, digest, raw_json)",
|
|
32
32
|
"manifest_edges(scan_id, parent_digest, child_digest, edge_kind)",
|
|
33
|
-
"manifest_reachability(scan_id, ancestor_digest, descendant_digest, min_distance)"
|
|
33
|
+
"manifest_reachability(scan_id, ancestor_digest, descendant_digest, min_distance)",
|
|
34
|
+
"manifest_graphs(scan_id, digest, graph_id)"
|
|
34
35
|
];
|
|
35
36
|
for (const spec of copySpecs) {
|
|
36
37
|
const [tableName, columnList] = spec.split("(");
|
|
@@ -3,14 +3,19 @@ export function rebuildManifestReachability(database, scanId) {
|
|
|
3
3
|
const manifestDigests = _loadManifestDigests(database, scanId);
|
|
4
4
|
const childDigestsByParent = new Map();
|
|
5
5
|
const parentDigestsByChild = new Map();
|
|
6
|
+
const neighborDigestsByDigest = new Map();
|
|
6
7
|
for (const digest of manifestDigests) {
|
|
7
8
|
childDigestsByParent.set(digest, new Set());
|
|
8
9
|
parentDigestsByChild.set(digest, new Set());
|
|
10
|
+
neighborDigestsByDigest.set(digest, new Set());
|
|
9
11
|
}
|
|
10
12
|
for (const manifestEdge of _loadManifestEdges(database, scanId)) {
|
|
11
13
|
childDigestsByParent.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
|
|
12
14
|
parentDigestsByChild.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
|
|
15
|
+
neighborDigestsByDigest.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
|
|
16
|
+
neighborDigestsByDigest.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
|
|
13
17
|
}
|
|
18
|
+
const graphIdsByDigest = _buildGraphIdsByDigest(manifestDigests, neighborDigestsByDigest);
|
|
14
19
|
const remainingChildrenCount = new Map();
|
|
15
20
|
const descendantDistancesByDigest = new Map();
|
|
16
21
|
const readyDigests = [];
|
|
@@ -61,9 +66,19 @@ export function rebuildManifestReachability(database, scanId) {
|
|
|
61
66
|
)
|
|
62
67
|
VALUES(?, ?, ?, ?)
|
|
63
68
|
`);
|
|
69
|
+
const insertGraphRow = database.prepare(`
|
|
70
|
+
INSERT OR REPLACE INTO manifest_graphs(
|
|
71
|
+
scan_id,
|
|
72
|
+
digest,
|
|
73
|
+
graph_id
|
|
74
|
+
)
|
|
75
|
+
VALUES(?, ?, ?)
|
|
76
|
+
`);
|
|
64
77
|
const rebuild = database.transaction(() => {
|
|
65
78
|
database.prepare("DELETE FROM manifest_reachability WHERE scan_id = ?").run(scanId);
|
|
79
|
+
database.prepare("DELETE FROM manifest_graphs WHERE scan_id = ?").run(scanId);
|
|
66
80
|
for (const digest of manifestDigests) {
|
|
81
|
+
insertGraphRow.run(scanId, digest, graphIdsByDigest.get(digest));
|
|
67
82
|
for (const [descendantDigest, distance] of descendantDistancesByDigest.get(digest) ?? []) {
|
|
68
83
|
insertRow.run(scanId, digest, descendantDigest, distance);
|
|
69
84
|
}
|
|
@@ -71,6 +86,32 @@ export function rebuildManifestReachability(database, scanId) {
|
|
|
71
86
|
});
|
|
72
87
|
rebuild();
|
|
73
88
|
}
|
|
89
|
+
function _buildGraphIdsByDigest(manifestDigests, neighborDigestsByDigest) {
|
|
90
|
+
const graphIdsByDigest = new Map();
|
|
91
|
+
let nextGraphId = 1;
|
|
92
|
+
for (const rootDigest of manifestDigests) {
|
|
93
|
+
if (graphIdsByDigest.has(rootDigest)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const pendingDigests = [rootDigest];
|
|
97
|
+
graphIdsByDigest.set(rootDigest, nextGraphId);
|
|
98
|
+
while (pendingDigests.length > 0) {
|
|
99
|
+
const digest = pendingDigests.pop();
|
|
100
|
+
if (!digest) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const neighborDigest of neighborDigestsByDigest.get(digest) ?? []) {
|
|
104
|
+
if (graphIdsByDigest.has(neighborDigest)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
graphIdsByDigest.set(neighborDigest, nextGraphId);
|
|
108
|
+
pendingDigests.push(neighborDigest);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
nextGraphId += 1;
|
|
112
|
+
}
|
|
113
|
+
return graphIdsByDigest;
|
|
114
|
+
}
|
|
74
115
|
function _refreshDigestTagEdges(database, scanId) {
|
|
75
116
|
database.prepare("DELETE FROM manifest_edges WHERE scan_id = ? AND edge_kind = 'digest-tag-referrer'").run(scanId);
|
|
76
117
|
database
|
|
@@ -78,20 +119,18 @@ function _refreshDigestTagEdges(database, scanId) {
|
|
|
78
119
|
INSERT OR IGNORE INTO manifest_edges(scan_id, parent_digest, child_digest, edge_kind)
|
|
79
120
|
SELECT
|
|
80
121
|
t.scan_id,
|
|
81
|
-
|
|
82
|
-
|
|
122
|
+
m.digest AS parent_digest,
|
|
123
|
+
'sha256:' || SUBSTR(t.tag, 8, 64) AS child_digest,
|
|
83
124
|
'digest-tag-referrer' AS edge_kind
|
|
84
125
|
FROM tags t
|
|
85
126
|
JOIN manifests m
|
|
86
127
|
ON m.scan_id = t.scan_id
|
|
87
128
|
AND m.version_id = t.version_id
|
|
88
|
-
JOIN manifests
|
|
89
|
-
ON
|
|
90
|
-
AND
|
|
129
|
+
JOIN manifests child_manifest
|
|
130
|
+
ON child_manifest.scan_id = t.scan_id
|
|
131
|
+
AND child_manifest.digest = 'sha256:' || SUBSTR(t.tag, 8, 64)
|
|
91
132
|
WHERE t.scan_id = ?
|
|
92
|
-
AND t.
|
|
93
|
-
AND LENGTH(t.tag) >= 71
|
|
94
|
-
AND SUBSTR(t.tag, 8, 64) NOT GLOB '*[^0-9A-Fa-f]*'
|
|
133
|
+
AND t.is_digest_tag = 1
|
|
95
134
|
`)
|
|
96
135
|
.run(scanId);
|
|
97
136
|
}
|
package/dist/db/_scan-writer.js
CHANGED
|
@@ -62,9 +62,6 @@ export class ScanWriter {
|
|
|
62
62
|
config_media_type,
|
|
63
63
|
subject_digest,
|
|
64
64
|
annotations_json,
|
|
65
|
-
platform_os,
|
|
66
|
-
platform_architecture,
|
|
67
|
-
platform_variant,
|
|
68
65
|
manifest_kind
|
|
69
66
|
)
|
|
70
67
|
VALUES(
|
|
@@ -76,9 +73,6 @@ export class ScanWriter {
|
|
|
76
73
|
@configMediaType,
|
|
77
74
|
@subjectDigest,
|
|
78
75
|
@annotationsJson,
|
|
79
|
-
@platformOs,
|
|
80
|
-
@platformArchitecture,
|
|
81
|
-
@platformVariant,
|
|
82
76
|
@manifestKind
|
|
83
77
|
)
|
|
84
78
|
`);
|
|
@@ -151,9 +145,6 @@ export class ScanWriter {
|
|
|
151
145
|
configMediaType: manifest.configMediaType ?? null,
|
|
152
146
|
subjectDigest: manifest.subjectDigest ?? null,
|
|
153
147
|
annotationsJson: manifest.annotations ? JSON.stringify(manifest.annotations) : null,
|
|
154
|
-
platformOs: manifest.platform?.os ?? null,
|
|
155
|
-
platformArchitecture: manifest.platform?.architecture ?? null,
|
|
156
|
-
platformVariant: manifest.platform?.variant ?? null,
|
|
157
148
|
manifestKind: manifest.manifestKind ?? null
|
|
158
149
|
});
|
|
159
150
|
}
|
package/dist/db/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { CleanupRunWriter } from "./_cleanup-run-writer.js";
|
|
|
4
4
|
export { DbMergeRepository } from "./_db-merge-repository.js";
|
|
5
5
|
export { PlannerRepository } from "./planner/index.js";
|
|
6
6
|
export { SnapshotRepository } from "./_snapshot-repository.js";
|
|
7
|
-
export type { DeletePlan, DeletePlanBlockReasonCode, DeletePlanSelectionMode, DeletePlanSelectionReason } from "./planner/index.js";
|
|
7
|
+
export type { DeletePlan, DeletePlanBlockReasonCode, DeletePlanSelectionMode, DeletePlanSelectionReason, DeletePlanValidationReasonCode, DeletePlanValidationStatus } from "./planner/index.js";
|
|
8
|
+
export { DeletePlanValidationReasonCodes, DeletePlanValidationStatuses } from "./planner/index.js";
|
|
8
9
|
export type { DbMergeSourceSummary } from "./_db-merge-repository.js";
|
|
9
10
|
export declare function openDatabase(databasePath: string): Database.Database;
|
package/dist/db/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { CleanupRunWriter } from "./_cleanup-run-writer.js";
|
|
|
5
5
|
export { DbMergeRepository } from "./_db-merge-repository.js";
|
|
6
6
|
export { PlannerRepository } from "./planner/index.js";
|
|
7
7
|
export { SnapshotRepository } from "./_snapshot-repository.js";
|
|
8
|
+
export { DeletePlanValidationReasonCodes, DeletePlanValidationStatuses } from "./planner/index.js";
|
|
8
9
|
export function openDatabase(databasePath) {
|
|
9
10
|
const database = new Database(databasePath);
|
|
10
11
|
initializeSchema(database);
|