From 7f0b820d038d8cb7f953fb150238b1739237e33d Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 31 Aug 2026 14:06:01 +0000 Subject: [PATCH] fix(sdk): handle spaces in project directory when bundling cloud function assets cdktf's TerraformAsset, when using AssetType.ARCHIVE, shells out to node .../private/fs.js with a command string that does not quote the paths. When the project directory contains spaces, the shell splits on the spaces, so the archive is written to the wrong place (or fails entirely). This later surfaces as: terraform apply: opening S3 object source (assets/.../archive.zip): no such file or directory during 'wing test -t tf-aws' / 'wing compile -t tf-aws' / AWS deploys. Instead of relying on that shell string, archive the bundle directory into a zip using execFileSync with an array of arguments (no shell) so paths with spaces are preserved, then reference it as a single TerraformAsset of type FILE (which is copied with copyFileSync, no shell). This is applied to all three cloud targets (aws, azure, gcp). Fixes #6465 --- packages/@winglang/sdk/src/shared/bundling.ts | 27 +++++++++++++++ .../sdk/src/target-tf-aws/function.ts | 13 ++++++-- .../sdk/src/target-tf-azure/function.ts | 10 ++++-- .../sdk/src/target-tf-gcp/function.ts | 11 ++++--- .../sdk/test/shared/bundling.test.ts | 33 ++++++++++++++++++- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/@winglang/sdk/src/shared/bundling.ts b/packages/@winglang/sdk/src/shared/bundling.ts index 568bb3ba83f..039db61c10c 100644 --- a/packages/@winglang/sdk/src/shared/bundling.ts +++ b/packages/@winglang/sdk/src/shared/bundling.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "child_process"; import * as crypto from "crypto"; import { mkdirSync, realpathSync, statSync, writeFileSync } from "fs"; import { stat } from "fs/promises"; @@ -132,6 +133,32 @@ export function createBundle( }; } +/** + * Archives (zips) the contents of `srcDir` into a single zip file at `destFile`. + * + * This replaces cdktf's `TerraformAsset` with `AssetType.ARCHIVE`, whose internal + * implementation shells out to `node .../private/fs.js ` using a + * command string that does NOT quote the paths. When the project directory (or + * any path component) contains spaces, the shell splits on the spaces and the + * archive is written to the wrong location (or fails entirely), which later + * causes errors like `opening S3 object source (assets/.../archive.zip): no + * such file or directory` during `terraform apply`. + * + * Instead of relying on that shell string, we invoke the archiver directly with + * `execFileSync` and an array of arguments (no shell), so paths containing + * spaces are handled correctly. The resulting zip file is then presented to + * `TerraformAsset` as a single `AssetType.FILE`, which is simply copied into the + * output directory with `copyFileSync` (no shell involved). + */ +export function createArchive(srcDir: string, destFile: string): void { + // Reuse the archiver bundled with cdktf (this repo pins cdktf to an exact + // version, so this internal module path is stable). It is invoked as a CLI + // with array arguments rather than a shell string to preserve spaces. + // eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-require-imports + const archiveScript = require.resolve("cdktf/lib/private/fs") as string; + execFileSync(process.execPath, [archiveScript, srcDir, destFile]); +} + export interface SourceMap { sourceRoot?: string; sources: string[]; diff --git a/packages/@winglang/sdk/src/target-tf-aws/function.ts b/packages/@winglang/sdk/src/target-tf-aws/function.ts index 343be3d29cb..2afc785ae12 100644 --- a/packages/@winglang/sdk/src/target-tf-aws/function.ts +++ b/packages/@winglang/sdk/src/target-tf-aws/function.ts @@ -1,3 +1,4 @@ +import { resolve } from "path"; import { AssetType, Lazy, TerraformAsset } from "cdktf"; import { Construct } from "constructs"; import { App } from "./app"; @@ -11,7 +12,7 @@ import { S3Object } from "../.gen/providers/aws/s3-object"; import { SecurityGroup } from "../.gen/providers/aws/security-group"; import * as cloud from "../cloud"; import { NotImplementedError } from "../core/errors"; -import { createBundle } from "../shared/bundling"; +import { createArchive, createBundle } from "../shared/bundling"; import { DEFAULT_MEMORY_SIZE } from "../shared/function"; import { NameOptions, ResourceNames } from "../shared/resource-names"; import { @@ -272,11 +273,17 @@ export class Function extends AwsFunction { const bundle = createBundle(this.entrypoint, externalLibraries); + // Archive the bundle directory into a zip ourselves, then reference it as a + // single file. This avoids cdktf's `AssetType.ARCHIVE` shelling out with an + // unquoted path, which breaks when the directory contains spaces. + const archiveZip = resolve(bundle.directory, "..", "archive.zip"); + createArchive(bundle.directory, archiveZip); + // would prefer to create TerraformAsset in the constructor, but using a CDKTF token for // the "path" argument isn't supported const asset = new TerraformAsset(this, "Asset", { - path: bundle.directory, - type: AssetType.ARCHIVE, + path: archiveZip, + type: AssetType.FILE, }); this.bundleHash = bundle.hash; diff --git a/packages/@winglang/sdk/src/target-tf-azure/function.ts b/packages/@winglang/sdk/src/target-tf-azure/function.ts index e24ef5fbb4e..247b5e4957d 100644 --- a/packages/@winglang/sdk/src/target-tf-azure/function.ts +++ b/packages/@winglang/sdk/src/target-tf-azure/function.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import { resolve } from "path"; import { AssetType, Lazy, TerraformAsset } from "cdktf"; import { Construct } from "constructs"; import { App } from "./app"; @@ -14,7 +15,7 @@ import { StorageBlob } from "../.gen/providers/azurerm/storage-blob"; import * as cloud from "../cloud"; import { LiftMap } from "../core"; import { NotImplementedError } from "../core/errors"; -import { createBundle } from "../shared/bundling"; +import { createArchive, createBundle } from "../shared/bundling"; import { CaseConventions, NameOptions, @@ -250,9 +251,12 @@ export class Function extends cloud.Function { ); // Create zip asset from function code + const archiveZip = resolve(codeDir, "..", "archive.zip"); + createArchive(codeDir, archiveZip); + const asset = new TerraformAsset(this, "Asset", { - path: `${codeDir}`, - type: AssetType.ARCHIVE, + path: archiveZip, + type: AssetType.FILE, }); this.assetPath = asset.path; diff --git a/packages/@winglang/sdk/src/target-tf-gcp/function.ts b/packages/@winglang/sdk/src/target-tf-gcp/function.ts index 59d95ae8877..a30301792a0 100644 --- a/packages/@winglang/sdk/src/target-tf-gcp/function.ts +++ b/packages/@winglang/sdk/src/target-tf-gcp/function.ts @@ -1,5 +1,5 @@ import { writeFileSync } from "fs"; -import { join, basename } from "path"; +import { join, basename, resolve } from "path"; import { AssetType, Lazy, TerraformAsset, Fn } from "cdktf"; import { Construct } from "constructs"; import { App } from "./app"; @@ -13,7 +13,7 @@ import { StorageBucketObject } from "../.gen/providers/google/storage-bucket-obj import * as cloud from "../cloud"; import { LiftMap } from "../core"; import { NotImplementedError } from "../core/errors"; -import { createBundle } from "../shared/bundling"; +import { createArchive, createBundle } from "../shared/bundling"; import { DEFAULT_MEMORY_SIZE } from "../shared/function"; import { CaseConventions, @@ -223,9 +223,12 @@ export class Function extends cloud.Function { ), ); + const archiveZip = resolve(bundle.directory, "..", "archive.zip"); + createArchive(bundle.directory, archiveZip); + const asset = new TerraformAsset(this, "Asset", { - path: bundle.directory, - type: AssetType.ARCHIVE, + path: archiveZip, + type: AssetType.FILE, }); this.assetPath = asset.path; diff --git a/packages/@winglang/sdk/test/shared/bundling.test.ts b/packages/@winglang/sdk/test/shared/bundling.test.ts index 41e5f18b0ed..857b527cddc 100644 --- a/packages/@winglang/sdk/test/shared/bundling.test.ts +++ b/packages/@winglang/sdk/test/shared/bundling.test.ts @@ -1,6 +1,37 @@ +import { + writeFileSync, + mkdtempSync, + readFileSync, + mkdirSync, + existsSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { describe, it, expect } from "vitest"; import { encode, decode } from "vlq"; -import { fixSourcemaps } from "../../src/shared/bundling"; +import { createArchive, fixSourcemaps } from "../../src/shared/bundling"; + +describe("createArchive", () => { + it("should create a zip archive when the directory path contains spaces", () => { + // create a temp directory with spaces in its name, mirroring the scenario in + // https://github.com/winglang/wing/issues/6465 where the project directory + // contains a space + const root = mkdtempSync(join(tmpdir(), "dir with spaces ")); + const srcDir = join(root, "src"); + const destFile = join(root, "archive.zip"); + mkdirSync(srcDir, { recursive: true }); + writeFileSync(join(srcDir, "index.js"), "module.exports = {};"); + + // WHEN + createArchive(srcDir, destFile); + + // THEN + expect(existsSync(destFile)).toBe(true); + // a valid zip archive starts with the "PK" magic bytes + expect(readFileSync(destFile).subarray(0, 2).toString()).toBe("PK"); + expect(readFileSync(destFile).length).toBeGreaterThan(0); + }); +}); describe("fixSourcemaps", () => { it("should fix sourcemaps", () => {