Skip to content

Commit

Permalink
[Experimental/Components] Add infrastructure for component discovery …
Browse files Browse the repository at this point in the history
…and schema inference (pulumi#18580)

Adds the `componentProviderHost` function to host a provider that
automatically discovers ComponentResource subclasses and generates a
schema.

This PR sets up the base infrastructure that searches for the
components, but does not do any schema inference besides the component
names.

Part of pulumi#18367
Ref pulumi#15939
  • Loading branch information
julienp authored Feb 13, 2025
1 parent 160e90b commit 471f1af
Show file tree
Hide file tree
Showing 11 changed files with 402 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changes:
- type: feat
scope: sdk/nodejs
description: "[Experimental/Components] Add infrastructure for component discovery and schema inference"
103 changes: 103 additions & 0 deletions sdk/nodejs/provider/experimental/analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2025-2025, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// The typescript import is used for type-checking only. Do not reference it in the emitted code.
// Use `ts` instead to access typescript library functions.
import typescript from "typescript";
import * as path from "path";

// Use the TypeScript shim which allows us to fallback to a vendored version of
// TypeScript if the user has not installed it.
// TODO: we should consider requiring the user to install TypeScript and not
// rely on the shim. In any case, we should add tests for providers with
// different versions of TypeScript in their dependencies, to ensure the
// analyzer code is compatible with all of them.
const ts: typeof typescript = require("../../typescript-shim");

export type ComponentDefinition = {
name: string;
};

export type TypeDefinition = {
name: string;
};

export type AnalyzeResult = {
components: Record<string, ComponentDefinition>;
typeDefinitons: Record<string, TypeDefinition>;
};

export class Analyzer {
private checker: typescript.TypeChecker;
private program: typescript.Program;
private components: Record<string, ComponentDefinition> = {};
private typeDefinitons: Record<string, TypeDefinition> = {};

constructor(dir: string) {
const configPath = `${dir}/tsconfig.json`;
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath));
this.program = ts.createProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
});
this.checker = this.program.getTypeChecker();
}

public analyze(): AnalyzeResult {
const sourceFiles = this.program.getSourceFiles();
for (const sourceFile of sourceFiles) {
if (sourceFile.fileName.includes("node_modules") || sourceFile.fileName.endsWith(".d.ts")) {
continue;
}
this.analyseFile(sourceFile);
}
return {
components: this.components,
typeDefinitons: this.typeDefinitons,
};
}

private analyseFile(sourceFile: typescript.SourceFile) {
// We intentionally visit only the top-level nodes, because we only
// support components defined at the top-level. We have no way to
// instantiate components defined inside functions or methods.
sourceFile.forEachChild((node) => {
if (ts.isClassDeclaration(node) && this.isPulumiComponent(node) && node.name) {
const componentName = node.name.text;
this.components[componentName] = {
name: componentName,
};
}
});
}

private isPulumiComponent(node: typescript.ClassDeclaration): boolean {
if (!node.heritageClauses) {
return false;
}

return node.heritageClauses.some((clause) => {
return clause.types.some((clauseNode) => {
const type = this.checker.getTypeAtLocation(clauseNode);
const symbol = type.getSymbol();
const matchesName = symbol?.escapedName === "ComponentResource";
const sourceFile = symbol?.declarations?.[0].getSourceFile();
const matchesSourceFile =
sourceFile?.fileName.endsWith("resource.ts") || sourceFile?.fileName.endsWith("resource.d.ts");
return matchesName && matchesSourceFile;
});
});
}
}
15 changes: 15 additions & 0 deletions sdk/nodejs/provider/experimental/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2025-2025, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

export * from "./provider";
76 changes: 76 additions & 0 deletions sdk/nodejs/provider/experimental/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2025-2025, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { readFileSync } from "fs";
import * as path from "path";
import { ComponentResourceOptions } from "../../resource";
import { ConstructResult, Provider } from "../provider";
import { Inputs } from "../../output";
import { main } from "../server";
import { generateSchema } from "./schema";
import { Analyzer } from "./analyzer";

class ComponentProvider implements Provider {
packageJSON: Record<string, any>;
version: string;
path: string;

constructor(readonly dir: string) {
const absDir = path.resolve(dir);
const packStr = readFileSync(`${absDir}/package.json`, { encoding: "utf-8" });
this.packageJSON = JSON.parse(packStr);
this.version = this.packageJSON.version;
this.path = absDir;
}

async getSchema(): Promise<string> {
const analyzer = new Analyzer(this.path);
const { components, typeDefinitons } = analyzer.analyze();
const schema = generateSchema(this.packageJSON, components, typeDefinitons);
return JSON.stringify(schema);
}

async construct(
name: string,
type: string,
inputs: Inputs,
options: ComponentResourceOptions,
): Promise<ConstructResult> {
throw new Error("Not implemented");
}
}

export function componentProviderHost(dirname?: string): Promise<void> {
const args = process.argv.slice(2);
// If dirname is not provided, get it from the call stack
if (!dirname) {
// Get the stack trace
const stack = new Error().stack;
// Parse the stack to get the caller's file
// Stack format is like:
// Error
// at componentProviderHost (.../src/index.ts:3:16)
// at Object.<anonymous> (.../caller/index.ts:4:1)
const callerLine = stack?.split("\n")[2];
const match = callerLine?.match(/\((.+):[0-9]+:[0-9]+\)/);
if (match?.[1]) {
dirname = path.dirname(match[1]);
} else {
throw new Error("Could not determine caller directory");
}
}

const prov = new ComponentProvider(dirname);
return main(prov, args);
}
118 changes: 118 additions & 0 deletions sdk/nodejs/provider/experimental/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright 2025-2025, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { ComponentDefinition, TypeDefinition } from "./analyzer";

export type PropertyType = "string" | "integer" | "number" | "boolean" | "array" | "object";

/**
* https://www.pulumi.com/docs/iac/using-pulumi/pulumi-packages/schema/#property
*/
export interface Property {
type: PropertyType;
items?: Property;
additionalProperties?: Property;
ref?: string;
plain?: boolean;
description?: string;
}

/**
* https://www.pulumi.com/docs/iac/using-pulumi/pulumi-packages/schema/#objecttype
*/
export interface ObjectType {
type: PropertyType;
description?: string;
properties?: { [key: string]: Property };
required?: string[];
}

/**
* https://www.pulumi.com/docs/iac/using-pulumi/pulumi-packages/schema/#complextype
*/
export interface ComplexType extends ObjectType {
enum?: string[];
}

/**
* https://www.pulumi.com/docs/iac/using-pulumi/pulumi-packages/schema/#resource
*/
export interface Resource extends ObjectType {
isComponent?: boolean;
inputProperties?: { [key: string]: Property };
requiredInputs?: string[];
}

/**
* https://www.pulumi.com/docs/iac/using-pulumi/pulumi-packages/schema/#package
*/
export interface PackageSpec {
name: string;
version?: string;
description?: string;
resources: { [key: string]: Resource };
types: { [key: string]: ComplexType };
language?: { [key: string]: any };
}

export function generateSchema(
packageJSON: Record<string, any>,
components: Record<string, ComponentDefinition>,
typeDefinitons: Record<string, TypeDefinition>,
): PackageSpec {
const providerName = packageJSON.name;
const result: PackageSpec = {
name: providerName,
version: packageJSON.version,
description: packageJSON.description,
resources: {},
types: {},
language: {
nodejs: {
dependencies: {},
devDependencies: {
typescript: "^5.0.0",
},
respectSchemaVersion: true,
},
python: {
respectSchemaVersion: true,
},
csharp: {
respectSchemaVersion: true,
},
java: {
respectSchemaVersion: true,
},
go: {
respectSchemaVersion: true,
},
},
};

for (const [name, component] of Object.entries(components)) {
result.resources[`${providerName}:index:${name}`] = {
type: "object",
isComponent: true,
};
}

for (const [name, type] of Object.entries(typeDefinitons)) {
result.types[`${providerName}:index:${name}`] = {
type: "object",
};
}

return result;
}
4 changes: 4 additions & 0 deletions sdk/nodejs/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"provider/provider.ts",
"provider/internals.ts",
"provider/server.ts",
"provider/experimental/index.ts",
"provider/experimental/analyzer.ts",
"provider/experimental/provider.ts",
"provider/experimental/schema.ts",
"runtime/index.ts",
"runtime/closure/codePaths.ts",
"runtime/closure/createClosure.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
runtime: nodejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2025-2025, Pulumi Corporation. All rights reserved.

import * as pulumi from "@pulumi/pulumi";

export interface MyComponentArgs { }

export class MyComponent extends pulumi.ComponentResource {
constructor(name: string, args: MyComponentArgs, opts?: pulumi.ComponentResourceOptions) {
super("nodejs-component-provider:index:MyComponent", name, args, opts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright 2025-2025, Pulumi Corporation. All rights reserved.

import { componentProviderHost } from "@pulumi/pulumi/provider/experimental";

componentProviderHost();
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "nodejs-component-provider",
"description": "Node.js Sample Components",
"version": "1.2.3",
"dependencies": {}
}
Loading

0 comments on commit 471f1af

Please sign in to comment.