Skip to content

Commit c7eccee

Browse files
committed
🐛 fix(containers): replace tagPrecision with computed tagPinned for hide-pinned filter (#270)
- Add isTagPinned() in tag/precision.ts with rolling-tag alias detection (latest, stable, edge, nightly, etc.) so non-versioned tags are correctly treated as unpinned - Add tagPinned computed property on Container model via defineProperty getter - Separate store startup initialization from collection creation to fix migration timing - UI: hide-pinned filter now checks tagPinned instead of tagPrecision - 🧪 Tests for rolling alias detection, container model property, mapper, and hide-pinned filter
1 parent aaf9962 commit c7eccee

17 files changed

+295
-25
lines changed

app/model/container.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ test('model should be validated when compliant', async () => {
508508

509509
linkTemplate: 'https://release-${major}.${minor}.${patch}.acme.com',
510510
link: 'https://release-1.0.0.acme.com',
511+
tagPinned: true,
511512
updateAvailable: true,
512513
updateKind: {
513514
kind: 'tag',
@@ -729,6 +730,62 @@ test.each([
729730
expect(containerValidated.image.tag.tagPrecision).toBe(tagPrecision);
730731
});
731732

733+
test('model should flag numeric version aliases as tagPinned even when tagPrecision is floating', () => {
734+
const containerValidated = container.validate({
735+
id: 'container-tag-pinned-floating',
736+
name: 'test-tag-pinned-floating',
737+
watcher: 'test',
738+
image: {
739+
id: 'image-tag-pinned-floating',
740+
registry: {
741+
name: 'hub',
742+
url: 'https://hub',
743+
},
744+
name: 'organization/image',
745+
tag: {
746+
value: '16-alpine',
747+
semver: true,
748+
tagPrecision: 'floating',
749+
},
750+
digest: {
751+
watch: false,
752+
},
753+
architecture: 'arch',
754+
os: 'os',
755+
},
756+
});
757+
758+
expect(containerValidated.tagPinned).toBe(true);
759+
});
760+
761+
test('model should not flag rolling aliases as tagPinned', () => {
762+
const containerValidated = container.validate({
763+
id: 'container-tag-unpinned-latest',
764+
name: 'test-tag-unpinned-latest',
765+
watcher: 'test',
766+
image: {
767+
id: 'image-tag-unpinned-latest',
768+
registry: {
769+
name: 'hub',
770+
url: 'https://hub',
771+
},
772+
name: 'organization/image',
773+
tag: {
774+
value: 'latest',
775+
semver: false,
776+
tagPrecision: 'floating',
777+
},
778+
digest: {
779+
watch: false,
780+
},
781+
architecture: 'arch',
782+
os: 'os',
783+
},
784+
});
785+
786+
expect(containerValidated.tagPinned).toBe(false);
787+
});
788+
732789
test('model should reject invalid image.tag.tagPrecision values', () => {
733790
expect(() =>
734791
container.validate({
@@ -1525,6 +1582,7 @@ test('flatten should be flatten the nested properties with underscores when call
15251582
status: 'unknown',
15261583
image_architecture: 'arch',
15271584
image_created: '2021-06-12T05:33:38.440Z',
1585+
image_digest_repo: undefined,
15281586
image_digest_watch: false,
15291587
image_id: 'image-123456789',
15301588
image_name: 'organization/image',
@@ -1541,6 +1599,7 @@ test('flatten should be flatten the nested properties with underscores when call
15411599
display_icon: 'mdi:docker',
15421600
result_link: 'https://release-2.0.0.acme.com',
15431601
result_tag: '2.0.0',
1602+
tag_pinned: true,
15441603
update_available: true,
15451604
update_kind_kind: 'tag',
15461605
update_kind_local_value: '1.0.0',

app/model/container.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ContainerSignatureVerification,
88
} from '../security/scan.js';
99
import * as tag from '../tag/index.js';
10+
import { isTagPinned } from '../tag/precision.js';
1011
import type {
1112
ContainerUpdateOperationPhase,
1213
ContainerUpdateOperationStatus,
@@ -152,6 +153,7 @@ export interface Container {
152153
link?: string;
153154
triggerInclude?: string;
154155
triggerExclude?: string;
156+
tagPinned?: boolean;
155157
updatePolicy?: ContainerUpdatePolicy;
156158
security?: ContainerSecurityState;
157159
image: ContainerImage;
@@ -248,6 +250,7 @@ const schema = joi.object({
248250
link: joi.string(),
249251
triggerInclude: joi.string(),
250252
triggerExclude: joi.string(),
253+
tagPinned: joi.boolean(),
251254
updatePolicy: joi.object({
252255
skipTags: joi.array().items(joi.string()),
253256
skipDigests: joi.array().items(joi.string()),
@@ -626,6 +629,15 @@ function getLink(container: Container, originalTagValue: string) {
626629
);
627630
}
628631

632+
function addTagPinnedProperty(container: Container) {
633+
Object.defineProperty(container, 'tagPinned', {
634+
enumerable: true,
635+
get(this: Container) {
636+
return isTagPinned(this.image.tag.value, this.transformTags);
637+
},
638+
});
639+
}
640+
629641
/**
630642
* Computed function to check whether there is an update.
631643
* @param container
@@ -755,6 +767,7 @@ export function validate(container: unknown): Container {
755767
delete containerValidated.image?.registry?.lookupUrl;
756768

757769
// Add computed properties
770+
addTagPinnedProperty(containerValidated);
758771
addUpdateAvailableProperty(containerValidated);
759772
addUpdateKindProperty(containerValidated);
760773
addUpdateAgeProperty(containerValidated);

app/store/app.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ test('createCollections should call migrate when versions are different', async
5353
addCollection: () => null,
5454
};
5555
app.createCollections(db);
56+
app.completeStartupInitialization();
5657
expect(migrate.migrate).toHaveBeenCalledWith('1.0.0', '2.0.0');
5758
});
5859

59-
test('createCollections should run startup repair even when versions are different', async () => {
60+
test('completeStartupInitialization should run startup repair even when versions are different', async () => {
6061
const db = {
6162
getCollection: () => ({
6263
findOne: () => ({
@@ -69,6 +70,7 @@ test('createCollections should run startup repair even when versions are differe
6970
addCollection: () => null,
7071
};
7172
app.createCollections(db);
73+
app.completeStartupInitialization();
7274
expect(migrate.repairDataOnStartup).toHaveBeenCalledTimes(1);
7375
});
7476

@@ -85,10 +87,11 @@ test('createCollections should not call migrate when versions are identical', as
8587
addCollection: () => null,
8688
};
8789
app.createCollections(db);
90+
app.completeStartupInitialization();
8891
expect(migrate.migrate).not.toHaveBeenCalled();
8992
});
9093

91-
test('createCollections should run startup repair when versions are identical', async () => {
94+
test('completeStartupInitialization should run startup repair when versions are identical', async () => {
9295
const db = {
9396
getCollection: () => ({
9497
findOne: () => ({
@@ -101,6 +104,7 @@ test('createCollections should run startup repair when versions are identical',
101104
addCollection: () => null,
102105
};
103106
app.createCollections(db);
107+
app.completeStartupInitialization();
104108
expect(migrate.migrate).not.toHaveBeenCalled();
105109
expect(migrate.repairDataOnStartup).toHaveBeenCalledTimes(1);
106110
});
@@ -167,6 +171,7 @@ test('isUpgrade should return false when app collection is empty (fresh install)
167171
}),
168172
};
169173
app.createCollections(db);
174+
app.completeStartupInitialization();
170175
expect(app.isUpgrade()).toBe(false);
171176
});
172177

@@ -183,6 +188,7 @@ test('isUpgrade should return true when app collection has a previous version (u
183188
addCollection: () => null,
184189
};
185190
app.createCollections(db);
191+
app.completeStartupInitialization();
186192
expect(app.isUpgrade()).toBe(true);
187193
});
188194

app/store/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ function saveAppInfosAndMigrate() {
4848

4949
export function createCollections(db: AppStoreDb) {
5050
app = initCollection(db, 'app') as AppCollection;
51+
}
52+
53+
export function completeStartupInitialization() {
5154
saveAppInfosAndMigrate();
5255
}
5356

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { createContainerFixture } from '../test/helpers.js';
5+
6+
const ENV_KEYS = ['DD_STORE_PATH', 'DD_STORE_FILE', 'DD_VERSION'] as const;
7+
8+
function setStoreEnv(storePath: string) {
9+
process.env.DD_STORE_PATH = storePath;
10+
process.env.DD_STORE_FILE = 'dd.json';
11+
process.env.DD_VERSION = '1.5.0';
12+
}
13+
14+
describe('store startup repair integration', () => {
15+
test('init should backfill missing tagPrecision for persisted containers after restart', async () => {
16+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'drydock-store-'));
17+
const previousEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
18+
19+
try {
20+
setStoreEnv(tempDir);
21+
vi.resetModules();
22+
23+
const store = await import('./index.js');
24+
const storeContainer = await import('./container.js');
25+
26+
await store.init();
27+
storeContainer.insertContainer(
28+
createContainerFixture({
29+
id: 'startup-repair-specific',
30+
image: {
31+
id: 'image-startup-repair-specific',
32+
registry: {
33+
name: 'registry',
34+
url: 'https://hub',
35+
},
36+
name: 'organization/image',
37+
tag: {
38+
value: '1.2.3',
39+
semver: true,
40+
},
41+
digest: {
42+
watch: false,
43+
repo: undefined,
44+
},
45+
architecture: 'arch',
46+
os: 'os',
47+
created: '2021-06-12T05:33:38.440Z',
48+
},
49+
result: {
50+
tag: '1.2.3',
51+
},
52+
}),
53+
);
54+
await store.save();
55+
56+
setStoreEnv(tempDir);
57+
vi.resetModules();
58+
59+
const restartedStore = await import('./index.js');
60+
const restartedContainer = await import('./container.js');
61+
62+
await restartedStore.init();
63+
64+
expect(restartedContainer.getContainerRaw('startup-repair-specific')?.image.tag).toEqual(
65+
expect.objectContaining({
66+
value: '1.2.3',
67+
tagPrecision: 'specific',
68+
}),
69+
);
70+
} finally {
71+
ENV_KEYS.forEach((key) => {
72+
const value = previousEnv[key];
73+
if (value === undefined) {
74+
delete process.env[key];
75+
} else {
76+
process.env[key] = value;
77+
}
78+
});
79+
fs.rmSync(tempDir, { recursive: true, force: true });
80+
vi.resetModules();
81+
}
82+
});
83+
});

app/store/index.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const {
4040
}
4141

4242
function createCollectionsMock() {
43-
return { createCollections: vi.fn() };
43+
return { createCollections: vi.fn(), completeStartupInitialization: vi.fn() };
4444
}
4545

4646
function createLogMock() {
@@ -124,6 +124,7 @@ describe('Store Module', () => {
124124
expect(notification.createCollections).toHaveBeenCalled();
125125
expect(settings.createCollections).toHaveBeenCalled();
126126
expect(updateOperation.createCollections).toHaveBeenCalled();
127+
expect(app.completeStartupInitialization).toHaveBeenCalled();
127128
});
128129

129130
test('should create directory if it does not exist', async () => {
@@ -173,6 +174,7 @@ describe('Store Module', () => {
173174
expect(notification.createCollections).toHaveBeenCalled();
174175
expect(settings.createCollections).toHaveBeenCalled();
175176
expect(updateOperation.createCollections).toHaveBeenCalled();
177+
expect(app.completeStartupInitialization).toHaveBeenCalled();
176178
});
177179

178180
test('should save database when persistence is enabled', async () => {

app/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ function createCollections() {
4444
notification.createCollections(db);
4545
settings.createCollections(db);
4646
updateOperation.createCollections(db);
47+
app.completeStartupInitialization();
4748
}
4849

4950
/**

app/tag/precision.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { classifyTagPrecision, getNumericTagShape } from './precision.js';
1+
import { classifyTagPrecision, getNumericTagShape, isTagPinned } from './precision.js';
22

33
describe('tag/precision', () => {
44
describe('getNumericTagShape', () => {
@@ -54,4 +54,16 @@ describe('tag/precision', () => {
5454
).toBe('specific');
5555
});
5656
});
57+
58+
describe('isTagPinned', () => {
59+
test('treats numeric version aliases as pinned', () => {
60+
expect(isTagPinned('16-alpine', undefined)).toBe(true);
61+
expect(isTagPinned('1.2.3', undefined)).toBe(true);
62+
});
63+
64+
test('treats rolling channel aliases as not pinned', () => {
65+
expect(isTagPinned('latest', undefined)).toBe(false);
66+
expect(isTagPinned('stable', undefined)).toBe(false);
67+
});
68+
});
5769
});

0 commit comments

Comments
 (0)