diff --git a/cmd/nelm/chart.go b/cmd/nelm/chart.go index 66bf0e0b..25a9ebbd 100644 --- a/cmd/nelm/chart.go +++ b/cmd/nelm/chart.go @@ -18,6 +18,7 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. cli.GroupCommandOptions{}, ) + cmd.AddCommand(newChartInitCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartRenderCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDependencyCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDownloadCommand(ctx, afterAllCommandsBuiltFuncs)) diff --git a/cmd/nelm/chart_init.go b/cmd/nelm/chart_init.go new file mode 100644 index 00000000..d5aa8ef2 --- /dev/null +++ b/cmd/nelm/chart_init.go @@ -0,0 +1,89 @@ +package main + +import ( + "cmp" + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/werf/common-go/pkg/cli" + "github.com/werf/nelm/pkg/action" + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/log" +) + +type chartInitConfig struct { + action.ChartInitOptions + + LogColorMode string + LogLevel string +} + +func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cfg := &chartInitConfig{} + + cmd := cli.NewSubCommand( + ctx, + "init [PATH]", + "Initialize a new chart.", + "Initialize a new chart in the specified directory. If PATH is not specified, uses the current directory.", + 10, // priority for ordering in help + chartCmdGroup, + cli.SubCommandOptions{ + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + }, + }, + func(cmd *cobra.Command, args []string) error { + ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), log.InfoLevel), log.SetupLoggingOptions{ + ColorMode: cfg.LogColorMode, + }) + + if len(args) > 0 { + cfg.ChartDirPath = args[0] + } + + if err := action.ChartInit(ctx, cfg.ChartInitOptions); err != nil { + return fmt.Errorf("chart init: %w", err) + } + + return nil + }, + ) + + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + if err := cli.AddFlag(cmd, &cfg.TS, "ts", false, "Initialize TypeScript chart", cli.AddFlagOptions{ + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.TempDirPath, "temp-dir", "", "The directory for temporary files. By default, create a new directory in the default system directory for temporary files", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalEnvVarRegexes, + Group: miscFlagGroup, + Type: cli.FlagTypeDir, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.LogColorMode, "color-mode", common.DefaultLogColorMode, "Color mode for logs. "+allowedLogColorModesHelp(), cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.LogLevel, "log-level", string(log.InfoLevel), "Set log level. "+allowedLogLevelsHelp(), cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + return nil + } + + return cmd +} diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 9297c0a5..8cd5118c 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "strings" "github.com/samber/lo" @@ -10,6 +11,8 @@ import ( helm_v3 "github.com/werf/3p-helm/cmd/helm" "github.com/werf/3p-helm/pkg/chart/loader" "github.com/werf/common-go/pkg/cli" + "github.com/werf/nelm/internal/tschart" + "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -33,6 +36,15 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" + if featgate.FeatGateTypescript.Enabled() { + transformer := tschart.NewTransformer() + for _, chartPath := range args { + if err := transformer.TransformChartDir(ctx, chartPath); err != nil { + return fmt.Errorf("transform TypeScript in %q: %w", chartPath, err) + } + } + } + if err := originalRunE(cmd, args); err != nil { return err } diff --git a/go.mod b/go.mod index 44654baf..939cda54 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,10 @@ require ( github.com/docker/cli v25.0.5+incompatible github.com/docker/docker v25.0.5+incompatible github.com/dominikbraun/graph v0.23.0 + github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86 + github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf github.com/evanphx/json-patch v5.8.0+incompatible + github.com/evanw/esbuild v0.27.0 github.com/fluxcd/flagger v1.36.1 github.com/goccy/go-yaml v1.15.23 github.com/google/go-cmp v0.6.0 @@ -94,6 +97,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.8.1 // indirect diff --git a/go.sum b/go.sum index e625b5f9..8c8955b7 100644 --- a/go.sum +++ b/go.sum @@ -111,10 +111,16 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86 h1:iY/kk+Fw7k49PRM4cS2wz9CVxO0jB61+h//XN9bbAS4= +github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE= +github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.8.0+incompatible h1:1Av9pn2FyxPdvrWNQszj1g6D6YthSmvCfcN6SYclTJg= github.com/evanphx/json-patch v5.8.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanw/esbuild v0.27.0 h1:1fbrgepqU1rZeu4VPcQRZJpvIfQpbrYqRr1wJdeMkfM= +github.com/evanw/esbuild v0.27.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -148,6 +154,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index c26d1ea5..0f2cf1b4 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "maps" "os" "path" "path/filepath" @@ -16,7 +17,7 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/3p-helm/pkg/action" - "github.com/werf/3p-helm/pkg/chart" + helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chart/loader" "github.com/werf/3p-helm/pkg/chartutil" "github.com/werf/3p-helm/pkg/cli" @@ -31,7 +32,9 @@ import ( "github.com/werf/3p-helm/pkg/werf/helmopts" "github.com/werf/nelm/internal/kube" "github.com/werf/nelm/internal/resource/spec" + "github.com/werf/nelm/internal/tschart" "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -53,7 +56,7 @@ type RenderChartOptions struct { } type RenderChartResult struct { - Chart *chart.Chart + Chart *helmchart.Chart Notes string ReleaseConfig map[string]interface{} ResourceSpecs []*spec.ResourceSpec @@ -147,6 +150,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.TraceStruct(ctx, runtime, "Runtime:") var isUpgrade bool + switch deployType { case common.DeployTypeUpgrade, common.DeployTypeRollback: isUpgrade = true @@ -204,6 +208,17 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s return nil, fmt.Errorf("render resources for chart %q: %w", chart.Name(), err) } + if featgate.FeatGateTypescript.Enabled() { + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues) + if err != nil { + return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) + } + + if len(jsRenderedTemplates) > 0 { + maps.Copy(renderedTemplates, jsRenderedTemplates) + } + } + log.Default.TraceStruct(ctx, renderedTemplates, "Rendered contents of templates/:") if r, err := renderedTemplatesToResourceSpecs(renderedTemplates, releaseNamespace, opts); err != nil { @@ -229,7 +244,25 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } -func validateChart(ctx context.Context, chart *chart.Chart) error { +func renderJSTemplates( + ctx context.Context, + chartPath string, + chart *helmchart.Chart, + renderedValues chartutil.Values, +) (map[string]string, error) { + log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) + + jsEngine := tschart.NewEngine() + + jsRenderedTemplates, err := jsEngine.RenderChartWithDependencies(ctx, chartPath, chart, renderedValues) + if err != nil { + return nil, err + } + + return jsRenderedTemplates, nil +} + +func validateChart(ctx context.Context, chart *helmchart.Chart) error { if chart == nil { return fmt.Errorf("load chart: %w", action.ErrMissingChart()) } @@ -253,6 +286,7 @@ func validateChart(ctx context.Context, chart *chart.Chart) error { func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, releaseNamespace string, opts RenderChartOptions) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec + for filePath, fileContent := range renderedTemplates { if strings.HasPrefix(path.Base(filePath), "_") || strings.HasSuffix(filePath, action.NotesFileSuffix) || diff --git a/internal/tschart/console.go b/internal/tschart/console.go new file mode 100644 index 00000000..0b58eb27 --- /dev/null +++ b/internal/tschart/console.go @@ -0,0 +1,14 @@ +package tschart + +import ( + "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/console" + "github.com/dop251/goja_nodejs/require" +) + +func SetupConsoleGlobal(runtime *goja.Runtime) { + registry := require.NewRegistry() + registry.Enable(runtime) + + console.Enable(runtime) +} diff --git a/internal/tschart/engine.go b/internal/tschart/engine.go new file mode 100644 index 00000000..a318d67e --- /dev/null +++ b/internal/tschart/engine.go @@ -0,0 +1,305 @@ +package tschart + +import ( + "context" + "fmt" + "path" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/chartutil" + "github.com/werf/nelm/pkg/log" +) + +func mergeRuntimeFiles(runtimeFiles, runtimeDepsFiles []*helmchart.File) []*helmchart.File { + if len(runtimeDepsFiles) == 0 { + return runtimeFiles + } + + merged := make([]*helmchart.File, 0, len(runtimeFiles)+len(runtimeDepsFiles)) + merged = append(merged, runtimeFiles...) + merged = append(merged, runtimeDepsFiles...) + + return merged +} + +type Engine struct{} + +func NewEngine() *Engine { + return &Engine{} +} + +func (e *Engine) RenderChartWithDependencies( + ctx context.Context, + rootChartPath string, + chart *helmchart.Chart, + renderedValues chartutil.Values, +) (map[string]string, error) { + allRendered := make(map[string]string) + + err := e.renderChartRecursive(ctx, rootChartPath, chart, renderedValues, chart.Name(), allRendered) + if err != nil { + return nil, err + } + + return allRendered, nil +} + +//nolint:funcorder // Helper method kept near the method that uses it for readability +func (e *Engine) renderChartRecursive( + ctx context.Context, + chartDirPath string, // Filesystem path (for local charts) + chart *helmchart.Chart, + values chartutil.Values, + pathPrefix string, // Output path prefix (e.g., "root/charts/sub") + results map[string]string, +) error { + log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) + + rendered, err := e.RenderFiles(ctx, chartDirPath, chart, values) + if err != nil { + return fmt.Errorf("render TypeScript for chart %q: %w", chart.Name(), err) + } + + for filename, content := range rendered { + outputPath := path.Join(pathPrefix, filename) + results[outputPath] = content + log.Default.Debug(ctx, "Added TypeScript output: %s", outputPath) + } + + for _, dep := range chart.Dependencies() { + depName := dep.Name() + + log.Default.Debug(ctx, "Processing dependency %q for chart %q", depName, chart.Name()) + + depValues := scopeValuesForSubchart(values, depName, dep) + + depDirPath := filepath.Join(chartDirPath, "charts", depName) + + depPathPrefix := path.Join(pathPrefix, "charts", depName) + + err := e.renderChartRecursive(ctx, depDirPath, dep, depValues, depPathPrefix, results) + if err != nil { + return fmt.Errorf("render dependency %q: %w", depName, err) + } + } + + return nil +} + +func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, subchart *helmchart.Chart) chartutil.Values { + scoped := chartutil.Values{} + + if caps, ok := parentValues["Capabilities"]; ok { + scoped["Capabilities"] = caps + } + + if release, ok := parentValues["Release"]; ok { + scoped["Release"] = release + } + + if runtime, ok := parentValues["Runtime"]; ok { + scoped["Runtime"] = runtime + } + + scoped["Chart"] = buildChartMetadata(subchart) + + if parentVals, ok := parentValues["Values"]; ok { + switch v := parentVals.(type) { + case map[string]interface{}: + if subVals, ok := v[subchartName]; ok { + scoped["Values"] = subVals + } else { + scoped["Values"] = map[string]interface{}{} + } + case chartutil.Values: + if subVals, ok := v[subchartName]; ok { + scoped["Values"] = subVals + } else { + scoped["Values"] = map[string]interface{}{} + } + default: + scoped["Values"] = map[string]interface{}{} + } + } else { + scoped["Values"] = map[string]interface{}{} + } + + files := make(map[string]interface{}, len(subchart.Files)) + for _, file := range subchart.Files { + files[file.Name] = file.Data + } + + scoped["Files"] = files + + return scoped +} + +func buildChartMetadata(chart *helmchart.Chart) map[string]interface{} { + metadata := map[string]interface{}{ + "Name": chart.Name(), + "Version": "", + } + + if chart.Metadata != nil { + metadata["Version"] = chart.Metadata.Version + metadata["AppVersion"] = chart.Metadata.AppVersion + metadata["Description"] = chart.Metadata.Description + metadata["Keywords"] = chart.Metadata.Keywords + metadata["Home"] = chart.Metadata.Home + metadata["Sources"] = chart.Metadata.Sources + metadata["Icon"] = chart.Metadata.Icon + metadata["APIVersion"] = chart.Metadata.APIVersion + metadata["Condition"] = chart.Metadata.Condition + metadata["Tags"] = chart.Metadata.Tags + metadata["Type"] = chart.Metadata.Type + metadata["Annotations"] = chart.Metadata.Annotations + + if chart.Metadata.Maintainers != nil { + maintainers := make([]map[string]interface{}, len(chart.Metadata.Maintainers)) + for i, m := range chart.Metadata.Maintainers { + maintainers[i] = map[string]interface{}{ + "Name": m.Name, + "Email": m.Email, + "URL": m.URL, + } + } + + metadata["Maintainers"] = maintainers + } + } + + return metadata +} + +func (e *Engine) RenderFiles(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { + mergedFiles := mergeRuntimeFiles(chart.RuntimeFiles, chart.RuntimeDepsFiles) + + var ( + vendorBundle string + packages []string + entrypoint string + ) + + vendorBundle, packages, err := GetVendorBundleFromFiles(mergedFiles) + if err != nil { + return nil, fmt.Errorf("get vendor bundle: %w", err) + } + + sourceFiles := ExtractSourceFiles(mergedFiles) + if len(sourceFiles) == 0 { + return map[string]string{}, nil + } + + entrypoint = findEntrypointFromFiles(sourceFiles) + if entrypoint == "" { + return map[string]string{}, nil + } + + appBundle, err := BuildAppBundleFromChartFiles(ctx, mergedFiles, packages) + if err != nil { + return nil, fmt.Errorf("build app bundle from chart files: %w", err) + } + + renderContext := buildRenderContext(renderedValues, chart) + + result, err := executeInGoja(vendorBundle, appBundle, renderContext) + if err != nil { + return nil, fmt.Errorf("execute bundle: %w", err) + } + + if result == nil { + return map[string]string{}, nil + } + + yamlOutput, err := resultToYAML(result) + if err != nil { + return nil, fmt.Errorf("convert result to YAML: %w", err) + } + + if strings.TrimSpace(yamlOutput) == "" { + return map[string]string{}, nil + } + + outputPath := path.Join(TSSourceDir, entrypoint) + + return map[string]string{ + outputPath: yamlOutput, + }, nil +} + +func findEntrypointFromFiles(sourceFiles map[string][]byte) string { + for _, ep := range EntryPoints { + if _, exists := sourceFiles[ep]; exists { + return ep + } + } + + return "" +} + +func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) map[string]interface{} { + renderContext := renderedValues.AsMap() + + if valuesInterface, ok := renderContext["Values"]; ok { + if chartValues, ok := valuesInterface.(chartutil.Values); ok { + renderContext["Values"] = chartValues.AsMap() + } + } + + renderContext["Chart"] = buildChartMetadata(chart) + + files := make(map[string]interface{}, len(chart.Files)) + for _, file := range chart.Files { + files[file.Name] = file.Data + } + + renderContext["Files"] = files + + return renderContext +} + +func resultToYAML(result interface{}) (string, error) { + resultMap, ok := result.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected render result type: %T", result) + } + + manifests, exists := resultMap["manifests"] + if !exists { + return "", fmt.Errorf("render result object does not contain 'manifests' field") + } + + return convertToYAML(manifests) +} + +func convertToYAML(value interface{}) (string, error) { + if arr, ok := value.([]interface{}); ok { + var results []string + + for i, item := range arr { + if item == nil { + continue + } + + yamlBytes, err := yaml.Marshal(item) + if err != nil { + return "", fmt.Errorf("marshal resource at index %d: %w", i, err) + } + + results = append(results, string(yamlBytes)) + } + + return strings.Join(results, "---\n"), nil + } + + yamlBytes, err := yaml.Marshal(value) + if err != nil { + return "", fmt.Errorf("marshal resource: %w", err) + } + + return string(yamlBytes), nil +} diff --git a/internal/tschart/engine_unit_test.go b/internal/tschart/engine_unit_test.go new file mode 100644 index 00000000..c92d4096 --- /dev/null +++ b/internal/tschart/engine_unit_test.go @@ -0,0 +1,640 @@ +//nolint:testpackage // White-box test needs access to internal functions +package tschart + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/chartutil" +) + +func newTestChart(files map[string]string) *chart.Chart { + var ( + fileList []*chart.File + runtimeFileList []*chart.File + ) + + for name, content := range files { + fileList = append(fileList, &chart.File{ + Name: name, + Data: []byte(content), + }) + runtimeFileList = append(runtimeFileList, &chart.File{ + Name: name, + Data: []byte(content), + }) + } + + testChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + }, + Files: fileList, + RuntimeFiles: runtimeFileList, + } + + return testChart +} + +func newTestValues(data map[string]interface{}) chartutil.Values { + return chartutil.Values(data) +} + +func TestRenderSimpleManifest(t *testing.T) { + sourceContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: context.Release.Name + '-config', + namespace: context.Release.Namespace + }, + data: { + replicas: String(context.Values.replicas) + } + }] + }; +} +` + testChart := newTestChart(map[string]string{ + "ts/src/index.ts": sourceContent, + }) + testChart.Metadata = &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + } + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{ + "replicas": 3, + }, + "Release": map[string]interface{}{ + "Name": "test-release", + "Namespace": "default", + "Revision": 1, + "IsInstall": true, + "IsUpgrade": false, + "Service": "Nelm", + }, + "Chart": map[string]interface{}{ + "Name": "test-chart", + "Version": "1.0.0", + }, + "Capabilities": map[string]interface{}{ + "APIVersions": []string{"v1", "apps/v1"}, + "KubeVersion": map[string]interface{}{ + "Version": "v1.29.0", + "Major": "1", + "Minor": "29", + }, + }, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderFiles(ctx, "", testChart, renderedValues) + require.NoError(t, err) + + assert.Len(t, renderedTemplates, 1) + assert.Contains(t, renderedTemplates, DefaultOutputFile) + + yaml := renderedTemplates[DefaultOutputFile] + assert.Contains(t, yaml, "kind: ConfigMap") + assert.Contains(t, yaml, "name: test-release-config") + assert.Contains(t, yaml, "namespace: default") + assert.Contains(t, yaml, "replicas: \"3\"") +} + +func TestRenderMultipleResources(t *testing.T) { + sourceContent := ` +export function render(context: any) { + return { + manifests: [ + { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: context.Release.Name + '-svc' } + }, + { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: context.Release.Name + '-deploy' } + } + ] + }; +} +` + testChart := newTestChart(map[string]string{ + "ts/src/index.ts": sourceContent, + }) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Release": map[string]interface{}{ + "Name": "test", + }, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderFiles(ctx, "", testChart, renderedValues) + require.NoError(t, err) + + assert.Len(t, renderedTemplates, 1) + + yaml := renderedTemplates[DefaultOutputFile] + + assert.Contains(t, yaml, "kind: Service") + assert.Contains(t, yaml, "kind: Deployment") + assert.Contains(t, yaml, "---") +} + +func TestRenderReturnsNull(t *testing.T) { + sourceContent := ` +export function render(context: any) { + if (!context.Values.enabled) { + return null; + } + return { + manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'test' } }] + }; +} +` + testChart := newTestChart(map[string]string{ + "ts/src/index.ts": sourceContent, + }) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{ + "enabled": false, + }, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderFiles(ctx, "", testChart, renderedValues) + require.NoError(t, err) + + assert.Empty(t, renderedTemplates) +} + +func TestNoTypeScriptSource(t *testing.T) { + // Create a chart without ts/ folder + testChart := newTestChart(map[string]string{ + "templates/deployment.yaml": "apiVersion: v1\nkind: Deployment", + }) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{}) + + engine := NewEngine() + renderedTemplates, err := engine.RenderFiles(ctx, "", testChart, renderedValues) + require.NoError(t, err) + + assert.Empty(t, renderedTemplates) +} + +func TestRenderWithModuleExportsObject(t *testing.T) { + sourceContent := ` +module.exports = { + render: function(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'test-object-pattern' } + }] + }; + } +}; +` + testChart := newTestChart(map[string]string{ + "ts/src/index.ts": sourceContent, + }) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{}) + + engine := NewEngine() + renderedTemplates, err := engine.RenderFiles(ctx, "", testChart, renderedValues) + require.NoError(t, err) + + assert.Len(t, renderedTemplates, 1) + yaml := renderedTemplates[DefaultOutputFile] + assert.Contains(t, yaml, "kind: ConfigMap") + assert.Contains(t, yaml, "name: test-object-pattern") +} + +func TestRenderFromPackagedChart(t *testing.T) { + // Simulate a packaged chart (not a local directory) with source files + sourceContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'packaged-chart-test' } + }] + }; +} +` + testChart := newTestChart(map[string]string{ + "ts/src/index.ts": sourceContent, + }) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{}) + + engine := NewEngine() + // Use a non-existent path to simulate packaged chart + renderedTemplates, err := engine.RenderFiles(ctx, "./non-existent-chart.tgz", testChart, renderedValues) + require.NoError(t, err) + + assert.Len(t, renderedTemplates, 1) + yaml := renderedTemplates[DefaultOutputFile] + assert.Contains(t, yaml, "kind: ConfigMap") + assert.Contains(t, yaml, "name: packaged-chart-test") +} + +// createTestChartWithSubchart creates a root chart with a TypeScript subchart dependency +func createTestChartWithSubchart(t *testing.T, rootContent, subchartContent string) *chart.Chart { + // Build subchart object with RuntimeFiles + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "ts-subchart", + Version: "0.1.0", + }, + Files: []*chart.File{}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(subchartContent)}, + }, + } + + // Build root chart object with dependency and RuntimeFiles + rootChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "root-chart", + Version: "1.0.0", + }, + Files: []*chart.File{}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(rootContent)}, + }, + } + rootChart.SetDependencies(subchart) + + return rootChart +} + +func TestRenderChartWithTSSubchart(t *testing.T) { + rootContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'root-config' }, + data: { + chartName: context.Chart.Name, + message: context.Values.rootMessage || 'default' + } + }] + }; +} +` + subchartContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'subchart-config' }, + data: { + chartName: context.Chart.Name, + message: context.Values.subMessage || 'default' + } + }] + }; +} +` + rootChart := createTestChartWithSubchart(t, rootContent, subchartContent) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{ + "rootMessage": "Hello from root", + "ts-subchart": map[string]interface{}{ + "subMessage": "Hello from subchart", + }, + }, + "Release": map[string]interface{}{ + "Name": "test-release", + "Namespace": "default", + }, + "Capabilities": map[string]interface{}{}, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderChartWithDependencies(ctx, "", rootChart, renderedValues) + require.NoError(t, err) + + // Should have 2 outputs: root and subchart + assert.Len(t, renderedTemplates, 2) + + // Check root chart output path + rootOutputPath := "root-chart/" + DefaultOutputFile + assert.Contains(t, renderedTemplates, rootOutputPath) + rootYaml := renderedTemplates[rootOutputPath] + assert.Contains(t, rootYaml, "name: root-config") + assert.Contains(t, rootYaml, "chartName: root-chart") + assert.Contains(t, rootYaml, "message: Hello from root") + + // Check subchart output path follows Helm convention + subchartOutputPath := "root-chart/charts/ts-subchart/" + DefaultOutputFile + assert.Contains(t, renderedTemplates, subchartOutputPath) + subYaml := renderedTemplates[subchartOutputPath] + assert.Contains(t, subYaml, "name: subchart-config") + assert.Contains(t, subYaml, "chartName: ts-subchart") + assert.Contains(t, subYaml, "message: Hello from subchart") +} + +func TestRenderClassicRootWithTSSubchart(t *testing.T) { + // Root chart has no ts/ directory (classic Go template chart) + // Only the subchart has TypeScript + subchartContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'ts-subchart-only' }, + data: { + chartName: context.Chart.Name, + releaseName: context.Release.Name + } + }] + }; +} +` + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "ts-subchart", + Version: "0.1.0", + }, + Files: []*chart.File{}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(subchartContent)}, + }, + } + + rootChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "classic-root", + Version: "1.0.0", + }, + Files: []*chart.File{}, + RuntimeFiles: []*chart.File{}, // No TypeScript in root + } + rootChart.SetDependencies(subchart) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{ + "ts-subchart": map[string]interface{}{}, + }, + "Release": map[string]interface{}{ + "Name": "my-release", + "Namespace": "default", + }, + "Capabilities": map[string]interface{}{}, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderChartWithDependencies(ctx, "", rootChart, renderedValues) + require.NoError(t, err) + + // Only subchart output (root has no ts/) + assert.Len(t, renderedTemplates, 1) + + subchartOutputPath := "classic-root/charts/ts-subchart/" + DefaultOutputFile + assert.Contains(t, renderedTemplates, subchartOutputPath) + yaml := renderedTemplates[subchartOutputPath] + assert.Contains(t, yaml, "name: ts-subchart-only") + assert.Contains(t, yaml, "chartName: ts-subchart") + assert.Contains(t, yaml, "releaseName: my-release") +} + +func TestScopeValuesForSubchart(t *testing.T) { + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "my-subchart", + Version: "2.0.0", + AppVersion: "1.5.0", + Description: "Test subchart", + }, + Files: []*chart.File{ + {Name: "README.md", Data: []byte("# Subchart")}, + }, + } + + parentValues := chartutil.Values{ + "Values": map[string]interface{}{ + "rootKey": "rootValue", + "my-subchart": map[string]interface{}{ + "subKey": "subValue", + "nested": map[string]interface{}{ + "deep": "value", + }, + }, + }, + "Release": map[string]interface{}{ + "Name": "test-release", + "Namespace": "prod", + }, + "Capabilities": map[string]interface{}{ + "KubeVersion": map[string]interface{}{ + "Version": "v1.28.0", + }, + }, + } + + scoped := scopeValuesForSubchart(parentValues, "my-subchart", subchart) + + // Release should be copied + assert.Equal(t, "test-release", scoped["Release"].(map[string]interface{})["Name"]) + assert.Equal(t, "prod", scoped["Release"].(map[string]interface{})["Namespace"]) + + // Capabilities should be copied + assert.NotNil(t, scoped["Capabilities"]) + + // Chart metadata should come from subchart + chartMeta := scoped["Chart"].(map[string]interface{}) + assert.Equal(t, "my-subchart", chartMeta["Name"]) + assert.Equal(t, "2.0.0", chartMeta["Version"]) + assert.Equal(t, "1.5.0", chartMeta["AppVersion"]) + + // Values should be scoped to subchart's values only + scopedValues := scoped["Values"].(map[string]interface{}) + assert.Equal(t, "subValue", scopedValues["subKey"]) + assert.Equal(t, "value", scopedValues["nested"].(map[string]interface{})["deep"]) + // Root values should NOT be present + assert.Nil(t, scopedValues["rootKey"]) + + // Files should come from subchart + assert.NotNil(t, scoped["Files"]) +} + +func TestScopeValuesForSubchartMissingValues(t *testing.T) { + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "missing-values-subchart", + Version: "1.0.0", + }, + } + + // Parent values don't have an entry for this subchart + parentValues := chartutil.Values{ + "Values": map[string]interface{}{ + "other-subchart": map[string]interface{}{ + "key": "value", + }, + }, + "Release": map[string]interface{}{ + "Name": "test", + }, + } + + scoped := scopeValuesForSubchart(parentValues, "missing-values-subchart", subchart) + + // Values should be empty map, not nil + assert.NotNil(t, scoped["Values"]) + assert.Empty(t, scoped["Values"]) +} + +func TestRenderNestedDependencies(t *testing.T) { + // Create a 3-level nested structure: root -> sub1 -> sub2 + rootContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'root' }, + data: { level: 'root' } + }] + }; +} +` + sub1Content := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'sub1' }, + data: { level: 'sub1' } + }] + }; +} +` + sub2Content := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'sub2' }, + data: { level: 'sub2' } + }] + }; +} +` + // Build chart objects with RuntimeFiles + sub2 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "sub2", Version: "0.1.0"}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(sub2Content)}, + }, + } + sub1 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "sub1", Version: "0.1.0"}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(sub1Content)}, + }, + } + sub1.SetDependencies(sub2) + + rootChart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "nested-root", Version: "1.0.0"}, + RuntimeFiles: []*chart.File{ + {Name: "ts/src/index.ts", Data: []byte(rootContent)}, + }, + } + rootChart.SetDependencies(sub1) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{ + "sub1": map[string]interface{}{ + "sub2": map[string]interface{}{}, + }, + }, + "Release": map[string]interface{}{"Name": "test"}, + "Capabilities": map[string]interface{}{}, + }) + + engine := NewEngine() + renderedTemplates, err := engine.RenderChartWithDependencies(ctx, "", rootChart, renderedValues) + require.NoError(t, err) + + // Should have 3 outputs + assert.Len(t, renderedTemplates, 3) + + // Verify paths follow Helm convention + assert.Contains(t, renderedTemplates, "nested-root/"+DefaultOutputFile) + assert.Contains(t, renderedTemplates, "nested-root/charts/sub1/"+DefaultOutputFile) + assert.Contains(t, renderedTemplates, "nested-root/charts/sub1/charts/sub2/"+DefaultOutputFile) + + // Verify content + assert.Contains(t, renderedTemplates["nested-root/"+DefaultOutputFile], "level: root") + assert.Contains(t, renderedTemplates["nested-root/charts/sub1/"+DefaultOutputFile], "level: sub1") + assert.Contains(t, renderedTemplates["nested-root/charts/sub1/charts/sub2/"+DefaultOutputFile], "level: sub2") +} + +func TestRenderSubchartError(t *testing.T) { + rootContent := ` +export function render(context: any) { + return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root' } }] }; +} +` + // Subchart has invalid TypeScript that will cause runtime error + subchartContent := ` +export function render(context: any) { + // This will cause a runtime error + const x: any = null; + x.nonExistent.deep; + return { manifests: [] }; +} +` + rootChart := createTestChartWithSubchart(t, rootContent, subchartContent) + + ctx := context.Background() + renderedValues := newTestValues(map[string]interface{}{ + "Values": map[string]interface{}{"ts-subchart": map[string]interface{}{}}, + "Release": map[string]interface{}{"Name": "test"}, + "Capabilities": map[string]interface{}{}, + }) + + engine := NewEngine() + _, err := engine.RenderChartWithDependencies(ctx, "", rootChart, renderedValues) + + // Should fail with error mentioning the subchart + require.Error(t, err) + assert.Contains(t, err.Error(), "ts-subchart") +} diff --git a/internal/tschart/init.go b/internal/tschart/init.go new file mode 100644 index 00000000..70d682ec --- /dev/null +++ b/internal/tschart/init.go @@ -0,0 +1,396 @@ +package tschart + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/werf/nelm/pkg/log" +) + +// InitChartStructure creates Chart.yaml and values.yaml if they don't exist. +// For .helmignore: creates if missing, or appends TS entries if exists. +// Returns error if ts/ directory already exists. +func InitChartStructure(ctx context.Context, chartPath, chartName string) error { + tsDir := filepath.Join(chartPath, TSSourceDir) + if _, err := os.Stat(tsDir); err == nil { + return fmt.Errorf("TypeScript directory already exists: %s. Cannot initialize in a directory with existing TypeScript chart files", tsDir) + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat %s: %w", tsDir, err) + } + + skipIfExists := []struct { + path string + content string + }{ + {filepath.Join(chartPath, "Chart.yaml"), generateChartYaml(chartName)}, + {filepath.Join(chartPath, "values.yaml"), generateValuesYaml()}, + } + + for _, f := range skipIfExists { + _, err := os.Stat(f.path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("stat %s: %w", f.path, err) + } + + if err == nil { + log.Default.Debug(ctx, "Skipping existing file %s", f.path) + continue + } + + if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write %s: %w", f.path, err) + } + + log.Default.Debug(ctx, "Created %s", f.path) + } + + // Handle .helmignore specially: create or enrich + helmignorePath := filepath.Join(chartPath, ".helmignore") + + _, err := os.Stat(helmignorePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("stat %s: %w", helmignorePath, err) + } + + if err == nil { + if err := AppendToHelmignore(chartPath); err != nil { + return fmt.Errorf("enrich .helmignore: %w", err) + } + + log.Default.Debug(ctx, "Enriched existing %s with TypeScript entries", helmignorePath) + } else { + if err := os.WriteFile(helmignorePath, []byte(generateHelmignoreWithTS()), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write %s: %w", helmignorePath, err) + } + + log.Default.Debug(ctx, "Created %s", helmignorePath) + } + + return nil +} + +// InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. +func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { + tsDir := filepath.Join(chartPath, TSSourceDir) + srcDir := filepath.Join(tsDir, "src") + + if _, err := os.Stat(tsDir); err == nil { + return fmt.Errorf("TypeScript directory already exists: %s", tsDir) + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat %s: %w", tsDir, err) + } + + files := []struct { + path string + content string + }{ + {filepath.Join(srcDir, "index.ts"), generateIndexTS()}, + {filepath.Join(srcDir, "helpers.ts"), generateHelpersTS()}, + {filepath.Join(srcDir, "deployment.ts"), generateDeploymentTS()}, + {filepath.Join(srcDir, "service.ts"), generateServiceTS()}, + {filepath.Join(tsDir, "tsconfig.json"), generateTSConfig()}, + {filepath.Join(tsDir, "package.json"), generatePackageJSON(chartName)}, + } + + if err := os.MkdirAll(srcDir, 0o755); err != nil { + return fmt.Errorf("create directory %s: %w", srcDir, err) + } + + for _, f := range files { + if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write %s: %w", f.path, err) + } + + log.Default.Debug(ctx, "Created %s", f.path) + } + + return nil +} + +func AppendToHelmignore(chartPath string) error { + helmignorePath := filepath.Join(chartPath, ".helmignore") + + existingContent, err := os.ReadFile(helmignorePath) + if err != nil { + return fmt.Errorf("read .helmignore: %w", err) + } + + content := string(existingContent) + if strings.Contains(content, "ts/dist/") { + return nil + } + + tsEntries := ` +# TypeScript chart files +ts/dist/ +` + newContent := strings.TrimRight(content, "\n") + "\n" + tsEntries + + if err := os.WriteFile(helmignorePath, []byte(newContent), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write .helmignore: %w", err) + } + + return nil +} + +func EnsureGitignore(chartPath string) error { + gitignorePath := filepath.Join(chartPath, ".gitignore") + + entries := []string{ + "ts/node_modules/", + "ts/vendor/", + "ts/dist/", + } + + existingContent, err := os.ReadFile(gitignorePath) + if os.IsNotExist(err) { + content := strings.Join(entries, "\n") + "\n" + if err := os.WriteFile(gitignorePath, []byte(content), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("create .gitignore: %w", err) + } + + return nil + } else if err != nil { + return fmt.Errorf("read .gitignore: %w", err) + } + + content := string(existingContent) + + var toAdd []string + + for _, entry := range entries { + if !strings.Contains(content, entry) { + toAdd = append(toAdd, entry) + } + } + + if len(toAdd) == 0 { + return nil + } + + newContent := strings.TrimRight(content, "\n") + "\n" + strings.Join(toAdd, "\n") + "\n" + if err := os.WriteFile(gitignorePath, []byte(newContent), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write .gitignore: %w", err) + } + + return nil +} + +func generateIndexTS() string { + return `import { RenderContext, RenderResult } from '@nelm/types'; +import { newDeployment } from './deployment'; +import { newService } from './service'; + +export function render($: RenderContext): RenderResult { + const manifests: object[] = []; + + manifests.push(newDeployment($)); + + if ($.Values.service?.enabled !== false) { + manifests.push(newService($)); + } + + return { manifests }; +} +` +} + +func generateHelpersTS() string { + return `import { RenderContext } from '@nelm/types'; + +/** + * Truncate string to max length, removing trailing hyphens. + */ +export function trunc(str: string, max: number): string { + if (str.length <= max) return str; + return str.slice(0, max).replace(/-+$/, ''); +} + +/** + * Get the fully qualified app name. + * Truncated at 63 chars (DNS naming spec limit). + */ +export function getFullname($: RenderContext): string { + if ($.Values.fullnameOverride) { + return trunc($.Values.fullnameOverride, 63); + } + + const chartName = $.Values.nameOverride || $.Chart.Name; + + if ($.Release.Name.includes(chartName)) { + return trunc($.Release.Name, 63); + } + + return trunc(` + "`${$.Release.Name}-${chartName}`" + `, 63); +} + +export function getLabels($: RenderContext): Record { + return { + 'app.kubernetes.io/name': $.Chart.Name, + 'app.kubernetes.io/instance': $.Release.Name, + }; +} + +export function getSelectorLabels($: RenderContext): Record { + return { + 'app.kubernetes.io/name': $.Chart.Name, + 'app.kubernetes.io/instance': $.Release.Name, + }; +} +` +} + +func generateDeploymentTS() string { + return `import { RenderContext } from '@nelm/types'; +import { getFullname, getLabels, getSelectorLabels } from './helpers'; + +export function newDeployment($: RenderContext): object { + const name = getFullname($); + + return { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name, + labels: getLabels($), + }, + spec: { + replicas: $.Values.replicaCount ?? 1, + selector: { + matchLabels: getSelectorLabels($), + }, + template: { + metadata: { + labels: getSelectorLabels($), + }, + spec: { + containers: [ + { + name: name, + image: ` + "`${$.Values.image?.repository}:${$.Values.image?.tag}`" + `, + ports: [ + { + name: 'http', + containerPort: $.Values.service?.port ?? 80, + }, + ], + }, + ], + }, + }, + }, + }; +} +` +} + +func generateServiceTS() string { + return `import { RenderContext } from '@nelm/types'; +import { getFullname, getLabels, getSelectorLabels } from './helpers'; + +export function newService($: RenderContext): object { + return { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: getFullname($), + labels: getLabels($), + }, + spec: { + type: $.Values.service?.type ?? 'ClusterIP', + ports: [ + { + port: $.Values.service?.port ?? 80, + targetPort: 'http', + }, + ], + selector: getSelectorLabels($), + }, + }; +} +` +} + +func generateTSConfig() string { + return `{ + "compilerOptions": { + "target": "ES2015", + "module": "CommonJS", + "declaration": true, + "declarationMap": true, + "inlineSourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +` +} + +func generatePackageJSON(chartName string) string { + return fmt.Sprintf(`{ + "name": "%s", + "version": "0.1.0", + "description": "TypeScript chart for %s", + "main": "src/index.ts", + "scripts": { + "build": "npx tsc --noEmit", + "typecheck": "npx tsc --noEmit" + }, + "keywords": [ + "helm", + "nelm", + "kubernetes", + "chart" + ], + "license": "Apache-2.0", + "dependencies": { + "@nelm/types": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} +`, chartName, chartName) +} + +func generateChartYaml(chartName string) string { + return fmt.Sprintf(`apiVersion: v2 +name: %s +version: 0.1.0 +`, chartName) +} + +func generateValuesYaml() string { + return `replicaCount: 1 + +image: + repository: nginx + tag: latest + +service: + enabled: true + type: ClusterIP + port: 80 +` +} + +func generateHelmignoreWithTS() string { + return `# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. + +# TypeScript chart files +ts/dist/ +` +} diff --git a/internal/tschart/init_test.go b/internal/tschart/init_test.go new file mode 100644 index 00000000..e988d875 --- /dev/null +++ b/internal/tschart/init_test.go @@ -0,0 +1,371 @@ +//nolint:gosec,testpackage // Test files use 0644 for test fixtures; white-box test needs access to internal functions +package tschart + +import ( + "context" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Init", func() { + var ( + ctx context.Context + tempDir string + ) + + BeforeEach(func() { + ctx = context.Background() + + var err error + + tempDir, err = os.MkdirTemp("", "tschart-init-test-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + Describe("InitTSBoilerplate", func() { + It("should create all expected files", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + // Check ts/src/ files + Expect(filepath.Join(chartPath, "ts", "src", "index.ts")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, "ts", "src", "helpers.ts")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, "ts", "src", "deployment.ts")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, "ts", "src", "service.ts")).To(BeARegularFile()) + + // Check ts/ root files + Expect(filepath.Join(chartPath, "ts", "tsconfig.json")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, "ts", "package.json")).To(BeARegularFile()) + }) + + It("should create correct directory structure", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Join(chartPath, "ts")).To(BeADirectory()) + Expect(filepath.Join(chartPath, "ts", "src")).To(BeADirectory()) + }) + + It("should substitute chart name in package.json", func() { + chartPath := filepath.Join(tempDir, "my-custom-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "my-custom-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "package.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring(`"name": "my-custom-chart"`)) + Expect(string(content)).To(ContainSubstring(`"description": "TypeScript chart for my-custom-chart"`)) + }) + + It("should include render function in index.ts", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "src", "index.ts")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("export function render")) + Expect(string(content)).To(ContainSubstring("RenderContext")) + Expect(string(content)).To(ContainSubstring("RenderResult")) + }) + + It("should include helper functions in helpers.ts", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "src", "helpers.ts")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("export function getFullname")) + Expect(string(content)).To(ContainSubstring("export function getLabels")) + Expect(string(content)).To(ContainSubstring("export function getSelectorLabels")) + }) + + It("should include resource generators in separate files", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + deploymentContent, err := os.ReadFile(filepath.Join(chartPath, "ts", "src", "deployment.ts")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(deploymentContent)).To(ContainSubstring("export function newDeployment")) + + serviceContent, err := os.ReadFile(filepath.Join(chartPath, "ts", "src", "service.ts")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(serviceContent)).To(ContainSubstring("export function newService")) + }) + + It("should include @nelm/types dependency in package.json", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "package.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring(`"@nelm/types"`)) + }) + + It("should include correct tsconfig.json options", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "tsconfig.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring(`"target": "ES2015"`)) + Expect(string(content)).To(ContainSubstring(`"module": "CommonJS"`)) + Expect(string(content)).To(ContainSubstring(`"strict": true`)) + Expect(string(content)).To(ContainSubstring(`"declaration": true`)) + }) + + It("should fail if ts/ directory already exists", func() { + chartPath := filepath.Join(tempDir, "test-chart") + tsDir := filepath.Join(chartPath, "ts") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + err := InitTSBoilerplate(ctx, chartPath, "test-chart") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TypeScript directory already exists")) + }) + }) + + Describe("InitChartStructure", func() { + It("should create Chart.yaml, values.yaml, .helmignore", func() { + chartPath := filepath.Join(tempDir, "ts-only-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "ts-only-chart") + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Join(chartPath, "Chart.yaml")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, "values.yaml")).To(BeARegularFile()) + Expect(filepath.Join(chartPath, ".helmignore")).To(BeARegularFile()) + }) + + It("should NOT create charts/ directory", func() { + chartPath := filepath.Join(tempDir, "ts-only-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "ts-only-chart") + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Join(chartPath, "charts")).NotTo(BeADirectory()) + }) + + It("should NOT create templates/ directory", func() { + chartPath := filepath.Join(tempDir, "ts-only-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "ts-only-chart") + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Join(chartPath, "templates")).NotTo(BeADirectory()) + }) + + It("should substitute chart name in Chart.yaml", func() { + chartPath := filepath.Join(tempDir, "my-ts-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "my-ts-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "Chart.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("name: my-ts-chart")) + }) + + It("should include TS entries in .helmignore", func() { + chartPath := filepath.Join(tempDir, "ts-only-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "ts-only-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + + It("should skip existing Chart.yaml", func() { + chartPath := filepath.Join(tempDir, "existing-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := "apiVersion: v2\nname: existing-name\nversion: 1.0.0\n" + Expect(os.WriteFile(filepath.Join(chartPath, "Chart.yaml"), []byte(existingContent), 0o644)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "new-name") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "Chart.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(existingContent)) + }) + + It("should skip existing values.yaml", func() { + chartPath := filepath.Join(tempDir, "existing-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := "myValue: 123\n" + Expect(os.WriteFile(filepath.Join(chartPath, "values.yaml"), []byte(existingContent), 0o644)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, "values.yaml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(existingContent)) + }) + + It("should enrich existing .helmignore with TS entries", func() { + chartPath := filepath.Join(tempDir, "existing-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := ".DS_Store\n.git/\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".helmignore"), []byte(existingContent), 0o644)).To(Succeed()) + + err := InitChartStructure(ctx, chartPath, "test-chart") + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring(".DS_Store")) + Expect(string(content)).To(ContainSubstring(".git/")) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + }) + + Describe("EnsureGitignore", func() { + It("should create minimal .gitignore if it does not exist", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := EnsureGitignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".gitignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("ts/node_modules/")) + Expect(string(content)).To(ContainSubstring("ts/vendor/")) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + + It("should append missing entries to existing .gitignore", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + originalContent := "# My project\n*.log\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".gitignore"), []byte(originalContent), 0o644)).To(Succeed()) + + err := EnsureGitignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".gitignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("# My project")) + Expect(string(content)).To(ContainSubstring("*.log")) + Expect(string(content)).To(ContainSubstring("ts/node_modules/")) + Expect(string(content)).To(ContainSubstring("ts/vendor/")) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + + It("should not duplicate entries if already present", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := "ts/node_modules/\nts/vendor/\nts/dist/\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".gitignore"), []byte(existingContent), 0o644)).To(Succeed()) + + err := EnsureGitignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".gitignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal(existingContent)) + }) + + It("should add only missing entries", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := "ts/node_modules/\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".gitignore"), []byte(existingContent), 0o644)).To(Succeed()) + + err := EnsureGitignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".gitignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("ts/node_modules/")) + Expect(string(content)).To(ContainSubstring("ts/vendor/")) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + }) + + Describe("AppendToHelmignore", func() { + It("should append TS entries to existing .helmignore", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + originalContent := "# Original content\n*.swp\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".helmignore"), []byte(originalContent), 0o644)).To(Succeed()) + + err := AppendToHelmignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("# Original content")) + Expect(string(content)).To(ContainSubstring("*.swp")) + Expect(string(content)).To(ContainSubstring("ts/dist/")) + }) + + It("should not duplicate entries if already present", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + existingContent := "# Existing\nts/dist/\n" + Expect(os.WriteFile(filepath.Join(chartPath, ".helmignore"), []byte(existingContent), 0o644)).To(Succeed()) + + err := AppendToHelmignore(chartPath) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) + Expect(err).NotTo(HaveOccurred()) + // Should not have duplicates + Expect(string(content)).To(Equal(existingContent)) + }) + + It("should return error if .helmignore does not exist", func() { + chartPath := filepath.Join(tempDir, "test-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := AppendToHelmignore(chartPath) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/tschart/runtime.go b/internal/tschart/runtime.go new file mode 100644 index 00000000..23da46e4 --- /dev/null +++ b/internal/tschart/runtime.go @@ -0,0 +1,127 @@ +package tschart + +import ( + "fmt" + + "github.com/dop251/goja" +) + +const RequireShim = ` +(function() { + var vendorRegistry = (typeof global !== 'undefined' && global.__NELM_VENDOR__) || + (typeof __NELM_VENDOR__ !== 'undefined' && __NELM_VENDOR__) || + (typeof __NELM_VENDOR_BUNDLE__ !== 'undefined' && __NELM_VENDOR_BUNDLE__.__NELM_VENDOR__) || + {}; + + function require(moduleName) { + if (vendorRegistry[moduleName]) { + return vendorRegistry[moduleName]; + } + throw new Error("Module '" + moduleName + "' not found in vendor bundle. Available modules: " + Object.keys(vendorRegistry).join(", ")); + } + + return require; +})() +` + +func createVM() (*goja.Runtime, error) { + vm := goja.New() + + global := vm.NewObject() + if err := vm.Set("global", global); err != nil { + return nil, fmt.Errorf("set global: %w", err) + } + + SetupConsoleGlobal(vm) + + return vm, nil +} + +func executeInGoja(vendorBundle, appBundle string, renderCtx map[string]interface{}) (interface{}, error) { + vm, err := createVM() + if err != nil { + return nil, fmt.Errorf("create VM: %w", err) + } + + if vendorBundle != "" { + if _, err := vm.RunString(vendorBundle); err != nil { + return nil, fmt.Errorf("vendor bundle failed: %w", formatJSError(vm, err, "vendor/libs.js")) + } + } + + requireFn, err := vm.RunString(RequireShim) + if err != nil { + return nil, fmt.Errorf("require shim failed: %w", err) + } + + if err := vm.Set("require", requireFn); err != nil { + return nil, fmt.Errorf("set require: %w", err) + } + + module := vm.NewObject() + exports := vm.NewObject() + + if err := module.Set("exports", exports); err != nil { + return nil, fmt.Errorf("set module.exports: %w", err) + } + + if err := vm.Set("module", module); err != nil { + return nil, fmt.Errorf("set module: %w", err) + } + + if err := vm.Set("exports", exports); err != nil { + return nil, fmt.Errorf("set exports: %w", err) + } + + if _, err := vm.RunString(appBundle); err != nil { + return nil, fmt.Errorf("app bundle failed: %w", formatJSError(vm, err, "app bundle")) + } + + moduleExports := vm.Get("module").ToObject(vm).Get("exports") + if moduleExports == nil || goja.IsUndefined(moduleExports) || goja.IsNull(moduleExports) { + return nil, fmt.Errorf("bundle does not export anything") + } + + renderFn := moduleExports.ToObject(vm).Get("render") + if renderFn == nil || goja.IsUndefined(renderFn) || goja.IsNull(renderFn) { + return nil, fmt.Errorf("bundle does not export 'render' function") + } + + callable, ok := goja.AssertFunction(renderFn) + if !ok { + return nil, fmt.Errorf("'render' export is not a function") + } + + result, err := callable(goja.Undefined(), vm.ToValue(renderCtx)) + if err != nil { + return nil, fmt.Errorf("render failed: %w", formatJSError(vm, err, "render()")) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, nil //nolint:nilnil // Returning nil result with nil error indicates render produced no output + } + + return result.Export(), nil +} + +func formatJSError(vm *goja.Runtime, err error, currentFile string) error { + if err == nil { + return nil + } + + gojaErr, ok := err.(*goja.Exception) + if !ok { + return err + } + + errMsg := gojaErr.Error() + + stackProp := gojaErr.Value().ToObject(vm).Get("stack") + if stackProp == nil || goja.IsUndefined(stackProp) || goja.IsNull(stackProp) { + return fmt.Errorf("%s\n at %s", errMsg, currentFile) + } + + stack := stackProp.String() + + return fmt.Errorf("%s", stack) +} diff --git a/internal/tschart/test_helpers_test.go b/internal/tschart/test_helpers_test.go new file mode 100644 index 00000000..6e0a0713 --- /dev/null +++ b/internal/tschart/test_helpers_test.go @@ -0,0 +1,6 @@ +//nolint:testpackage // White-box test needs access to internal functions +package tschart + +// DefaultOutputFile is the expected output path for TypeScript charts in tests. +// The actual output path in production is determined by the entrypoint found. +const DefaultOutputFile = "ts/src/index.ts" diff --git a/internal/tschart/transformer.go b/internal/tschart/transformer.go new file mode 100644 index 00000000..5644e570 --- /dev/null +++ b/internal/tschart/transformer.go @@ -0,0 +1,168 @@ +package tschart + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +const ( + TSSourceDir = "ts/" + VendorBundleFile = "ts/vendor/libs.js" + VendorBundleDir = "ts/vendor" + TSConfigFile = "tsconfig.json" +) + +var EntryPoints = []string{"src/index.ts", "src/index.js"} + +type Transformer struct{} + +func NewTransformer() *Transformer { + return &Transformer{} +} + +type Metafile struct { + Inputs map[string]struct { + Bytes int `json:"bytes"` + } `json:"inputs"` +} + +// --- Internal utilities --- + +func extractPackageNames(metafileJSON string) ([]string, error) { + var meta Metafile + if err := json.Unmarshal([]byte(metafileJSON), &meta); err != nil { + return nil, fmt.Errorf("parse metafile: %w", err) + } + + pkgSet := make(map[string]struct{}) + + for inputPath := range meta.Inputs { + // Handle both regular paths and virtual namespace paths (e.g., "virtual:node_modules/...") + normalizedPath := inputPath + if strings.HasPrefix(inputPath, "virtual:") { + normalizedPath = strings.TrimPrefix(inputPath, "virtual:") + } + + if strings.HasPrefix(normalizedPath, "node_modules/") { + parts := strings.Split(normalizedPath, "/") + + var pkgName string + if len(parts) >= 2 && strings.HasPrefix(parts[1], "@") && len(parts) >= 3 { + pkgName = parts[1] + "/" + parts[2] + } else if len(parts) >= 2 { + pkgName = parts[1] + } + + if pkgName != "" { + pkgSet[pkgName] = struct{}{} + } + } + } + + packages := make([]string, 0, len(pkgSet)) + for pkg := range pkgSet { + packages = append(packages, pkg) + } + + sort.Strings(packages) + + return packages, nil +} + +func extractPackagesFromVendorBundle(bundle string) []string { + re := regexp.MustCompile(`__NELM_VENDOR__\[["']([^"']+)["']\]`) + matches := re.FindAllStringSubmatch(bundle, -1) + + pkgSet := make(map[string]struct{}) + + for _, match := range matches { + if len(match) >= 2 { + pkgSet[match[1]] = struct{}{} + } + } + + packages := make([]string, 0, len(pkgSet)) + for pkg := range pkgSet { + packages = append(packages, pkg) + } + + sort.Strings(packages) + + return packages +} + +func generateVendorEntrypoint(packages []string) string { + var builder strings.Builder + builder.WriteString("var __NELM_VENDOR__ = {};\n") + + for _, pkg := range packages { + fmt.Fprintf(&builder, "__NELM_VENDOR__['%s'] = require('%s');\n", pkg, pkg) + } + + builder.WriteString("if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; }\n") + builder.WriteString("if (typeof exports !== 'undefined') { exports.__NELM_VENDOR__ = __NELM_VENDOR__; }\n") + + return builder.String() +} + +func formatBuildErrors(errors []esbuild.Message) error { + if len(errors) == 0 { + return nil + } + + var errMsg strings.Builder + errMsg.WriteString("TypeScript transpilation failed:\n") + + for i, msg := range errors { + if i > 0 { + errMsg.WriteString("\n") + } + + if msg.Location != nil { + errMsg.WriteString(fmt.Sprintf(" File: %s:%d:%d\n", + msg.Location.File, + msg.Location.Line, + msg.Location.Column, + )) + + if msg.Location.LineText != "" { + errMsg.WriteString(fmt.Sprintf(" %s\n", msg.Location.LineText)) + + if msg.Location.Column > 0 { + spaces := strings.Repeat(" ", msg.Location.Column) + errMsg.WriteString(fmt.Sprintf(" %s^\n", spaces)) + } + } + } + + errMsg.WriteString(fmt.Sprintf(" Error: %s\n", msg.Text)) + + if len(msg.Notes) > 0 { + for _, note := range msg.Notes { + errMsg.WriteString(fmt.Sprintf(" Note: %s\n", note.Text)) + } + } + } + + return fmt.Errorf("%s", errMsg.String()) +} + +func loaderFromPath(path string) esbuild.Loader { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".ts", ".tsx": + return esbuild.LoaderTS + case ".jsx": + return esbuild.LoaderJSX + case ".json": + return esbuild.LoaderJSON + default: + return esbuild.LoaderJS + } +} diff --git a/internal/tschart/transformer_dir.go b/internal/tschart/transformer_dir.go new file mode 100644 index 00000000..3d60e376 --- /dev/null +++ b/internal/tschart/transformer_dir.go @@ -0,0 +1,263 @@ +package tschart + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" + + "github.com/werf/nelm/pkg/log" +) + +func (t *Transformer) TransformChartDir(ctx context.Context, chartPath string) error { + absChartPath, err := filepath.Abs(chartPath) + if err != nil { + return fmt.Errorf("get absolute path: %w", err) + } + + stat, err := os.Stat(absChartPath) + if err != nil { + if os.IsNotExist(err) { + log.Default.Debug(ctx, "Skipping TypeScript transformation: %s does not exist", absChartPath) + return nil + } + + return fmt.Errorf("stat %s: %w", absChartPath, err) + } + + if !stat.IsDir() { + return fmt.Errorf("%s is not a directory", absChartPath) + } + + tsDir := filepath.Join(absChartPath, TSSourceDir) + if _, err := os.Stat(tsDir); err != nil { + if os.IsNotExist(err) { + log.Default.Debug(ctx, "No %s directory found, skipping transformation", TSSourceDir) + return nil + } + + return fmt.Errorf("stat %s: %w", tsDir, err) + } + + entrypointFile, err := findEntrypointInDir(tsDir) + if err != nil { + return fmt.Errorf("find entrypoint: %w", err) + } + + if entrypointFile == "" { + log.Default.Debug(ctx, "No TypeScript entrypoint found, skipping transformation") + return nil + } + + nodeModulesPath := filepath.Join(tsDir, "node_modules") + if _, err := os.Stat(nodeModulesPath); err != nil { + if os.IsNotExist(err) { + log.Default.Debug(ctx, "No node_modules directory found, skipping vendor bundle") + return nil + } + + return fmt.Errorf("stat %s: %w", nodeModulesPath, err) + } + + log.Default.Info(ctx, "Building vendor bundle for TypeScript chart: %s", absChartPath) + + vendorBundle, packages, err := buildVendorBundleInDir(tsDir, entrypointFile) + if err != nil { + return fmt.Errorf("build vendor bundle: %w", err) + } + + if len(packages) == 0 { + log.Default.Debug(ctx, "No npm packages used, skipping vendor bundle") + return nil + } + + log.Default.Info(ctx, "Bundled %d npm packages: %s", len(packages), strings.Join(packages, ", ")) + + vendorPath := filepath.Join(absChartPath, VendorBundleFile) + if err := os.MkdirAll(filepath.Dir(vendorPath), 0o755); err != nil { + return fmt.Errorf("create vendor directory: %w", err) + } + + if err := os.WriteFile(vendorPath, []byte(vendorBundle), 0o644); err != nil { //nolint:gosec // Chart files should be world-readable + return fmt.Errorf("write vendor bundle to %s: %w", vendorPath, err) + } + + log.Default.Info(ctx, "Wrote vendor bundle to %s", VendorBundleFile) + + return nil +} + +func GetVendorBundleFromDir(ctx context.Context, chartPath string) (string, []string, error) { + absChartPath, err := filepath.Abs(chartPath) + if err != nil { + return "", nil, fmt.Errorf("get absolute path: %w", err) + } + + tsDir := filepath.Join(absChartPath, TSSourceDir) + nodeModulesPath := filepath.Join(tsDir, "node_modules") + vendorPath := filepath.Join(absChartPath, VendorBundleFile) + + entrypointFile, err := findEntrypointInDir(tsDir) + if err != nil { + return "", nil, fmt.Errorf("find entrypoint: %w", err) + } + + if entrypointFile == "" { + return "", nil, nil + } + + _, err = os.Stat(nodeModulesPath) + if err != nil && !os.IsNotExist(err) { + return "", nil, fmt.Errorf("stat %s: %w", nodeModulesPath, err) + } + + if err == nil { + log.Default.Debug(ctx, "Building vendor bundle from node_modules") + return buildVendorBundleInDir(tsDir, entrypointFile) + } + + _, err = os.Stat(vendorPath) + if err != nil && !os.IsNotExist(err) { + return "", nil, fmt.Errorf("stat %s: %w", vendorPath, err) + } + + if err == nil { + log.Default.Debug(ctx, "Using pre-built vendor bundle from %s", vendorPath) + + vendorBytes, err := os.ReadFile(vendorPath) + if err != nil { + return "", nil, fmt.Errorf("read vendor bundle: %w", err) + } + + packages := extractPackagesFromVendorBundle(string(vendorBytes)) + + return string(vendorBytes), packages, nil + } + + log.Default.Debug(ctx, "No vendor dependencies found") + + return "", nil, nil +} + +func BuildAppBundleFromDir(ctx context.Context, chartPath string, externalPackages []string) (string, error) { + absChartPath, err := filepath.Abs(chartPath) + if err != nil { + return "", fmt.Errorf("get absolute path: %w", err) + } + + tsDir := filepath.Join(absChartPath, TSSourceDir) + + entrypointFile, err := findEntrypointInDir(tsDir) + if err != nil { + return "", fmt.Errorf("find entrypoint: %w", err) + } + + if entrypointFile == "" { + return "", fmt.Errorf("no TypeScript entrypoint found") + } + + log.Default.Debug(ctx, "Building app bundle from %s", tsDir) + + return buildAppBundle(tsDir, entrypointFile, externalPackages) +} + +func buildVendorBundleInDir(chartTSDir, entrypoint string) (vendorBundle string, packages []string, err error) { + absEntrypoint := filepath.Join(chartTSDir, entrypoint) + + scanResult := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{absEntrypoint}, + Bundle: true, + Write: false, + Metafile: true, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatCommonJS, + Target: esbuild.ES2015, + AbsWorkingDir: chartTSDir, + }) + + if len(scanResult.Errors) > 0 { + return "", nil, formatBuildErrors(scanResult.Errors) + } + + packages, err = extractPackageNames(scanResult.Metafile) + if err != nil { + return "", nil, err + } + + if len(packages) == 0 { + return "", packages, nil + } + + virtualEntry := generateVendorEntrypoint(packages) + + vendorResult := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{ + Contents: virtualEntry, + ResolveDir: chartTSDir, + Loader: esbuild.LoaderJS, + }, + Bundle: true, + Write: false, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2015, + GlobalName: "__NELM_VENDOR_BUNDLE__", + AbsWorkingDir: chartTSDir, + }) + + if len(vendorResult.Errors) > 0 { + return "", nil, formatBuildErrors(vendorResult.Errors) + } + + if len(vendorResult.OutputFiles) == 0 { + return "", nil, fmt.Errorf("no output files from vendor bundle build") + } + + return string(vendorResult.OutputFiles[0].Contents), packages, nil +} + +func buildAppBundle(chartTSDir, entrypoint string, externalPackages []string) (string, error) { + absEntrypoint := filepath.Join(chartTSDir, entrypoint) + + result := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{absEntrypoint}, + Bundle: true, + Write: false, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatCommonJS, + Target: esbuild.ES2015, + External: externalPackages, + AbsWorkingDir: chartTSDir, + Sourcemap: esbuild.SourceMapInline, + }) + + if len(result.Errors) > 0 { + return "", formatBuildErrors(result.Errors) + } + + if len(result.OutputFiles) == 0 { + return "", fmt.Errorf("no output files from app bundle build") + } + + return string(result.OutputFiles[0].Contents), nil +} + +func findEntrypointInDir(tsDir string) (string, error) { + for _, ep := range EntryPoints { + epPath := filepath.Join(tsDir, ep) + + _, err := os.Stat(epPath) + if err == nil { + return ep, nil + } + + if !os.IsNotExist(err) { + return "", fmt.Errorf("stat %s: %w", epPath, err) + } + } + + return "", nil +} diff --git a/internal/tschart/transformer_mem.go b/internal/tschart/transformer_mem.go new file mode 100644 index 00000000..d144c1e7 --- /dev/null +++ b/internal/tschart/transformer_mem.go @@ -0,0 +1,429 @@ +package tschart + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/nelm/pkg/log" +) + +// GetVendorBundleFromFiles returns the vendor bundle and list of packages. +// If node_modules are present in files, it builds the vendor bundle in-memory. +// Otherwise, it looks for a pre-built vendor bundle at ts/vendor/libs.js. +func GetVendorBundleFromFiles(files []*helmchart.File) (string, []string, error) { + // Check if node_modules are present - if so, build vendor bundle in-memory + if hasNodeModules(files) { + filesMap := prepareFilesMap(files) + + // Find entrypoint + var entrypoint string + + for _, ep := range EntryPoints { + if _, exists := filesMap[ep]; exists { + entrypoint = ep + break + } + } + + if entrypoint == "" { + return "", nil, nil // No entrypoint, no vendor bundle needed + } + + vendorBundle, packages, err := buildVendorBundleFromFiles(filesMap, entrypoint) + if err != nil { + return "", nil, fmt.Errorf("build vendor bundle from node_modules: %w", err) + } + + return vendorBundle, packages, nil + } + + // Fall back to pre-built vendor bundle + for _, f := range files { + if f.Name == VendorBundleFile { + packages := extractPackagesFromVendorBundle(string(f.Data)) + return string(f.Data), packages, nil + } + } + + return "", nil, nil +} + +func BuildAppBundleFromChartFiles(ctx context.Context, files []*helmchart.File, externalPackages []string) (string, error) { + sourceFiles := ExtractSourceFiles(files) + if len(sourceFiles) == 0 { + return "", fmt.Errorf("no source files found in chart") + } + + var entrypoint string + + for _, ep := range EntryPoints { + if _, exists := sourceFiles[ep]; exists { + entrypoint = ep + break + } + } + + if entrypoint == "" { + return "", fmt.Errorf("no entrypoint found in source files") + } + + log.Default.Debug(ctx, "Building app bundle from chart files with entrypoint %s", entrypoint) + + return buildAppBundleFromFiles(sourceFiles, entrypoint, externalPackages) +} + +func ExtractSourceFiles(files []*helmchart.File) map[string][]byte { + sourceFiles := make(map[string][]byte) + + for _, f := range files { + if strings.HasPrefix(f.Name, TSSourceDir+"src/") { + relativePath := strings.TrimPrefix(f.Name, TSSourceDir) + sourceFiles[relativePath] = f.Data + } + } + + return sourceFiles +} + +// buildVendorBundleFromFiles builds a vendor bundle from in-memory source files and node_modules. +// It scans the entrypoint to find which packages are used, then bundles them. +func buildVendorBundleFromFiles(files map[string][]byte, entrypoint string) (vendorBundle string, packages []string, err error) { + scanResult := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{entrypoint}, + Bundle: true, + Write: false, + Metafile: true, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatCommonJS, + Target: esbuild.ES2015, + Plugins: []esbuild.Plugin{createVirtualFSPluginWithNodeModules(files)}, + }) + + if len(scanResult.Errors) > 0 { + return "", nil, formatBuildErrors(scanResult.Errors) + } + + packages, err = extractPackageNames(scanResult.Metafile) + if err != nil { + return "", nil, err + } + + if len(packages) == 0 { + return "", packages, nil + } + + virtualEntry := generateVendorEntrypoint(packages) + + vendorResult := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{ + Contents: virtualEntry, + ResolveDir: ".", + Loader: esbuild.LoaderJS, + }, + Bundle: true, + Write: false, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2015, + GlobalName: "__NELM_VENDOR_BUNDLE__", + Plugins: []esbuild.Plugin{createVirtualFSPluginWithNodeModules(files)}, + }) + + if len(vendorResult.Errors) > 0 { + return "", nil, formatBuildErrors(vendorResult.Errors) + } + + if len(vendorResult.OutputFiles) == 0 { + return "", nil, fmt.Errorf("no output files from vendor bundle build") + } + + return string(vendorResult.OutputFiles[0].Contents), packages, nil +} + +func buildAppBundleFromFiles(files map[string][]byte, entrypoint string, externalPackages []string) (string, error) { + result := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{entrypoint}, + Bundle: true, + Write: false, + Platform: esbuild.PlatformNode, + Format: esbuild.FormatCommonJS, + Target: esbuild.ES2015, + External: externalPackages, + Sourcemap: esbuild.SourceMapInline, + Plugins: []esbuild.Plugin{createVirtualFSPlugin(files)}, + }) + + if len(result.Errors) > 0 { + return "", formatBuildErrors(result.Errors) + } + + if len(result.OutputFiles) == 0 { + return "", fmt.Errorf("no output files from app bundle build") + } + + return string(result.OutputFiles[0].Contents), nil +} + +// prepareFilesMap converts []*helmchart.File to map[string][]byte, stripping the ts/ prefix. +// This prepares files for use with the virtual FS plugin. +func prepareFilesMap(files []*helmchart.File) map[string][]byte { + result := make(map[string][]byte) + + for _, f := range files { + // Strip ts/ prefix so paths become like "src/index.ts" and "node_modules/lodash/index.js" + name := strings.TrimPrefix(f.Name, TSSourceDir) + result[name] = f.Data + } + + return result +} + +func hasNodeModules(files []*helmchart.File) bool { + for _, f := range files { + if strings.HasPrefix(f.Name, TSSourceDir+"node_modules/") { + return true + } + } + + return false +} + +// virtualFSResolver provides file resolution for virtual filesystem plugins. +type virtualFSResolver struct { + files map[string][]byte +} + +func newVirtualFSResolver(files map[string][]byte) *virtualFSResolver { + return &virtualFSResolver{files: files} +} + +func (r *virtualFSResolver) exists(path string) bool { + _, exists := r.files[path] + return exists +} + +func (r *virtualFSResolver) resolve(basePath string) string { + if r.exists(basePath) { + return basePath + } + + for _, ext := range []string{".ts", ".tsx", ".js", ".jsx", ".json"} { + if r.exists(basePath + ext) { + return basePath + ext + } + } + + for _, ext := range []string{".ts", ".js"} { + indexPath := filepath.Join(basePath, "index"+ext) + if r.exists(indexPath) { + return indexPath + } + } + + return "" +} + +func (r *virtualFSResolver) resolveNodeModule(pkgName string) string { + pkgPath := filepath.Join("node_modules", pkgName) + + pkgJSONPath := filepath.Join(pkgPath, "package.json") + if pkgJSON, exists := r.files[pkgJSONPath]; exists { + var pkg struct { + Main string `json:"main"` + Module string `json:"module"` + Exports interface{} `json:"exports"` + } + if err := json.Unmarshal(pkgJSON, &pkg); err == nil { + if pkg.Main != "" { + mainPath := filepath.Join(pkgPath, pkg.Main) + if resolved := r.resolve(mainPath); resolved != "" { + return resolved + } + } + + if pkg.Module != "" { + modulePath := filepath.Join(pkgPath, pkg.Module) + if resolved := r.resolve(modulePath); resolved != "" { + return resolved + } + } + } + } + + if resolved := r.resolve(pkgPath); resolved != "" { + return resolved + } + + return "" +} + +func (r *virtualFSResolver) load(path string) (esbuild.OnLoadResult, error) { + content, exists := r.files[path] + if !exists { + return esbuild.OnLoadResult{}, fmt.Errorf("file not found in virtual fs: %s", path) + } + + contentStr := string(content) + loader := loaderFromPath(path) + + return esbuild.OnLoadResult{ + Contents: &contentStr, + Loader: loader, + ResolveDir: filepath.Dir(path), + }, nil +} + +func createVirtualFSPlugin(files map[string][]byte) esbuild.Plugin { + r := newVirtualFSResolver(files) + + return esbuild.Plugin{ + Name: "virtual-fs", + Setup: func(build esbuild.PluginBuild) { + build.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, + func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if args.Importer == "" { + finalPath := r.resolve(args.Path) + if finalPath != "" { + return esbuild.OnResolveResult{ + Path: finalPath, + Namespace: "virtual", + }, nil + } + } + + if !strings.HasPrefix(args.Path, ".") && !strings.HasPrefix(args.Path, "/") && args.Importer != "" { + return esbuild.OnResolveResult{}, nil + } + + var resolvedPath string + + if strings.HasPrefix(args.Path, "./") || strings.HasPrefix(args.Path, "../") { + var baseDir string + if args.Importer != "" { + baseDir = filepath.Dir(args.Importer) + } else { + baseDir = args.ResolveDir + } + + resolvedPath = filepath.Clean(filepath.Join(baseDir, args.Path)) + } else { + resolvedPath = args.Path + } + + resolvedPath = strings.TrimPrefix(resolvedPath, "./") + + finalPath := r.resolve(resolvedPath) + if finalPath != "" { + return esbuild.OnResolveResult{ + Path: finalPath, + Namespace: "virtual", + }, nil + } + + return esbuild.OnResolveResult{}, nil + }) + + build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "virtual"}, + func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + return r.load(args.Path) + }) + }, + } +} + +// createVirtualFSPluginWithNodeModules creates an esbuild plugin that resolves both +// source files and node_modules from in-memory files map. +func createVirtualFSPluginWithNodeModules(files map[string][]byte) esbuild.Plugin { + r := newVirtualFSResolver(files) + + return esbuild.Plugin{ + Name: "virtual-fs-with-node-modules", + Setup: func(build esbuild.PluginBuild) { + build.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, + func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if args.Importer == "" { + finalPath := r.resolve(args.Path) + if finalPath != "" { + return esbuild.OnResolveResult{ + Path: finalPath, + Namespace: "virtual", + }, nil + } + } + + if !strings.HasPrefix(args.Path, ".") && !strings.HasPrefix(args.Path, "/") { + parts := strings.SplitN(args.Path, "/", 2) + pkgName := parts[0] + + if strings.HasPrefix(pkgName, "@") && len(parts) > 1 { + subparts := strings.SplitN(parts[1], "/", 2) + + pkgName = pkgName + "/" + subparts[0] + if len(subparts) > 1 { + parts = []string{pkgName, subparts[1]} + } else { + parts = []string{pkgName} + } + } + + if len(parts) == 2 { + subPath := filepath.Join("node_modules", pkgName, parts[1]) + if resolved := r.resolve(subPath); resolved != "" { + return esbuild.OnResolveResult{ + Path: resolved, + Namespace: "virtual", + }, nil + } + } else { + if resolved := r.resolveNodeModule(pkgName); resolved != "" { + return esbuild.OnResolveResult{ + Path: resolved, + Namespace: "virtual", + }, nil + } + } + + return esbuild.OnResolveResult{}, fmt.Errorf("cannot resolve package %q in virtual filesystem", args.Path) + } + + var resolvedPath string + + if strings.HasPrefix(args.Path, "./") || strings.HasPrefix(args.Path, "../") { + var baseDir string + if args.Importer != "" { + baseDir = filepath.Dir(args.Importer) + } else { + baseDir = args.ResolveDir + } + + resolvedPath = filepath.Clean(filepath.Join(baseDir, args.Path)) + } else { + resolvedPath = args.Path + } + + resolvedPath = strings.TrimPrefix(resolvedPath, "./") + + finalPath := r.resolve(resolvedPath) + if finalPath != "" { + return esbuild.OnResolveResult{ + Path: finalPath, + Namespace: "virtual", + }, nil + } + + return esbuild.OnResolveResult{}, nil + }) + + build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "virtual"}, + func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + return r.load(args.Path) + }) + }, + } +} diff --git a/internal/tschart/transformer_unit_test.go b/internal/tschart/transformer_unit_test.go new file mode 100644 index 00000000..3c2b1f2c --- /dev/null +++ b/internal/tschart/transformer_unit_test.go @@ -0,0 +1,400 @@ +//nolint:gosec,testpackage // Test files use 0644 for test fixtures; white-box test needs access to internal functions +package tschart + +import ( + "context" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Transformer", func() { + var ( + ctx context.Context + transformer *Transformer + ) + + BeforeEach(func() { + ctx = context.Background() + transformer = NewTransformer() + }) + + Describe("TransformChartDir", func() { + var tempDir string + + BeforeEach(func() { + var err error + + tempDir, err = os.MkdirTemp("", "tschart-test-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + Context("when chart path is not a directory", func() { + It("should skip transformation for non-existent path", func() { + err := transformer.TransformChartDir(ctx, "./non-existent-path") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return error for file path", func() { + filePath := filepath.Join(tempDir, "chart.tgz") + Expect(os.WriteFile(filePath, []byte("dummy"), 0o644)).To(Succeed()) + + err := transformer.TransformChartDir(ctx, filePath) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("is not a directory")) + }) + }) + + Context("when chart has no ts/ directory", func() { + It("should skip transformation silently", func() { + chartPath := filepath.Join(tempDir, "my-chart") + Expect(os.MkdirAll(chartPath, 0o755)).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + // No vendor bundle should be created + vendorPath := filepath.Join(chartPath, VendorBundleFile) + _, err = os.Stat(vendorPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) + + Context("when chart has ts/ directory but no entrypoint", func() { + It("should skip transformation silently", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(tsDir, "helpers.ts"), + []byte("export const foo = 'bar';"), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + vendorPath := filepath.Join(chartPath, VendorBundleFile) + _, err = os.Stat(vendorPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) + + Context("when chart has TypeScript entrypoint but no node_modules", func() { + It("should skip vendor bundle creation", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(tsDir, "index.ts"), + []byte(` + export function render(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'test' }, + data: { key: 'value' } + }] + }; + } + `), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + // No vendor bundle should be created since there's no node_modules + vendorPath := filepath.Join(chartPath, VendorBundleFile) + _, err = os.Stat(vendorPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) + + Context("when chart has TypeScript with fake node_modules", func() { + It("should create vendor bundle with dependencies", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + // Create source that imports a fake module + Expect(os.WriteFile( + filepath.Join(tsDir, "index.ts"), + []byte(` + import { helper } from 'fake-lib'; + export function render(context: any) { + return { manifests: [helper(context)] }; + } + `), + 0o644, + )).To(Succeed()) + + // Create fake node_modules + fakeLibDir := filepath.Join(chartPath, "ts", "node_modules", "fake-lib") + Expect(os.MkdirAll(fakeLibDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(fakeLibDir, "package.json"), + []byte(`{"name": "fake-lib", "version": "1.0.0", "main": "index.js"}`), + 0o644, + )).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(fakeLibDir, "index.js"), + []byte(` + module.exports.helper = function(ctx) { + return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: ctx.Release.Name } }; + }; + `), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + // Vendor bundle should be created + vendorPath := filepath.Join(chartPath, VendorBundleFile) + vendorContent, err := os.ReadFile(vendorPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(vendorContent)).To(ContainSubstring("__NELM_VENDOR__")) + Expect(string(vendorContent)).To(ContainSubstring("fake-lib")) + }) + }) + + Context("when TypeScript has syntax errors", func() { + It("should return formatted error", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + // Create node_modules to trigger vendor bundle build + nodeModulesDir := filepath.Join(chartPath, "ts", "node_modules", "some-lib") + Expect(os.MkdirAll(nodeModulesDir, 0o755)).To(Succeed()) + Expect(os.WriteFile( + filepath.Join(nodeModulesDir, "package.json"), + []byte(`{"name": "some-lib", "version": "1.0.0", "main": "index.js"}`), + 0o644, + )).To(Succeed()) + Expect(os.WriteFile( + filepath.Join(nodeModulesDir, "index.js"), + []byte(`module.exports = {};`), + 0o644, + )).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(tsDir, "index.ts"), + []byte(` + import 'some-lib'; + export function render(context: any) { + return { manifests: [ + } + `), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TypeScript transpilation failed")) + }) + }) + + Context("when chart has multiple TypeScript files with imports", func() { + It("should detect all dependencies", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + // Create entrypoint + Expect(os.WriteFile( + filepath.Join(tsDir, "index.ts"), + []byte(` + import { helper } from './helpers'; + import { util } from 'fake-util'; + export function render(context: any) { + return { manifests: [helper(context, util)] }; + } + `), + 0o644, + )).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(tsDir, "helpers.ts"), + []byte(` + export function helper(context: any, util: any) { + return { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: context.Release.Name } + }; + } + `), + 0o644, + )).To(Succeed()) + + // Create fake node_modules + fakeUtilDir := filepath.Join(chartPath, "ts", "node_modules", "fake-util") + Expect(os.MkdirAll(fakeUtilDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(fakeUtilDir, "package.json"), + []byte(`{"name": "fake-util", "version": "1.0.0", "main": "index.js"}`), + 0o644, + )).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(fakeUtilDir, "index.js"), + []byte(`module.exports.util = function() { return 'utility'; };`), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + vendorPath := filepath.Join(chartPath, VendorBundleFile) + vendorContent, err := os.ReadFile(vendorPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(vendorContent)).To(ContainSubstring("fake-util")) + }) + }) + + Context("when chart has JavaScript entrypoint (index.js)", func() { + It("should work with JS entrypoint and node_modules", func() { + chartPath := filepath.Join(tempDir, "my-chart") + tsDir := filepath.Join(chartPath, "ts", "src") + Expect(os.MkdirAll(tsDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(tsDir, "index.js"), + []byte(` + const lib = require('js-lib'); + exports.render = function(context) { + return { manifests: [{ apiVersion: 'v1', kind: 'Pod' }] }; + }; + `), + 0o644, + )).To(Succeed()) + + // Create fake node_modules + jsLibDir := filepath.Join(chartPath, "ts", "node_modules", "js-lib") + Expect(os.MkdirAll(jsLibDir, 0o755)).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(jsLibDir, "package.json"), + []byte(`{"name": "js-lib", "version": "1.0.0", "main": "index.js"}`), + 0o644, + )).To(Succeed()) + + Expect(os.WriteFile( + filepath.Join(jsLibDir, "index.js"), + []byte(`module.exports = { hello: 'world' };`), + 0o644, + )).To(Succeed()) + + err := transformer.TransformChartDir(ctx, chartPath) + Expect(err).NotTo(HaveOccurred()) + + vendorPath := filepath.Join(chartPath, VendorBundleFile) + vendorContent, err := os.ReadFile(vendorPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(vendorContent)).To(ContainSubstring("js-lib")) + }) + }) + }) + + Describe("extractPackageNames", func() { + It("should extract regular packages from metafile", func() { + metafile := `{ + "inputs": { + "node_modules/lodash/index.js": {"bytes": 100}, + "node_modules/lodash/merge.js": {"bytes": 50}, + "node_modules/axios/lib/axios.js": {"bytes": 200}, + "src/index.ts": {"bytes": 500} + } + }` + + packages, err := extractPackageNames(metafile) + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(ConsistOf("axios", "lodash")) + }) + + It("should extract scoped packages from metafile", func() { + metafile := `{ + "inputs": { + "node_modules/@types/node/index.d.ts": {"bytes": 100}, + "node_modules/@babel/core/lib/index.js": {"bytes": 200}, + "src/index.ts": {"bytes": 500} + } + }` + + packages, err := extractPackageNames(metafile) + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(ConsistOf("@types/node", "@babel/core")) + }) + + It("should return empty list when no node_modules", func() { + metafile := `{ + "inputs": { + "src/index.ts": {"bytes": 500}, + "src/helpers.ts": {"bytes": 200} + } + }` + + packages, err := extractPackageNames(metafile) + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(BeEmpty()) + }) + }) + + Describe("extractPackagesFromVendorBundle", func() { + It("should extract package names from vendor bundle", func() { + bundle := ` + var __NELM_VENDOR__ = {}; + __NELM_VENDOR__['lodash'] = require('lodash'); + __NELM_VENDOR__['axios'] = require('axios'); + __NELM_VENDOR__['@types/node'] = require('@types/node'); + ` + + packages := extractPackagesFromVendorBundle(bundle) + Expect(packages).To(ConsistOf("lodash", "axios", "@types/node")) + }) + + It("should return empty list for bundle without packages", func() { + bundle := ` + var __NELM_VENDOR__ = {}; + if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } + ` + + packages := extractPackagesFromVendorBundle(bundle) + Expect(packages).To(BeEmpty()) + }) + }) + + Describe("generateVendorEntrypoint", func() { + It("should generate correct entrypoint", func() { + packages := []string{"lodash", "axios"} + entry := generateVendorEntrypoint(packages) + + Expect(entry).To(ContainSubstring("var __NELM_VENDOR__ = {};")) + Expect(entry).To(ContainSubstring("__NELM_VENDOR__['lodash'] = require('lodash');")) + Expect(entry).To(ContainSubstring("__NELM_VENDOR__['axios'] = require('axios');")) + Expect(entry).To(ContainSubstring("global.__NELM_VENDOR__ = __NELM_VENDOR__")) + }) + + It("should handle empty package list", func() { + packages := []string{} + entry := generateVendorEntrypoint(packages) + + Expect(entry).To(ContainSubstring("var __NELM_VENDOR__ = {};")) + Expect(entry).NotTo(ContainSubstring("require(")) + }) + }) +}) diff --git a/internal/tschart/tschart_test.go b/internal/tschart/tschart_test.go new file mode 100644 index 00000000..e38dc8c2 --- /dev/null +++ b/internal/tschart/tschart_test.go @@ -0,0 +1,497 @@ +//nolint:testpackage // White-box test needs access to internal functions +package tschart + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/chartutil" +) + +func TestTSChart(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "TSChart Integration Suite") +} + +// createChartWithTSSource creates a chart with TypeScript source in RuntimeFiles +func createChartWithTSSource(sourceContent string) *helmchart.Chart { + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + }, + Files: []*helmchart.File{}, + RuntimeFiles: []*helmchart.File{ + {Name: "ts/src/index.ts", Data: []byte(sourceContent)}, + }, + } +} + +// createChartWithTSFiles creates a chart with multiple TypeScript files in RuntimeFiles +func createChartWithTSFiles(files map[string]string) *helmchart.Chart { + var runtimeFiles []*helmchart.File + for name, content := range files { + runtimeFiles = append(runtimeFiles, &helmchart.File{ + Name: "ts/" + name, + Data: []byte(content), + }) + } + + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + }, + Files: []*helmchart.File{}, + RuntimeFiles: runtimeFiles, + } +} + +var _ = Describe("TSChart Integration Tests", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Describe("Full Flow: TypeScript -> Render -> YAML", func() { + It("should handle simple TypeScript with types", func() { + sourceContent := ` +export function render(context: any) { + const releaseName: string = context.Release.Name; + const replicas: number = context.Values.replicas || 1; + + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: releaseName + '-config', + namespace: context.Release.Namespace + }, + data: { + replicas: String(replicas), + message: 'Hello from TypeScript!' + } + }] + }; +} +` + chart := createChartWithTSSource(sourceContent) + + engine := NewEngine() + values := chartutil.Values{ + "Values": map[string]interface{}{ + "replicas": 3, + }, + "Release": map[string]interface{}{ + "Name": "test-release", + "Namespace": "default", + }, + } + + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + Expect(renderedTemplates).To(HaveKey(DefaultOutputFile)) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("kind: ConfigMap")) + Expect(yaml).To(ContainSubstring("name: test-release-config")) + Expect(yaml).To(ContainSubstring("namespace: default")) + Expect(yaml).To(ContainSubstring("replicas: \"3\"")) + Expect(yaml).To(ContainSubstring("message: Hello from TypeScript!")) + }) + + It("should handle module.exports.render pattern", func() { + sourceContent := ` +module.exports.render = function(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'module-exports-test' } + }] + }; +}; +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("kind: ConfigMap")) + Expect(yaml).To(ContainSubstring("name: module-exports-test")) + }) + + It("should handle module.exports = { render } pattern", func() { + sourceContent := ` +module.exports = { + render: function(context: any) { + return { + manifests: [{ + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: 'object-pattern-test' } + }] + }; + } +}; +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("kind: Secret")) + Expect(yaml).To(ContainSubstring("name: object-pattern-test")) + }) + + It("should handle TypeScript features (template literals, arrow functions)", func() { + sourceContent := ` +export const render = (context: any) => { + const prefix = context.Release.Name; + const resources = [1, 2, 3].map(i => ({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: prefix + '-config-' + i + }, + data: { + index: String(i) + } + })); + + return { + manifests: resources + }; +}; +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{ + "Release": map[string]interface{}{ + "Name": "my-app", + }, + } + + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("name: my-app-config-1")) + Expect(yaml).To(ContainSubstring("name: my-app-config-2")) + Expect(yaml).To(ContainSubstring("name: my-app-config-3")) + Expect(yaml).To(ContainSubstring("---")) + }) + + It("should handle TypeScript interfaces and types", func() { + sourceContent := ` +interface RenderContext { + Release: { + Name: string; + Namespace: string; + }; + Values: { + replicas?: number; + }; +} + +interface Manifest { + apiVersion: string; + kind: string; + metadata: { + name: string; + }; + spec?: any; +} + +export function render(context: RenderContext) { + const manifest: Manifest = { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: context.Release.Name + }, + spec: { + replicas: context.Values.replicas || 1 + } + }; + + return { + manifests: [manifest] + }; +} +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{ + "Release": map[string]interface{}{ + "Name": "typed-app", + "Namespace": "production", + }, + "Values": map[string]interface{}{ + "replicas": 5, + }, + } + + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("kind: Deployment")) + Expect(yaml).To(ContainSubstring("name: typed-app")) + Expect(yaml).To(ContainSubstring("replicas: 5")) + }) + }) + + Describe("Error handling with sourcemaps", func() { + It("should show TypeScript error with source location", func() { + sourceContent := ` +export function render(context: any) { + // This will throw a runtime error + const obj: any = null; + obj.nonExistentProperty; + + return { + manifests: [] + }; +} +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + _, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("index.ts")) + Expect(err.Error()).To(ContainSubstring("undefined")) + }) + + It("should show error when render function is missing", func() { + sourceContent := ` +export function notRender(context: any) { + return { manifests: [] }; +} +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + _, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not export 'render' function")) + }) + + // Note: esbuild doesn't perform type checking, only syntax/transpilation + PIt("should show TypeScript type errors (skipped - esbuild doesn't type check)", func() { + sourceContent := ` +export function render(context: any) { + const x: number = "not a number"; + return { manifests: [] }; +} +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + // esbuild doesn't check types, so this will succeed + _, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Multiple files and imports", func() { + It("should handle TypeScript with multiple files", func() { + chart := createChartWithTSFiles(map[string]string{ + "src/index.ts": ` +import { createConfigMap } from './helpers'; + +export function render(context: any) { + return { + manifests: [createConfigMap(context.Release.Name)] + }; +} +`, + "src/helpers.ts": ` +export function createConfigMap(name: string) { + return { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: name + '-config' + }, + data: { + source: 'helper-function' + } + }; +} +`, + }) + engine := NewEngine() + values := chartutil.Values{ + "Release": map[string]interface{}{ + "Name": "multi-file-app", + }, + } + + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("name: multi-file-app-config")) + Expect(yaml).To(ContainSubstring("source: helper-function")) + }) + }) + + Describe("Inline sourcemaps", func() { + It("should include inline sourcemaps for error reporting", func() { + sourceContent := ` +export function render(context: any) { + // Intentionally access undefined to trigger error with sourcemap + const x: any = undefined; + x.foo.bar; // This line should appear in error + return { manifests: [] }; +} +` + chart := createChartWithTSSource(sourceContent) + engine := NewEngine() + values := chartutil.Values{} + _, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).To(HaveOccurred()) + // The error should reference the original .ts file thanks to sourcemaps + Expect(err.Error()).To(ContainSubstring("index.ts")) + }) + }) + + Describe("Packaged charts with source files", func() { + It("should render from packaged chart source files", func() { + // Create chart with source files (simulates packaged chart) + sourceContent := ` +export function render(context: any) { + return { + manifests: [{ + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: "packaged-source-test" } + }] + }; +} +` + chart := &helmchart.Chart{ + RuntimeFiles: []*helmchart.File{ + {Name: "ts/src/index.ts", Data: []byte(sourceContent)}, + }, + } + + engine := NewEngine() + values := chartutil.Values{} + // Use non-existent path to simulate packaged chart + renderedTemplates, err := engine.RenderFiles(ctx, "./packaged-chart.tgz", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("name: packaged-source-test")) + }) + + It("should use vendor bundle from packaged chart for npm dependencies", func() { + // Create a vendor bundle that provides a fake module + vendorBundle := ` +var __NELM_VENDOR_BUNDLE__ = (function() { + var __NELM_VENDOR__ = {}; + __NELM_VENDOR__['fake-lib'] = { + helper: function(name) { + return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: name + '-from-vendor' } }; + } + }; + if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } + return { __NELM_VENDOR__: __NELM_VENDOR__ }; +})(); +` + sourceContent := ` +const fakeLib = require('fake-lib'); +export function render(context: any) { + return { + manifests: [fakeLib.helper(context.Release.Name)] + }; +} +` + chart := &helmchart.Chart{ + RuntimeFiles: []*helmchart.File{ + {Name: VendorBundleFile, Data: []byte(vendorBundle)}, + {Name: "ts/src/index.ts", Data: []byte(sourceContent)}, + }, + } + + engine := NewEngine() + values := chartutil.Values{ + "Release": map[string]interface{}{ + "Name": "vendor-test", + }, + } + renderedTemplates, err := engine.RenderFiles(ctx, "./packaged-chart.tgz", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("name: vendor-test-from-vendor")) + }) + }) + + Describe("npm dependencies with vendor bundle", func() { + It("should render chart with npm dependencies from node_modules", func() { + // Create chart with source files and node_modules in RuntimeFiles/RuntimeDepsFiles + chart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + }, + Files: []*helmchart.File{}, + RuntimeFiles: []*helmchart.File{ + { + Name: "ts/src/index.ts", + Data: []byte(` +import { helper } from 'fake-lib'; + +export function render(context: any) { + return { + manifests: [helper(context.Release.Name)] + }; +} +`), + }, + }, + RuntimeDepsFiles: []*helmchart.File{ + { + Name: "ts/node_modules/fake-lib/package.json", + Data: []byte(`{"name": "fake-lib", "version": "1.0.0", "main": "index.js"}`), + }, + { + Name: "ts/node_modules/fake-lib/index.js", + Data: []byte(` +module.exports.helper = function(name) { + return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: name + '-from-npm' } }; +}; +`), + }, + }, + } + + engine := NewEngine() + values := chartutil.Values{ + "Release": map[string]interface{}{ + "Name": "npm-test", + }, + } + + renderedTemplates, err := engine.RenderFiles(ctx, "", chart, values) + Expect(err).NotTo(HaveOccurred()) + + yaml := renderedTemplates[DefaultOutputFile] + Expect(yaml).To(ContainSubstring("name: npm-test-from-npm")) + }) + }) +}) diff --git a/pkg/action/chart_init.go b/pkg/action/chart_init.go new file mode 100644 index 00000000..921425a9 --- /dev/null +++ b/pkg/action/chart_init.go @@ -0,0 +1,61 @@ +package action + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/werf/nelm/internal/tschart" + "github.com/werf/nelm/pkg/featgate" + "github.com/werf/nelm/pkg/log" +) + +type ChartInitOptions struct { + ChartDirPath string + TS bool + TempDirPath string +} + +func ChartInit(ctx context.Context, opts ChartInitOptions) error { + chartPath := opts.ChartDirPath + if chartPath == "" { + chartPath = "." + } + + absPath, err := filepath.Abs(chartPath) + if err != nil { + return fmt.Errorf("get absolute path: %w", err) + } + + chartName := filepath.Base(absPath) + + if !opts.TS { + return fmt.Errorf("non-TypeScript chart initialization not implemented yet, use --ts flag") + } + + if !featgate.FeatGateTypescript.Enabled() { + log.Default.Warn(ctx, "TypeScript charts require NELM_FEAT_TYPESCRIPT=true environment variable") + return fmt.Errorf("TypeScript charts feature is not enabled. Set NELM_FEAT_TYPESCRIPT=true to use this feature") + } + + if err := os.MkdirAll(absPath, 0o755); err != nil { + return fmt.Errorf("create directory %s: %w", absPath, err) + } + + if err := tschart.InitChartStructure(ctx, absPath, chartName); err != nil { + return fmt.Errorf("init chart structure: %w", err) + } + + if err := tschart.InitTSBoilerplate(ctx, absPath, chartName); err != nil { + return fmt.Errorf("init TypeScript boilerplate: %w", err) + } + + if err := tschart.EnsureGitignore(absPath); err != nil { + return fmt.Errorf("ensure .gitignore: %w", err) + } + + log.Default.Info(ctx, "Initialized TypeScript chart in %s", absPath) + + return nil +} diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 1f8f3bae..22bda09a 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -156,6 +156,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu } var clientFactory *kube.ClientFactory + if opts.Remote { if len(opts.KubeConfigPaths) > 0 { var splitPaths []string @@ -305,6 +306,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu } var showFiles []string + for _, file := range opts.ShowOnlyFiles { absFile, err := filepath.Abs(file) if err != nil { @@ -335,6 +337,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu renderOutStream io.Writer renderColorLevel color.Level ) + if opts.OutputFilePath != "" { file, err := os.Create(opts.OutputFilePath) if err != nil { diff --git a/pkg/featgate/feat.go b/pkg/featgate/feat.go index afc41de1..2970390c 100644 --- a/pkg/featgate/feat.go +++ b/pkg/featgate/feat.go @@ -58,6 +58,11 @@ var ( "resource-validation", "Validate chart resources against specific Kubernetes resources' schemas", ) + + FeatGateTypescript = NewFeatGate( + "typescript", + `Enable TypeScript chart rendering from ts/ directory`, + ) ) // A feature gate, which enabled/disables a specific feature. Can be toggled via an env var or