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", () => {