@stream44.studio/t44-docker.com 0.1.0-rc.3
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/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +29 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +23 -0
- package/README.md +73 -0
- package/caps/Cli.test.ts +116 -0
- package/caps/Cli.ts +134 -0
- package/caps/Container.test.ts +168 -0
- package/caps/Container.ts +484 -0
- package/caps/ContainerContext.test.ts +78 -0
- package/caps/ContainerContext.ts +111 -0
- package/caps/Containers.test.ts +59 -0
- package/caps/Containers.ts +47 -0
- package/caps/Hub.test.ts +107 -0
- package/caps/Hub.ts +367 -0
- package/caps/Image/tpl/Dockerfile.alpine +40 -0
- package/caps/Image/tpl/Dockerfile.distroless +45 -0
- package/caps/Image/tpl/package.json +8 -0
- package/caps/Image.test.ts +269 -0
- package/caps/Image.ts +623 -0
- package/caps/ImageContext.test.ts +130 -0
- package/caps/ImageContext.ts +126 -0
- package/caps/Project.test.ts +267 -0
- package/caps/Project.ts +304 -0
- package/lib/waitForFetch.ts +95 -0
- package/package.json +19 -0
- package/structs/Hub/WorkspaceConnectionConfig.ts +53 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bun test --timeout 30000
|
|
2
|
+
|
|
3
|
+
import * as bunTest from 'bun:test'
|
|
4
|
+
import { run } from 't44/standalone-rt'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
test: { describe, it, expect },
|
|
8
|
+
containers,
|
|
9
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
10
|
+
const spine = await encapsulate({
|
|
11
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
12
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
13
|
+
'#': {
|
|
14
|
+
test: {
|
|
15
|
+
type: CapsulePropertyTypes.Mapping,
|
|
16
|
+
value: 't44/caps/ProjectTest',
|
|
17
|
+
options: { '#': { bunTest, env: {} } }
|
|
18
|
+
},
|
|
19
|
+
containers: {
|
|
20
|
+
type: CapsulePropertyTypes.Mapping,
|
|
21
|
+
value: './Containers',
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}, {
|
|
26
|
+
importMeta: import.meta,
|
|
27
|
+
importStack: makeImportStack(),
|
|
28
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/Containers.test'
|
|
29
|
+
})
|
|
30
|
+
return { spine }
|
|
31
|
+
}, async ({ spine, apis }: any) => {
|
|
32
|
+
return apis[spine.capsuleSourceLineRef]
|
|
33
|
+
}, { importMeta: import.meta })
|
|
34
|
+
|
|
35
|
+
describe('Containers Capsule', () => {
|
|
36
|
+
|
|
37
|
+
describe('list', () => {
|
|
38
|
+
it('should return a string with default format', async () => {
|
|
39
|
+
const result = await containers.list({ all: true });
|
|
40
|
+
expect(typeof result).toBe('string');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return a string with custom format', async () => {
|
|
44
|
+
const result = await containers.list({ all: true, format: '{{.ID}}' });
|
|
45
|
+
expect(typeof result).toBe('string');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return an array when json is true', async () => {
|
|
49
|
+
const result = await containers.list({ all: true, json: true });
|
|
50
|
+
expect(Array.isArray(result)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should filter by name', async () => {
|
|
54
|
+
const result = await containers.list({ all: true, filter: 'name=nonexistent-container-xyz', format: '{{.ID}}' });
|
|
55
|
+
expect(typeof result).toBe('string');
|
|
56
|
+
expect((result as string).trim()).toBe('');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export async function capsule({
|
|
2
|
+
encapsulate,
|
|
3
|
+
CapsulePropertyTypes,
|
|
4
|
+
makeImportStack
|
|
5
|
+
}: {
|
|
6
|
+
encapsulate: any
|
|
7
|
+
CapsulePropertyTypes: any
|
|
8
|
+
makeImportStack: any
|
|
9
|
+
}) {
|
|
10
|
+
|
|
11
|
+
return encapsulate({
|
|
12
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
13
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
14
|
+
'#': {
|
|
15
|
+
cli: {
|
|
16
|
+
type: CapsulePropertyTypes.Mapping,
|
|
17
|
+
value: './Cli',
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
list: {
|
|
21
|
+
type: CapsulePropertyTypes.Function,
|
|
22
|
+
value: async function (this: any, options: {
|
|
23
|
+
all?: boolean; filter?: string; format?: string; json?: boolean;
|
|
24
|
+
} = {}): Promise<string | any[]> {
|
|
25
|
+
const { all = false, filter, format, json = false } = options;
|
|
26
|
+
const args = ['ps'];
|
|
27
|
+
if (all) args.push('-a');
|
|
28
|
+
if (filter) args.push('--filter', filter);
|
|
29
|
+
if (json && !format) { args.push('--format', 'json'); }
|
|
30
|
+
else if (format) { args.push('--format', format); }
|
|
31
|
+
const result = await this.cli.exec(args);
|
|
32
|
+
if (json && !format) {
|
|
33
|
+
const lines = result.split('\n').filter((line: string) => line.trim());
|
|
34
|
+
if (lines.length === 0) return [];
|
|
35
|
+
return lines.map((line: string) => JSON.parse(line));
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, {
|
|
43
|
+
importMeta: import.meta,
|
|
44
|
+
importStack: makeImportStack(),
|
|
45
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/Containers',
|
|
46
|
+
})
|
|
47
|
+
}
|
package/caps/Hub.test.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bun test --timeout 60000
|
|
2
|
+
|
|
3
|
+
import * as bunTest from 'bun:test'
|
|
4
|
+
import { run } from 't44/workspace-rt'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
test: { describe, it, expect },
|
|
8
|
+
hub,
|
|
9
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
10
|
+
const spine = await encapsulate({
|
|
11
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
12
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
13
|
+
'#': {
|
|
14
|
+
test: {
|
|
15
|
+
type: CapsulePropertyTypes.Mapping,
|
|
16
|
+
value: 't44/caps/ProjectTest',
|
|
17
|
+
options: {
|
|
18
|
+
'#': {
|
|
19
|
+
bunTest,
|
|
20
|
+
env: {
|
|
21
|
+
DOCKERHUB_USERNAME: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:username' },
|
|
22
|
+
DOCKERHUB_PASSWORD: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:password' },
|
|
23
|
+
DOCKERHUB_ORGANIZATION: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:organization' },
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
hub: {
|
|
29
|
+
type: CapsulePropertyTypes.Mapping,
|
|
30
|
+
value: './Hub',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, {
|
|
35
|
+
importMeta: import.meta,
|
|
36
|
+
importStack: makeImportStack(),
|
|
37
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/Hub.test'
|
|
38
|
+
})
|
|
39
|
+
return { spine }
|
|
40
|
+
}, async ({ spine, apis }: any) => {
|
|
41
|
+
return apis[spine.capsuleSourceLineRef]
|
|
42
|
+
}, {
|
|
43
|
+
importMeta: import.meta
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Docker Hub Capsule', function () {
|
|
47
|
+
|
|
48
|
+
it('should have default values', function () {
|
|
49
|
+
expect(hub.verbose).toBe(false);
|
|
50
|
+
expect(hub._token).toBeUndefined();
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('authenticate()', async function () {
|
|
54
|
+
const token = await hub.authenticate();
|
|
55
|
+
expect(token).toBeTruthy();
|
|
56
|
+
expect(typeof token).toBe('string');
|
|
57
|
+
expect(hub._token).toBe(token);
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('getNamespace() returns organization or username', async function () {
|
|
61
|
+
const ns = await hub.getNamespace();
|
|
62
|
+
expect(ns).toBeTruthy();
|
|
63
|
+
expect(typeof ns).toBe('string');
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('getStats() for a public repository', async function () {
|
|
67
|
+
const stats = await hub.getStats({
|
|
68
|
+
repository: 'alpine',
|
|
69
|
+
namespace: 'library',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(stats).toBeDefined();
|
|
73
|
+
expect(stats.name).toBe('alpine');
|
|
74
|
+
expect(stats.namespace).toBe('library');
|
|
75
|
+
expect(stats.pull_count).toBeGreaterThan(0);
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('getTags() for a public repository', async function () {
|
|
79
|
+
const tags = await hub.getTags({
|
|
80
|
+
repository: 'alpine',
|
|
81
|
+
namespace: 'library',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(tags).toBeArray();
|
|
85
|
+
expect(tags.length).toBeGreaterThan(0);
|
|
86
|
+
expect(tags).toContain('latest');
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ensureTagged() succeeds for existing tag', async function () {
|
|
90
|
+
const tag = await hub.ensureTagged({
|
|
91
|
+
repository: 'alpine',
|
|
92
|
+
namespace: 'library',
|
|
93
|
+
tag: 'latest',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(tag).toBe('latest');
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('ensureTagged() throws for non-existent tag', async function () {
|
|
100
|
+
await expect(hub.ensureTagged({
|
|
101
|
+
repository: 'alpine',
|
|
102
|
+
namespace: 'library',
|
|
103
|
+
tag: 'this-tag-does-not-exist-ever-12345',
|
|
104
|
+
})).rejects.toThrow('not found');
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
})
|
package/caps/Hub.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Hub API Capsule
|
|
3
|
+
* @see https://docs.docker.com/docker-hub/api/latest/
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function capsule({
|
|
7
|
+
encapsulate,
|
|
8
|
+
CapsulePropertyTypes,
|
|
9
|
+
makeImportStack
|
|
10
|
+
}: {
|
|
11
|
+
encapsulate: any
|
|
12
|
+
CapsulePropertyTypes: any
|
|
13
|
+
makeImportStack: any
|
|
14
|
+
}) {
|
|
15
|
+
|
|
16
|
+
return encapsulate({
|
|
17
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
18
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
19
|
+
'#@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig': {
|
|
20
|
+
as: '$ConnectionConfig'
|
|
21
|
+
},
|
|
22
|
+
'#': {
|
|
23
|
+
|
|
24
|
+
verbose: {
|
|
25
|
+
type: CapsulePropertyTypes.Literal,
|
|
26
|
+
value: false,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
_token: {
|
|
30
|
+
type: CapsulePropertyTypes.Literal,
|
|
31
|
+
value: undefined as string | undefined,
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the namespace (organization or username)
|
|
36
|
+
*/
|
|
37
|
+
getNamespace: {
|
|
38
|
+
type: CapsulePropertyTypes.Function,
|
|
39
|
+
value: async function (this: any): Promise<string> {
|
|
40
|
+
const org = await this.$ConnectionConfig.getConfigValue('organization').catch(() => undefined);
|
|
41
|
+
const username = await this.$ConnectionConfig.getConfigValue('username');
|
|
42
|
+
return org || username;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Authenticate with Docker Hub and get a JWT token
|
|
48
|
+
*/
|
|
49
|
+
authenticate: {
|
|
50
|
+
type: CapsulePropertyTypes.Function,
|
|
51
|
+
value: async function (this: any): Promise<string> {
|
|
52
|
+
const username = await this.$ConnectionConfig.getConfigValue('username');
|
|
53
|
+
const password = await this.$ConnectionConfig.getConfigValue('password');
|
|
54
|
+
|
|
55
|
+
if (this.verbose) {
|
|
56
|
+
console.log(`[Hub] Authenticating as ${username}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const response = await fetch('https://hub.docker.com/v2/users/login', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
username,
|
|
64
|
+
password,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const error = await response.text();
|
|
70
|
+
throw new Error(`Docker Hub authentication failed: ${response.status} ${error}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = await response.json() as { token?: string };
|
|
74
|
+
this._token = data.token;
|
|
75
|
+
|
|
76
|
+
if (!this._token) {
|
|
77
|
+
throw new Error('Docker Hub authentication failed: No token received');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.verbose) {
|
|
81
|
+
console.log(`[Hub] Authentication successful`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return this._token;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ensure we have a valid token
|
|
90
|
+
*/
|
|
91
|
+
ensureAuthenticated: {
|
|
92
|
+
type: CapsulePropertyTypes.Function,
|
|
93
|
+
value: async function (this: any): Promise<string> {
|
|
94
|
+
if (!this._token) {
|
|
95
|
+
await this.authenticate();
|
|
96
|
+
}
|
|
97
|
+
return this._token!;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Internal helper to make API calls to Docker Hub
|
|
103
|
+
*/
|
|
104
|
+
apiCall: {
|
|
105
|
+
type: CapsulePropertyTypes.Function,
|
|
106
|
+
value: async function (this: any, options: {
|
|
107
|
+
method: 'GET' | 'POST' | 'DELETE';
|
|
108
|
+
path: string;
|
|
109
|
+
requireAuth?: boolean;
|
|
110
|
+
body?: any;
|
|
111
|
+
}): Promise<any> {
|
|
112
|
+
const { method, path, requireAuth = true, body } = options;
|
|
113
|
+
|
|
114
|
+
const headers: Record<string, string> = {};
|
|
115
|
+
|
|
116
|
+
if (requireAuth) {
|
|
117
|
+
const token = await this.ensureAuthenticated();
|
|
118
|
+
headers['Authorization'] = `JWT ${token}`;
|
|
119
|
+
} else if (this._token) {
|
|
120
|
+
headers['Authorization'] = `JWT ${this._token}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (body) {
|
|
124
|
+
headers['Content-Type'] = 'application/json';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const url = `https://hub.docker.com${path}`;
|
|
128
|
+
|
|
129
|
+
if (this.verbose) {
|
|
130
|
+
console.log(`[Hub] ${method} ${path}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
method,
|
|
135
|
+
headers,
|
|
136
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const error = await response.text();
|
|
141
|
+
throw new Error(`API call failed: ${method} ${path} - ${response.status} ${error}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (method === 'DELETE') {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return await response.json();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get all tags in a repository
|
|
154
|
+
*/
|
|
155
|
+
getTags: {
|
|
156
|
+
type: CapsulePropertyTypes.Function,
|
|
157
|
+
value: async function (this: any, options: {
|
|
158
|
+
repository: string;
|
|
159
|
+
namespace?: string;
|
|
160
|
+
}): Promise<string[]> {
|
|
161
|
+
const namespace = options.namespace || this.getNamespace();
|
|
162
|
+
const repository = options.repository;
|
|
163
|
+
|
|
164
|
+
const data = await this.apiCall({
|
|
165
|
+
method: 'GET',
|
|
166
|
+
path: `/v2/repositories/${namespace}/${repository}/tags/?page_size=100`,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return data.results?.map((result: any) => result.name) || [];
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Verify that a tag exists in the repository
|
|
175
|
+
*/
|
|
176
|
+
ensureTagged: {
|
|
177
|
+
type: CapsulePropertyTypes.Function,
|
|
178
|
+
value: async function (this: any, options: {
|
|
179
|
+
repository: string;
|
|
180
|
+
tag: string;
|
|
181
|
+
namespace?: string;
|
|
182
|
+
}): Promise<string> {
|
|
183
|
+
const tags = await this.getTags({
|
|
184
|
+
repository: options.repository,
|
|
185
|
+
namespace: options.namespace,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!tags.includes(options.tag)) {
|
|
189
|
+
throw new Error(`Tag ${options.tag} not found in repository`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return options.tag;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get repository statistics including pull count, star count, etc.
|
|
198
|
+
*/
|
|
199
|
+
getStats: {
|
|
200
|
+
type: CapsulePropertyTypes.Function,
|
|
201
|
+
value: async function (this: any, options: {
|
|
202
|
+
repository: string;
|
|
203
|
+
namespace?: string;
|
|
204
|
+
}): Promise<{
|
|
205
|
+
pull_count: number;
|
|
206
|
+
star_count: number;
|
|
207
|
+
name: string;
|
|
208
|
+
namespace: string;
|
|
209
|
+
description: string;
|
|
210
|
+
is_private: boolean;
|
|
211
|
+
last_updated: string;
|
|
212
|
+
}> {
|
|
213
|
+
const namespace = options.namespace || this.getNamespace();
|
|
214
|
+
const repository = options.repository;
|
|
215
|
+
|
|
216
|
+
const data = await this.apiCall({
|
|
217
|
+
method: 'GET',
|
|
218
|
+
path: `/v2/repositories/${namespace}/${repository}/`,
|
|
219
|
+
requireAuth: false,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
pull_count: data.pull_count || 0,
|
|
224
|
+
star_count: data.star_count || 0,
|
|
225
|
+
name: data.name,
|
|
226
|
+
namespace: data.namespace,
|
|
227
|
+
description: data.description || '',
|
|
228
|
+
is_private: data.is_private || false,
|
|
229
|
+
last_updated: data.last_updated,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Delete a specific tag from a repository
|
|
236
|
+
*/
|
|
237
|
+
deleteTag: {
|
|
238
|
+
type: CapsulePropertyTypes.Function,
|
|
239
|
+
value: async function (this: any, options: {
|
|
240
|
+
repository: string;
|
|
241
|
+
tag: string;
|
|
242
|
+
namespace?: string;
|
|
243
|
+
timeoutMs?: number;
|
|
244
|
+
pollIntervalMs?: number;
|
|
245
|
+
}): Promise<void> {
|
|
246
|
+
const namespace = options.namespace || this.getNamespace();
|
|
247
|
+
const repository = options.repository;
|
|
248
|
+
const tag = options.tag;
|
|
249
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
250
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
251
|
+
|
|
252
|
+
if (this.verbose) {
|
|
253
|
+
console.log(`[Hub] Deleting tag ${namespace}/${repository}:${tag}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await this.apiCall({
|
|
258
|
+
method: 'DELETE',
|
|
259
|
+
path: `/v2/repositories/${namespace}/${repository}/tags/${tag}/`,
|
|
260
|
+
});
|
|
261
|
+
} catch (error: any) {
|
|
262
|
+
if (error?.message?.includes('403')) {
|
|
263
|
+
throw new Error('Tag deletion not permitted: token lacks delete permissions');
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Poll to verify deletion
|
|
269
|
+
const startTime = Date.now();
|
|
270
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
271
|
+
const tags = await this.getTags({ repository, namespace });
|
|
272
|
+
if (!tags.includes(tag)) {
|
|
273
|
+
if (this.verbose) {
|
|
274
|
+
console.log(`[Hub] Tag deleted successfully`);
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
throw new Error(`Tag deletion verification timed out after ${timeoutMs}ms`);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Delete an entire repository from Docker Hub
|
|
287
|
+
*/
|
|
288
|
+
deleteRepository: {
|
|
289
|
+
type: CapsulePropertyTypes.Function,
|
|
290
|
+
value: async function (this: any, options: {
|
|
291
|
+
repository: string;
|
|
292
|
+
namespace?: string;
|
|
293
|
+
wait?: boolean;
|
|
294
|
+
timeoutMs?: number;
|
|
295
|
+
pollIntervalMs?: number;
|
|
296
|
+
}): Promise<void> {
|
|
297
|
+
const namespace = options.namespace || this.getNamespace();
|
|
298
|
+
const repository = options.repository;
|
|
299
|
+
const { wait = false, timeoutMs = 5 * 60 * 1000, pollIntervalMs = 15000 } = options;
|
|
300
|
+
|
|
301
|
+
if (this.verbose) {
|
|
302
|
+
console.log(`[Hub] Deleting repository ${namespace}/${repository}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await this.apiCall({
|
|
306
|
+
method: 'DELETE',
|
|
307
|
+
path: `/v2/repositories/${namespace}/${repository}/`,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (wait) {
|
|
311
|
+
const startTime = Date.now();
|
|
312
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
313
|
+
try {
|
|
314
|
+
await this.getStats({ repository, namespace });
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
316
|
+
} catch (error: any) {
|
|
317
|
+
if (error?.message?.includes('404') || error?.message?.includes('not found')) {
|
|
318
|
+
if (this.verbose) {
|
|
319
|
+
console.log(`[Hub] Repository deletion confirmed`);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Timeout waiting for repository deletion after ${timeoutMs}ms`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Login to Docker Hub registry via CLI
|
|
333
|
+
*/
|
|
334
|
+
loginCli: {
|
|
335
|
+
type: CapsulePropertyTypes.Function,
|
|
336
|
+
value: async function (this: any, options?: { cli?: any }): Promise<string> {
|
|
337
|
+
const cli = options?.cli;
|
|
338
|
+
if (!cli) {
|
|
339
|
+
throw new Error('cli capsule must be provided to loginCli');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const username = await this.$ConnectionConfig.getConfigValue('username');
|
|
343
|
+
const password = await this.$ConnectionConfig.getConfigValue('password');
|
|
344
|
+
|
|
345
|
+
if (this.verbose) {
|
|
346
|
+
console.log(`[Hub] Logging in to Docker Hub as ${username}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const result = await cli.exec([
|
|
350
|
+
'login',
|
|
351
|
+
'-u', username,
|
|
352
|
+
'--password-stdin',
|
|
353
|
+
'registry.hub.docker.com'
|
|
354
|
+
], { stdin: password + '\n' });
|
|
355
|
+
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}, {
|
|
363
|
+
importMeta: import.meta,
|
|
364
|
+
importStack: makeImportStack(),
|
|
365
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/Hub',
|
|
366
|
+
})
|
|
367
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Smallest option: Alpine with glibc (~16MB base + binary)
|
|
2
|
+
# Best for: Production where size is critical
|
|
3
|
+
# Trade-off: Minimal debugging tools
|
|
4
|
+
|
|
5
|
+
FROM frolvlad/alpine-glibc:latest
|
|
6
|
+
|
|
7
|
+
# Install wget for health checks and bun for native modules
|
|
8
|
+
RUN apk add --no-cache wget ca-certificates unzip bash curl libstdc++ libgcc
|
|
9
|
+
|
|
10
|
+
# Install Bun to system-wide location
|
|
11
|
+
RUN wget -qO- https://bun.sh/install | bash -s -- bun-v1.2.0 && \
|
|
12
|
+
mv /root/.bun/bin/bun /usr/local/bin/bun && \
|
|
13
|
+
chmod +x /usr/local/bin/bun
|
|
14
|
+
|
|
15
|
+
# Set working directory
|
|
16
|
+
WORKDIR /app
|
|
17
|
+
|
|
18
|
+
# Copy all app files from build context
|
|
19
|
+
COPY . /app/
|
|
20
|
+
|
|
21
|
+
# Install only production dependencies (native modules)
|
|
22
|
+
RUN bun install --production --no-save
|
|
23
|
+
|
|
24
|
+
# Make binaries executable (if any)
|
|
25
|
+
RUN find /app -type f -name "*.sh" -exec chmod +x {} \; || true
|
|
26
|
+
RUN find /app/dist -type f -executable -exec chmod +x {} \; 2>/dev/null || true
|
|
27
|
+
|
|
28
|
+
# Create non-root user
|
|
29
|
+
RUN addgroup -g 1001 -S appuser && \
|
|
30
|
+
adduser -u 1001 -S appuser -G appuser && \
|
|
31
|
+
chown -R appuser:appuser /app
|
|
32
|
+
|
|
33
|
+
# Switch to non-root user
|
|
34
|
+
USER appuser
|
|
35
|
+
|
|
36
|
+
# Environment variables (can be overridden at runtime)
|
|
37
|
+
ENV IPFS_GATEWAY_TOKEN=""
|
|
38
|
+
|
|
39
|
+
# Run the app using bun
|
|
40
|
+
CMD ["bun", "run", "start"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Most secure option: Google Distroless (~20-30MB base)
|
|
2
|
+
# Best for: Production security (no shell, minimal attack surface)
|
|
3
|
+
# Trade-off: No wget for health checks, harder to debug
|
|
4
|
+
|
|
5
|
+
# Build stage for installing native dependencies
|
|
6
|
+
FROM oven/bun:1.2-slim AS builder
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
# Copy all app files
|
|
9
|
+
COPY . /app/
|
|
10
|
+
RUN bun install --production --no-save
|
|
11
|
+
|
|
12
|
+
# Use Debian base for glibc compatibility
|
|
13
|
+
FROM gcr.io/distroless/base-debian12:latest
|
|
14
|
+
|
|
15
|
+
# Set working directory
|
|
16
|
+
WORKDIR /app
|
|
17
|
+
|
|
18
|
+
# Copy Bun runtime from builder
|
|
19
|
+
COPY --from=builder /usr/local/bin/bun /usr/local/bin/bun
|
|
20
|
+
|
|
21
|
+
# Copy C++ runtime libraries from builder (required by libsql native module)
|
|
22
|
+
COPY --from=builder /usr/lib/*/libstdc++.so.6 /usr/lib/
|
|
23
|
+
COPY --from=builder /usr/lib/*/libgcc_s.so.1 /usr/lib/
|
|
24
|
+
|
|
25
|
+
# Copy native dependencies from builder
|
|
26
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
27
|
+
|
|
28
|
+
# Copy all app files from builder
|
|
29
|
+
COPY --from=builder /app /app
|
|
30
|
+
|
|
31
|
+
# Create non-root user and set ownership
|
|
32
|
+
# Note: distroless uses numeric UIDs since there's no shell to create users
|
|
33
|
+
COPY --from=builder --chown=1001:1001 /app /app
|
|
34
|
+
|
|
35
|
+
# Switch to non-root user (numeric UID since distroless has no user database)
|
|
36
|
+
USER 1001
|
|
37
|
+
|
|
38
|
+
# Environment variables (can be overridden at runtime)
|
|
39
|
+
ENV IPFS_GATEWAY_TOKEN=""
|
|
40
|
+
|
|
41
|
+
# Note: Health checks won't work without wget/curl
|
|
42
|
+
# You'll need to rely on external monitoring or TCP checks
|
|
43
|
+
|
|
44
|
+
# Run the app using bun
|
|
45
|
+
CMD ["bun", "run", "start"]
|