Skip to content

Commit e6e3931

Browse files
committed
Published using @Stream44 Studio
Signed-off-by: Christoph <christoph@christoph.diy>
1 parent 06315cd commit e6e3931

6 files changed

Lines changed: 167 additions & 50 deletions

File tree

caps/Container.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,15 @@ export async function capsule({
161161
forceColor?: boolean;
162162
verbose?: boolean;
163163
command?: string;
164+
logCommand?: boolean;
164165
}): Promise<string> {
165166
const ctx = containerContext ?? this.context.derive();
166167
const {
167168
image, name, detach = true, ports = [], volumes = [], env = {},
168169
removeOnExit: remove = false, interactive = false, tty = false,
169170
workdir, network, platform, waitFor, waitTimeout = 30000,
170171
showOutput = false, forceColor = true, verbose = false,
171-
command,
172+
command, logCommand = false,
172173
} = ctx;
173174
const self = this;
174175

@@ -229,6 +230,18 @@ export async function capsule({
229230

230231
if (verbose) console.log(`[run] Full command: docker ${args.join(' ')}`);
231232

233+
if (logCommand) {
234+
// Build a copy-pasteable command with env vars inline
235+
const shellArgs = args.map(arg => {
236+
// Quote args that contain spaces or special chars
237+
if (/[\s"'$`\\!]/.test(arg)) {
238+
return `'${arg.replace(/'/g, "'\\''")}'`;
239+
}
240+
return arg;
241+
});
242+
console.log(`\n 📋 Copy-pasteable command:\n docker ${shellArgs.join(' ').replace(/ -d /, ' ')}\n`);
243+
}
244+
232245
if (waitFor) {
233246
containerProc = Bun.spawn(['docker', ...args], { stdout: 'pipe', stderr: 'pipe' });
234247
const proc = containerProc;

caps/Hub.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ describe('Docker Hub Capsule', function () {
106106
expect(tag).toBe('latest');
107107
})
108108

109+
it('getTag() returns metadata for a specific tag', async function () {
110+
const tagMeta = await hub.getTag({
111+
repository: 'alpine',
112+
namespace: 'library',
113+
tag: 'latest',
114+
});
115+
116+
expect(tagMeta).toBeDefined();
117+
expect(tagMeta.name).toBe('latest');
118+
expect(tagMeta.last_updated || tagMeta.tag_last_pushed || tagMeta.last_pushed).toBeTruthy();
119+
})
120+
109121
it('ensureTagged() throws for non-existent tag', async function () {
110122
await expect(hub.ensureTagged({
111123
repository: 'alpine',

caps/Hub.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@ export async function capsule({
193193
}
194194
},
195195

196+
/**
197+
* Get metadata for a specific tag in a repository
198+
*/
199+
getTag: {
200+
type: CapsulePropertyTypes.Function,
201+
value: async function (this: any, options: {
202+
repository: string;
203+
tag: string;
204+
namespace?: string;
205+
}): Promise<any> {
206+
const namespace = options.namespace || await this.getNamespace();
207+
const repository = options.repository;
208+
const tag = options.tag;
209+
210+
return await this.apiCall({
211+
method: 'GET',
212+
path: `/v2/repositories/${namespace}/${repository}/tags/${tag}`,
213+
requireAuth: false,
214+
});
215+
}
216+
},
217+
196218
/**
197219
* Get repository statistics including pull count, star count, etc.
198220
*/

caps/Image.ts

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -516,10 +516,10 @@ export async function capsule({
516516
},
517517

518518
/**
519-
* Build and optionally push a multi-platform image using docker buildx.
520-
* Uses `docker buildx build --platform linux/amd64,linux/arm64 [--push] -t tag1 [-t tag2] .`
521-
* When push is true, this creates a proper multi-arch manifest on the registry
522-
* with NO separate per-arch tags — only the manifest tags appear.
519+
* Build and optionally push a multi-platform image.
520+
* Builds each architecture separately (so arch-dependent file callbacks
521+
* receive the correct archDir), pushes per-arch images, then creates
522+
* and pushes multi-arch manifest lists for each requested tag.
523523
*/
524524
buildMultiPlatform: {
525525
type: CapsulePropertyTypes.Function,
@@ -531,7 +531,7 @@ export async function capsule({
531531
attestations?: { sbom?: boolean; provenance?: boolean };
532532
buildArgs?: Record<string, string>;
533533
}): Promise<{ tags: string[] }> {
534-
const { variant, tags, push, files, attestations, buildArgs } = opts;
534+
const { variant, tags, push, files, attestations } = opts;
535535

536536
if (!variant) {
537537
throw new Error('variant must be set for buildMultiPlatform');
@@ -545,7 +545,7 @@ export async function capsule({
545545
throw new Error(`unknown variant: ${variant}`);
546546
}
547547

548-
// Build platforms string from DOCKER_ARCHS
548+
const archKeys = Object.keys(this.cli.DOCKER_ARCHS);
549549
const platforms = Object.values(this.cli.DOCKER_ARCHS)
550550
.map((a: any) => `${a.os}/${a.arch}`)
551551
.join(',');
@@ -554,53 +554,34 @@ export async function capsule({
554554
console.log(`\nBuilding ${variant} for ${platforms}${push ? ' (with push)' : ''}...`);
555555
}
556556

557-
// Prepare build context using the first arch (context is arch-independent for buildx)
558-
const firstArchKey = Object.keys(this.cli.DOCKER_ARCHS)[0];
559-
const firstArch = this.cli.DOCKER_ARCHS[firstArchKey];
560-
const buildContextDir = this.context.getBuildContextDir({ variant });
561-
562-
await this.prepareBuildContext({
563-
appBaseDir: this.context.appBaseDir,
564-
buildContextDir,
565-
templateDir: this.context.templateDir,
566-
variant: variantInfo,
567-
arch: firstArch,
568-
files: files ?? this.context.files,
569-
buildScriptName: this.context.buildScriptName,
570-
});
571-
572-
// Build args for docker buildx build
573-
const args = ['buildx', 'build'];
574-
args.push('--platform', platforms);
575-
576-
const dockerfilePath = join(buildContextDir, 'Dockerfile');
577-
args.push('-f', dockerfilePath);
557+
// Build each architecture separately so that arch-dependent file
558+
// callbacks (e.g. files: { 'csrv': ({ archDir }) => ... }) receive
559+
// the correct archDir for each platform.
560+
const perArchImages: string[] = [];
561+
562+
for (const archKey of archKeys) {
563+
const result = await this.buildVariant({
564+
variant,
565+
arch: archKey,
566+
files,
567+
attestations,
568+
});
569+
perArchImages.push(result.imageTag);
578570

579-
for (const tag of tags) {
580-
args.push('-t', tag);
571+
if (push) {
572+
await this.cli.exec(['push', result.imageTag]);
573+
}
581574
}
582575

583576
if (push) {
584-
args.push('--push');
585-
}
586-
587-
if (buildArgs) {
588-
for (const [key, value] of Object.entries(buildArgs)) {
589-
args.push('--build-arg', `${key}=${value}`);
577+
// Create and push a manifest list for each requested tag,
578+
// pointing to the per-arch images we just pushed.
579+
for (const tag of tags) {
580+
await this.cli.exec(['manifest', 'create', '--amend', tag, ...perArchImages]);
581+
await this.cli.exec(['manifest', 'push', tag]);
590582
}
591583
}
592584

593-
if (attestations?.sbom) {
594-
args.push('--attest', 'type=sbom');
595-
}
596-
if (attestations?.provenance) {
597-
args.push('--attest', 'type=provenance,mode=max');
598-
}
599-
600-
args.push(buildContextDir);
601-
602-
await this.cli.exec(args);
603-
604585
if (this.context.verbose) {
605586
console.log(`✅ Built multi-platform ${variant} (${platforms})`);
606587
for (const tag of tags) {

caps/Project.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,95 @@ console.log("READY");
528528
}, 180000);
529529
});
530530

531+
describe('buildMultiPlatform with arch-dependent files', () => {
532+
it('should call files callback with correct archDir per architecture', async () => {
533+
const appDir = join(workbenchDir, 'project-multiplatform-archfiles-test');
534+
await createSampleApp(appDir);
535+
536+
// Create arch-specific marker files so we can verify each image gets the right one
537+
const archDirs = ['linux-arm64', 'linux-x64'];
538+
for (const archDir of archDirs) {
539+
const dir = join(appDir, 'dist', archDir);
540+
await mkdir(dir, { recursive: true });
541+
await writeFile(join(dir, 'marker'), `arch=${archDir}\n`);
542+
}
543+
544+
await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
545+
const spine = await encapsulate({
546+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
547+
'#@stream44.studio/encapsulate/structs/Capsule': {},
548+
'#': {
549+
project: {
550+
type: CapsulePropertyTypes.Mapping,
551+
value: './Project',
552+
options: {
553+
'@stream44.studio/t44-docker.com/caps/ImageContext': {
554+
'#': {
555+
organization: 'test-docker-com',
556+
repository: 'multiplatform-archfiles-test',
557+
verbose: true,
558+
files: {
559+
'marker': ({ appBaseDir, archDir }: any) => {
560+
const p = join(appBaseDir, 'dist', archDir, 'marker');
561+
console.log(` [files callback] archDir=${archDir} -> ${p}`);
562+
return p;
563+
},
564+
'package.json': { scripts: { start: 'cat /app/marker' } },
565+
},
566+
},
567+
},
568+
},
569+
},
570+
}
571+
}
572+
}, { importMeta: import.meta, importStack: makeImportStack(), capsuleName: '@stream44.studio/t44-docker.com/caps/Project.test.multiplatform-archfiles' })
573+
return { spine }
574+
}, async ({ spine, apis }: any) => {
575+
const project = apis[spine.capsuleSourceLineRef].project;
576+
577+
project.image.context.appBaseDir = appDir;
578+
project.image.context.buildContextBaseDir = join(appDir, '.~o/t44-docker.com');
579+
580+
// buildMultiPlatform builds each arch via buildVariant
581+
const result = await project.image.buildMultiPlatform({
582+
variant: 'alpine',
583+
tags: ['test-docker-com/multiplatform-archfiles-test:test-multiarch'],
584+
push: false,
585+
});
586+
587+
expect(result.tags.length).toBe(1);
588+
589+
// Verify each per-arch image contains the correct marker file
590+
const archExpectations: Record<string, string> = {
591+
'linux-arm64': 'arch=linux-arm64',
592+
'linux-x64': 'arch=linux-x64',
593+
};
594+
595+
for (const [archKey, expectedContent] of Object.entries(archExpectations)) {
596+
const archInfo = project.cli.DOCKER_ARCHS[archKey];
597+
const imageTag = project.image.context.getImageTag({ variant: 'alpine', arch: archKey });
598+
console.log(` Checking ${imageTag} (${archInfo.arch}) for: ${expectedContent}`);
599+
600+
const output = await project.cli.exec([
601+
'run', '--rm',
602+
'--platform', `${archInfo.os}/${archInfo.arch}`,
603+
'--entrypoint', '/bin/cat',
604+
imageTag,
605+
'/app/marker',
606+
]);
607+
expect(output.trim()).toBe(expectedContent);
608+
}
609+
610+
// Cleanup
611+
for (const archKey of Object.keys(project.cli.DOCKER_ARCHS)) {
612+
const imageTag = project.image.context.getImageTag({ variant: 'alpine', arch: archKey });
613+
await project.image.removeImage({ image: imageTag, force: true }).catch(() => { });
614+
}
615+
await project.cli.exec(['rmi', 'test-docker-com/multiplatform-archfiles-test:test-multiarch']).catch(() => { });
616+
}, { importMeta: import.meta, runFromSnapshot: false })
617+
}, 180000);
618+
});
619+
531620
describe('retagImages', () => {
532621
it('should retag images from source to target org/repo', async () => {
533622
const appDir = join(workbenchDir, 'project-retag-test');

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"name": "@stream44.studio/t44-docker.com",
3-
"version": "0.1.0-rc.10",
3+
"version": "0.1.0-rc.11",
44
"private": false,
55
"license": "MIT",
66
"type": "module",
77
"scripts": {
88
"test": "bun test"
99
},
1010
"dependencies": {
11-
"@stream44.studio/t44": "^0.4.0-rc.33",
12-
"@stream44.studio/encapsulate": "^0.4.0-rc.29"
11+
"@stream44.studio/t44": "^0.4.0-rc.34",
12+
"@stream44.studio/encapsulate": "^0.4.0-rc.32"
1313
},
1414
"devDependencies": {
1515
"@types/bun": "^1.3.4",

0 commit comments

Comments
 (0)