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.
Files changed (72) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +1 -1
  3. package/README.md +45 -63
  4. package/dist/cleanup-summary/_cleanup-summary-markdown.d.ts +0 -1
  5. package/dist/cleanup-summary/_cleanup-summary-markdown.js +149 -39
  6. package/dist/cleanup-summary/_cleanup-summary.d.ts +22 -10
  7. package/dist/cleanup-summary/_cleanup-summary.js +26 -11
  8. package/dist/cleanup-summary/index.d.ts +1 -1
  9. package/dist/cli/_cleanup-command.js +82 -23
  10. package/dist/cli/_json-output.d.ts +1 -0
  11. package/dist/cli/_json-output.js +11 -0
  12. package/dist/cli/_tag-selector-resolver.js +36 -13
  13. package/dist/cli/index.js +1 -5
  14. package/dist/core/_types.d.ts +9 -6
  15. package/dist/core/_types.js +8 -1
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/db/_cleanup-run-writer.d.ts +1 -1
  19. package/dist/db/_cleanup-run-writer.js +28 -7
  20. package/dist/db/_db-merge-cleanup-copy.js +4 -2
  21. package/dist/db/_db-merge-scan-copy.js +3 -2
  22. package/dist/db/_manifest-reachability.js +47 -8
  23. package/dist/db/_scan-writer.js +0 -9
  24. package/dist/db/index.d.ts +2 -1
  25. package/dist/db/index.js +1 -0
  26. package/dist/db/planner/_planner-direct-target-root-options.d.ts +11 -0
  27. package/dist/db/planner/_planner-direct-target-root-options.js +1 -0
  28. package/dist/db/planner/_planner-direct-target-root-tag-filters.d.ts +9 -0
  29. package/dist/db/planner/_planner-direct-target-root-tag-filters.js +42 -0
  30. package/dist/db/planner/_planner-direct-target-roots-combined-sql.d.ts +7 -0
  31. package/dist/db/planner/_planner-direct-target-roots-combined-sql.js +198 -0
  32. package/dist/db/planner/_planner-direct-target-roots-combined.d.ts +4 -0
  33. package/dist/db/planner/_planner-direct-target-roots-combined.js +10 -0
  34. package/dist/db/planner/_planner-direct-target-roots-tagged.d.ts +4 -0
  35. package/dist/db/planner/_planner-direct-target-roots-tagged.js +125 -0
  36. package/dist/db/planner/_planner-direct-target-roots.d.ts +2 -11
  37. package/dist/db/planner/_planner-direct-target-roots.js +8 -192
  38. package/dist/db/planner/_planner-direct-target-tags.d.ts +1 -1
  39. package/dist/db/planner/_planner-direct-target-tags.js +7 -6
  40. package/dist/db/planner/_planner-output.js +34 -13
  41. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.d.ts +1 -0
  42. package/dist/db/planner/_planner-plan-artifacts-blocked-roots-sql.js +65 -0
  43. package/dist/db/planner/_planner-plan-artifacts-closure-sql.d.ts +1 -0
  44. package/dist/db/planner/_planner-plan-artifacts-closure-sql.js +195 -0
  45. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.d.ts +1 -0
  46. package/dist/db/planner/_planner-plan-artifacts-supported-untag-only-sql.js +86 -0
  47. package/dist/db/planner/_planner-plan-artifacts.js +26 -128
  48. package/dist/db/planner/_planner-repository.d.ts +2 -1
  49. package/dist/db/planner/_planner-repository.js +3 -1
  50. package/dist/db/planner/_planner-sql.js +13 -2
  51. package/dist/db/planner/_planner-types.d.ts +23 -8
  52. package/dist/db/planner/_planner-types.js +14 -3
  53. package/dist/db/planner/index.d.ts +2 -1
  54. package/dist/db/planner/index.js +1 -0
  55. package/dist/execute/_plan-executor.d.ts +1 -1
  56. package/dist/execute/_plan-executor.js +38 -16
  57. package/dist/execute/_types.d.ts +2 -19
  58. package/dist/execute/_untag-client.d.ts +2 -2
  59. package/dist/execute/_untag-client.js +1 -42
  60. package/dist/execute/index.d.ts +1 -1
  61. package/dist/ingest/github/_manifest-kind.d.ts +7 -1
  62. package/dist/ingest/github/_manifest-kind.js +21 -6
  63. package/package.json +16 -10
  64. package/resources/sql/schema/001_schema.sql +17 -4
  65. package/dist/cli/_untag-command.d.ts +0 -1
  66. package/dist/cli/_untag-command.js +0 -57
  67. package/resources/sql/views/002_v_missing_digests.sql +0 -32
  68. package/resources/sql/views/003_v_scan_root_manifests.sql +0 -44
  69. package/resources/sql/views/004_v_digest_tag_relations.sql +0 -50
  70. package/resources/sql/views/005_v_cleanup_root_closure_members.sql +0 -101
  71. package/resources/sql/views/006_v_cleanup_blocking_overlaps.sql +0 -42
  72. 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.listRootTags));
4
- const fullyDeletableRoots = roots.filter((root) => root.validationStatus === "fully-deletable");
5
- const blockedRoots = roots.filter((root) => root.validationStatus === "blocked");
6
- const untagOnlyRoots = roots.filter((root) => root.validationStatus === "untag-only");
7
- const affectedManifestDigests = options.listAffectedManifestDigests(fullyDeletableRoots.map((root) => root.digest));
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: affectedManifestDigests.map((digest) => ({ digest })),
21
- deletedPackageVersions: options.executionSummary?.deletedPackageVersions ?? [],
22
- untaggedTags: options.executionSummary?.untaggedTags ?? [],
23
- unsupportedUntagRoots: options.executionSummary?.unsupportedUntagRoots ?? []
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, listRootTags) {
27
- const rootTags = listRootTags(decision.versionId);
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
- cleanupRunWriter.persistCleanupRun(scanId, plan, {
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
- listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
28
- listAffectedManifestDigests: (rootDigests) => _listAffectedManifestDigests(database, scanId, rootDigests)
30
+ rootTagsByVersionId,
31
+ changes: _loadSummaryChanges(database, cleanupRunId)
29
32
  });
30
33
  logger.debug(`Completed dry-run cleanup for ${inputs.owner}/${inputs.packageName}`);
31
- console.log(JSON.stringify(summary));
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
- listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
42
- listAffectedManifestDigests: (rootDigests) => _listAffectedManifestDigests(database, scanId, rootDigests),
44
+ rootTagsByVersionId,
45
+ changes: _loadSummaryChanges(database, cleanupRunId),
43
46
  executionSummary
44
47
  });
45
48
  logger.debug(`Completed cleanup for ${inputs.owner}/${inputs.packageName}`);
46
- console.log(JSON.stringify(summary));
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, "all-missing");
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, "some-missing");
24
+ return _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, BrokenIndexModes.someMissing);
21
25
  }
22
26
  function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestamp, mode) {
23
- const havingClause = mode === "all-missing"
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
- SELECT DISTINCT dtr.tag
97
- FROM v_digest_tag_relations dtr
98
- INNER JOIN package_versions pv
99
- ON pv.scan_id = dtr.scan_id
100
- AND pv.version_id = dtr.artifact_version_id
101
- WHERE dtr.owner = ?
102
- AND dtr.package_name = ?
103
- AND dtr.parent_exists = 0
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 dtr.tag
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
@@ -1,4 +1,12 @@
1
- export type ManifestKind = "image_index" | "image_manifest" | "artifact_manifest" | "attestation_manifest" | "signature_manifest";
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;
@@ -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
+ };
@@ -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";
@@ -1,3 +1,4 @@
1
+ export { ManifestKinds } from "./_types.js";
1
2
  export { buildHttpErrorMessage } from "./_http-error.js";
2
3
  export { getOwnerURIComponent } from "./_github-package-owner.js";
3
4
  export { digestFromDigestTag, isDigestTag } from "./_digest-tag.js";
@@ -1,5 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
- import type { DeletePlan } from "./planner/index.js";
2
+ import { type DeletePlan } from "./planner/index.js";
3
3
  export declare class CleanupRunWriter {
4
4
  #private;
5
5
  constructor(database: Database.Database);
@@ -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 === "untag-only").length;
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 === "blocked").length;
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, platform_os, platform_architecture, platform_variant, manifest_kind)",
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
- 'sha256:' || SUBSTR(t.tag, 8, 64) AS parent_digest,
82
- m.digest AS child_digest,
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 parent_manifest
89
- ON parent_manifest.scan_id = t.scan_id
90
- AND parent_manifest.digest = 'sha256:' || SUBSTR(t.tag, 8, 64)
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.tag LIKE 'sha256-%'
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
  }
@@ -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
  }
@@ -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);