From e6b355210dc4c6b59a19e7c4429b897738de33e4 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 14 Feb 2026 01:23:51 +0300 Subject: [PATCH 01/30] feat: deno runtime for typescript Signed-off-by: Dmitry Mordvinov --- cmd/nelm/release_install.go | 7 ++ internal/chart/chart_render.go | 6 +- internal/ts/deno.go | 178 +++++++++++++++++++++++++++++++++ internal/ts/render.go | 17 +++- pkg/action/release_install.go | 3 + pkg/common/common.go | 10 +- 6 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 internal/ts/deno.go diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index ce6d7d98..cd4239e2 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -313,6 +313,13 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.RebuildTsVendorBundle, "rebuild-ts-vendor", false, "Rebuild the Deno vendor bundle even if it already exists.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.ReleaseName, "release", "", "The release name. Must be unique within the release namespace", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: mainFlagGroup, diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index 5987ac8e..e1b77249 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -53,6 +53,7 @@ type RenderChartOptions struct { Remote bool SubchartNotes bool TemplatesAllowDNS bool + RebuildTsVendorBundle bool } type RenderChartResult struct { @@ -216,7 +217,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues) + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTsVendorBundle) if err != nil { return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) } @@ -256,10 +257,11 @@ func renderJSTemplates( chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, + rebuildVendor bool, ) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - result, err := ts.RenderChart(ctx, chart, renderedValues) + result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildVendor) if err != nil { return nil, fmt.Errorf("render TypeScript: %w", err) } diff --git a/internal/ts/deno.go b/internal/ts/deno.go new file mode 100644 index 00000000..8eb1ed46 --- /dev/null +++ b/internal/ts/deno.go @@ -0,0 +1,178 @@ +package ts + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path" + "slices" + "strings" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/chartutil" + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/log" +) + +func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartDir string, rebuildVendor bool) (map[string]string, error) { + mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) + tsRootDir := chartDir + "/" + common.ChartTSSourceDir + + var ( + hasNodeModules bool + useVendorMap bool + vendorFiles []*helmchart.File + ) + for _, file := range mergedFiles { + if strings.HasPrefix(file.Name, common.ChartTSSourceDir+"node_modules/") { + hasNodeModules = true + } else if strings.HasPrefix(file.Name, common.ChartTSVendorBundleDir) { + vendorFiles = append(vendorFiles, file) + } else if file.Name == common.ChartTSSourceDir+common.ChartTSVendorMap { + useVendorMap = true + } + } + + if hasNodeModules && (rebuildVendor || len(vendorFiles) == 0) { + err := buildDenoVendorBundle(ctx, tsRootDir) + if err != nil { + return nil, fmt.Errorf("build deno vendor bundle: %w", err) + } + } + + sourceFiles := extractSourceFiles(mergedFiles) + if len(sourceFiles) == 0 { + return map[string]string{}, nil + } + + entrypoint := findEntrypointInFiles(sourceFiles) + if entrypoint == "" { + return map[string]string{}, nil + } + + result, err := runDenoApp(ctx, tsRootDir, useVendorMap, entrypoint, buildRenderContext(renderedValues, chart)) + if err != nil { + return nil, fmt.Errorf("run deno app: %w", err) + } + + if result == nil { + return map[string]string{}, nil + } + + yamlOutput, err := convertRenderResultToYAML(result) + if err != nil { + return nil, fmt.Errorf("convert render result to yaml: %w", err) + } + + if strings.TrimSpace(yamlOutput) == "" { + return map[string]string{}, nil + } + + return map[string]string{ + path.Join(common.ChartTSSourceDir, entrypoint): yamlOutput, + }, nil +} + +func buildDenoVendorBundle(ctx context.Context, tsRootDir string) error { + denoBin, ok := os.LookupEnv("DENO_BIN") + if !ok || denoBin == "" { + denoBin = "deno" + } + + cmd := exec.CommandContext(ctx, denoBin, "run", "-A", "build.ts") + cmd.Dir = tsRootDir + cmd.Stdout = os.Stdout + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + _, _ = os.Stderr.Write(exitErr.Stderr) + } + + return fmt.Errorf("get deno build output: %w", err) + } + + return nil +} + +func runDenoApp(ctx context.Context, tsRootDir string, useVendorMap bool, entryPoint string, renderCtx map[string]any) (map[string]interface{}, error) { + denoBin, ok := os.LookupEnv("DENO_BIN") + if !ok || denoBin == "" { + denoBin = "deno" + } + + args := []string{"run"} + if useVendorMap { + args = append(args, "--import-map", common.ChartTSVendorMap) + } + + args = append(args, entryPoint) + + cmd := exec.CommandContext(ctx, denoBin, args...) + cmd.Dir = tsRootDir + cmd.Stderr = os.Stderr + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("get stdin pipe: %w", err) + } + + go func() { + defer func() { + _ = stdin.Close() + }() + + _ = json.NewEncoder(stdin).Encode(renderCtx) + }() + + reader, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("get stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start process: %w", err) + } + + waitForJSONString := func() (string, error) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + text := scanner.Text() + log.Default.Debug(ctx, text) + + if strings.HasPrefix(text, common.ChartTSRenderResultPrefix) { + _, str, found := strings.Cut(text, common.ChartTSRenderResultPrefix) + if found { + return str, nil + } + } + } + + return "", errors.New("render output not found") + } + + jsonString, errJson := waitForJSONString() + + if err := cmd.Wait(); err != nil { + return nil, fmt.Errorf("wait process: %w", err) + } + + if errJson != nil { + return nil, fmt.Errorf("wait for render output: %w", errJson) + } + + if jsonString == "" { + return nil, fmt.Errorf("unexpected render output format") + } + + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonString), &result); err != nil { + return nil, fmt.Errorf("unmarshal render output: %w", err) + } + + return result, nil +} diff --git a/internal/ts/render.go b/internal/ts/render.go index a0571889..ee922470 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -3,6 +3,7 @@ package ts import ( "context" "fmt" + "os" "path" "slices" "strings" @@ -15,20 +16,25 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildVendor bool) (map[string]string, error) { allRendered := make(map[string]string) - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), allRendered); err != nil { + wd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get current working directory: %w", err) + } + + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), wd, allRendered, rebuildVendor); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix string, results map[string]string) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartDir string, results map[string]string, rebuildVendor bool) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderFiles(ctx, chart, values) + rendered, err := renderDenoFiles(ctx, chart, values, chartDir, rebuildVendor) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -48,7 +54,9 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch dep, scopeValuesForSubchart(values, depName, dep), path.Join(pathPrefix, "charts", depName), + path.Join(chartDir, "charts", depName), results, + rebuildVendor, ) if err != nil { return fmt.Errorf("render dependency %q: %w", depName, err) @@ -58,6 +66,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } +// TODO: remove after finish the Deno implementation func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index f955791d..f06f2ae8 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -163,6 +163,8 @@ type ReleaseInstallOptions struct { // Timeout is the maximum duration for the entire release installation operation. // If 0, no timeout is applied and the operation runs until completion or error. Timeout time.Duration + // RebuildTsVendorBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. + RebuildTsVendorBundle bool } func ReleaseInstall(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseInstallOptions) error { @@ -343,6 +345,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re Remote: true, SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, + RebuildTsVendorBundle: opts.RebuildTsVendorBundle, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/common/common.go b/pkg/common/common.go index 1dd97cc4..750d6d39 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -26,12 +26,18 @@ var ( const ( // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. ChartTSSourceDir = "ts/" - // ChartTSVendorBundleFile is the path to the vendor bundle file in a Helm chart. - ChartTSVendorBundleFile = ChartTSSourceDir + "vendor/libs.js" + // ChartTSVendorBundleDir is the path to the vendor bundle dir in a Helm chart. + ChartTSVendorBundleDir = ChartTSSourceDir + "dist/vendor/" + // ChartTSVendorMap is the path to the deno import mapping for the vendor bundle. + ChartTSVendorMap = "dist/vendor_map.json" // ChartTSEntryPointTS is the TypeScript entry point path. ChartTSEntryPointTS = "src/index.ts" // ChartTSEntryPointJS is the JavaScript entry point path. ChartTSEntryPointJS = "src/index.js" + // ChartTSRenderResultPrefix is the prefix for the rendered output. + ChartTSRenderResultPrefix = "NELM_RENDER_RESULT:" + // TODO: remove after deno implementation is ready, and use ChartTSVendorBundleDir instead + ChartTSVendorBundleFile = ChartTSSourceDir + "vendor/libs.js" ) // ChartTSEntryPoints defines supported TypeScript/JavaScript entry points (in priority order). From d8f4d768bf75bf11792b0b14ca025b239090393d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 17 Feb 2026 13:28:50 +0300 Subject: [PATCH 02/30] wip: chart ts init / chart ts build, changes for ts init Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart.go | 4 +- cmd/nelm/chart_init.go | 15 +- cmd/nelm/chart_pack.go | 2 +- cmd/nelm/chart_ts.go | 25 + cmd/nelm/chart_ts_build.go | 75 + cmd/nelm/chart_ts_init.go | 83 + cmd/nelm/groups.go | 1 + cmd/nelm/release_install.go | 2 +- internal/chart/chart_render.go | 6 +- internal/ts/bundle.go | 277 --- internal/ts/bundle_ai_test.go | 265 --- internal/ts/bundle_test.go | 185 -- internal/ts/deno.go | 79 +- internal/ts/esbuild.go | 222 -- internal/ts/export_test.go | 9 +- internal/ts/files.go | 22 - internal/ts/init.go | 4 +- internal/ts/init_templates.go | 53 +- internal/ts/package.go | 89 - internal/ts/package_test.go | 109 - internal/ts/render.go | 51 +- internal/ts/render_ai_test.go | 2028 ----------------- internal/ts/render_test.go | 529 ----- internal/ts/runtime.go | 136 -- pkg/action/chart_ts_build.go | 40 + .../{chart_init.go => chart_ts_init.go} | 11 +- pkg/action/release_install.go | 6 +- pkg/common/common.go | 4 +- 28 files changed, 302 insertions(+), 4030 deletions(-) create mode 100644 cmd/nelm/chart_ts.go create mode 100644 cmd/nelm/chart_ts_build.go create mode 100644 cmd/nelm/chart_ts_init.go delete mode 100644 internal/ts/bundle.go delete mode 100644 internal/ts/bundle_ai_test.go delete mode 100644 internal/ts/bundle_test.go delete mode 100644 internal/ts/esbuild.go delete mode 100644 internal/ts/package.go delete mode 100644 internal/ts/package_test.go delete mode 100644 internal/ts/render_ai_test.go delete mode 100644 internal/ts/render_test.go delete mode 100644 internal/ts/runtime.go create mode 100644 pkg/action/chart_ts_build.go rename pkg/action/{chart_init.go => chart_ts_init.go} (83%) diff --git a/cmd/nelm/chart.go b/cmd/nelm/chart.go index 25a9ebbd..7a488871 100644 --- a/cmd/nelm/chart.go +++ b/cmd/nelm/chart.go @@ -18,7 +18,8 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. cli.GroupCommandOptions{}, ) - cmd.AddCommand(newChartInitCommand(ctx, afterAllCommandsBuiltFuncs)) + // TODO: add chart init command when it's implemented + // cmd.AddCommand(newChartInitCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartRenderCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDependencyCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDownloadCommand(ctx, afterAllCommandsBuiltFuncs)) @@ -26,6 +27,7 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. cmd.AddCommand(newChartPackCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartLintCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartSecretCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartTSCommand(ctx, afterAllCommandsBuiltFuncs)) return cmd } diff --git a/cmd/nelm/chart_init.go b/cmd/nelm/chart_init.go index d5aa8ef2..8e65ed07 100644 --- a/cmd/nelm/chart_init.go +++ b/cmd/nelm/chart_init.go @@ -8,14 +8,13 @@ import ( "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 - + TempDirPath string + ChartDirPath string LogColorMode string LogLevel string } @@ -45,21 +44,13 @@ func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cfg.ChartDirPath = args[0] } - if err := action.ChartInit(ctx, cfg.ChartInitOptions); err != nil { - return fmt.Errorf("chart init: %w", err) - } + // TODO: implement chart init logic for non-TypeScript charts 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, diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index b5d60ddf..5a7dff92 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -38,7 +38,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co if featgate.FeatGateTypescript.Enabled() { for _, chartPath := range args { - if err := ts.BuildVendorBundleToDir(ctx, chartPath); err != nil { + if err := ts.BuildVendorBundle(ctx, chartPath); err != nil { return fmt.Errorf("build TypeScript vendor bundle in %q: %w", chartPath, err) } } diff --git a/cmd/nelm/chart_ts.go b/cmd/nelm/chart_ts.go new file mode 100644 index 00000000..e468b341 --- /dev/null +++ b/cmd/nelm/chart_ts.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/werf/common-go/pkg/cli" +) + +func newChartTSCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cmd := cli.NewGroupCommand( + ctx, + "ts", + "Manage TypeScript charts.", + "Manage TypeScript charts.", + tsCmdGroup, + cli.GroupCommandOptions{}, + ) + + cmd.AddCommand(newChartTSInitCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartTSBuildCommand(ctx, afterAllCommandsBuiltFuncs)) + + return cmd +} diff --git a/cmd/nelm/chart_ts_build.go b/cmd/nelm/chart_ts_build.go new file mode 100644 index 00000000..c87c88d7 --- /dev/null +++ b/cmd/nelm/chart_ts_build.go @@ -0,0 +1,75 @@ +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 chartTSBuildConfig struct { + action.ChartTSBuildOptions + + LogColorMode string + LogLevel string +} + +func newChartTSBuildCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cfg := &chartTSBuildConfig{} + + cmd := cli.NewSubCommand( + ctx, + "build [PATH]", + "Build vendor for typescript chart.", + "Build vendor for typescript chart in the specified directory. If PATH is not specified, uses the current directory.", + 10, // priority for ordering in help + tsCmdGroup, + 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.ChartTSBuild(ctx, cfg.ChartTSBuildOptions); err != nil { + return fmt.Errorf("chart build: %w", err) + } + + return nil + }, + ) + + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + 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_ts_init.go b/cmd/nelm/chart_ts_init.go new file mode 100644 index 00000000..b19c4307 --- /dev/null +++ b/cmd/nelm/chart_ts_init.go @@ -0,0 +1,83 @@ +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 chartTSInitConfig struct { + action.ChartTSInitOptions + + LogColorMode string + LogLevel string +} + +func newChartTSInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cfg := &chartTSInitConfig{} + + cmd := cli.NewSubCommand( + ctx, + "init [PATH]", + "Initialize a new typescript chart.", + "Initialize a new typescript chart in the specified directory. If PATH is not specified, uses the current directory.", + 20, // priority for ordering in help + tsCmdGroup, + 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.ChartTSInit(ctx, cfg.ChartTSInitOptions); err != nil { + return fmt.Errorf("chart init: %w", err) + } + + return nil + }, + ) + + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + 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/groups.go b/cmd/nelm/groups.go index b246ba7f..4f900f4c 100644 --- a/cmd/nelm/groups.go +++ b/cmd/nelm/groups.go @@ -9,6 +9,7 @@ var ( chartCmdGroup = cli.NewCommandGroup("chart", "Chart commands:", 90) secretCmdGroup = cli.NewCommandGroup("secret", "Secret commands:", 80) dependencyCmdGroup = cli.NewCommandGroup("dependency", "Dependency commands:", 70) + tsCmdGroup = cli.NewCommandGroup("ts", "TypeScript chart commands:", 60) repoCmdGroup = cli.NewCommandGroup("repo", "Repo commands:", 60) miscCmdGroup = cli.NewCommandGroup("misc", "Other commands:", 0) diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index cd4239e2..8d1ecd83 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -313,7 +313,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTsVendorBundle, "rebuild-ts-vendor", false, "Rebuild the Deno vendor bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.RebuildTSVendorBundle, "rebuild-ts-vendor", false, "Rebuild the Deno vendor bundle even if it already exists.", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: miscFlagGroup, }); err != nil { diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index e1b77249..cd9adee7 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -53,7 +53,7 @@ type RenderChartOptions struct { Remote bool SubchartNotes bool TemplatesAllowDNS bool - RebuildTsVendorBundle bool + RebuildTSVendorBundle bool } type RenderChartResult struct { @@ -217,7 +217,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTsVendorBundle) + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSVendorBundle) if err != nil { return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) } @@ -261,7 +261,7 @@ func renderJSTemplates( ) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildVendor) + result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildVendor, chartPath) if err != nil { return nil, fmt.Errorf("render TypeScript: %w", err) } diff --git a/internal/ts/bundle.go b/internal/ts/bundle.go deleted file mode 100644 index 2de64b7e..00000000 --- a/internal/ts/bundle.go +++ /dev/null @@ -1,277 +0,0 @@ -package ts - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - esbuild "github.com/evanw/esbuild/pkg/api" - - helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/3p-helm/pkg/werf/file" - "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/log" -) - -// BuildVendorBundleToDir scans the chart's TypeScript source for npm dependencies -// and creates a vendor bundle file at ts/vendor/libs.js. -func BuildVendorBundleToDir(ctx context.Context, chartPath string) error { - absChartPath, err := filepath.Abs(chartPath) - if err != nil { - return fmt.Errorf("get absolute path: %w", err) - } - - tsFiles, err := loadTSFilesForVendorBundle(ctx, absChartPath) - if err != nil { - return err - } - - if len(tsFiles) == 0 { - // empty means skip (path doesn't exist, not a directory, or no ts/ dir) - return nil - } - - entrypoint := findEntrypointInFiles(tsFiles) - if entrypoint == "" { - log.Default.Debug(ctx, "Skipping vendor bundle: no entrypoint found") - - return nil - } - - if !hasNodeModules(tsFiles) { - log.Default.Debug(ctx, "Skipping vendor bundle: no node_modules directory") - - return nil - } - - log.Default.Info(ctx, "Building vendor bundle for TypeScript chart: %s", absChartPath) - - vendorBundle, packages, err := buildVendorBundleFromFiles(ctx, tsFiles, entrypoint) - if err != nil { - return fmt.Errorf("build vendor bundle: %w", err) - } - - if len(packages) == 0 { - log.Default.Debug(ctx, "Skipping vendor bundle: no npm packages used") - - return nil - } - - log.Default.Info(ctx, "Bundled %d npm packages: %s", len(packages), strings.Join(packages, ", ")) - - if file.ChartFileWriter != nil { - if err := file.ChartFileWriter.WriteChartFile(ctx, common.ChartTSVendorBundleFile, []byte(vendorBundle)); err != nil { - return fmt.Errorf("write vendor bundle: %w", err) - } - } else { - vendorPath := filepath.Join(absChartPath, common.ChartTSVendorBundleFile) - 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 { - return fmt.Errorf("write vendor bundle to %s: %w", vendorPath, err) - } - } - - log.Default.Info(ctx, "Wrote vendor bundle to %s", common.ChartTSVendorBundleFile) - - return nil -} - -// loadTSFilesForVendorBundle loads TypeScript files for vendor bundle building. -// Returns empty map if the path should be skipped (doesn't exist, not a directory, or no ts/ dir). -func loadTSFilesForVendorBundle(ctx context.Context, absChartPath string) (map[string][]byte, error) { - if file.ChartFileReader != nil { - return loadTSFilesFromGiterminism(ctx, absChartPath) - } - - return loadTSFilesFromFilesystem(ctx, absChartPath) -} - -func loadTSFilesFromGiterminism(ctx context.Context, absChartPath string) (map[string][]byte, error) { - isDir, err := file.ChartFileReader.ChartIsDir(absChartPath) - if err != nil { - return nil, fmt.Errorf("check directory %s: %w", absChartPath, err) - } - - if !isDir { - log.Default.Debug(ctx, "Skipping vendor bundle: %s is not a directory", absChartPath) - - return map[string][]byte{}, nil - } - - chartFiles, err := file.ChartFileReader.LoadChartDir(ctx, absChartPath) - if err != nil { - return nil, fmt.Errorf("load chart dir: %w", err) - } - - tsFiles := filterTSFiles(chartFiles) - if len(tsFiles) == 0 { - log.Default.Debug(ctx, "Skipping vendor bundle: no %s directory", common.ChartTSSourceDir) - - return tsFiles, nil - } - - return tsFiles, nil -} - -func loadTSFilesFromFilesystem(ctx context.Context, absChartPath string) (map[string][]byte, error) { - stat, err := os.Stat(absChartPath) - if err != nil { - if os.IsNotExist(err) { - log.Default.Debug(ctx, "Skipping vendor bundle: chart path %s does not exist", absChartPath) - - return map[string][]byte{}, nil - } - - return nil, fmt.Errorf("stat %s: %w", absChartPath, err) - } - - if !stat.IsDir() { - return nil, fmt.Errorf("build vendor bundle to dir: %s is not a directory", absChartPath) - } - - tsDir := filepath.Join(absChartPath, common.ChartTSSourceDir) - if _, err := os.Stat(tsDir); err != nil { - if os.IsNotExist(err) { - log.Default.Debug(ctx, "Skipping vendor bundle: no %s directory", common.ChartTSSourceDir) - - return map[string][]byte{}, nil - } - - return nil, fmt.Errorf("stat %s: %w", tsDir, err) - } - - tsFiles, err := loadTSFilesFromDir(tsDir) - if err != nil { - return nil, fmt.Errorf("load ts files from %s: %w", tsDir, err) - } - - return tsFiles, nil -} - -func loadTSFilesFromDir(tsDir string) (map[string][]byte, error) { - result := make(map[string][]byte) - - err := filepath.WalkDir(tsDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - relPath, err := filepath.Rel(tsDir, path) - if err != nil { - return fmt.Errorf("get relative path: %w", err) - } - - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read file %s: %w", path, err) - } - - result[relPath] = data - - return nil - }) - if err != nil { - return nil, fmt.Errorf("walk dir %s: %w", tsDir, err) - } - - return result, nil -} - -func resolveVendorBundle(ctx context.Context, files []*helmchart.File) (string, []string, error) { - // Check if node_modules exists in files - hasNodeModules := false - for _, f := range files { - if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"node_modules/") { - hasNodeModules = true - break - } - } - - if hasNodeModules { - filesMap := make(map[string][]byte) - for _, f := range files { - if !strings.HasPrefix(f.Name, common.ChartTSSourceDir) { - continue - } - - filesMap[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data - } - - entrypoint := findEntrypointInFiles(filesMap) - if entrypoint == "" { - return "", nil, nil - } - - return buildVendorBundleFromFiles(ctx, filesMap, entrypoint) - } - - // Look for pre-built vendor bundle - for _, f := range files { - if f.Name == common.ChartTSVendorBundleFile { - return string(f.Data), extractPackagesFromVendorBundle(string(f.Data)), nil - } - } - - return "", nil, nil -} - -func buildVendorBundleFromFiles(ctx context.Context, files map[string][]byte, entrypoint string) (string, []string, error) { - packages, err := scanPackagesFromFiles(ctx, entrypoint, newVirtualFSPlugin(files, true)) - if err != nil { - return "", nil, err - } - - if len(packages) == 0 { - return "", nil, nil - } - - vendorOpts := newVendorEsbuildOptions(packages, ".") - vendorOpts.Plugins = []esbuild.Plugin{newVirtualFSPlugin(files, true)} - - bundle, err := runEsbuildBundle(vendorOpts) - if err != nil { - return "", nil, err - } - - return bundle, packages, nil -} - -func buildAppBundleFromFiles(ctx context.Context, files map[string][]byte, externalPackages []string) (string, error) { - entrypoint := findEntrypointInFiles(files) - if entrypoint == "" { - return "", fmt.Errorf("build app bundle: no entrypoint found") - } - - log.Default.Debug(ctx, "Building app bundle from chart files with entrypoint %s", entrypoint) - - opts := newEsbuildOptions() - opts.EntryPoints = []string{entrypoint} - opts.External = externalPackages - opts.Sourcemap = esbuild.SourceMapInline - opts.Plugins = []esbuild.Plugin{newVirtualFSPlugin(files, false)} - - return runEsbuildBundle(opts) -} - -func scanPackagesFromFiles(ctx context.Context, entrypoint string, plugin esbuild.Plugin) ([]string, error) { - scanOpts := newEsbuildOptions() - scanOpts.EntryPoints = []string{entrypoint} - scanOpts.Metafile = true - scanOpts.Plugins = []esbuild.Plugin{plugin} - - scanResult := esbuild.Build(scanOpts) - if len(scanResult.Errors) > 0 { - return nil, formatEsbuildErrors(scanResult.Errors) - } - - return extractPackageNames(scanResult.Metafile) -} diff --git a/internal/ts/bundle_ai_test.go b/internal/ts/bundle_ai_test.go deleted file mode 100644 index b021452f..00000000 --- a/internal/ts/bundle_ai_test.go +++ /dev/null @@ -1,265 +0,0 @@ -//go:build ai_tests - -package ts_test - -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" - "github.com/werf/nelm/internal/ts" - "github.com/werf/nelm/pkg/common" -) - -func TestAI_VendorBundle(t *testing.T) { - t.Run("scoped package in vendor bundle", func(t *testing.T) { - vendorBundle := ` -var __NELM_VENDOR_BUNDLE__ = (function() { - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['@myorg/utils'] = { - formatName: function(name) { return '@myorg:' + name; } - }; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - return { __NELM_VENDOR__: __NELM_VENDOR__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const utils = require('@myorg/utils'); -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: utils.formatName(ctx.Release.Name) } - }] - }; -} -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "scoped-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "name: '@myorg:scoped-test'") - }) - - t.Run("multiple packages in vendor bundle", func(t *testing.T) { - vendorBundle := ` -var __NELM_VENDOR_BUNDLE__ = (function() { - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['lodash'] = { - merge: function(a, b) { return Object.assign({}, a, b); }, - get: function(obj, path, def) { - return path.split('.').reduce((o, k) => o && o[k], obj) || def; - } - }; - __NELM_VENDOR__['yaml'] = { - stringify: function(obj) { return JSON.stringify(obj); } - }; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - return { __NELM_VENDOR__: __NELM_VENDOR__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const _ = require('lodash'); -const yaml = require('yaml'); -export function render(ctx: any) { - const base = { app: 'test' }; - const merged = _.merge(base, ctx.Values.labels || {}); - const nested = _.get(ctx, 'Values.deeply.nested', 'default'); - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'multi-pkg', labels: merged }, - data: { nested, serialized: yaml.stringify(merged) } - }] - }; -} -`)}, - }, - } - values := chartutil.Values{"Values": map[string]any{"labels": map[string]any{"env": "prod"}}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "app: test") - assert.Contains(t, yaml, "env: prod") - assert.Contains(t, yaml, "nested: default") - }) - - t.Run("package with subpath import", func(t *testing.T) { - vendorBundle := ` -var __NELM_VENDOR_BUNDLE__ = (function() { - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['mylib'] = { - core: { create: function(name) { return { name: name }; } }, - utils: { format: function(s) { return s.toUpperCase(); } } - }; - __NELM_VENDOR__['mylib/core'] = __NELM_VENDOR__['mylib'].core; - __NELM_VENDOR__['mylib/utils'] = __NELM_VENDOR__['mylib'].utils; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - return { __NELM_VENDOR__: __NELM_VENDOR__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const core = require('mylib/core'); -const utils = require('mylib/utils'); -export function render(ctx: any) { - const obj = core.create(ctx.Release.Name); - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: utils.format(obj.name) } - }] - }; -} -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "subpath"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "name: SUBPATH") - }) - - t.Run("missing package gives clear error", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -const missing = require('nonexistent-package'); -export function render(ctx: any) { - return { manifests: [missing.create()] }; -} -`)}, - }, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "nonexistent-package") - }) - - t.Run("vendor bundle with complex nested exports", func(t *testing.T) { - vendorBundle := ` -var __NELM_VENDOR_BUNDLE__ = (function() { - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['k8s-helpers'] = { - metadata: { - createLabels: function(name, version) { - return { - 'app.kubernetes.io/name': name, - 'app.kubernetes.io/version': version, - 'app.kubernetes.io/managed-by': 'nelm' - }; - }, - createAnnotations: function(opts) { - return { 'description': opts.description || 'No description' }; - } - }, - resources: { - configMap: function(name, data) { - return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: name }, data: data }; - } - } - }; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - return { __NELM_VENDOR__: __NELM_VENDOR__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "2.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const k8s = require('k8s-helpers'); -export function render(ctx: any) { - const labels = k8s.metadata.createLabels(ctx.Release.Name, ctx.Chart.Version); - const annotations = k8s.metadata.createAnnotations({ description: 'Test config' }); - const cm = k8s.resources.configMap(ctx.Release.Name + '-config', { key: 'value' }); - cm.metadata.labels = labels; - cm.metadata.annotations = annotations; - return { manifests: [cm] }; -} -`)}, - }, - } - values := chartutil.Values{ - "Release": map[string]any{"Name": "nested-vendor"}, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "app.kubernetes.io/name: nested-vendor") - assert.Contains(t, yaml, "app.kubernetes.io/version: 2.0.0") - assert.Contains(t, yaml, "app.kubernetes.io/managed-by: nelm") - assert.Contains(t, yaml, "description: Test config") - }) - - t.Run("CommonJS require from vendor", func(t *testing.T) { - vendorBundle := ` -var __NELM_VENDOR_BUNDLE__ = (function() { - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['cjs-module'] = { - config: { name: 'cjs-config' }, - namedExport: function() { return 'named'; }, - anotherNamed: 'value' - }; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - return { __NELM_VENDOR__: __NELM_VENDOR__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const cjsModule = require('cjs-module'); -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'cjs-imports' }, - data: { - configName: cjsModule.config.name, - named: cjsModule.namedExport(), - another: cjsModule.anotherNamed - } - }] - }; -} -`)}, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "configName: cjs-config") - assert.Contains(t, yaml, "named: named") - assert.Contains(t, yaml, "another: value") - }) -} diff --git a/internal/ts/bundle_test.go b/internal/ts/bundle_test.go deleted file mode 100644 index 864a25c6..00000000 --- a/internal/ts/bundle_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package ts_test - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/werf/nelm/internal/ts" - "github.com/werf/nelm/pkg/common" -) - -func TestBuildVendorBundleToDir(t *testing.T) { - t.Run("skips non-existent path", func(t *testing.T) { - err := ts.BuildVendorBundleToDir(context.Background(), "./non-existent-path") - require.NoError(t, err) - }) - - t.Run("error for file path", func(t *testing.T) { - tempDir := t.TempDir() - filePath := filepath.Join(tempDir, "chart.tgz") - require.NoError(t, os.WriteFile(filePath, []byte("dummy"), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), filePath) - require.Error(t, err) - assert.Contains(t, err.Error(), "is not a directory") - }) - - t.Run("skips chart without ts directory", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - require.NoError(t, os.MkdirAll(chartPath, 0o755)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - _, err = os.Stat(vendorPath) - assert.True(t, os.IsNotExist(err)) - }) - - t.Run("skips ts directory without entrypoint", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(tsDir, "helpers.ts"), []byte("export const foo = 'bar';"), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - _, err = os.Stat(vendorPath) - assert.True(t, os.IsNotExist(err)) - }) - - t.Run("skips entrypoint without node_modules", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(tsDir, "index.ts"), []byte(` -export function render(context: any) { - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'test' } }] }; -} -`), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - _, err = os.Stat(vendorPath) - assert.True(t, os.IsNotExist(err)) - }) - - t.Run("creates vendor bundle with dependencies", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - - // Source with import - require.NoError(t, os.WriteFile(filepath.Join(tsDir, "index.ts"), []byte(` -import { helper } from 'fake-lib'; -export function render(context: any) { return { manifests: [helper(context)] }; } -`), 0o644)) - - // Fake node_modules - fakeLibDir := filepath.Join(chartPath, "ts", "node_modules", "fake-lib") - require.NoError(t, os.MkdirAll(fakeLibDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(fakeLibDir, "package.json"), []byte(`{"name": "fake-lib", "version": "1.0.0", "main": "index.js"}`), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(fakeLibDir, "index.js"), []byte(` -module.exports.helper = function(ctx) { - return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: ctx.Release.Name } }; -}; -`), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - vendorContent, err := os.ReadFile(vendorPath) - require.NoError(t, err) - assert.Contains(t, string(vendorContent), "__NELM_VENDOR__") - assert.Contains(t, string(vendorContent), "fake-lib") - }) - - t.Run("error on TypeScript syntax error", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - - // Create node_modules to trigger build - nodeModulesDir := filepath.Join(chartPath, "ts", "node_modules", "some-lib") - require.NoError(t, os.MkdirAll(nodeModulesDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(nodeModulesDir, "package.json"), []byte(`{"name": "some-lib", "version": "1.0.0", "main": "index.js"}`), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(nodeModulesDir, "index.js"), []byte(`module.exports = {};`), 0o644)) - - // Syntax error in source - require.NoError(t, os.WriteFile(filepath.Join(tsDir, "index.ts"), []byte(` -import 'some-lib'; -export function render(context: any) { return { manifests: [ } -`), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.Error(t, err) - assert.Contains(t, err.Error(), "TypeScript transpilation failed") - }) - - t.Run("detects multiple dependencies", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - - // Source with multiple imports - require.NoError(t, 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)) - require.NoError(t, 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)) - - // Fake node_modules - fakeUtilDir := filepath.Join(chartPath, "ts", "node_modules", "fake-util") - require.NoError(t, os.MkdirAll(fakeUtilDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(fakeUtilDir, "package.json"), []byte(`{"name": "fake-util", "version": "1.0.0", "main": "index.js"}`), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(fakeUtilDir, "index.js"), []byte(`module.exports.util = function() { return 'utility'; };`), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - vendorContent, err := os.ReadFile(vendorPath) - require.NoError(t, err) - assert.Contains(t, string(vendorContent), "fake-util") - }) - - t.Run("works with JavaScript entrypoint", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-chart") - tsDir := filepath.Join(chartPath, "ts", "src") - require.NoError(t, os.MkdirAll(tsDir, 0o755)) - - require.NoError(t, os.WriteFile(filepath.Join(tsDir, "index.js"), []byte(` -const lib = require('js-lib'); -exports.render = function(context) { return { manifests: [{ apiVersion: 'v1', kind: 'Pod' }] }; }; -`), 0o644)) - - // Fake node_modules - jsLibDir := filepath.Join(chartPath, "ts", "node_modules", "js-lib") - require.NoError(t, os.MkdirAll(jsLibDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(jsLibDir, "package.json"), []byte(`{"name": "js-lib", "version": "1.0.0", "main": "index.js"}`), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(jsLibDir, "index.js"), []byte(`module.exports = { hello: 'world' };`), 0o644)) - - err := ts.BuildVendorBundleToDir(context.Background(), chartPath) - require.NoError(t, err) - - vendorPath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - vendorContent, err := os.ReadFile(vendorPath) - require.NoError(t, err) - assert.Contains(t, string(vendorContent), "js-lib") - }) -} diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 8eb1ed46..03402add 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -8,83 +8,20 @@ import ( "fmt" "os" "os/exec" - "path" - "slices" "strings" - helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/3p-helm/pkg/chartutil" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) -func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartDir string, rebuildVendor bool) (map[string]string, error) { - mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) - tsRootDir := chartDir + "/" + common.ChartTSSourceDir - - var ( - hasNodeModules bool - useVendorMap bool - vendorFiles []*helmchart.File - ) - for _, file := range mergedFiles { - if strings.HasPrefix(file.Name, common.ChartTSSourceDir+"node_modules/") { - hasNodeModules = true - } else if strings.HasPrefix(file.Name, common.ChartTSVendorBundleDir) { - vendorFiles = append(vendorFiles, file) - } else if file.Name == common.ChartTSSourceDir+common.ChartTSVendorMap { - useVendorMap = true - } - } - - if hasNodeModules && (rebuildVendor || len(vendorFiles) == 0) { - err := buildDenoVendorBundle(ctx, tsRootDir) - if err != nil { - return nil, fmt.Errorf("build deno vendor bundle: %w", err) - } - } - - sourceFiles := extractSourceFiles(mergedFiles) - if len(sourceFiles) == 0 { - return map[string]string{}, nil - } - - entrypoint := findEntrypointInFiles(sourceFiles) - if entrypoint == "" { - return map[string]string{}, nil - } - - result, err := runDenoApp(ctx, tsRootDir, useVendorMap, entrypoint, buildRenderContext(renderedValues, chart)) - if err != nil { - return nil, fmt.Errorf("run deno app: %w", err) - } - - if result == nil { - return map[string]string{}, nil - } - - yamlOutput, err := convertRenderResultToYAML(result) - if err != nil { - return nil, fmt.Errorf("convert render result to yaml: %w", err) - } - - if strings.TrimSpace(yamlOutput) == "" { - return map[string]string{}, nil - } - - return map[string]string{ - path.Join(common.ChartTSSourceDir, entrypoint): yamlOutput, - }, nil -} - -func buildDenoVendorBundle(ctx context.Context, tsRootDir string) error { +func BuildVendorBundle(ctx context.Context, chartPath string) error { denoBin, ok := os.LookupEnv("DENO_BIN") if !ok || denoBin == "" { denoBin = "deno" } - cmd := exec.CommandContext(ctx, denoBin, "run", "-A", "build.ts") - cmd.Dir = tsRootDir + cmd := exec.CommandContext(ctx, denoBin, "run", "-A", common.ChartTSBuildScript) + cmd.Dir = chartPath + "/" + common.ChartTSSourceDir cmd.Stdout = os.Stdout if err := cmd.Run(); err != nil { @@ -99,7 +36,7 @@ func buildDenoVendorBundle(ctx context.Context, tsRootDir string) error { return nil } -func runDenoApp(ctx context.Context, tsRootDir string, useVendorMap bool, entryPoint string, renderCtx map[string]any) (map[string]interface{}, error) { +func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint string, renderCtx map[string]any) (map[string]interface{}, error) { denoBin, ok := os.LookupEnv("DENO_BIN") if !ok || denoBin == "" { denoBin = "deno" @@ -113,7 +50,7 @@ func runDenoApp(ctx context.Context, tsRootDir string, useVendorMap bool, entryP args = append(args, entryPoint) cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = tsRootDir + cmd.Dir = chartPath + "/" + common.ChartTSSourceDir cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() @@ -155,14 +92,14 @@ func runDenoApp(ctx context.Context, tsRootDir string, useVendorMap bool, entryP return "", errors.New("render output not found") } - jsonString, errJson := waitForJSONString() + jsonString, errJSON := waitForJSONString() if err := cmd.Wait(); err != nil { return nil, fmt.Errorf("wait process: %w", err) } - if errJson != nil { - return nil, fmt.Errorf("wait for render output: %w", errJson) + if errJSON != nil { + return nil, fmt.Errorf("wait for render output: %w", errJSON) } if jsonString == "" { diff --git a/internal/ts/esbuild.go b/internal/ts/esbuild.go deleted file mode 100644 index fc2baec1..00000000 --- a/internal/ts/esbuild.go +++ /dev/null @@ -1,222 +0,0 @@ -package ts - -import ( - "encoding/json" - "fmt" - "path" - "strings" - - esbuild "github.com/evanw/esbuild/pkg/api" -) - -// virtualFSResolver resolves imports from in-memory file map. -type virtualFSResolver struct { - files map[string][]byte -} - -func (r *virtualFSResolver) load(filePath string) (esbuild.OnLoadResult, error) { - content, ok := r.files[filePath] - if !ok { - return esbuild.OnLoadResult{}, fmt.Errorf("load file: %s not found", filePath) - } - - s := string(content) - - return esbuild.OnLoadResult{ - Contents: &s, - Loader: esbuildLoaderFromPath(filePath), - ResolveDir: path.Dir(filePath), - }, nil -} - -func (r *virtualFSResolver) resolve(basePath string) string { - if _, ok := r.files[basePath]; ok { - return basePath - } - - for _, ext := range []string{".ts", ".tsx", ".js", ".jsx", ".json"} { - if _, ok := r.files[basePath+ext]; ok { - return basePath + ext - } - } - - for _, ext := range []string{".ts", ".js"} { - p := path.Join(basePath, "index"+ext) - if _, ok := r.files[p]; ok { - return p - } - } - - return "" -} - -func (r *virtualFSResolver) resolvePackageImport(importPath string) string { - pkgName, subPath := parsePackageImport(importPath) - if subPath != "" { - return r.resolve(path.Join("node_modules", pkgName, subPath)) - } - - pkgPath := path.Join("node_modules", pkgName) - if pkgJSON, ok := r.files[path.Join(pkgPath, "package.json")]; ok { - var pkg struct { - Main string `json:"main"` - Module string `json:"module"` - } - if json.Unmarshal(pkgJSON, &pkg) == nil { - for _, entry := range []string{pkg.Main, pkg.Module} { - if entry != "" { - if resolved := r.resolve(path.Join(pkgPath, entry)); resolved != "" { - return resolved - } - } - } - } - } - - return r.resolve(pkgPath) -} - -func runEsbuildBundle(opts esbuild.BuildOptions) (string, error) { - result := esbuild.Build(opts) - if len(result.Errors) > 0 { - return "", formatEsbuildErrors(result.Errors) - } - - if len(result.OutputFiles) == 0 { - return "", fmt.Errorf("run esbuild bundle: no output files") - } - - return string(result.OutputFiles[0].Contents), nil -} - -func esbuildLoaderFromPath(filePath string) esbuild.Loader { - switch strings.ToLower(path.Ext(filePath)) { - case ".ts", ".tsx": - return esbuild.LoaderTS - case ".jsx": - return esbuild.LoaderJSX - case ".json": - return esbuild.LoaderJSON - default: - return esbuild.LoaderJS - } -} - -func formatEsbuildErrors(errors []esbuild.Message) error { - if len(errors) == 0 { - return nil - } - - var sb strings.Builder - sb.WriteString("TypeScript transpilation failed:\n") - - for _, msg := range errors { - if loc := msg.Location; loc != nil { - fmt.Fprintf(&sb, " %s:%d:%d: %s\n", loc.File, loc.Line, loc.Column, msg.Text) - - if loc.LineText != "" { - fmt.Fprintf(&sb, " %s\n %s^\n", loc.LineText, strings.Repeat(" ", loc.Column)) - } - } else { - fmt.Fprintf(&sb, " %s\n", msg.Text) - } - - for _, note := range msg.Notes { - fmt.Fprintf(&sb, " Note: %s\n", note.Text) - } - } - - return fmt.Errorf("%s", sb.String()) -} - -func newEsbuildOptions() esbuild.BuildOptions { - return esbuild.BuildOptions{ - Bundle: true, - Write: false, - Platform: esbuild.PlatformNode, - Format: esbuild.FormatCommonJS, - Target: esbuild.ES2015, - } -} - -func newVendorEsbuildOptions(packages []string, resolveDir string) esbuild.BuildOptions { - return esbuild.BuildOptions{ - Stdin: &esbuild.StdinOptions{ - Contents: generateVendorEntrypoint(packages), - ResolveDir: resolveDir, - Loader: esbuild.LoaderJS, - }, - Bundle: true, - Write: false, - Platform: esbuild.PlatformNode, - Format: esbuild.FormatIIFE, - Target: esbuild.ES2015, - GlobalName: "__NELM_VENDOR_BUNDLE__", - } -} - -func newVirtualFSPlugin(files map[string][]byte, resolveNodeModules bool) esbuild.Plugin { - r := &virtualFSResolver{ - files: 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 == "" { - if resolved := r.resolve(args.Path); resolved != "" { - return esbuild.OnResolveResult{Path: resolved, Namespace: "virtual"}, nil - } - } - - if !strings.HasPrefix(args.Path, ".") && !strings.HasPrefix(args.Path, "/") { - if !resolveNodeModules { - return esbuild.OnResolveResult{}, nil - } - - if resolved := r.resolvePackageImport(args.Path); resolved != "" { - return esbuild.OnResolveResult{Path: resolved, Namespace: "virtual"}, nil - } - - return esbuild.OnResolveResult{}, fmt.Errorf("resolve import: cannot resolve package %q", args.Path) - } - - baseDir := args.ResolveDir - if args.Importer != "" { - baseDir = path.Dir(args.Importer) - } - - resolvedPath := strings.TrimPrefix(path.Clean(path.Join(baseDir, args.Path)), "./") - if resolved := r.resolve(resolvedPath); resolved != "" { - return esbuild.OnResolveResult{Path: resolved, 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) - }) - }, - } -} - -func parsePackageImport(importPath string) (pkgName, subPath string) { - parts := strings.SplitN(importPath, "/", 2) - if strings.HasPrefix(parts[0], "@") && len(parts) > 1 { - subparts := strings.SplitN(parts[1], "/", 2) - - pkgName = parts[0] + "/" + subparts[0] - if len(subparts) > 1 { - subPath = subparts[1] - } - } else { - pkgName = parts[0] - if len(parts) > 1 { - subPath = parts[1] - } - } - - return pkgName, subPath -} diff --git a/internal/ts/export_test.go b/internal/ts/export_test.go index 9a1b49a4..6e747f31 100644 --- a/internal/ts/export_test.go +++ b/internal/ts/export_test.go @@ -1,10 +1,3 @@ package ts -var ( - ExtractPackageNames = extractPackageNames - ExtractPackagesFromVendorBundle = extractPackagesFromVendorBundle - GenerateVendorEntrypoint = generateVendorEntrypoint - RenderFiles = renderFiles - ResolveVendorBundle = resolveVendorBundle - ScopeValuesForSubchart = scopeValuesForSubchart -) +var ScopeValuesForSubchart = scopeValuesForSubchart diff --git a/internal/ts/files.go b/internal/ts/files.go index 46c5fa11..889b8216 100644 --- a/internal/ts/files.go +++ b/internal/ts/files.go @@ -4,7 +4,6 @@ import ( "strings" helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/3p-helm/pkg/werf/file" "github.com/werf/nelm/pkg/common" ) @@ -28,24 +27,3 @@ func findEntrypointInFiles(files map[string][]byte) string { return "" } - -func filterTSFiles(files []*file.ChartExtenderBufferedFile) map[string][]byte { - result := make(map[string][]byte) - for _, f := range files { - if strings.HasPrefix(f.Name, common.ChartTSSourceDir) { - result[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data - } - } - - return result -} - -func hasNodeModules(files map[string][]byte) bool { - for name := range files { - if strings.HasPrefix(name, "node_modules/") { - return true - } - } - - return false -} diff --git a/internal/ts/init.go b/internal/ts/init.go index 600de61e..3357e3d0 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -50,7 +50,7 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error // Handle .helmignore: create or enrich helmignorePath := filepath.Join(chartPath, ".helmignore") - if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/dist/"}); err != nil { + if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/vendor/"}); err != nil { return fmt.Errorf("ensure helmignore entries: %w", err) } @@ -79,7 +79,7 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { {content: deploymentTSContent, path: filepath.Join(srcDir, "deployment.ts")}, {content: serviceTSContent, path: filepath.Join(srcDir, "service.ts")}, {content: tsconfigContent, path: filepath.Join(tsDir, "tsconfig.json")}, - {content: packageJSON(chartName), path: filepath.Join(tsDir, "package.json")}, + {content: denoJSONTmpl, path: filepath.Join(tsDir, "deno.json")}, } if err := os.MkdirAll(srcDir, 0o755); err != nil { diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 2ad99976..3273e9e6 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -8,7 +8,7 @@ name: %s version: 0.1.0 ` deploymentTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; -import { getFullname, getLabels, getSelectorLabels } from './helpers'; +import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; export function newDeployment($: RenderContext): object { const name = getFullname($); @@ -53,7 +53,7 @@ export function newDeployment($: RenderContext): object { # negation (prefixed with !). Only one pattern per line. # TypeScript chart files -ts/dist/ +ts/vendor/ ` helpersTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; @@ -97,11 +97,11 @@ export function getSelectorLabels($: RenderContext): Record { }; } ` - indexTSContent = `import type { RenderContext, RenderResult } from '@nelm/chart-ts-sdk'; -import { newDeployment } from './deployment'; -import { newService } from './service'; + indexTSContent = `import { RenderContext, RenderResult, runRender } from '@nelm/chart-ts-sdk'; +import { newDeployment } from './deployment.ts'; +import { newService } from './service.ts'; -export function render($: RenderContext): RenderResult { +function render($: RenderContext): RenderResult { const manifests: object[] = []; manifests.push(newDeployment($)); @@ -112,33 +112,20 @@ export function render($: RenderContext): RenderResult { return { manifests }; } + +await runRender(render); ` - packageJSONTmpl = `{ - "name": "%s", - "version": "0.1.0", - "description": "TypeScript chart for %s", - "main": "src/index.ts", - "scripts": { - "build": "npx tsc", - "typecheck": "npx tsc --noEmit" - }, - "keywords": [ - "helm", - "nelm", - "kubernetes", - "chart" - ], - "license": "Apache-2.0", - "dependencies": { - "@nelm/chart-ts-sdk": "^0.1.2" - }, - "devDependencies": { - "typescript": "^5.0.0" + denoJSONTmpl = `{ + "nodeModulesDir": "manual", + "vendor": true, + "imports": { + "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2", + "esbuild-wasm": "npm:esbuild-wasm@0.25.0" } } ` serviceTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; -import { getFullname, getLabels, getSelectorLabels } from './helpers'; +import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; export function newService($: RenderContext): object { return { @@ -175,10 +162,14 @@ export function newService($: RenderContext): object { "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "outDir": "./dist" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist" + ] } ` valuesYamlContent = `replicaCount: 1 @@ -197,7 +188,3 @@ service: func chartYaml(chartName string) string { return fmt.Sprintf(chartYamlTmpl, chartName) } - -func packageJSON(chartName string) string { - return fmt.Sprintf(packageJSONTmpl, chartName, chartName) -} diff --git a/internal/ts/package.go b/internal/ts/package.go deleted file mode 100644 index dd94cb2d..00000000 --- a/internal/ts/package.go +++ /dev/null @@ -1,89 +0,0 @@ -package ts - -import ( - "encoding/json" - "fmt" - "path/filepath" - "regexp" - "sort" - "strings" -) - -func extractPackageNames(metafileJSON string) ([]string, error) { - var meta struct { - Inputs map[string]json.RawMessage `json:"inputs"` - } - 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 { - normalizedPath := filepath.ToSlash(strings.TrimPrefix(inputPath, "virtual:")) - if strings.HasPrefix(normalizedPath, "node_modules/") { - normalizedPath = "/" + normalizedPath - } - - nodeModulesIdx := strings.LastIndex(normalizedPath, "/node_modules/") - if nodeModulesIdx == -1 { - continue - } - - parts := strings.Split(strings.TrimPrefix(normalizedPath[nodeModulesIdx:], "/node_modules/"), "/") - if len(parts) < 2 { - continue - } - - if strings.HasPrefix(parts[0], "@") { - if len(parts) >= 3 { - pkgSet[parts[0]+"/"+parts[1]] = struct{}{} - } - } else { - pkgSet[parts[0]] = 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 sb strings.Builder - sb.WriteString("var __NELM_VENDOR__ = {};\n") - - for _, pkg := range packages { - fmt.Fprintf(&sb, "__NELM_VENDOR__['%s'] = require('%s');\n", pkg, pkg) - } - - sb.WriteString("if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; }\n") - sb.WriteString("if (typeof exports !== 'undefined') { exports.__NELM_VENDOR__ = __NELM_VENDOR__; }\n") - - return sb.String() -} diff --git a/internal/ts/package_test.go b/internal/ts/package_test.go deleted file mode 100644 index 13b089fd..00000000 --- a/internal/ts/package_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package ts_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/werf/nelm/internal/ts" -) - -func TestExtractPackageNames(t *testing.T) { - t.Run("extracts regular packages", func(t *testing.T) { - 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 := ts.ExtractPackageNames(metafile) - require.NoError(t, err) - assert.ElementsMatch(t, []string{"axios", "lodash"}, packages) - }) - - t.Run("extracts scoped packages", func(t *testing.T) { - 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 := ts.ExtractPackageNames(metafile) - require.NoError(t, err) - assert.ElementsMatch(t, []string{"@types/node", "@babel/core"}, packages) - }) - - t.Run("returns empty for no node_modules", func(t *testing.T) { - metafile := `{ - "inputs": { - "src/index.ts": {"bytes": 500}, - "src/helpers.ts": {"bytes": 200} - } - }` - - packages, err := ts.ExtractPackageNames(metafile) - require.NoError(t, err) - assert.Empty(t, packages) - }) - - t.Run("handles nested node_modules paths (pnpm style)", func(t *testing.T) { - metafile := `{ - "inputs": { - "virtual:node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js": {"bytes": 100} - } - }` - - packages, err := ts.ExtractPackageNames(metafile) - require.NoError(t, err) - assert.ElementsMatch(t, []string{"lodash"}, packages) - }) -} - -func TestExtractPackagesFromVendorBundle(t *testing.T) { - t.Run("extracts package names", func(t *testing.T) { - bundle := ` - var __NELM_VENDOR__ = {}; - __NELM_VENDOR__['lodash'] = require('lodash'); - __NELM_VENDOR__['axios'] = require('axios'); - __NELM_VENDOR__['@types/node'] = require('@types/node'); - ` - - packages := ts.ExtractPackagesFromVendorBundle(bundle) - assert.ElementsMatch(t, []string{"lodash", "axios", "@types/node"}, packages) - }) - - t.Run("returns empty for no packages", func(t *testing.T) { - bundle := ` - var __NELM_VENDOR__ = {}; - if (typeof global !== 'undefined') { global.__NELM_VENDOR__ = __NELM_VENDOR__; } - ` - - packages := ts.ExtractPackagesFromVendorBundle(bundle) - assert.Empty(t, packages) - }) -} - -func TestGenerateVendorEntrypoint(t *testing.T) { - t.Run("generates correct entrypoint", func(t *testing.T) { - packages := []string{"lodash", "axios"} - entry := ts.GenerateVendorEntrypoint(packages) - - assert.Contains(t, entry, "var __NELM_VENDOR__ = {};") - assert.Contains(t, entry, "__NELM_VENDOR__['lodash'] = require('lodash');") - assert.Contains(t, entry, "__NELM_VENDOR__['axios'] = require('axios');") - assert.Contains(t, entry, "global.__NELM_VENDOR__ = __NELM_VENDOR__") - }) - - t.Run("handles empty package list", func(t *testing.T) { - entry := ts.GenerateVendorEntrypoint([]string{}) - - assert.Contains(t, entry, "var __NELM_VENDOR__ = {};") - assert.NotContains(t, entry, "require(") - }) -} diff --git a/internal/ts/render.go b/internal/ts/render.go index ee922470..6ddc0d9f 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -3,7 +3,6 @@ package ts import ( "context" "fmt" - "os" "path" "slices" "strings" @@ -16,25 +15,20 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildVendor bool) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildVendor bool, chartPath string) (map[string]string, error) { allRendered := make(map[string]string) - wd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("get current working directory: %w", err) - } - - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), wd, allRendered, rebuildVendor); err != nil { + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, rebuildVendor); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartDir string, results map[string]string, rebuildVendor bool) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, rebuildVendor bool) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderDenoFiles(ctx, chart, values, chartDir, rebuildVendor) + rendered, err := renderDenoFiles(ctx, chart, values, chartPath, rebuildVendor) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -54,7 +48,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch dep, scopeValuesForSubchart(values, depName, dep), path.Join(pathPrefix, "charts", depName), - path.Join(chartDir, "charts", depName), + path.Join(chartPath, "charts", depName), results, rebuildVendor, ) @@ -66,13 +60,29 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -// TODO: remove after finish the Deno implementation -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildVendor bool) (map[string]string, error) { mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) - vendorBundle, packages, err := resolveVendorBundle(ctx, mergedFiles) - if err != nil { - return nil, fmt.Errorf("resolve vendor bundle: %w", err) + var ( + hasNodeModules bool + useVendorMap bool + vendorFiles []*helmchart.File + ) + for _, file := range mergedFiles { + if strings.HasPrefix(file.Name, common.ChartTSSourceDir+"node_modules/") { + hasNodeModules = true + } else if strings.HasPrefix(file.Name, common.ChartTSVendorBundleDir) { + vendorFiles = append(vendorFiles, file) + } else if file.Name == common.ChartTSSourceDir+common.ChartTSVendorMap { + useVendorMap = true + } + } + + if hasNodeModules && (rebuildVendor || len(vendorFiles) == 0) { + err := BuildVendorBundle(ctx, chartPath) + if err != nil { + return nil, fmt.Errorf("build deno vendor bundle: %w", err) + } } sourceFiles := extractSourceFiles(mergedFiles) @@ -85,14 +95,9 @@ func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues cha return map[string]string{}, nil } - appBundle, err := buildAppBundleFromFiles(ctx, sourceFiles, packages) - if err != nil { - return nil, fmt.Errorf("build app bundle: %w", err) - } - - result, err := runBundle(vendorBundle, appBundle, buildRenderContext(renderedValues, chart)) + result, err := runApp(ctx, chartPath, useVendorMap, entrypoint, buildRenderContext(renderedValues, chart)) if err != nil { - return nil, fmt.Errorf("run bundle: %w", err) + return nil, fmt.Errorf("run deno app: %w", err) } if result == nil { diff --git a/internal/ts/render_ai_test.go b/internal/ts/render_ai_test.go deleted file mode 100644 index 00978535..00000000 --- a/internal/ts/render_ai_test.go +++ /dev/null @@ -1,2028 +0,0 @@ -//go:build ai_tests - -package ts_test - -import ( - "context" - "strings" - "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" - "github.com/werf/nelm/internal/ts" - "github.com/werf/nelm/pkg/common" -) - -// ============================================================================= -// Context Object Completeness -// ============================================================================= - -func TestAI_ContextCompleteness(t *testing.T) { - t.Run("Release object fields", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const r = ctx.Release; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'release-info' }, - data: { - name: r.Name, - namespace: r.Namespace, - isUpgrade: String(r.IsUpgrade || false), - isInstall: String(r.IsInstall || false), - revision: String(r.Revision || 1), - service: r.Service || 'Helm' - } - }] - }; -} -`)}}, - } - values := chartutil.Values{ - "Release": map[string]any{ - "Name": "myrelease", - "Namespace": "mynamespace", - "IsUpgrade": true, - "IsInstall": false, - "Revision": 3, - "Service": "Helm", - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: myrelease") - assert.Contains(t, yaml, "namespace: mynamespace") - assert.Contains(t, yaml, "isUpgrade: \"true\"") - assert.Contains(t, yaml, "isInstall: \"false\"") - assert.Contains(t, yaml, "revision: \"3\"") - }) - - t.Run("Chart object fields", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "mychart", - Version: "1.2.3", - AppVersion: "4.5.6", - Description: "My awesome chart", - Home: "https://example.com", - Icon: "https://example.com/icon.png", - Keywords: []string{"web", "app"}, - Type: "application", - }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const c = ctx.Chart; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'chart-info' }, - data: { - name: c.Name, - version: c.Version, - appVersion: c.AppVersion || '', - description: c.Description || '', - type: c.Type || 'application' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: mychart") - assert.Contains(t, yaml, "version: 1.2.3") - assert.Contains(t, yaml, "appVersion: 4.5.6") - assert.Contains(t, yaml, "description: My awesome chart") - }) - - t.Run("Capabilities object", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const caps = ctx.Capabilities || {}; - const kube = caps.KubeVersion || {}; - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'caps-info' }, - data: { - kubeVersion: kube.Version || 'unknown', - kubeMajor: kube.Major || '', - kubeMinor: kube.Minor || '', - helmVersion: caps.HelmVersion?.Version || 'v3' - } - }] - }; -} -`)}}, - } - values := chartutil.Values{ - "Capabilities": map[string]any{ - "KubeVersion": map[string]any{ - "Version": "v1.28.0", - "Major": "1", - "Minor": "28", - }, - "HelmVersion": map[string]any{ - "Version": "v3.14.0", - }, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "kubeVersion: v1.28.0") - assert.Contains(t, yaml, "kubeMajor: \"1\"") - assert.Contains(t, yaml, "kubeMinor: \"28\"") - }) - - t.Run("Values with complex nesting", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const v = ctx.Values; - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'values-test' }, - spec: { - replicas: v.replicas, - template: { - spec: { - containers: [{ - name: 'app', - image: v.image.repository + ':' + v.image.tag, - ports: v.ports.map((p: any) => ({ containerPort: p })), - env: Object.entries(v.env || {}).map(([k, val]) => ({ name: k, value: val })) - }] - } - } - } - }] - }; -} -`)}}, - } - values := chartutil.Values{ - "Values": map[string]any{ - "replicas": 3, - "image": map[string]any{ - "repository": "nginx", - "tag": "1.21", - }, - "ports": []any{80, 443}, - "env": map[string]any{ - "LOG_LEVEL": "info", - "DEBUG": "false", - }, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "replicas: 3") - assert.Contains(t, yaml, "image: nginx:1.21") - assert.Contains(t, yaml, "containerPort: 80") - assert.Contains(t, yaml, "name: LOG_LEVEL") - }) - - t.Run("Files object access", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - Files: []*chart.File{ - {Name: "config/app.properties", Data: []byte("key1=value1\nkey2=value2")}, - {Name: "scripts/init.sh", Data: []byte("#!/bin/bash\necho hello")}, - }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const files = ctx.Files || {}; - const fileList = Object.keys(files); - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'files-test' }, - data: { - fileCount: String(fileList.length), - files: fileList.join(',') - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "fileCount:") - }) - - t.Run("Template object (current template info)", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const tpl = ctx.Template || {}; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'template-info' }, - data: { - name: tpl.Name || 'unknown', - basePath: tpl.BasePath || '' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{ - "Template": map[string]any{ - "Name": "test/templates/configmap.yaml", - "BasePath": "test/templates", - }, - }) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "name:") - }) -} - -// ============================================================================= -// Error Messages and Debugging -// ============================================================================= - -func TestAI_ErrorMessages(t *testing.T) { - t.Run("syntax error includes file name", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { manifests: [{ // missing closing -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "index.ts") - }) - - t.Run("runtime error includes source location", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const obj: any = null; - return obj.property.nested; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "index.ts") - }) - - t.Run("missing render function has clear message", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function notRender(ctx: any) { - return { manifests: [] }; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "render") - }) - - t.Run("type error in helper file includes correct file", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import { broken } from './helpers'; -export function render(ctx: any) { - return { manifests: [broken()] }; -} -`)}, - {Name: "ts/src/helpers.ts", Data: []byte(` -export function broken() { - const x: any = null; - return x.foo.bar; -} -`)}, - }, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - errStr := err.Error() - assert.True(t, strings.Contains(errStr, "helpers.ts") || strings.Contains(errStr, "index.ts"), - "error should reference source file: %s", errStr) - }) - - t.Run("undefined variable error is descriptive", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { manifests: [{ name: undefinedVariable }] }; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "undefinedVariable") - }) - - t.Run("subchart error includes chart path", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "failing-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - throw new Error("intentional failure"); -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(subchart) - - values := chartutil.Values{ - "Values": map[string]any{"failing-sub": map[string]any{}}, - "Release": map[string]any{"Name": "test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - _, err := ts.RenderChart(context.Background(), root, values) - require.Error(t, err) - assert.Contains(t, err.Error(), "failing-sub") - }) - - t.Run("thrown Error object message is preserved", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - if (!ctx.Values.required) { - throw new Error("required value is missing"); - } - return { manifests: [] }; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{"Values": map[string]any{}}) - require.Error(t, err) - assert.Contains(t, err.Error(), "required value is missing") - }) - - t.Run("thrown string is captured", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - throw "string error message"; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "string error message") - }) -} - -// ============================================================================= -// Import/Export Patterns -// ============================================================================= - -func TestAI_ImportExportPatterns(t *testing.T) { - t.Run("re-exports from barrel file", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import { createDeployment, createService } from './resources'; -export function render(ctx: any) { - return { - manifests: [ - createDeployment(ctx.Release.Name), - createService(ctx.Release.Name) - ] - }; -} -`)}, - {Name: "ts/src/resources/index.ts", Data: []byte(` -export { createDeployment } from './deployment'; -export { createService } from './service'; -`)}, - {Name: "ts/src/resources/deployment.ts", Data: []byte(` -export function createDeployment(name: string) { - return { apiVersion: 'apps/v1', kind: 'Deployment', metadata: { name: name + '-deploy' } }; -} -`)}, - {Name: "ts/src/resources/service.ts", Data: []byte(` -export function createService(name: string) { - return { apiVersion: 'v1', kind: 'Service', metadata: { name: name + '-svc' } }; -} -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "barrel-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: barrel-test-deploy") - assert.Contains(t, yaml, "name: barrel-test-svc") - }) - - t.Run("default export", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import config from './config'; -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'default-export' }, - data: config - }] - }; -} -`)}, - {Name: "ts/src/config.ts", Data: []byte(` -export default { - key1: 'value1', - key2: 'value2' -}; -`)}, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "key1: value1") - assert.Contains(t, yaml, "key2: value2") - }) - - t.Run("mixed default and named exports", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import Builder, { VERSION, helper } from './builder'; -export function render(ctx: any) { - const b = new Builder(ctx.Release.Name); - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: b.getName() }, - data: { version: VERSION, extra: helper() } - }] - }; -} -`)}, - {Name: "ts/src/builder.ts", Data: []byte(` -export const VERSION = '1.0.0'; -export function helper() { return 'helped'; } -export default class Builder { - constructor(private name: string) {} - getName() { return this.name + '-built'; } -} -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "mixed-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: mixed-test-built") - assert.Contains(t, yaml, "version: 1.0.0") - assert.Contains(t, yaml, "extra: helped") - }) - - t.Run("import with alias", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import { createConfigMap as cm, createSecret as sec } from './helpers'; -export function render(ctx: any) { - return { manifests: [cm('my-cm'), sec('my-secret')] }; -} -`)}, - {Name: "ts/src/helpers.ts", Data: []byte(` -export function createConfigMap(name: string) { - return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name } }; -} -export function createSecret(name: string) { - return { apiVersion: 'v1', kind: 'Secret', metadata: { name } }; -} -`)}, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "kind: ConfigMap") - assert.Contains(t, yaml, "name: my-cm") - assert.Contains(t, yaml, "kind: Secret") - assert.Contains(t, yaml, "name: my-secret") - }) - - t.Run("namespace import", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import * as utils from './utils'; -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: utils.formatName(ctx.Release.Name) }, - data: { labels: JSON.stringify(utils.defaultLabels) } - }] - }; -} -`)}, - {Name: "ts/src/utils.ts", Data: []byte(` -export function formatName(name: string) { - return name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); -} -export const defaultLabels = { managed: 'true', source: 'ts' }; -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "My_App"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: my-app") - }) - - t.Run("circular imports between helpers", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import { createA } from './a'; -export function render(ctx: any) { - return { manifests: [createA('test')] }; -} -`)}, - {Name: "ts/src/a.ts", Data: []byte(` -import { formatB } from './b'; -export function createA(name: string) { - return { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: formatB(name) } }; -} -export function formatA(s: string) { return 'A-' + s; } -`)}, - {Name: "ts/src/b.ts", Data: []byte(` -import { formatA } from './a'; -export function formatB(s: string) { return 'B-' + formatA(s); } -`)}, - }, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: B-A-test") - }) - - t.Run("deep nested imports", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -import { render as doRender } from './lib/core/render'; -export function render(ctx: any) { return doRender(ctx); } -`)}, - {Name: "ts/src/lib/core/render.ts", Data: []byte(` -import { getMetadata } from '../utils/metadata'; -export function render(ctx: any) { - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: getMetadata(ctx) }] }; -} -`)}, - {Name: "ts/src/lib/utils/metadata.ts", Data: []byte(` -import { formatName } from '../../helpers/format'; -export function getMetadata(ctx: any) { return { name: formatName(ctx.Release.Name) }; } -`)}, - {Name: "ts/src/helpers/format.ts", Data: []byte(` -export function formatName(name: string) { return name + '-formatted'; } -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "deep"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: deep-formatted") - }) -} - -func TestAI_RenderChartWithDependencies_ChartMetadata(t *testing.T) { - t.Run("subchart receives correct Chart metadata", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "my-subchart", - Version: "2.3.4", - AppVersion: "1.2.3", - Description: "A test subchart", - Keywords: []string{"test", "subchart"}, - }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { - name: 'meta-cm', - labels: { - 'chart': ctx.Chart.Name + '-' + ctx.Chart.Version, - 'app.kubernetes.io/version': ctx.Chart.AppVersion - } - }, - data: { - chartName: ctx.Chart.Name, - chartVersion: ctx.Chart.Version, - appVersion: ctx.Chart.AppVersion, - description: ctx.Chart.Description - } - }] - }; -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(subchart) - - values := chartutil.Values{ - "Values": map[string]any{"my-subchart": map[string]any{}}, - "Release": map[string]any{"Name": "meta-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - yaml := result["root/charts/my-subchart/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "chartName: my-subchart") - assert.Contains(t, yaml, "chartVersion: 2.3.4") - assert.Contains(t, yaml, "appVersion: 1.2.3") - assert.Contains(t, yaml, "description: A test subchart") - assert.Contains(t, yaml, "chart: my-subchart-2.3.4") - assert.Contains(t, yaml, "app.kubernetes.io/version: 1.2.3") - }) -} - -func TestAI_RenderChartWithDependencies_ConditionalSubcharts(t *testing.T) { - t.Run("subchart conditionally renders based on enabled flag", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "optional-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - if (!ctx.Values.enabled) { - return null; - } - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'optional-cm' }, - data: { enabled: 'true' } - }] - }; -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'root-cm' }, - data: { always: 'present' } - }] - }; -} -`)}}, - } - root.SetDependencies(subchart) - - // Test with subchart disabled - valuesDisabled := chartutil.Values{ - "Values": map[string]any{ - "optional-sub": map[string]any{"enabled": false}, - }, - "Release": map[string]any{"Name": "conditional-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, valuesDisabled) - require.NoError(t, err) - - // Root should be rendered, subchart should return empty - assert.Contains(t, result, "root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result["root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "always: present") - - // Subchart returns null, so it should be empty - subPath := "root/charts/optional-sub/" + common.ChartTSSourceDir + common.ChartTSEntryPointTS - if yaml, exists := result[subPath]; exists { - assert.Empty(t, yaml) - } - - // Test with subchart enabled - valuesEnabled := chartutil.Values{ - "Values": map[string]any{ - "optional-sub": map[string]any{"enabled": true}, - }, - "Release": map[string]any{"Name": "conditional-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result2, err := ts.RenderChart(context.Background(), root, valuesEnabled) - require.NoError(t, err) - - assert.Contains(t, result2["root/charts/optional-sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "enabled: \"true\"") - }) - - t.Run("multiple conditional subcharts with different states", func(t *testing.T) { - makeConditionalChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - if (!ctx.Values.enabled) return null; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: '` + name + `-cm' }, - data: { component: '` + name + `' } - }] - }; -} -`)}}, - } - } - - redis := makeConditionalChart("redis") - postgres := makeConditionalChart("postgres") - mongodb := makeConditionalChart("mongodb") - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "app", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'app-cm' } }] }; }`)}}, - } - root.SetDependencies(redis, postgres, mongodb) - - values := chartutil.Values{ - "Values": map[string]any{ - "redis": map[string]any{"enabled": true}, - "postgres": map[string]any{"enabled": false}, - "mongodb": map[string]any{"enabled": true}, - }, - "Release": map[string]any{"Name": "multi-db", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - // App always renders - assert.Contains(t, result, "app/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - - // Redis enabled - assert.Contains(t, result["app/charts/redis/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "component: redis") - - // Postgres disabled - should be empty or not contain component - postgresYaml := result["app/charts/postgres/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Empty(t, postgresYaml) - - // MongoDB enabled - assert.Contains(t, result["app/charts/mongodb/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "component: mongodb") - }) -} - -// ============================================================================= -// Subchart Scenarios -// ============================================================================= - -func TestAI_RenderChartWithDependencies_DeepNesting(t *testing.T) { - t.Run("4 levels deep subchart hierarchy", func(t *testing.T) { - level4 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level4", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level4-cm' }, data: { depth: '4', chart: ctx.Chart.Name } }] }; }`)}}, - } - level3 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level3", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level3-cm' }, data: { depth: '3', chart: ctx.Chart.Name } }] }; }`)}}, - } - level3.SetDependencies(level4) - - level2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level2", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level2-cm' }, data: { depth: '2', chart: ctx.Chart.Name } }] }; }`)}}, - } - level2.SetDependencies(level3) - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root-cm' }, data: { depth: '1', chart: ctx.Chart.Name } }] }; }`)}}, - } - root.SetDependencies(level2) - - values := chartutil.Values{ - "Values": map[string]any{ - "level2": map[string]any{ - "level3": map[string]any{ - "level4": map[string]any{}, - }, - }, - }, - "Release": map[string]any{"Name": "deep-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 4) - assert.Contains(t, result, "root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "root/charts/level2/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "root/charts/level2/charts/level3/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "root/charts/level2/charts/level3/charts/level4/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - - assert.Contains(t, result["root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "depth: \"1\"") - assert.Contains(t, result["root/charts/level2/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "depth: \"2\"") - assert.Contains(t, result["root/charts/level2/charts/level3/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "depth: \"3\"") - assert.Contains(t, result["root/charts/level2/charts/level3/charts/level4/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "depth: \"4\"") - }) - - t.Run("5 levels with values propagated correctly", func(t *testing.T) { - makeChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: ctx.Chart.Name + '-cm' }, - data: { - chart: ctx.Chart.Name, - message: ctx.Values.message || 'no-message', - release: ctx.Release.Name - } - }] - }; -} -`)}}, - } - } - - l5 := makeChart("l5") - l4 := makeChart("l4") - l4.SetDependencies(l5) - l3 := makeChart("l3") - l3.SetDependencies(l4) - l2 := makeChart("l2") - l2.SetDependencies(l3) - root := makeChart("root") - root.SetDependencies(l2) - - values := chartutil.Values{ - "Values": map[string]any{ - "message": "root-msg", - "l2": map[string]any{ - "message": "l2-msg", - "l3": map[string]any{ - "message": "l3-msg", - "l4": map[string]any{ - "message": "l4-msg", - "l5": map[string]any{ - "message": "l5-msg", - }, - }, - }, - }, - }, - "Release": map[string]any{"Name": "deep-values", "Namespace": "test"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 5) - assert.Contains(t, result["root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "message: root-msg") - assert.Contains(t, result["root/charts/l2/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "message: l2-msg") - assert.Contains(t, result["root/charts/l2/charts/l3/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "message: l3-msg") - assert.Contains(t, result["root/charts/l2/charts/l3/charts/l4/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "message: l4-msg") - assert.Contains(t, result["root/charts/l2/charts/l3/charts/l4/charts/l5/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "message: l5-msg") - }) -} - -func TestAI_RenderChartWithDependencies_GlobalValues(t *testing.T) { - t.Run("global values available to all subcharts", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const global = ctx.Values.global || {}; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'sub-cm' }, - data: { - imageRegistry: global.imageRegistry || 'default-registry', - imagePullPolicy: global.imagePullPolicy || 'IfNotPresent' - } - }] - }; -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const global = ctx.Values.global || {}; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'root-cm' }, - data: { - imageRegistry: global.imageRegistry || 'default-registry', - imagePullPolicy: global.imagePullPolicy || 'IfNotPresent' - } - }] - }; -} -`)}}, - } - root.SetDependencies(subchart) - - values := chartutil.Values{ - "Values": map[string]any{ - "global": map[string]any{ - "imageRegistry": "my-registry.io", - "imagePullPolicy": "Always", - }, - "sub": map[string]any{ - "global": map[string]any{ - "imageRegistry": "my-registry.io", - "imagePullPolicy": "Always", - }, - }, - }, - "Release": map[string]any{"Name": "global-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 2) - assert.Contains(t, result["root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "imageRegistry: my-registry.io") - assert.Contains(t, result["root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "imagePullPolicy: Always") - assert.Contains(t, result["root/charts/sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "imageRegistry: my-registry.io") - assert.Contains(t, result["root/charts/sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "imagePullPolicy: Always") - }) - - t.Run("global values in nested subcharts", func(t *testing.T) { - makeGlobalAwareChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const global = ctx.Values.global || {}; - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: ctx.Chart.Name + '-cm' }, - data: { - environment: global.environment || 'unknown', - chart: ctx.Chart.Name - } - }] - }; -} -`)}}, - } - } - - leaf := makeGlobalAwareChart("leaf") - middle := makeGlobalAwareChart("middle") - middle.SetDependencies(leaf) - root := makeGlobalAwareChart("root") - root.SetDependencies(middle) - - globalVals := map[string]any{"environment": "production"} - - values := chartutil.Values{ - "Values": map[string]any{ - "global": globalVals, - "middle": map[string]any{ - "global": globalVals, - "leaf": map[string]any{ - "global": globalVals, - }, - }, - }, - "Release": map[string]any{"Name": "nested-global", "Namespace": "prod"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 3) - for _, yaml := range result { - assert.Contains(t, yaml, "environment: production") - } - }) -} - -func TestAI_RenderChartWithDependencies_MixedTSAndNonTS(t *testing.T) { - t.Run("TS root with non-TS subchart", func(t *testing.T) { - nonTSSubchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - - tsRoot := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'ts-root-cm' }, - data: { source: 'typescript' } - }] - }; -} -`)}}, - } - tsRoot.SetDependencies(nonTSSubchart) - - values := chartutil.Values{ - "Values": map[string]any{"classic-sub": map[string]any{}}, - "Release": map[string]any{"Name": "mixed", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), tsRoot, values) - require.NoError(t, err) - - assert.Len(t, result, 1) - assert.Contains(t, result, "ts-root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result["ts-root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "source: typescript") - }) - - t.Run("non-TS root with TS subchart", func(t *testing.T) { - tsSubchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'ts-sub-cm' }, - data: { source: 'typescript-subchart' } - }] - }; -} -`)}}, - } - - classicRoot := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - classicRoot.SetDependencies(tsSubchart) - - values := chartutil.Values{ - "Values": map[string]any{"ts-sub": map[string]any{}}, - "Release": map[string]any{"Name": "mixed", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), classicRoot, values) - require.NoError(t, err) - - assert.Len(t, result, 1) - assert.Contains(t, result, "classic-root/charts/ts-sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result["classic-root/charts/ts-sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "source: typescript-subchart") - }) - - t.Run("alternating TS and non-TS in deep hierarchy", func(t *testing.T) { - level4 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l4-ts", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l4' }, data: { type: 'ts' } }] }; }`)}}, - } - - level3 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l3-classic", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - level3.SetDependencies(level4) - - level2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l2-ts", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l2' }, data: { type: 'ts' } }] }; }`)}}, - } - level2.SetDependencies(level3) - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root-classic", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(level2) - - values := chartutil.Values{ - "Values": map[string]any{ - "l2-ts": map[string]any{ - "l3-classic": map[string]any{ - "l4-ts": map[string]any{}, - }, - }, - }, - "Release": map[string]any{"Name": "alt", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 2) - assert.Contains(t, result, "root-classic/charts/l2-ts/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "root-classic/charts/l2-ts/charts/l3-classic/charts/l4-ts/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - }) -} - -func TestAI_RenderChartWithDependencies_SiblingSubcharts(t *testing.T) { - t.Run("multiple sibling subcharts at same level", func(t *testing.T) { - frontend := &chart.Chart{ - Metadata: &chart.Metadata{Name: "frontend", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'frontend' }, - spec: { replicas: ctx.Values.replicas || 1 } - }] - }; -} -`)}}, - } - - backend := &chart.Chart{ - Metadata: &chart.Metadata{Name: "backend", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'backend' }, - spec: { replicas: ctx.Values.replicas || 1 } - }] - }; -} -`)}}, - } - - worker := &chart.Chart{ - Metadata: &chart.Metadata{Name: "worker", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'worker' }, - spec: { replicas: ctx.Values.replicas || 1 } - }] - }; -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "myapp", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(frontend, backend, worker) - - values := chartutil.Values{ - "Values": map[string]any{ - "frontend": map[string]any{"replicas": 3}, - "backend": map[string]any{"replicas": 2}, - "worker": map[string]any{"replicas": 5}, - }, - "Release": map[string]any{"Name": "myapp", "Namespace": "production"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - assert.Len(t, result, 3) - assert.Contains(t, result["myapp/charts/frontend/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "replicas: 3") - assert.Contains(t, result["myapp/charts/backend/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "replicas: 2") - assert.Contains(t, result["myapp/charts/worker/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "replicas: 5") - }) - - t.Run("sibling subcharts with independent errors do not affect each other", func(t *testing.T) { - goodChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "good", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'good-cm' } }] }; -} -`)}}, - } - - badChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "bad", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const x: any = null; - return x.boom; // This will throw -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(goodChart, badChart) - - values := chartutil.Values{ - "Values": map[string]any{"good": map[string]any{}, "bad": map[string]any{}}, - "Release": map[string]any{"Name": "test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - _, err := ts.RenderChart(context.Background(), root, values) - require.Error(t, err) - assert.Contains(t, err.Error(), "bad") - }) -} - -func TestAI_RenderChartWithDependencies_ValueOverrides(t *testing.T) { - t.Run("parent overrides subchart default values", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'sub-deploy' }, - spec: { - replicas: ctx.Values.replicas || 1, - template: { - spec: { - containers: [{ - image: ctx.Values.image || 'default:latest', - resources: ctx.Values.resources || { limits: { cpu: '100m' } } - }] - } - } - } - }] - }; -} -`)}}, - } - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(subchart) - - values := chartutil.Values{ - "Values": map[string]any{ - "sub": map[string]any{ - "replicas": 5, - "image": "my-image:v2", - "resources": map[string]any{ - "limits": map[string]any{ - "cpu": "500m", - "memory": "512Mi", - }, - }, - }, - }, - "Release": map[string]any{"Name": "override-test", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - yaml := result["root/charts/sub/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "replicas: 5") - assert.Contains(t, yaml, "image: my-image:v2") - assert.Contains(t, yaml, "cpu: 500m") - assert.Contains(t, yaml, "memory: 512Mi") - }) - - t.Run("nested value overrides through multiple levels", func(t *testing.T) { - leaf := &chart.Chart{ - Metadata: &chart.Metadata{Name: "leaf", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'leaf-cm' }, - data: { - setting1: ctx.Values.setting1 || 'default1', - setting2: ctx.Values.setting2 || 'default2', - nested: JSON.stringify(ctx.Values.nested || {}) - } - }] - }; -} -`)}}, - } - - middle := &chart.Chart{ - Metadata: &chart.Metadata{Name: "middle", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - middle.SetDependencies(leaf) - - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, - } - root.SetDependencies(middle) - - values := chartutil.Values{ - "Values": map[string]any{ - "middle": map[string]any{ - "leaf": map[string]any{ - "setting1": "overridden1", - "setting2": "overridden2", - "nested": map[string]any{ - "deep": "value", - }, - }, - }, - }, - "Release": map[string]any{"Name": "nested-override", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), root, values) - require.NoError(t, err) - - yaml := result["root/charts/middle/charts/leaf/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "setting1: overridden1") - assert.Contains(t, yaml, "setting2: overridden2") - assert.Contains(t, yaml, `"deep":"value"`) - }) -} - -// ============================================================================= -// TypeScript Language Features -// ============================================================================= - -func TestAI_TypeScriptFeatures(t *testing.T) { - t.Run("generic functions compile and work", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -function identity(arg: T): T { - return arg; -} - -function createResource(resource: T): T { - return resource; -} - -export function render(ctx: any) { - const name = identity(ctx.Release.Name); - const cm = createResource({ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name } }); - return { manifests: [cm] }; -} -`)}}, - } - values := chartutil.Values{"Release": map[string]any{"Name": "generic-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "name: generic-test") - }) - - t.Run("enums compile and work", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -enum ResourceType { - ConfigMap = "ConfigMap", - Secret = "Secret", - Deployment = "Deployment" -} - -const enum Protocol { - TCP = "TCP", - UDP = "UDP" -} - -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: ResourceType.ConfigMap, - metadata: { name: 'enum-test' }, - data: { protocol: Protocol.TCP } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "kind: ConfigMap") - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "protocol: TCP") - }) - - t.Run("type unions and intersections", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -type StringOrNumber = string | number; -type Named = { name: string }; -type Versioned = { version: string }; -type NamedAndVersioned = Named & Versioned; - -export function render(ctx: any) { - const replicas: StringOrNumber = ctx.Values.replicas || 1; - const meta: NamedAndVersioned = { name: 'test', version: '1.0' }; - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: meta.name }, - data: { replicas: String(replicas), version: meta.version } - }] - }; -} -`)}}, - } - values := chartutil.Values{"Values": map[string]any{"replicas": 3}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "replicas: \"3\"") - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "version: \"1.0\"") - }) - - t.Run("optional chaining and nullish coalescing", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const nested = ctx.Values?.deeply?.nested?.value ?? 'default'; - const port = ctx.Values.port ?? 8080; - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'optional-test' }, - data: { nested, port: String(port) } - }] - }; -} -`)}}, - } - values := chartutil.Values{"Values": map[string]any{}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "nested: default") - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "port: \"8080\"") - }) - - t.Run("class with methods", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -class ResourceBuilder { - private name: string; - - constructor(name: string) { - this.name = name; - } - - buildConfigMap(data: Record) { - return { - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: this.name }, - data - }; - } -} - -export function render(ctx: any) { - const builder = new ResourceBuilder(ctx.Release.Name + '-config'); - return { manifests: [builder.buildConfigMap({ key: 'value' })] }; -} -`)}}, - } - values := chartutil.Values{"Release": map[string]any{"Name": "class-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "name: class-test-config") - assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "key: value") - }) - - t.Run("spread operator", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const baseLabels = { app: 'myapp', version: '1.0' }; - const extraLabels = ctx.Values.extraLabels || {}; - - const merged = { ...baseLabels, ...extraLabels }; - - const resources = [ - { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'cm1' } }, - { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'cm2' } } - ]; - const additional = ctx.Values.additional || []; - - return { manifests: [...resources, ...additional].map(r => ({ ...r, metadata: { ...r.metadata, labels: merged } })) }; -} -`)}}, - } - values := chartutil.Values{"Values": map[string]any{"extraLabels": map[string]any{"env": "prod"}}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "app: myapp") - assert.Contains(t, yaml, "env: prod") - }) - - t.Run("destructuring assignment", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const { Name: releaseName, Namespace: namespace } = ctx.Release; - const { replicas = 1, image = 'nginx:latest' } = ctx.Values; - const [first, second = 'default'] = ctx.Values.items || ['item1']; - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: releaseName, namespace }, - data: { replicas: String(replicas), image, first, second } - }] - }; -} -`)}}, - } - values := chartutil.Values{ - "Release": map[string]any{"Name": "destruct-test", "Namespace": "myns"}, - "Values": map[string]any{"replicas": 5}, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: destruct-test") - assert.Contains(t, yaml, "namespace: myns") - assert.Contains(t, yaml, "replicas: \"5\"") - assert.Contains(t, yaml, "first: item1") - assert.Contains(t, yaml, "second: default") - }) - - t.Run("template literals", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "mychart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - const { Name, Namespace } = ctx.Release; - const fullName = ` + "`${Name}-${ctx.Chart.Name}`" + `; - const fqdn = ` + "`${Name}.${Namespace}.svc.cluster.local`" + `; - - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: fullName }, - data: { fqdn } - }] - }; -} -`)}}, - } - values := chartutil.Values{ - "Release": map[string]any{"Name": "myrelease", "Namespace": "mynamespace"}, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: myrelease-mychart") - assert.Contains(t, yaml, "fqdn: myrelease.mynamespace.svc.cluster.local") - }) - - t.Run("async/await is not supported at top level", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export async function render(ctx: any) { - await Promise.resolve(); - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'async-test' } }] }; -} -`)}}, - } - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - - // Async render should either fail or return a promise (which won't be valid) - // The exact behavior depends on implementation - if err == nil { - t.Log("async render compiled but may not work as expected") - } - }) -} - -// ============================================================================= -// YAML Output Edge Cases -// ============================================================================= - -func TestAI_YAMLOutput(t *testing.T) { - t.Run("special characters are escaped", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'special-chars' }, - data: { - colon: 'value: with colon', - hash: 'value # with hash', - quotes: "it's a \"quoted\" value", - ampersand: 'foo & bar', - asterisk: '*.example.com', - question: 'is this ok?', - pipe: 'cmd | grep', - brackets: '[item1, item2]', - braces: '{key: value}' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "colon:") - assert.Contains(t, yaml, "hash:") - assert.Contains(t, yaml, "quotes:") - }) - - t.Run("multiline strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'multiline' }, - data: { - script: 'line1\nline2\nline3', - config: 'key1=value1\nkey2=value2' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "script:") - assert.Contains(t, yaml, "config:") - }) - - t.Run("null and undefined values", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'nulls' }, - data: { - nullValue: null, - undefinedValue: undefined, - emptyString: '', - zero: 0, - falseValue: false - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "emptyString:") - assert.Contains(t, yaml, "zero: 0") - assert.Contains(t, yaml, "falseValue: false") - }) - - t.Run("empty objects and arrays", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { - name: 'empties', - labels: {}, - annotations: {} - }, - data: {} - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: empties") - assert.Contains(t, yaml, "labels:") - assert.Contains(t, yaml, "data:") - }) - - t.Run("numeric strings stay as strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'numeric-strings' }, - data: { - port: '8080', - version: '1.0', - zipcode: '02134', - phone: '555-1234', - scientific: '1e10' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - - // These should remain as strings, not be converted to numbers - assert.Contains(t, yaml, "port:") - assert.Contains(t, yaml, "zipcode:") - }) - - t.Run("boolean-like strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'bool-strings' }, - data: { - yesString: 'yes', - noString: 'no', - trueString: 'true', - falseString: 'false', - onString: 'on', - offString: 'off' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "yesString:") - assert.Contains(t, yaml, "noString:") - }) - - t.Run("deeply nested objects", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { name: 'deep' }, - spec: { - template: { - spec: { - containers: [{ - name: 'app', - resources: { - limits: { - cpu: '100m', - memory: '128Mi' - }, - requests: { - cpu: '50m', - memory: '64Mi' - } - }, - env: [ - { name: 'VAR1', value: 'val1' }, - { name: 'VAR2', valueFrom: { secretKeyRef: { name: 'secret', key: 'key' } } } - ] - }] - } - } - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "cpu: 100m") - assert.Contains(t, yaml, "memory: 128Mi") - assert.Contains(t, yaml, "name: VAR1") - assert.Contains(t, yaml, "secretKeyRef:") - }) - - t.Run("array of primitives", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'arrays' }, - data: { - hosts: ['host1.example.com', 'host2.example.com', 'host3.example.com'].join(','), - ports: [80, 443, 8080].map(String).join(',') - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "host1.example.com") - assert.Contains(t, yaml, "80,443,8080") - }) - - t.Run("unicode characters", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` -export function render(ctx: any) { - return { - manifests: [{ - apiVersion: 'v1', - kind: 'ConfigMap', - metadata: { name: 'unicode' }, - data: { - japanese: '日本語', - emoji: '🚀', - chinese: '中文', - arabic: 'العربية', - mixed: 'Hello 世界 🌍' - } - }] - }; -} -`)}}, - } - - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "japanese:") - assert.Contains(t, yaml, "emoji:") - }) -} diff --git a/internal/ts/render_test.go b/internal/ts/render_test.go deleted file mode 100644 index 4aec2657..00000000 --- a/internal/ts/render_test.go +++ /dev/null @@ -1,529 +0,0 @@ -package ts_test - -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" - "github.com/werf/nelm/internal/ts" - "github.com/werf/nelm/pkg/common" -) - -func TestRenderChartWithDependencies(t *testing.T) { - t.Run("root and subchart both rendered", func(t *testing.T) { - rootChart := createTestChartWithSubchart( - `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' } }] }; -}`, - `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' } }] }; -}`, - ) - - values := chartutil.Values{ - "Values": map[string]any{ - "rootMessage": "Hello from root", - "ts-subchart": map[string]any{"subMessage": "Hello from subchart"}, - }, - "Release": map[string]any{"Name": "test-release", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), rootChart, values) - require.NoError(t, err) - - assert.Len(t, result, 2) - - rootPath := "root-chart/" + common.ChartTSSourceDir + common.ChartTSEntryPointTS - assert.Contains(t, result, rootPath) - assert.Contains(t, result[rootPath], "name: root-config") - assert.Contains(t, result[rootPath], "chartName: root-chart") - assert.Contains(t, result[rootPath], "message: Hello from root") - - subPath := "root-chart/charts/ts-subchart/" + common.ChartTSSourceDir + common.ChartTSEntryPointTS - assert.Contains(t, result, subPath) - assert.Contains(t, result[subPath], "name: subchart-config") - assert.Contains(t, result[subPath], "chartName: ts-subchart") - assert.Contains(t, result[subPath], "message: Hello from subchart") - }) - - t.Run("classic root with TS subchart only", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{ - {Name: "ts/src/index.ts", Data: []byte(` -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 } }] }; -} -`)}, - }, - } - rootChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, // No TypeScript in root - } - rootChart.SetDependencies(subchart) - - values := chartutil.Values{ - "Values": map[string]any{"ts-subchart": map[string]any{}}, - "Release": map[string]any{"Name": "my-release", "Namespace": "default"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), rootChart, values) - require.NoError(t, err) - - assert.Len(t, result, 1) - - subPath := "classic-root/charts/ts-subchart/" + common.ChartTSSourceDir + common.ChartTSEntryPointTS - assert.Contains(t, result, subPath) - assert.Contains(t, result[subPath], "name: ts-subchart-only") - assert.Contains(t, result[subPath], "releaseName: my-release") - }) - - t.Run("nested dependencies (3 levels)", func(t *testing.T) { - sub2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub2", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub2' }, data: { level: 'sub2' } }] }; }`)}}, - } - sub1 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub1", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub1' }, data: { level: 'sub1' } }] }; }`)}}, - } - 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(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root' }, data: { level: 'root' } }] }; }`)}}, - } - rootChart.SetDependencies(sub1) - - values := chartutil.Values{ - "Values": map[string]any{"sub1": map[string]any{"sub2": map[string]any{}}}, - "Release": map[string]any{"Name": "test"}, - "Capabilities": map[string]any{}, - } - - result, err := ts.RenderChart(context.Background(), rootChart, values) - require.NoError(t, err) - - assert.Len(t, result, 3) - assert.Contains(t, result, "nested-root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "nested-root/charts/sub1/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - assert.Contains(t, result, "nested-root/charts/sub1/charts/sub2/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS) - - assert.Contains(t, result["nested-root/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "level: root") - assert.Contains(t, result["nested-root/charts/sub1/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "level: sub1") - assert.Contains(t, result["nested-root/charts/sub1/charts/sub2/"+common.ChartTSSourceDir+common.ChartTSEntryPointTS], "level: sub2") - }) - - t.Run("subchart error includes chart name", func(t *testing.T) { - rootChart := createTestChartWithSubchart( - `export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root' } }] }; }`, - `export function render(c: any) { const x: any = null; x.foo.bar; return { manifests: [] }; }`, - ) - - values := chartutil.Values{ - "Values": map[string]any{"ts-subchart": map[string]any{}}, - "Release": map[string]any{"Name": "test"}, - "Capabilities": map[string]any{}, - } - - _, err := ts.RenderChart(context.Background(), rootChart, values) - require.Error(t, err) - assert.Contains(t, err.Error(), "ts-subchart") - }) -} - -func TestRenderFiles(t *testing.T) { - t.Run("no TypeScript source returns empty", func(t *testing.T) { - ch := newTestChart(nil) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - assert.Empty(t, result) - }) - - t.Run("simple manifest", func(t *testing.T) { - ch := newChartWithTS(` -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) } - }] - }; -} -`) - values := chartutil.Values{ - "Values": map[string]any{"replicas": 3}, - "Release": map[string]any{"Name": "test-release", "Namespace": "default"}, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - require.Len(t, result, 1) - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - 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"`) - }) - - t.Run("multiple resources with separator", func(t *testing.T) { - ch := newChartWithTS(` -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' } } - ] - }; -} -`) - values := chartutil.Values{"Release": map[string]any{"Name": "test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "kind: Service") - assert.Contains(t, yaml, "kind: Deployment") - assert.Contains(t, yaml, "---") - }) - - t.Run("null result returns empty", func(t *testing.T) { - ch := newChartWithTS(` -export function render(context: any) { - if (!context.Values.enabled) return null; - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'test' } }] }; -} -`) - values := chartutil.Values{"Values": map[string]any{"enabled": false}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - assert.Empty(t, result) - }) - - t.Run("module.exports.render pattern", func(t *testing.T) { - ch := newChartWithTS(` -module.exports.render = function(context: any) { - return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'module-exports-test' } }] }; -}; -`) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: module-exports-test") - }) - - t.Run("module.exports object pattern", func(t *testing.T) { - ch := newChartWithTS(` -module.exports = { - render: function(context: any) { - return { manifests: [{ apiVersion: 'v1', kind: 'Secret', metadata: { name: 'object-pattern-test' } }] }; - } -}; -`) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "kind: Secret") - assert.Contains(t, yaml, "name: object-pattern-test") - }) - - t.Run("arrow function and array methods", func(t *testing.T) { - ch := newChartWithTS(` -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 }; -}; -`) - values := chartutil.Values{"Release": map[string]any{"Name": "my-app"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: my-app-config-1") - assert.Contains(t, yaml, "name: my-app-config-2") - assert.Contains(t, yaml, "name: my-app-config-3") - }) - - t.Run("TypeScript interfaces compile correctly", func(t *testing.T) { - ch := newChartWithTS(` -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] }; -} -`) - values := chartutil.Values{ - "Release": map[string]any{"Name": "typed-app", "Namespace": "production"}, - "Values": map[string]any{"replicas": 5}, - } - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "kind: Deployment") - assert.Contains(t, yaml, "name: typed-app") - assert.Contains(t, yaml, "replicas: 5") - }) - - t.Run("multiple files with imports", func(t *testing.T) { - ch := 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' } - }; -} -`, - }) - values := chartutil.Values{"Release": map[string]any{"Name": "multi-file-app"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: multi-file-app-config") - assert.Contains(t, yaml, "source: helper-function") - }) - - t.Run("error when render function missing", func(t *testing.T) { - ch := newChartWithTS(`export function notRender(context: any) { return { manifests: [] }; }`) - - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "no 'render' function exported") - }) - - t.Run("runtime error shows source location", func(t *testing.T) { - ch := newChartWithTS(` -export function render(context: any) { - const obj: any = null; - obj.nonExistentProperty; - return { manifests: [] }; -} -`) - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "index.ts") - }) - - t.Run("vendor bundle provides npm dependencies", func(t *testing.T) { - 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__ }; -})(); -` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ - {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, - {Name: "ts/src/index.ts", Data: []byte(` -const fakeLib = require('fake-lib'); -export function render(context: any) { - return { manifests: [fakeLib.helper(context.Release.Name)] }; -} -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "vendor-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: vendor-test-from-vendor") - }) - - t.Run("node_modules in RuntimeDepsFiles", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.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: []*chart.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' } }; -}; -`)}, - }, - } - values := chartutil.Values{"Release": map[string]any{"Name": "npm-test"}} - - result, err := ts.RenderFiles(context.Background(), ch, values) - require.NoError(t, err) - - yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] - assert.Contains(t, yaml, "name: npm-test-from-npm") - }) -} - -func TestScopeValuesForSubchart(t *testing.T) { - t.Run("scopes values correctly", func(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]any{ - "rootKey": "rootValue", - "my-subchart": map[string]any{ - "subKey": "subValue", - "nested": map[string]any{"deep": "value"}, - }, - }, - "Release": map[string]any{"Name": "test-release", "Namespace": "prod"}, - "Capabilities": map[string]any{"KubeVersion": map[string]any{"Version": "v1.28.0"}}, - } - - scoped := ts.ScopeValuesForSubchart(parentValues, "my-subchart", subchart) - - // Release copied - assert.Equal(t, "test-release", scoped["Release"].(map[string]any)["Name"]) - assert.Equal(t, "prod", scoped["Release"].(map[string]any)["Namespace"]) - - // Capabilities copied - assert.NotNil(t, scoped["Capabilities"]) - - // Chart metadata from subchart - chartMeta := scoped["Chart"].(map[string]any) - assert.Equal(t, "my-subchart", chartMeta["Name"]) - assert.Equal(t, "2.0.0", chartMeta["Version"]) - assert.Equal(t, "1.5.0", chartMeta["AppVersion"]) - - // Values scoped to subchart - scopedValues := scoped["Values"].(map[string]any) - assert.Equal(t, "subValue", scopedValues["subKey"]) - assert.Equal(t, "value", scopedValues["nested"].(map[string]any)["deep"]) - assert.Nil(t, scopedValues["rootKey"]) - - // Files from subchart - assert.NotNil(t, scoped["Files"]) - }) - - t.Run("missing subchart values returns empty map", func(t *testing.T) { - subchart := &chart.Chart{Metadata: &chart.Metadata{Name: "missing-values-subchart", Version: "1.0.0"}} - parentValues := chartutil.Values{ - "Values": map[string]any{"other-subchart": map[string]any{"key": "value"}}, - "Release": map[string]any{"Name": "test"}, - } - - scoped := ts.ScopeValuesForSubchart(parentValues, "missing-values-subchart", subchart) - - assert.NotNil(t, scoped["Values"]) - assert.Empty(t, scoped["Values"]) - }) -} - -func createChartWithTSFiles(files map[string]string) *chart.Chart { - var runtimeFiles []*chart.File - for name, content := range files { - runtimeFiles = append(runtimeFiles, &chart.File{Name: "ts/" + name, Data: []byte(content)}) - } - - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: runtimeFiles, - } -} - -func createTestChartWithSubchart(rootContent, subchartContent string) *chart.Chart { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(subchartContent)}}, - } - rootChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(rootContent)}}, - } - rootChart.SetDependencies(subchart) - - return rootChart -} - -func newChartWithTS(sourceContent string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(sourceContent)}}, - } -} - -// Test helpers - -func newTestChart(files map[string]string) *chart.Chart { - var runtimeFiles []*chart.File - for name, content := range files { - runtimeFiles = append(runtimeFiles, &chart.File{Name: name, Data: []byte(content)}) - } - - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: runtimeFiles, - } -} diff --git a/internal/ts/runtime.go b/internal/ts/runtime.go deleted file mode 100644 index e8aa9943..00000000 --- a/internal/ts/runtime.go +++ /dev/null @@ -1,136 +0,0 @@ -package ts - -import ( - "fmt" - - "github.com/dop251/goja" - "github.com/dop251/goja_nodejs/console" - "github.com/dop251/goja_nodejs/require" -) - -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 runBundle(vendorBundle, appBundle string, renderCtx map[string]any) (any, 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("execute vendor bundle: %w", formatJSError(vm, err, "vendor/libs.js")) - } - } - - requireFn, err := vm.RunString(requireShim) - if err != nil { - return nil, fmt.Errorf("execute require shim: %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("execute app bundle: %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("run bundle: no exports") - } - - renderFn := moduleExports.ToObject(vm).Get("render") - if renderFn == nil || goja.IsUndefined(renderFn) || goja.IsNull(renderFn) { - return nil, fmt.Errorf("run bundle: no 'render' function exported") - } - - callable, ok := goja.AssertFunction(renderFn) - if !ok { - return nil, fmt.Errorf("run bundle: 'render' export is not a function") - } - - result, err := callable(goja.Undefined(), vm.ToValue(renderCtx)) - if err != nil { - return nil, fmt.Errorf("call render function: %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 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) - } - - setupConsole(vm) - - return vm, 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) -} - -func setupConsole(runtime *goja.Runtime) { - registry := require.NewRegistry() - registry.Enable(runtime) - - console.Enable(runtime) -} diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go new file mode 100644 index 00000000..6cbff742 --- /dev/null +++ b/pkg/action/chart_ts_build.go @@ -0,0 +1,40 @@ +package action + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/werf/nelm/internal/ts" + "github.com/werf/nelm/pkg/featgate" + "github.com/werf/nelm/pkg/log" +) + +type ChartTSBuildOptions struct { + ChartDirPath string +} + +func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { + chartPath := opts.ChartDirPath + if chartPath == "" { + chartPath = "." + } + + absPath, err := filepath.Abs(chartPath) + if err != nil { + return fmt.Errorf("get absolute path: %w", err) + } + + 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 := ts.BuildVendorBundle(ctx, absPath); err != nil { + return fmt.Errorf("build TypeScript vendor bundle: %w", err) + } + + log.Default.Info(ctx, "Built vendor for TypeScript chart in %s", absPath) + + return nil +} diff --git a/pkg/action/chart_init.go b/pkg/action/chart_ts_init.go similarity index 83% rename from pkg/action/chart_init.go rename to pkg/action/chart_ts_init.go index d3ebb057..6820f9cd 100644 --- a/pkg/action/chart_init.go +++ b/pkg/action/chart_ts_init.go @@ -11,13 +11,12 @@ import ( "github.com/werf/nelm/pkg/log" ) -type ChartInitOptions struct { +type ChartTSInitOptions struct { ChartDirPath string - TS bool - TempDirPath string + TempDirPath string // TODO: remove this? } -func ChartInit(ctx context.Context, opts ChartInitOptions) error { +func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { chartPath := opts.ChartDirPath if chartPath == "" { chartPath = "." @@ -30,10 +29,6 @@ func ChartInit(ctx context.Context, opts ChartInitOptions) error { 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") diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index f06f2ae8..7ffc82e8 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -163,8 +163,8 @@ type ReleaseInstallOptions struct { // Timeout is the maximum duration for the entire release installation operation. // If 0, no timeout is applied and the operation runs until completion or error. Timeout time.Duration - // RebuildTsVendorBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. - RebuildTsVendorBundle bool + // RebuildTSVendorBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. + RebuildTSVendorBundle bool } func ReleaseInstall(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseInstallOptions) error { @@ -345,7 +345,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re Remote: true, SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, - RebuildTsVendorBundle: opts.RebuildTsVendorBundle, + RebuildTSVendorBundle: opts.RebuildTSVendorBundle, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/common/common.go b/pkg/common/common.go index 750d6d39..0dbf89d2 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -28,6 +28,8 @@ const ( ChartTSSourceDir = "ts/" // ChartTSVendorBundleDir is the path to the vendor bundle dir in a Helm chart. ChartTSVendorBundleDir = ChartTSSourceDir + "dist/vendor/" + // ChartTSBuildScript is the path to the vendor build script. + ChartTSBuildScript = "node_modules/@nelm/chart-ts-sdk/dist/build.js" // ChartTSVendorMap is the path to the deno import mapping for the vendor bundle. ChartTSVendorMap = "dist/vendor_map.json" // ChartTSEntryPointTS is the TypeScript entry point path. @@ -36,8 +38,6 @@ const ( ChartTSEntryPointJS = "src/index.js" // ChartTSRenderResultPrefix is the prefix for the rendered output. ChartTSRenderResultPrefix = "NELM_RENDER_RESULT:" - // TODO: remove after deno implementation is ready, and use ChartTSVendorBundleDir instead - ChartTSVendorBundleFile = ChartTSSourceDir + "vendor/libs.js" ) // ChartTSEntryPoints defines supported TypeScript/JavaScript entry points (in priority order). From deb99607765bd9dad071f579247a0bbb268a3b40 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 17 Feb 2026 14:15:00 +0300 Subject: [PATCH 03/30] wip: fixes for init ts test Signed-off-by: Dmitry Mordvinov --- internal/ts/init_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index 0f37afd4..83807d22 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -133,7 +133,7 @@ func TestInitChartStructure(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) require.NoError(t, err) - assert.Contains(t, string(content), "ts/dist/") + assert.Contains(t, string(content), "ts/vendor/") }) t.Run("skips existing Chart.yaml", func(t *testing.T) { @@ -180,7 +180,7 @@ func TestInitChartStructure(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(content), ".DS_Store") assert.Contains(t, string(content), ".git/") - assert.Contains(t, string(content), "ts/dist/") + assert.Contains(t, string(content), "ts/vendor/") }) } @@ -200,7 +200,7 @@ func TestInitTSBoilerplate(t *testing.T) { // Check ts/ root files assert.FileExists(t, filepath.Join(chartPath, "ts", "tsconfig.json")) - assert.FileExists(t, filepath.Join(chartPath, "ts", "package.json")) + assert.FileExists(t, filepath.Join(chartPath, "ts", "deno.json")) }) t.Run("creates correct directory structure", func(t *testing.T) { @@ -214,17 +214,18 @@ func TestInitTSBoilerplate(t *testing.T) { assert.DirExists(t, filepath.Join(chartPath, "ts", "src")) }) - t.Run("substitutes chart name in package.json", func(t *testing.T) { + t.Run("includes correct deno.json config", func(t *testing.T) { chartPath := filepath.Join(t.TempDir(), "my-custom-chart") require.NoError(t, os.MkdirAll(chartPath, 0o755)) err := ts.InitTSBoilerplate(context.Background(), chartPath, "my-custom-chart") require.NoError(t, err) - content, err := os.ReadFile(filepath.Join(chartPath, "ts", "package.json")) + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "deno.json")) require.NoError(t, err) - assert.Contains(t, string(content), `"name": "my-custom-chart"`) - assert.Contains(t, string(content), `"description": "TypeScript chart for my-custom-chart"`) + assert.Contains(t, string(content), `"nodeModulesDir": "manual"`) + assert.Contains(t, string(content), `"vendor": true`) + assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) t.Run("includes render function in index.ts", func(t *testing.T) { @@ -236,9 +237,10 @@ func TestInitTSBoilerplate(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, "ts", "src", "index.ts")) require.NoError(t, err) - assert.Contains(t, string(content), "export function render") + assert.Contains(t, string(content), "function render") assert.Contains(t, string(content), "RenderContext") assert.Contains(t, string(content), "RenderResult") + assert.Contains(t, string(content), "runRender") }) t.Run("includes helper functions in helpers.ts", func(t *testing.T) { @@ -278,7 +280,7 @@ func TestInitTSBoilerplate(t *testing.T) { err := ts.InitTSBoilerplate(context.Background(), chartPath, "test-chart") require.NoError(t, err) - content, err := os.ReadFile(filepath.Join(chartPath, "ts", "package.json")) + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "deno.json")) require.NoError(t, err) assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) From ced72e24496bd18e7faabeda9262767882d54b9a Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 18 Feb 2026 00:36:41 +0300 Subject: [PATCH 04/30] wip: `deno task build` in deno.json Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_init.go | 3 ++- internal/ts/init.go | 2 +- internal/ts/init_templates.go | 8 +++++++- internal/ts/init_test.go | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/nelm/chart_init.go b/cmd/nelm/chart_init.go index 8e65ed07..a7689b90 100644 --- a/cmd/nelm/chart_init.go +++ b/cmd/nelm/chart_init.go @@ -19,7 +19,8 @@ type chartInitConfig struct { LogLevel string } -func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +// newChartInitCommand +func _(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { cfg := &chartInitConfig{} cmd := cli.NewSubCommand( diff --git a/internal/ts/init.go b/internal/ts/init.go index 3357e3d0..c3c5e3d7 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -79,7 +79,7 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { {content: deploymentTSContent, path: filepath.Join(srcDir, "deployment.ts")}, {content: serviceTSContent, path: filepath.Join(srcDir, "service.ts")}, {content: tsconfigContent, path: filepath.Join(tsDir, "tsconfig.json")}, - {content: denoJSONTmpl, path: filepath.Join(tsDir, "deno.json")}, + {content: denoJSON(common.ChartTSBuildScript), path: filepath.Join(tsDir, "deno.json")}, } if err := os.MkdirAll(srcDir, 0o755); err != nil { diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 3273e9e6..f30403d7 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -117,7 +117,9 @@ await runRender(render); ` denoJSONTmpl = `{ "nodeModulesDir": "manual", - "vendor": true, + "tasks": { + "build": "deno run -A %s" + }, "imports": { "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2", "esbuild-wasm": "npm:esbuild-wasm@0.25.0" @@ -188,3 +190,7 @@ service: func chartYaml(chartName string) string { return fmt.Sprintf(chartYamlTmpl, chartName) } + +func denoJSON(scriptPath string) string { + return fmt.Sprintf(denoJSONTmpl, scriptPath) +} diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index 83807d22..51c0621d 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/werf/nelm/internal/ts" + "github.com/werf/nelm/pkg/common" ) func TestEnsureGitignore(t *testing.T) { @@ -224,7 +225,7 @@ func TestInitTSBoilerplate(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, "ts", "deno.json")) require.NoError(t, err) assert.Contains(t, string(content), `"nodeModulesDir": "manual"`) - assert.Contains(t, string(content), `"vendor": true`) + assert.Contains(t, string(content), fmt.Sprintf(`"build": "deno run -A %s`, common.ChartTSBuildScript)) assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) From 38f495baf20451055dc1a5d90ae5a66c5fb90747 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 18 Feb 2026 00:37:27 +0300 Subject: [PATCH 05/30] wip: missing import Signed-off-by: Dmitry Mordvinov --- internal/ts/init_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index 51c0621d..fdc5d498 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -2,6 +2,7 @@ package ts_test import ( "context" + "fmt" "os" "path/filepath" "testing" From 0cde3ee2c79de5f1d86cf1be06dbbd47428deb27 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 18 Feb 2026 22:16:25 +0300 Subject: [PATCH 06/30] wip: deno bundle implementation Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 2 +- cmd/nelm/release_install.go | 2 +- go.mod | 10 +--- go.sum | 15 +---- internal/chart/chart_render.go | 8 +-- internal/ts/deno.go | 104 ++++++++++++++++++++++++++------- internal/ts/files.go | 18 ++++++ internal/ts/init_templates.go | 6 +- internal/ts/init_test.go | 3 +- internal/ts/render.go | 78 +++++++++++++++---------- pkg/action/chart_ts_build.go | 6 +- pkg/action/release_install.go | 6 +- pkg/common/common.go | 8 +-- 13 files changed, 171 insertions(+), 95 deletions(-) diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 5a7dff92..e5e65ceb 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -38,7 +38,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co if featgate.FeatGateTypescript.Enabled() { for _, chartPath := range args { - if err := ts.BuildVendorBundle(ctx, chartPath); err != nil { + if err := ts.BuildBundleToFile(ctx, chartPath); err != nil { return fmt.Errorf("build TypeScript vendor bundle in %q: %w", chartPath, err) } } diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 8d1ecd83..eb4b0b80 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -313,7 +313,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTSVendorBundle, "rebuild-ts-vendor", false, "Rebuild the Deno vendor bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: miscFlagGroup, }); err != nil { diff --git a/go.mod b/go.mod index 408e2973..6cace435 100644 --- a/go.mod +++ b/go.mod @@ -13,10 +13,8 @@ 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/dustin/go-humanize v1.0.1 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/go-resty/resty/v2 v2.17.1 github.com/goccy/go-yaml v1.15.23 @@ -31,8 +29,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 github.com/moby/term v0.5.0 github.com/ohler55/ojg v1.26.7 - github.com/onsi/ginkgo/v2 v2.20.1 - github.com/onsi/gomega v1.36.0 github.com/pkg/errors v0.9.1 github.com/samber/lo v1.49.1 github.com/sirupsen/logrus v1.9.3 @@ -98,15 +94,12 @@ 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/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect @@ -176,7 +169,6 @@ require ( golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.35.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa // indirect google.golang.org/grpc v1.62.1 // indirect diff --git a/go.sum b/go.sum index 5ea933ca..2f79d6cf 100644 --- a/go.sum +++ b/go.sum @@ -111,16 +111,12 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= @@ -156,12 +152,11 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= -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= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -425,10 +420,6 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= -github.com/werf/3p-helm v0.0.0-20260204140535-11a50c572ad8 h1:PrrWCukmr1enIzLoKRzWdhyUIoWkUmF5Gtvl1XhifZ0= -github.com/werf/3p-helm v0.0.0-20260204140535-11a50c572ad8/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= -github.com/werf/3p-helm v0.0.0-20260206103658-171c7659858a h1:LtJjZfpvNKyh48On7h7SnGV6nbkzfR18oMcWLJAKm8o= -github.com/werf/3p-helm v0.0.0-20260206103658-171c7659858a/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf h1:lWl6myftkC7mIRJ15LvUPNVHIzi7CIVHZhyhsNqD5mc= github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index cd9adee7..1cfd37bd 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -53,7 +53,7 @@ type RenderChartOptions struct { Remote bool SubchartNotes bool TemplatesAllowDNS bool - RebuildTSVendorBundle bool + RebuildTSBundle bool } type RenderChartResult struct { @@ -217,7 +217,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSVendorBundle) + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle) if err != nil { return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) } @@ -257,11 +257,11 @@ func renderJSTemplates( chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, - rebuildVendor bool, + rebuildBundle bool, ) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildVendor, chartPath) + result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath) if err != nil { return nil, fmt.Errorf("render TypeScript: %w", err) } diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 03402add..57bc0dc0 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -8,49 +8,99 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" + "github.com/dustin/go-humanize" + "github.com/gookit/color" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) -func BuildVendorBundle(ctx context.Context, chartPath string) error { - denoBin, ok := os.LookupEnv("DENO_BIN") - if !ok || denoBin == "" { - denoBin = "deno" +func getDenoBinary() string { + if denoBin, ok := os.LookupEnv("DENO_BIN"); ok && denoBin != "" { + return denoBin + } + + return "deno" +} + +func BuildBundleToFile(ctx context.Context, chartPath string) error { + srcDir := filepath.Join(chartPath, common.ChartTSSourceDir, "src") + + files, err := os.ReadDir(srcDir) + if err != nil { + return fmt.Errorf("read source directory %q: %w", srcDir, err) } - cmd := exec.CommandContext(ctx, denoBin, "run", "-A", common.ChartTSBuildScript) - cmd.Dir = chartPath + "/" + common.ChartTSSourceDir - cmd.Stdout = os.Stdout + entrypoint := findEntrypointInDir(files) + if entrypoint == "" { + return fmt.Errorf("entry point not found in source directory") + } - if err := cmd.Run(); err != nil { + bundle, err := buildBundle(ctx, chartPath, entrypoint) + if err != nil { + return fmt.Errorf("build bundle: %w", err) + } + + if err := saveBundleToFile(chartPath, bundle); err != nil { + return fmt.Errorf("save bundle: %w", err) + } + + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", "dist/bundle.js", humanize.Bytes(uint64(len(bundle)))) + + return nil +} + +func buildBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { + denoBin := getDenoBinary() + cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) + cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) + + output, err := cmd.Output() + if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { _, _ = os.Stderr.Write(exitErr.Stderr) } - return fmt.Errorf("get deno build output: %w", err) + return nil, fmt.Errorf("get deno build output: %w", err) } - return nil + return output, nil } -func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint string, renderCtx map[string]any) (map[string]interface{}, error) { - denoBin, ok := os.LookupEnv("DENO_BIN") - if !ok || denoBin == "" { - denoBin = "deno" +func saveBundleToFile(chartPath string, bundle []byte) error { + distDir := filepath.Join(chartPath, common.ChartTSSourceDir, "dist") + if err := os.MkdirAll(distDir, 0o775); err != nil { + return fmt.Errorf("mkdir %q: %w", distDir, err) } - args := []string{"run"} - if useVendorMap { - args = append(args, "--import-map", common.ChartTSVendorMap) + bundlePath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) + if err := os.WriteFile(bundlePath, bundle, 0o644); err != nil { + return fmt.Errorf("write vendor bundle to file %q: %w", bundlePath, err) } - args = append(args, entryPoint) + return nil +} + +func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx string) (map[string]interface{}, error) { + args := []string{ + "run", + "--deny-read", + "--deny-write", + "--deny-net", + "--deny-env", + "--deny-run", + "-", + "--ctx", + renderCtx, + } + denoBin := getDenoBinary() cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = chartPath + "/" + common.ChartTSSourceDir + cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() @@ -58,12 +108,14 @@ func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint return nil, fmt.Errorf("get stdin pipe: %w", err) } + stdinErrChan := make(chan error, 1) go func() { defer func() { _ = stdin.Close() }() - _ = json.NewEncoder(stdin).Encode(renderCtx) + _, writeErr := stdin.Write(bundleData) + stdinErrChan <- writeErr }() reader, err := cmd.StdoutPipe() @@ -77,6 +129,10 @@ func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint waitForJSONString := func() (string, error) { scanner := bufio.NewScanner(reader) + // Increase buffer size to handle large JSON outputs (up to 10MB) + const maxScannerBuffer = 10 * 1024 * 1024 + scanner.Buffer(make([]byte, 64*1024), maxScannerBuffer) + for scanner.Scan() { text := scanner.Text() log.Default.Debug(ctx, text) @@ -89,6 +145,10 @@ func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint } } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("scan stdout: %w", err) + } + return "", errors.New("render output not found") } @@ -98,6 +158,10 @@ func runApp(ctx context.Context, chartPath string, useVendorMap bool, entryPoint return nil, fmt.Errorf("wait process: %w", err) } + if stdinErr := <-stdinErrChan; stdinErr != nil { + return nil, fmt.Errorf("write bundle data to stdin: %w", stdinErr) + } + if errJSON != nil { return nil, fmt.Errorf("wait for render output: %w", errJSON) } diff --git a/internal/ts/files.go b/internal/ts/files.go index 889b8216..b067230f 100644 --- a/internal/ts/files.go +++ b/internal/ts/files.go @@ -1,6 +1,7 @@ package ts import ( + "os" "strings" helmchart "github.com/werf/3p-helm/pkg/chart" @@ -27,3 +28,20 @@ func findEntrypointInFiles(files map[string][]byte) string { return "" } + +func findEntrypointInDir(files []os.DirEntry) string { + for _, f := range files { + if f.IsDir() { + continue + } + + name := "src/" + f.Name() + for _, ep := range common.ChartTSEntryPoints { + if name == ep { + return ep + } + } + } + + return "" +} diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index f30403d7..85756eea 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -116,13 +116,11 @@ function render($: RenderContext): RenderResult { await runRender(render); ` denoJSONTmpl = `{ - "nodeModulesDir": "manual", "tasks": { - "build": "deno run -A %s" + "build": "%s" }, "imports": { - "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2", - "esbuild-wasm": "npm:esbuild-wasm@0.25.0" + "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2" } } ` diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index fdc5d498..e42bcffe 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -225,8 +225,7 @@ func TestInitTSBoilerplate(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, "ts", "deno.json")) require.NoError(t, err) - assert.Contains(t, string(content), `"nodeModulesDir": "manual"`) - assert.Contains(t, string(content), fmt.Sprintf(`"build": "deno run -A %s`, common.ChartTSBuildScript)) + assert.Contains(t, string(content), fmt.Sprintf(`"build": "%s"`, common.ChartTSBuildScript)) assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) diff --git a/internal/ts/render.go b/internal/ts/render.go index 6ddc0d9f..aec8b9de 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -2,11 +2,15 @@ package ts import ( "context" + "encoding/json" "fmt" "path" "slices" "strings" + "github.com/dustin/go-humanize" + "github.com/gookit/color" + "github.com/samber/lo" "sigs.k8s.io/yaml" helmchart "github.com/werf/3p-helm/pkg/chart" @@ -15,20 +19,20 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildVendor bool, chartPath string) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath string) (map[string]string, error) { allRendered := make(map[string]string) - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, rebuildVendor); err != nil { + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, rebuildBundle); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, rebuildVendor bool) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, rebuildBundle bool) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderDenoFiles(ctx, chart, values, chartPath, rebuildVendor) + rendered, err := renderFiles(ctx, chart, values, chartPath, rebuildBundle) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -50,7 +54,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch path.Join(pathPrefix, "charts", depName), path.Join(chartPath, "charts", depName), results, - rebuildVendor, + rebuildBundle, ) if err != nil { return fmt.Errorf("render dependency %q: %w", depName, err) @@ -60,31 +64,9 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildVendor bool) (map[string]string, error) { +func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildBundle bool) (map[string]string, error) { mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) - var ( - hasNodeModules bool - useVendorMap bool - vendorFiles []*helmchart.File - ) - for _, file := range mergedFiles { - if strings.HasPrefix(file.Name, common.ChartTSSourceDir+"node_modules/") { - hasNodeModules = true - } else if strings.HasPrefix(file.Name, common.ChartTSVendorBundleDir) { - vendorFiles = append(vendorFiles, file) - } else if file.Name == common.ChartTSSourceDir+common.ChartTSVendorMap { - useVendorMap = true - } - } - - if hasNodeModules && (rebuildVendor || len(vendorFiles) == 0) { - err := BuildVendorBundle(ctx, chartPath) - if err != nil { - return nil, fmt.Errorf("build deno vendor bundle: %w", err) - } - } - sourceFiles := extractSourceFiles(mergedFiles) if len(sourceFiles) == 0 { return map[string]string{}, nil @@ -95,7 +77,36 @@ func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues return map[string]string{}, nil } - result, err := runApp(ctx, chartPath, useVendorMap, entrypoint, buildRenderContext(renderedValues, chart)) + bundleFile, foundBundle := lo.Find(mergedFiles, func(f *helmchart.File) bool { + return f.Name == common.ChartTSVendorBundleFile + }) + + var bundle []byte + if rebuildBundle || !foundBundle { + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Run bundle for ")+"%s", entrypoint) + + bundleRes, err := buildBundle(ctx, chartPath, entrypoint) + if err != nil { + return nil, fmt.Errorf("build bundle for chart %q: %w", chart.Name(), err) + } + + bundle = bundleRes + + if err := saveBundleToFile(chartPath, bundle); err != nil { + return nil, fmt.Errorf("save bundle: %w", err) + } + + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", "dist/bundle.js", humanize.Bytes(uint64(len(bundle)))) + } else { + bundle = bundleFile.Data + } + + renderCtx, err := buildRenderContext(renderedValues, chart) + if err != nil { + return nil, fmt.Errorf("build render context: %w", err) + } + + result, err := runApp(ctx, chartPath, bundle, renderCtx) if err != nil { return nil, fmt.Errorf("run deno app: %w", err) } @@ -118,7 +129,7 @@ func renderDenoFiles(ctx context.Context, chart *helmchart.Chart, renderedValues }, nil } -func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) map[string]any { +func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) (string, error) { renderContext := renderedValues.AsMap() if valuesInterface, ok := renderContext["Values"]; ok { @@ -136,7 +147,12 @@ func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) renderContext["Files"] = files - return renderContext + jsonInput, err := json.Marshal(renderContext) + if err != nil { + return "", fmt.Errorf("marshal render context to json: %w", err) + } + + return string(jsonInput), nil } func convertRenderResultToYAML(result any) (string, error) { diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 6cbff742..62f6b35b 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -30,11 +30,11 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("TypeScript charts feature is not enabled. Set NELM_FEAT_TYPESCRIPT=true to use this feature") } - if err := ts.BuildVendorBundle(ctx, absPath); err != nil { - return fmt.Errorf("build TypeScript vendor bundle: %w", err) + if err := ts.BuildBundleToFile(ctx, absPath); err != nil { + return fmt.Errorf("build TypeScript bundle: %w", err) } - log.Default.Info(ctx, "Built vendor for TypeScript chart in %s", absPath) + log.Default.Info(ctx, "TypeScript chart bundled in %s", absPath) return nil } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 7ffc82e8..92283688 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -163,8 +163,8 @@ type ReleaseInstallOptions struct { // Timeout is the maximum duration for the entire release installation operation. // If 0, no timeout is applied and the operation runs until completion or error. Timeout time.Duration - // RebuildTSVendorBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. - RebuildTSVendorBundle bool + // RebuildTSBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. + RebuildTSBundle bool } func ReleaseInstall(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseInstallOptions) error { @@ -345,7 +345,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re Remote: true, SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, - RebuildTSVendorBundle: opts.RebuildTSVendorBundle, + RebuildTSBundle: opts.RebuildTSBundle, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/common/common.go b/pkg/common/common.go index 0dbf89d2..74248737 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -26,12 +26,10 @@ var ( const ( // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. ChartTSSourceDir = "ts/" - // ChartTSVendorBundleDir is the path to the vendor bundle dir in a Helm chart. - ChartTSVendorBundleDir = ChartTSSourceDir + "dist/vendor/" + // ChartTSVendorBundleFile is the path to the bundle in a Helm chart. + ChartTSVendorBundleFile = ChartTSSourceDir + "dist/bundle.js" // ChartTSBuildScript is the path to the vendor build script. - ChartTSBuildScript = "node_modules/@nelm/chart-ts-sdk/dist/build.js" - // ChartTSVendorMap is the path to the deno import mapping for the vendor bundle. - ChartTSVendorMap = "dist/vendor_map.json" + ChartTSBuildScript = "deno bundle --output=dist/bundle.js src/index.ts" // ChartTSEntryPointTS is the TypeScript entry point path. ChartTSEntryPointTS = "src/index.ts" // ChartTSEntryPointJS is the JavaScript entry point path. From 2f26171ae4019458ad667d8dfab50963f702b827 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 18 Feb 2026 22:41:02 +0300 Subject: [PATCH 07/30] wip: format fix Signed-off-by: Dmitry Mordvinov --- pkg/action/release_install.go | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 90dbd8b8..4e8aad2c 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -337,23 +337,23 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Debug(ctx, "Render chart") - renderChartResult, err := chart.RenderChart(ctx, opts.Chart, releaseName, releaseNamespace, newRevision, deployType, helmRegistryClient, clientFactory, chart.RenderChartOptions{ - ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, - ValuesOptions: opts.ValuesOptions, - ChartProvenanceKeyring: opts.ChartProvenanceKeyring, - ChartProvenanceStrategy: opts.ChartProvenanceStrategy, - ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, - ChartVersion: opts.ChartVersion, - HelmOptions: helmOptions, - NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, - Remote: true, - SubchartNotes: opts.ShowSubchartNotes, - TemplatesAllowDNS: opts.TemplatesAllowDNS, - RebuildTSBundle: opts.RebuildTSBundle, - }) - if err != nil { - return fmt.Errorf("render chart: %w", err) - } + renderChartResult, err := chart.RenderChart(ctx, opts.Chart, releaseName, releaseNamespace, newRevision, deployType, helmRegistryClient, clientFactory, chart.RenderChartOptions{ + ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, + ValuesOptions: opts.ValuesOptions, + ChartProvenanceKeyring: opts.ChartProvenanceKeyring, + ChartProvenanceStrategy: opts.ChartProvenanceStrategy, + ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, + ChartVersion: opts.ChartVersion, + HelmOptions: helmOptions, + NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, + Remote: true, + SubchartNotes: opts.ShowSubchartNotes, + TemplatesAllowDNS: opts.TemplatesAllowDNS, + RebuildTSBundle: opts.RebuildTSBundle, + }) + if err != nil { + return fmt.Errorf("render chart: %w", err) + } log.Default.Debug(ctx, "Build transformed resource specs") From 238a355d8bb262b6103c91e681f3384412a538d3 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 19 Feb 2026 10:17:11 +0300 Subject: [PATCH 08/30] wip: helm ignore node_modules Signed-off-by: Dmitry Mordvinov --- internal/ts/deno.go | 1 + internal/ts/init.go | 2 +- internal/ts/init_templates.go | 1 + internal/ts/init_test.go | 2 ++ internal/ts/render.go | 7 ++----- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 57bc0dc0..10114578 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -88,6 +88,7 @@ func saveBundleToFile(chartPath string, bundle []byte) error { func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx string) (map[string]interface{}, error) { args := []string{ "run", + "--no-remote", "--deny-read", "--deny-write", "--deny-net", diff --git a/internal/ts/init.go b/internal/ts/init.go index c3c5e3d7..b9e4c7c2 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -50,7 +50,7 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error // Handle .helmignore: create or enrich helmignorePath := filepath.Join(chartPath, ".helmignore") - if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/vendor/"}); err != nil { + if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/vendor/", "ts/node_modules/"}); err != nil { return fmt.Errorf("ensure helmignore entries: %w", err) } diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 85756eea..1dde79a3 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -54,6 +54,7 @@ export function newDeployment($: RenderContext): object { # TypeScript chart files ts/vendor/ +ts/node_modules/ ` helpersTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index e42bcffe..a92eac6e 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -136,6 +136,7 @@ func TestInitChartStructure(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, ".helmignore")) require.NoError(t, err) assert.Contains(t, string(content), "ts/vendor/") + assert.Contains(t, string(content), "ts/node_modules/") }) t.Run("skips existing Chart.yaml", func(t *testing.T) { @@ -183,6 +184,7 @@ func TestInitChartStructure(t *testing.T) { assert.Contains(t, string(content), ".DS_Store") assert.Contains(t, string(content), ".git/") assert.Contains(t, string(content), "ts/vendor/") + assert.Contains(t, string(content), "ts/node_modules/") }) } diff --git a/internal/ts/render.go b/internal/ts/render.go index aec8b9de..54cefcdc 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "path" - "slices" "strings" "github.com/dustin/go-humanize" @@ -65,9 +64,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch } func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildBundle bool) (map[string]string, error) { - mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) - - sourceFiles := extractSourceFiles(mergedFiles) + sourceFiles := extractSourceFiles(chart.RuntimeFiles) if len(sourceFiles) == 0 { return map[string]string{}, nil } @@ -77,7 +74,7 @@ func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues cha return map[string]string{}, nil } - bundleFile, foundBundle := lo.Find(mergedFiles, func(f *helmchart.File) bool { + bundleFile, foundBundle := lo.Find(chart.RuntimeFiles, func(f *helmchart.File) bool { return f.Name == common.ChartTSVendorBundleFile }) From 04225080b95b6e7189b0d7cdf01a5aafbdce62c3 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 20 Feb 2026 13:00:03 +0300 Subject: [PATCH 09/30] wip: move `deno bundle` logic to 3p-helm, draft for `nelm chart ts build`, `nelm chart pack` Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 9 +--- internal/ts/deno.go | 83 ++++-------------------------------- internal/ts/export_test.go | 2 +- internal/ts/files.go | 47 -------------------- internal/ts/init.go | 11 +++-- internal/ts/init_test.go | 3 +- internal/ts/render.go | 44 ++++--------------- pkg/action/chart_ts_build.go | 49 +++++++++++++++++++-- pkg/common/common.go | 18 -------- 9 files changed, 74 insertions(+), 192 deletions(-) delete mode 100644 internal/ts/files.go diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index e5e65ceb..fae5bd7e 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "strings" "github.com/samber/lo" @@ -10,8 +9,8 @@ import ( helm_v3 "github.com/werf/3p-helm/cmd/helm" "github.com/werf/3p-helm/pkg/chart/loader" + "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/common-go/pkg/cli" - "github.com/werf/nelm/internal/ts" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -37,11 +36,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" if featgate.FeatGateTypescript.Enabled() { - for _, chartPath := range args { - if err := ts.BuildBundleToFile(ctx, chartPath); err != nil { - return fmt.Errorf("build TypeScript vendor bundle in %q: %w", chartPath, err) - } - } + tsbundle.BundleEnabled = true } if err := originalRunE(cmd, args); err != nil { diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 10114578..f49e36d1 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -11,79 +11,14 @@ import ( "path/filepath" "strings" - "github.com/dustin/go-humanize" - "github.com/gookit/color" - - "github.com/werf/nelm/pkg/common" + "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/log" ) -func getDenoBinary() string { - if denoBin, ok := os.LookupEnv("DENO_BIN"); ok && denoBin != "" { - return denoBin - } - - return "deno" -} - -func BuildBundleToFile(ctx context.Context, chartPath string) error { - srcDir := filepath.Join(chartPath, common.ChartTSSourceDir, "src") - - files, err := os.ReadDir(srcDir) - if err != nil { - return fmt.Errorf("read source directory %q: %w", srcDir, err) - } - - entrypoint := findEntrypointInDir(files) - if entrypoint == "" { - return fmt.Errorf("entry point not found in source directory") - } - - bundle, err := buildBundle(ctx, chartPath, entrypoint) - if err != nil { - return fmt.Errorf("build bundle: %w", err) - } - - if err := saveBundleToFile(chartPath, bundle); err != nil { - return fmt.Errorf("save bundle: %w", err) - } - - log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", "dist/bundle.js", humanize.Bytes(uint64(len(bundle)))) - - return nil -} - -func buildBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { - denoBin := getDenoBinary() - cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) - cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) - - output, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - _, _ = os.Stderr.Write(exitErr.Stderr) - } - - return nil, fmt.Errorf("get deno build output: %w", err) - } - - return output, nil -} - -func saveBundleToFile(chartPath string, bundle []byte) error { - distDir := filepath.Join(chartPath, common.ChartTSSourceDir, "dist") - if err := os.MkdirAll(distDir, 0o775); err != nil { - return fmt.Errorf("mkdir %q: %w", distDir, err) - } - - bundlePath := filepath.Join(chartPath, common.ChartTSVendorBundleFile) - if err := os.WriteFile(bundlePath, bundle, 0o644); err != nil { - return fmt.Errorf("write vendor bundle to file %q: %w", bundlePath, err) - } - - return nil -} +const ( + // renderResultPrefix is the prefix for the rendered output. + renderResultPrefix = "NELM_RENDER_RESULT:" +) func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx string) (map[string]interface{}, error) { args := []string{ @@ -99,9 +34,9 @@ func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx renderCtx, } - denoBin := getDenoBinary() + denoBin := tsbundle.GetDenoBinary() cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) + cmd.Dir = filepath.Join(chartPath, tsbundle.ChartTSSourceDir) cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() @@ -138,8 +73,8 @@ func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx text := scanner.Text() log.Default.Debug(ctx, text) - if strings.HasPrefix(text, common.ChartTSRenderResultPrefix) { - _, str, found := strings.Cut(text, common.ChartTSRenderResultPrefix) + if strings.HasPrefix(text, renderResultPrefix) { + _, str, found := strings.Cut(text, renderResultPrefix) if found { return str, nil } diff --git a/internal/ts/export_test.go b/internal/ts/export_test.go index 6e747f31..0e66a69a 100644 --- a/internal/ts/export_test.go +++ b/internal/ts/export_test.go @@ -1,3 +1,3 @@ package ts -var ScopeValuesForSubchart = scopeValuesForSubchart +var ChartTSBuildScript = denoBuildScript diff --git a/internal/ts/files.go b/internal/ts/files.go deleted file mode 100644 index b067230f..00000000 --- a/internal/ts/files.go +++ /dev/null @@ -1,47 +0,0 @@ -package ts - -import ( - "os" - "strings" - - helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/nelm/pkg/common" -) - -func extractSourceFiles(files []*helmchart.File) map[string][]byte { - sourceFiles := make(map[string][]byte) - for _, f := range files { - if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"src/") { - sourceFiles[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data - } - } - - return sourceFiles -} - -func findEntrypointInFiles(files map[string][]byte) string { - for _, ep := range common.ChartTSEntryPoints { - if _, ok := files[ep]; ok { - return ep - } - } - - return "" -} - -func findEntrypointInDir(files []os.DirEntry) string { - for _, f := range files { - if f.IsDir() { - continue - } - - name := "src/" + f.Name() - for _, ep := range common.ChartTSEntryPoints { - if name == ep { - return ep - } - } - } - - return "" -} diff --git a/internal/ts/init.go b/internal/ts/init.go index b9e4c7c2..1e01695d 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -7,15 +7,18 @@ import ( "path/filepath" "strings" - "github.com/werf/nelm/pkg/common" + tsbundle "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/log" ) +// denoBuildScript task for manual bundle run. +const denoBuildScript = "deno bundle --output=dist/bundle.js src/index.ts" + // 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, common.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, tsbundle.ChartTSSourceDir) if _, err := os.Stat(tsDir); err == nil { return fmt.Errorf("init chart structure: typescript directory already exists: %s", tsDir) } else if !os.IsNotExist(err) { @@ -61,7 +64,7 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error // InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { - tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, tsbundle.ChartTSSourceDir) srcDir := filepath.Join(tsDir, "src") if _, err := os.Stat(tsDir); err == nil { @@ -79,7 +82,7 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { {content: deploymentTSContent, path: filepath.Join(srcDir, "deployment.ts")}, {content: serviceTSContent, path: filepath.Join(srcDir, "service.ts")}, {content: tsconfigContent, path: filepath.Join(tsDir, "tsconfig.json")}, - {content: denoJSON(common.ChartTSBuildScript), path: filepath.Join(tsDir, "deno.json")}, + {content: denoJSON(denoBuildScript), path: filepath.Join(tsDir, "deno.json")}, } if err := os.MkdirAll(srcDir, 0o755); err != nil { diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index a92eac6e..b5a94ac1 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "github.com/werf/nelm/internal/ts" - "github.com/werf/nelm/pkg/common" ) func TestEnsureGitignore(t *testing.T) { @@ -227,7 +226,7 @@ func TestInitTSBoilerplate(t *testing.T) { content, err := os.ReadFile(filepath.Join(chartPath, "ts", "deno.json")) require.NoError(t, err) - assert.Contains(t, string(content), fmt.Sprintf(`"build": "%s"`, common.ChartTSBuildScript)) + assert.Contains(t, string(content), fmt.Sprintf(`"build": "%s"`, ts.ChartTSBuildScript)) assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) diff --git a/internal/ts/render.go b/internal/ts/render.go index 54cefcdc..0e0ec0ee 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -7,20 +7,21 @@ import ( "path" "strings" - "github.com/dustin/go-humanize" - "github.com/gookit/color" - "github.com/samber/lo" "sigs.k8s.io/yaml" helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chartutil" - "github.com/werf/nelm/pkg/common" + tsbundle "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/log" ) func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath string) (map[string]string, error) { allRendered := make(map[string]string) + if err := tsbundle.ProcessChartRecursive(ctx, chart, chartPath, false); err != nil { + return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) + } + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, rebuildBundle); err != nil { return nil, err } @@ -64,40 +65,11 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch } func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildBundle bool) (map[string]string, error) { - sourceFiles := extractSourceFiles(chart.RuntimeFiles) - if len(sourceFiles) == 0 { - return map[string]string{}, nil - } - - entrypoint := findEntrypointInFiles(sourceFiles) - if entrypoint == "" { + entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) + if entrypoint == "" || bundle == nil { return map[string]string{}, nil } - bundleFile, foundBundle := lo.Find(chart.RuntimeFiles, func(f *helmchart.File) bool { - return f.Name == common.ChartTSVendorBundleFile - }) - - var bundle []byte - if rebuildBundle || !foundBundle { - log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Run bundle for ")+"%s", entrypoint) - - bundleRes, err := buildBundle(ctx, chartPath, entrypoint) - if err != nil { - return nil, fmt.Errorf("build bundle for chart %q: %w", chart.Name(), err) - } - - bundle = bundleRes - - if err := saveBundleToFile(chartPath, bundle); err != nil { - return nil, fmt.Errorf("save bundle: %w", err) - } - - log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", "dist/bundle.js", humanize.Bytes(uint64(len(bundle)))) - } else { - bundle = bundleFile.Data - } - renderCtx, err := buildRenderContext(renderedValues, chart) if err != nil { return nil, fmt.Errorf("build render context: %w", err) @@ -122,7 +94,7 @@ func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues cha } return map[string]string{ - path.Join(common.ChartTSSourceDir, entrypoint): yamlOutput, + path.Join(tsbundle.ChartTSSourceDir, entrypoint): yamlOutput, }, nil } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 62f6b35b..9599416a 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -3,9 +3,18 @@ package action import ( "context" "fmt" + "os" "path/filepath" + "strings" - "github.com/werf/nelm/internal/ts" + "github.com/dustin/go-humanize" + "github.com/gookit/color" + "github.com/samber/lo" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/chart/loader" + "github.com/werf/3p-helm/pkg/werf/helmopts" + tsbundle "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -30,8 +39,42 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("TypeScript charts feature is not enabled. Set NELM_FEAT_TYPESCRIPT=true to use this feature") } - if err := ts.BuildBundleToFile(ctx, absPath); err != nil { - return fmt.Errorf("build TypeScript bundle: %w", err) + helmOpts := helmopts.HelmOptions{ + ChartLoadOpts: helmopts.ChartLoadOptions{ + ChartType: helmopts.ChartTypeSubchart, + }, + } + + chart, err := loader.Load(absPath, helmOpts) + if err != nil { + return fmt.Errorf("load chart: %w", err) + } + + if err = tsbundle.ProcessChartRecursive(ctx, chart, absPath, true); err != nil { + return fmt.Errorf("process chart: %w", err) + } + + bundles := lo.Filter(chart.Raw, func(file *helmchart.File, _ int) bool { + return strings.Contains(file.Name, tsbundle.ChartTSBundleFile) + }) + + if len(bundles) == 0 { + return nil + } + + for _, bundle := range bundles { + bundlePath := filepath.Join(absPath, bundle.Name) + dirPath := filepath.Dir(bundlePath) + + if err := os.MkdirAll(dirPath, 0o775); err != nil { + return fmt.Errorf("mkdir %q: %w", dirPath, err) + } + + if err := os.WriteFile(bundlePath, bundle.Data, 0o644); err != nil { + return fmt.Errorf("write bundle to file %q: %w", bundlePath, err) + } + + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", bundlePath, humanize.Bytes(uint64(len(bundle.Data)))) } log.Default.Info(ctx, "TypeScript chart bundled in %s", absPath) diff --git a/pkg/common/common.go b/pkg/common/common.go index 8706b015..359aece7 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -23,24 +23,6 @@ var ( Version = "0.0.0" ) -const ( - // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. - ChartTSSourceDir = "ts/" - // ChartTSVendorBundleFile is the path to the bundle in a Helm chart. - ChartTSVendorBundleFile = ChartTSSourceDir + "dist/bundle.js" - // ChartTSBuildScript is the path to the vendor build script. - ChartTSBuildScript = "deno bundle --output=dist/bundle.js src/index.ts" - // ChartTSEntryPointTS is the TypeScript entry point path. - ChartTSEntryPointTS = "src/index.ts" - // ChartTSEntryPointJS is the JavaScript entry point path. - ChartTSEntryPointJS = "src/index.js" - // ChartTSRenderResultPrefix is the prefix for the rendered output. - ChartTSRenderResultPrefix = "NELM_RENDER_RESULT:" -) - -// ChartTSEntryPoints defines supported TypeScript/JavaScript entry points (in priority order). -var ChartTSEntryPoints = [...]string{ChartTSEntryPointTS, ChartTSEntryPointJS} - const ( DefaultBurstLimit = 100 // TODO(major): switch to if-possible From d7956320ce6b482a41e9d82e11e7d9783b390525 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 20 Feb 2026 21:17:21 +0300 Subject: [PATCH 10/30] feat: fixes for ts build Signed-off-by: Dmitry Mordvinov --- go.mod | 2 +- go.sum | 4 ++-- internal/ts/deno.go | 4 +--- internal/ts/render.go | 4 ++-- pkg/action/chart_ts_build.go | 10 ++++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 6cace435..f7a89bb4 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.5.0 - github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf + github.com/werf/3p-helm v0.0.0-20260220154239-5d04719baf9d github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 github.com/werf/lockgate v0.1.1 diff --git a/go.sum b/go.sum index 2f79d6cf..54b4c837 100644 --- a/go.sum +++ b/go.sum @@ -420,8 +420,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= -github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf h1:lWl6myftkC7mIRJ15LvUPNVHIzi7CIVHZhyhsNqD5mc= -github.com/werf/3p-helm v0.0.0-20260211143448-0b619e3cc3bf/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= +github.com/werf/3p-helm v0.0.0-20260220154239-5d04719baf9d h1:GBFzHHwQwH0fh0pjv+H+CPSH2QiSM64NrqPQqaZPTkY= +github.com/werf/3p-helm v0.0.0-20260220154239-5d04719baf9d/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 h1:ee55f/lNya8V9jCBsQWDhvOw6y1fB0uysop8te9aUcM= diff --git a/internal/ts/deno.go b/internal/ts/deno.go index f49e36d1..28f66691 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "github.com/werf/3p-helm/pkg/werf/ts" @@ -20,7 +19,7 @@ const ( renderResultPrefix = "NELM_RENDER_RESULT:" ) -func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx string) (map[string]interface{}, error) { +func runApp(ctx context.Context, bundleData []byte, renderCtx string) (map[string]interface{}, error) { args := []string{ "run", "--no-remote", @@ -36,7 +35,6 @@ func runApp(ctx context.Context, chartPath string, bundleData []byte, renderCtx denoBin := tsbundle.GetDenoBinary() cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = filepath.Join(chartPath, tsbundle.ChartTSSourceDir) cmd.Stderr = os.Stderr stdin, err := cmd.StdinPipe() diff --git a/internal/ts/render.go b/internal/ts/render.go index 0e0ec0ee..1a7d2393 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -18,7 +18,7 @@ import ( func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath string) (map[string]string, error) { allRendered := make(map[string]string) - if err := tsbundle.ProcessChartRecursive(ctx, chart, chartPath, false); err != nil { + if err := tsbundle.BundleTSChartsRecursive(ctx, chart, chartPath, false); err != nil { return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) } @@ -75,7 +75,7 @@ func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues cha return nil, fmt.Errorf("build render context: %w", err) } - result, err := runApp(ctx, chartPath, bundle, renderCtx) + result, err := runApp(ctx, bundle.Data, renderCtx) if err != nil { return nil, fmt.Errorf("run deno app: %w", err) } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 9599416a..b88e037e 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -39,9 +39,11 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("TypeScript charts feature is not enabled. Set NELM_FEAT_TYPESCRIPT=true to use this feature") } + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Run bundle for ")+"%s", absPath) + helmOpts := helmopts.HelmOptions{ ChartLoadOpts: helmopts.ChartLoadOptions{ - ChartType: helmopts.ChartTypeSubchart, + ChartType: helmopts.ChartTypeChart, }, } @@ -50,7 +52,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("load chart: %w", err) } - if err = tsbundle.ProcessChartRecursive(ctx, chart, absPath, true); err != nil { + if err = tsbundle.BundleTSChartsRecursive(ctx, chart, absPath, true); err != nil { return fmt.Errorf("process chart: %w", err) } @@ -74,10 +76,10 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("write bundle to file %q: %w", bundlePath, err) } - log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", bundlePath, humanize.Bytes(uint64(len(bundle.Data)))) + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Bundled: ")+"%s - %s", bundle.Name, humanize.Bytes(uint64(len(bundle.Data)))) } - log.Default.Info(ctx, "TypeScript chart bundled in %s", absPath) + log.Default.Info(ctx, "TypeScript chart bundled successfully") return nil } From 13102ea1c395765742e1770ffcb4b9a76881635e Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 25 Feb 2026 14:56:21 +0300 Subject: [PATCH 11/30] wip: ChartName for ts init (from werf) Signed-off-by: Dmitry Mordvinov --- pkg/action/chart_ts_init.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/action/chart_ts_init.go b/pkg/action/chart_ts_init.go index 6820f9cd..b9f5880c 100644 --- a/pkg/action/chart_ts_init.go +++ b/pkg/action/chart_ts_init.go @@ -13,7 +13,8 @@ import ( type ChartTSInitOptions struct { ChartDirPath string - TempDirPath string // TODO: remove this? + ChartName string + TempDirPath string } func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { @@ -28,6 +29,9 @@ func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { } chartName := filepath.Base(absPath) + if opts.ChartName != "" { + chartName = opts.ChartName + } if !featgate.FeatGateTypescript.Enabled() { log.Default.Warn(ctx, "TypeScript charts require NELM_FEAT_TYPESCRIPT=true environment variable") From b21d3a7986c330cc4c5897b26361b619b3b44d9c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 25 Feb 2026 15:19:34 +0300 Subject: [PATCH 12/30] wip: apply wormatter Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_init.go | 2 +- internal/chart/chart_render.go | 10 ++-------- internal/ts/deno.go | 8 ++++---- internal/ts/init.go | 30 ++++++++++++++---------------- internal/ts/init_templates.go | 18 +++++++++--------- internal/ts/render.go | 11 +++++------ pkg/action/chart_ts_build.go | 1 + pkg/action/release_install.go | 4 ++-- 8 files changed, 38 insertions(+), 46 deletions(-) diff --git a/cmd/nelm/chart_init.go b/cmd/nelm/chart_init.go index a7689b90..00506c0c 100644 --- a/cmd/nelm/chart_init.go +++ b/cmd/nelm/chart_init.go @@ -13,10 +13,10 @@ import ( ) type chartInitConfig struct { - TempDirPath string ChartDirPath string LogColorMode string LogLevel string + TempDirPath string } // newChartInitCommand diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index f0a887ed..1a4accbf 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -50,10 +50,10 @@ type RenderChartOptions struct { HelmOptions helmopts.HelmOptions LocalKubeVersion string NoStandaloneCRDs bool + RebuildTSBundle bool Remote bool SubchartNotes bool TemplatesAllowDNS bool - RebuildTSBundle bool } type RenderChartResult struct { @@ -354,13 +354,7 @@ func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } -func renderJSTemplates( - ctx context.Context, - chartPath string, - chart *helmchart.Chart, - renderedValues chartutil.Values, - rebuildBundle bool, -) (map[string]string, error) { +func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath) diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 28f66691..5fffa819 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -14,10 +14,9 @@ import ( "github.com/werf/nelm/pkg/log" ) -const ( - // renderResultPrefix is the prefix for the rendered output. - renderResultPrefix = "NELM_RENDER_RESULT:" -) +const +// renderResultPrefix is the prefix for the rendered output. +renderResultPrefix = "NELM_RENDER_RESULT:" func runApp(ctx context.Context, bundleData []byte, renderCtx string) (map[string]interface{}, error) { args := []string{ @@ -63,6 +62,7 @@ func runApp(ctx context.Context, bundleData []byte, renderCtx string) (map[strin waitForJSONString := func() (string, error) { scanner := bufio.NewScanner(reader) + // Increase buffer size to handle large JSON outputs (up to 10MB) const maxScannerBuffer = 10 * 1024 * 1024 scanner.Buffer(make([]byte, 64*1024), maxScannerBuffer) diff --git a/internal/ts/init.go b/internal/ts/init.go index 1e01695d..b1966ac9 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -11,9 +11,22 @@ import ( "github.com/werf/nelm/pkg/log" ) -// denoBuildScript task for manual bundle run. const denoBuildScript = "deno bundle --output=dist/bundle.js src/index.ts" +// EnsureGitignore adds TypeScript entries to .gitignore, creating if needed. +func EnsureGitignore(chartPath string) error { + entries := []string{ + "ts/node_modules/", + "ts/vendor/", + } + + return ensureFileEntries( + filepath.Join(chartPath, ".gitignore"), + strings.Join(entries, "\n")+"\n", + entries, + ) +} + // 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. @@ -100,21 +113,6 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { return nil } -// EnsureGitignore adds TypeScript entries to .gitignore, creating if needed. -func EnsureGitignore(chartPath string) error { - entries := []string{ - "ts/node_modules/", - "ts/vendor/", - "ts/dist/", - } - - return ensureFileEntries( - filepath.Join(chartPath, ".gitignore"), - strings.Join(entries, "\n")+"\n", - entries, - ) -} - // ensureFileEntries ensures a file contains all required entries. // If file doesn't exist, creates it with defaultContent. // If file exists, appends any missing entries. diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 1dde79a3..2f7aa03e 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -6,6 +6,15 @@ const ( chartYamlTmpl = `apiVersion: v2 name: %s version: 0.1.0 +` + denoJSONTmpl = `{ + "tasks": { + "build": "%s" + }, + "imports": { + "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2" + } +} ` deploymentTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; @@ -115,15 +124,6 @@ function render($: RenderContext): RenderResult { } await runRender(render); -` - denoJSONTmpl = `{ - "tasks": { - "build": "%s" - }, - "imports": { - "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2" - } -} ` serviceTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; diff --git a/internal/ts/render.go b/internal/ts/render.go index 1a7d2393..d8fb6dc4 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -18,21 +18,21 @@ import ( func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath string) (map[string]string, error) { allRendered := make(map[string]string) - if err := tsbundle.BundleTSChartsRecursive(ctx, chart, chartPath, false); err != nil { + if err := tsbundle.BundleTSChartsRecursive(ctx, chart, chartPath, rebuildBundle); err != nil { return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) } - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, rebuildBundle); err != nil { + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, rebuildBundle bool) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderFiles(ctx, chart, values, chartPath, rebuildBundle) + rendered, err := renderFiles(ctx, chart, values) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -54,7 +54,6 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch path.Join(pathPrefix, "charts", depName), path.Join(chartPath, "charts", depName), results, - rebuildBundle, ) if err != nil { return fmt.Errorf("render dependency %q: %w", depName, err) @@ -64,7 +63,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, chartPath string, rebuildBundle bool) (map[string]string, error) { +func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) if entrypoint == "" || bundle == nil { return map[string]string{}, nil diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index b88e037e..4b0834a0 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -36,6 +36,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { 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") } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index b0073d57..fbbd89f5 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -106,6 +106,8 @@ type ReleaseInstallOptions struct { PlanArtifactLifetime time.Duration // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string + // RebuildTSBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. + RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -125,8 +127,6 @@ type ReleaseInstallOptions struct { // Timeout is the maximum duration for the entire release installation operation. // If 0, no timeout is applied and the operation runs until completion or error. Timeout time.Duration - // RebuildTSBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. - RebuildTSBundle bool } type runRollbackPlanOptions struct { From 7c13ab281ace698e4475895ca48e059097f636c4 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 25 Feb 2026 22:08:15 +0300 Subject: [PATCH 13/30] wip: write context as `input.yaml` into temp dir and pass to deno run Signed-off-by: Dmitry Mordvinov --- internal/chart/chart_render.go | 7 +- internal/ts/deno.go | 107 +++++++++++------------ internal/ts/init.go | 1 + internal/ts/init_templates.go | 56 ++++++++++++ internal/ts/render.go | 134 +++++++++++------------------ pkg/action/chart_lint.go | 1 + pkg/action/chart_render.go | 1 + pkg/action/release_install.go | 1 + pkg/action/release_plan_install.go | 1 + 9 files changed, 166 insertions(+), 143 deletions(-) diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index 1a4accbf..d152705b 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -53,6 +53,7 @@ type RenderChartOptions struct { RebuildTSBundle bool Remote bool SubchartNotes bool + TempDirPath string TemplatesAllowDNS bool } @@ -223,7 +224,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle) + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle, opts.TempDirPath) if err != nil { return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) } @@ -354,10 +355,10 @@ func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } -func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool) (map[string]string, error) { +func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, tempDirPath string) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath) + result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath, tempDirPath) if err != nil { return nil, fmt.Errorf("render TypeScript: %w", err) } diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 5fffa819..54869807 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -3,111 +3,106 @@ package ts import ( "bufio" "context" - "encoding/json" - "errors" "fmt" - "os" "os/exec" - "strings" + + "github.com/gookit/color" "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/log" ) -const -// renderResultPrefix is the prefix for the rendered output. -renderResultPrefix = "NELM_RENDER_RESULT:" +const ( + renderInputFileName = "input.yaml" + renderOutputFileName = "output.yaml" +) -func runApp(ctx context.Context, bundleData []byte, renderCtx string) (map[string]interface{}, error) { +func runApp(ctx context.Context, bundleData []byte, renderDir string) error { args := []string{ "run", "--no-remote", - "--deny-read", - "--deny-write", + // deno permissions: allow read/write only for input and output files, deny all else. + "--allow-read=" + renderInputFileName, + "--allow-write=" + renderOutputFileName, "--deny-net", "--deny-env", "--deny-run", + // write bundle data to Stdin "-", - "--ctx", - renderCtx, + // pass input and output file names as arguments + "--input-file=" + renderInputFileName, + "--output-file=" + renderOutputFileName, } denoBin := tsbundle.GetDenoBinary() cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Stderr = os.Stderr + cmd.Dir = renderDir + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("get stdin pipe: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("get stdout pipe: %w", err) + } - stdin, err := cmd.StdinPipe() + stderrPipe, err := cmd.StderrPipe() if err != nil { - return nil, fmt.Errorf("get stdin pipe: %w", err) + return fmt.Errorf("get stderr pipe: %w", err) } stdinErrChan := make(chan error, 1) go func() { defer func() { - _ = stdin.Close() + _ = stdinPipe.Close() }() - _, writeErr := stdin.Write(bundleData) + _, writeErr := stdinPipe.Write(bundleData) stdinErrChan <- writeErr }() - reader, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("get stdout pipe: %w", err) - } - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start process: %w", err) + return fmt.Errorf("start process: %w", err) } - waitForJSONString := func() (string, error) { - scanner := bufio.NewScanner(reader) - - // Increase buffer size to handle large JSON outputs (up to 10MB) - const maxScannerBuffer = 10 * 1024 * 1024 - scanner.Buffer(make([]byte, 64*1024), maxScannerBuffer) - + stdoutErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) for scanner.Scan() { text := scanner.Text() log.Default.Debug(ctx, text) - - if strings.HasPrefix(text, renderResultPrefix) { - _, str, found := strings.Cut(text, renderResultPrefix) - if found { - return str, nil - } - } } - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("scan stdout: %w", err) - } + stdoutErrChan <- scanner.Err() + }() - return "", errors.New("render output not found") - } + stderrErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + log.Default.Debug(ctx, color.Style{color.Red}.Sprint(scanner.Text())) + } - jsonString, errJSON := waitForJSONString() + stderrErrChan <- scanner.Err() + }() if err := cmd.Wait(); err != nil { - return nil, fmt.Errorf("wait process: %w", err) - } - - if stdinErr := <-stdinErrChan; stdinErr != nil { - return nil, fmt.Errorf("write bundle data to stdin: %w", stdinErr) + return fmt.Errorf("wait process: %w", err) } - if errJSON != nil { - return nil, fmt.Errorf("wait for render output: %w", errJSON) + if err := <-stdinErrChan; err != nil { + return fmt.Errorf("write bundle data to stdinPipe: %w", err) } - if jsonString == "" { - return nil, fmt.Errorf("unexpected render output format") + if err := <-stdoutErrChan; err != nil { + return fmt.Errorf("read stdout: %w", err) } - var result map[string]interface{} - if err := json.Unmarshal([]byte(jsonString), &result); err != nil { - return nil, fmt.Errorf("unmarshal render output: %w", err) + if err := <-stderrErrChan; err != nil { + return fmt.Errorf("read stderr: %w", err) } - return result, nil + return nil } diff --git a/internal/ts/init.go b/internal/ts/init.go index b1966ac9..e582754e 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -96,6 +96,7 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { {content: serviceTSContent, path: filepath.Join(srcDir, "service.ts")}, {content: tsconfigContent, path: filepath.Join(tsDir, "tsconfig.json")}, {content: denoJSON(denoBuildScript), path: filepath.Join(tsDir, "deno.json")}, + {content: inputExample(chartName), path: filepath.Join(tsDir, "input.example.yaml")}, } if err := os.MkdirAll(srcDir, 0o755); err != nil { diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 2f7aa03e..8d2f853b 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -124,6 +124,58 @@ function render($: RenderContext): RenderResult { } await runRender(render); +` + inputExampleContent = `Capabilities: + APIVersions: + - v1 + HelmVersion: + go_version: go1.25.5 + version: v3.14 + KubeVersion: + Major: "1" + Minor: "33" + Version: v1.33.6+k3s1 +Chart: + APIVersion: v2 + Annotations: + app.kubernetes.io/managed-by: Helm + AppVersion: 1.27.4 + Condition: nginx.enabled + Description: An example Helm chart for Kubernetes + Home: https://github.com/werf/nelm-chart-ts-sdk + Icon: https://helm.sh/img/helm.svg + Keywords: + - nginx + - webserver + Maintainers: + - Email: maintainer@example.com + Name: John Doe + URL: https://example.com + Name: %[1]s + Sources: + - https://github.com/werf/nelm-chart-ts-sdk + Tags: frontend + Type: application + Version: 0.1.0 +Files: + .gitignore: "" + .helmignore: "" +Release: + IsInstall: false + IsUpgrade: true + Name: %[1]s + Namespace: %[1]s + Revision: 171 + Service: Helm +Values: + image: + repository: nginx + tag: latest + replicaCount: 1 + service: + enabled: true + port: 80 + type: ClusterIP ` serviceTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; @@ -193,3 +245,7 @@ func chartYaml(chartName string) string { func denoJSON(scriptPath string) string { return fmt.Sprintf(denoJSONTmpl, scriptPath) } + +func inputExample(chartName string) string { + return fmt.Sprintf(inputExampleContent, chartName) +} diff --git a/internal/ts/render.go b/internal/ts/render.go index d8fb6dc4..353fd0ab 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -2,8 +2,8 @@ package ts import ( "context" - "encoding/json" "fmt" + "os" "path" "strings" @@ -15,24 +15,24 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath string) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath string) (map[string]string, error) { allRendered := make(map[string]string) if err := tsbundle.BundleTSChartsRecursive(ctx, chart, chartPath, rebuildBundle); err != nil { return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) } - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered); err != nil { + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, tempDirPath); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, tempDirPath string) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderFiles(ctx, chart, values) + rendered, err := renderFiles(ctx, chart, values, tempDirPath) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -54,6 +54,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch path.Join(pathPrefix, "charts", depName), path.Join(chartPath, "charts", depName), results, + tempDirPath, ) if err != nil { return fmt.Errorf("render dependency %q: %w", depName, err) @@ -63,80 +64,43 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string) (map[string]string, error) { entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) if entrypoint == "" || bundle == nil { return map[string]string{}, nil } - renderCtx, err := buildRenderContext(renderedValues, chart) + renderDir := path.Join(tempDirPath, "typescript-render", chart.Name()) + if err := os.MkdirAll(renderDir, 0o755); err != nil { + return map[string]string{}, fmt.Errorf("create temp dir for render context: %w", err) + } + + err := writeInputRenderContext(renderedValues, chart, renderDir) if err != nil { return nil, fmt.Errorf("build render context: %w", err) } - result, err := runApp(ctx, bundle.Data, renderCtx) + err = runApp(ctx, bundle.Data, renderDir) if err != nil { return nil, fmt.Errorf("run deno app: %w", err) } - if result == nil { - return map[string]string{}, nil - } - - yamlOutput, err := convertRenderResultToYAML(result) + resultBytes, err := os.ReadFile(path.Join(renderDir, renderOutputFileName)) if err != nil { - return nil, fmt.Errorf("convert render result to yaml: %w", err) + return nil, fmt.Errorf("read output file: %w", err) } - if strings.TrimSpace(yamlOutput) == "" { + result := string(resultBytes) + + if strings.TrimSpace(result) == "" { return map[string]string{}, nil } return map[string]string{ - path.Join(tsbundle.ChartTSSourceDir, entrypoint): yamlOutput, + path.Join(tsbundle.ChartTSSourceDir, entrypoint): result, }, nil } -func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) (string, error) { - 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]any, len(chart.Files)) - for _, file := range chart.Files { - files[file.Name] = file.Data - } - - renderContext["Files"] = files - - jsonInput, err := json.Marshal(renderContext) - if err != nil { - return "", fmt.Errorf("marshal render context to json: %w", err) - } - - return string(jsonInput), nil -} - -func convertRenderResultToYAML(result any) (string, error) { - resultMap, ok := result.(map[string]any) - if !ok { - return "", fmt.Errorf("convert render result to yaml: unexpected type %T", result) - } - - manifests, exists := resultMap["manifests"] - if !exists { - return "", fmt.Errorf("convert render result to yaml: missing 'manifests' field") - } - - return marshalManifests(manifests) -} - func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, subchart *helmchart.Chart) chartutil.Values { scoped := chartutil.Values{ "Chart": buildChartMetadata(subchart), @@ -175,6 +139,36 @@ func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, return scoped } +func writeInputRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart, renderDir string) error { + 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]any, len(chart.Files)) + for _, file := range chart.Files { + files[file.Name] = file.Data + } + + renderContext["Files"] = files + + yamlInput, err := yaml.Marshal(renderContext) + if err != nil { + return fmt.Errorf("marshal render context to json: %w", err) + } + + if err := os.WriteFile(path.Join(renderDir, renderInputFileName), yamlInput, 0o644); err != nil { + return fmt.Errorf("write render context to file: %w", err) + } + + return nil +} + func buildChartMetadata(chart *helmchart.Chart) map[string]any { metadata := map[string]any{ "Name": chart.Name(), @@ -214,31 +208,3 @@ func buildChartMetadata(chart *helmchart.Chart) map[string]any { return metadata } - -func marshalManifests(value any) (string, error) { - arr, ok := value.([]any) - if !ok { - yamlBytes, err := yaml.Marshal(value) - if err != nil { - return "", fmt.Errorf("marshal resource: %w", err) - } - - return string(yamlBytes), nil - } - - var results []string - for _, item := range arr { - if item == nil { - continue - } - - yamlBytes, err := yaml.Marshal(item) - if err != nil { - return "", fmt.Errorf("marshal manifest: %w", err) - } - - results = append(results, string(yamlBytes)) - } - - return strings.Join(results, "---\n"), nil -} diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 7e268501..8d8c6301 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -264,6 +264,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { LocalKubeVersion: opts.LocalKubeVersion, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index f1574f25..0b221da9 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -267,6 +267,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu LocalKubeVersion: opts.LocalKubeVersion, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index fbbd89f5..a9f28f79 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -363,6 +363,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, RebuildTSBundle: opts.RebuildTSBundle, + TempDirPath: opts.TempDirPath, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index ae78593d..e37e3b17 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -269,6 +269,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, Remote: true, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, }) if err != nil { return fmt.Errorf("render chart: %w", err) From 92a0580eee2b7d1ad7fb06e66e2a8d5e2af60078 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 25 Feb 2026 22:13:33 +0300 Subject: [PATCH 14/30] wip: test fixes for ts/init Signed-off-by: Dmitry Mordvinov --- internal/ts/init_test.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index b5a94ac1..93f6f597 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -25,7 +25,6 @@ func TestEnsureGitignore(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(content), "ts/node_modules/") assert.Contains(t, string(content), "ts/vendor/") - assert.Contains(t, string(content), "ts/dist/") }) t.Run("appends missing entries to existing .gitignore", func(t *testing.T) { @@ -44,7 +43,6 @@ func TestEnsureGitignore(t *testing.T) { assert.Contains(t, string(content), "*.log") assert.Contains(t, string(content), "ts/node_modules/") assert.Contains(t, string(content), "ts/vendor/") - assert.Contains(t, string(content), "ts/dist/") }) t.Run("does not duplicate entries", func(t *testing.T) { @@ -76,7 +74,6 @@ func TestEnsureGitignore(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(content), "ts/node_modules/") assert.Contains(t, string(content), "ts/vendor/") - assert.Contains(t, string(content), "ts/dist/") }) } @@ -204,6 +201,7 @@ func TestInitTSBoilerplate(t *testing.T) { // Check ts/ root files assert.FileExists(t, filepath.Join(chartPath, "ts", "tsconfig.json")) assert.FileExists(t, filepath.Join(chartPath, "ts", "deno.json")) + assert.FileExists(t, filepath.Join(chartPath, "ts", "input.example.yaml")) }) t.Run("creates correct directory structure", func(t *testing.T) { @@ -302,6 +300,21 @@ func TestInitTSBoilerplate(t *testing.T) { assert.Contains(t, string(content), `"declaration": true`) }) + t.Run("includes chart name in input.example.yaml", func(t *testing.T) { + chartPath := filepath.Join(t.TempDir(), "my-custom-chart") + require.NoError(t, os.MkdirAll(chartPath, 0o755)) + + err := ts.InitTSBoilerplate(context.Background(), chartPath, "my-custom-chart") + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(chartPath, "ts", "input.example.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), "Name: my-custom-chart") + assert.Contains(t, string(content), "Namespace: my-custom-chart") + assert.Contains(t, string(content), "Values:") + assert.Contains(t, string(content), "Capabilities:") + }) + t.Run("fails if ts/ directory already exists", func(t *testing.T) { chartPath := filepath.Join(t.TempDir(), "test-chart") tsDir := filepath.Join(chartPath, "ts") From 83406cff0029c848561642fcba8e1e6c5e1b1844 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 26 Feb 2026 11:22:48 +0300 Subject: [PATCH 15/30] wip: tree folder structure for typescript render Signed-off-by: Dmitry Mordvinov --- internal/ts/init_templates.go | 38 ++++++++++++++-------------- internal/ts/render.go | 47 ++++++++++++++--------------------- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 8d2f853b..26a7aeb9 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -129,43 +129,41 @@ await runRender(render); APIVersions: - v1 HelmVersion: - go_version: go1.25.5 - version: v3.14 + go_version: go1.25.0 + version: v3.20 KubeVersion: Major: "1" - Minor: "33" - Version: v1.33.6+k3s1 + Minor: "35" + Version: v1.35.0 Chart: APIVersion: v2 Annotations: - app.kubernetes.io/managed-by: Helm - AppVersion: 1.27.4 - Condition: nginx.enabled - Description: An example Helm chart for Kubernetes - Home: https://github.com/werf/nelm-chart-ts-sdk - Icon: https://helm.sh/img/helm.svg + anno: value + AppVersion: 1.0.0 + Condition: %[1]s.enabled + Description: %[1]s description + Home: https://example.org/home + Icon: https://example.org/icon Keywords: - - nginx - - webserver + - %[1]s Maintainers: - - Email: maintainer@example.com - Name: John Doe - URL: https://example.com + - Email: john@example.com + Name: john + URL: https://example.com/john Name: %[1]s Sources: - - https://github.com/werf/nelm-chart-ts-sdk - Tags: frontend + - https://example.org/%[1]s + Tags: %[1]s Type: application Version: 0.1.0 Files: - .gitignore: "" - .helmignore: "" + myfile: "content" Release: IsInstall: false IsUpgrade: true Name: %[1]s Namespace: %[1]s - Revision: 171 + Revision: 2 Service: Helm Values: image: diff --git a/internal/ts/render.go b/internal/ts/render.go index 353fd0ab..7491404a 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -32,15 +32,19 @@ func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues cha func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, tempDirPath string) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - rendered, err := renderFiles(ctx, chart, values, tempDirPath) - if err != nil { - return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) - } + entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) - for filename, content := range rendered { - outputPath := path.Join(pathPrefix, filename) - results[outputPath] = content - log.Default.Debug(ctx, "Rendered output: %s", outputPath) + if entrypoint != "" && bundle != nil { + content, err := renderChart(ctx, bundle, chart, values, tempDirPath) + if err != nil { + return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) + } + + if content != "" { + outputPath := path.Join(pathPrefix, tsbundle.ChartTSSourceDir, entrypoint) + results[outputPath] = content + log.Default.Debug(ctx, "Rendered output: %s", outputPath) + } } for _, dep := range chart.Dependencies() { @@ -64,41 +68,28 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string) (map[string]string, error) { - entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) - if entrypoint == "" || bundle == nil { - return map[string]string{}, nil - } - - renderDir := path.Join(tempDirPath, "typescript-render", chart.Name()) +func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string) (string, error) { + renderDir := path.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) if err := os.MkdirAll(renderDir, 0o755); err != nil { - return map[string]string{}, fmt.Errorf("create temp dir for render context: %w", err) + return "", fmt.Errorf("create temp dir for render context: %w", err) } err := writeInputRenderContext(renderedValues, chart, renderDir) if err != nil { - return nil, fmt.Errorf("build render context: %w", err) + return "", fmt.Errorf("build render context: %w", err) } err = runApp(ctx, bundle.Data, renderDir) if err != nil { - return nil, fmt.Errorf("run deno app: %w", err) + return "", fmt.Errorf("run deno app: %w", err) } resultBytes, err := os.ReadFile(path.Join(renderDir, renderOutputFileName)) if err != nil { - return nil, fmt.Errorf("read output file: %w", err) - } - - result := string(resultBytes) - - if strings.TrimSpace(result) == "" { - return map[string]string{}, nil + return "", fmt.Errorf("read output file: %w", err) } - return map[string]string{ - path.Join(tsbundle.ChartTSSourceDir, entrypoint): result, - }, nil + return strings.TrimSpace(string(resultBytes)), nil } func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, subchart *helmchart.Chart) chartutil.Values { From 28db0c4155c1f56440d3d840fef4bd482eccf9c9 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 26 Feb 2026 22:37:23 +0300 Subject: [PATCH 16/30] wip: DenoRuntime class + binary downloader with double-checked locking + atomic mv Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 3 +- internal/ts/deno.go | 108 ------------- internal/ts/init.go | 6 +- internal/ts/render.go | 24 +-- pkg/action/chart_ts_build.go | 7 +- pkg/deno/downloader.go | 220 +++++++++++++++++++++++++ pkg/deno/runtime.go | 304 +++++++++++++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 126 deletions(-) delete mode 100644 internal/ts/deno.go create mode 100644 pkg/deno/downloader.go create mode 100644 pkg/deno/runtime.go diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index fae5bd7e..131dedb9 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -11,6 +11,7 @@ import ( "github.com/werf/3p-helm/pkg/chart/loader" "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/common-go/pkg/cli" + "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -36,7 +37,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" if featgate.FeatGateTypescript.Enabled() { - tsbundle.BundleEnabled = true + tsruntime.TSRuntime = deno.NewDenoRuntime(true) } if err := originalRunE(cmd, args); err != nil { diff --git a/internal/ts/deno.go b/internal/ts/deno.go deleted file mode 100644 index 54869807..00000000 --- a/internal/ts/deno.go +++ /dev/null @@ -1,108 +0,0 @@ -package ts - -import ( - "bufio" - "context" - "fmt" - "os/exec" - - "github.com/gookit/color" - - "github.com/werf/3p-helm/pkg/werf/ts" - "github.com/werf/nelm/pkg/log" -) - -const ( - renderInputFileName = "input.yaml" - renderOutputFileName = "output.yaml" -) - -func runApp(ctx context.Context, bundleData []byte, renderDir string) error { - args := []string{ - "run", - "--no-remote", - // deno permissions: allow read/write only for input and output files, deny all else. - "--allow-read=" + renderInputFileName, - "--allow-write=" + renderOutputFileName, - "--deny-net", - "--deny-env", - "--deny-run", - // write bundle data to Stdin - "-", - // pass input and output file names as arguments - "--input-file=" + renderInputFileName, - "--output-file=" + renderOutputFileName, - } - - denoBin := tsbundle.GetDenoBinary() - cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = renderDir - - stdinPipe, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("get stdin pipe: %w", err) - } - - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("get stdout pipe: %w", err) - } - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("get stderr pipe: %w", err) - } - - stdinErrChan := make(chan error, 1) - go func() { - defer func() { - _ = stdinPipe.Close() - }() - - _, writeErr := stdinPipe.Write(bundleData) - stdinErrChan <- writeErr - }() - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start process: %w", err) - } - - stdoutErrChan := make(chan error, 1) - go func() { - scanner := bufio.NewScanner(stdoutPipe) - for scanner.Scan() { - text := scanner.Text() - log.Default.Debug(ctx, text) - } - - stdoutErrChan <- scanner.Err() - }() - - stderrErrChan := make(chan error, 1) - go func() { - scanner := bufio.NewScanner(stderrPipe) - for scanner.Scan() { - log.Default.Debug(ctx, color.Style{color.Red}.Sprint(scanner.Text())) - } - - stderrErrChan <- scanner.Err() - }() - - if err := cmd.Wait(); err != nil { - return fmt.Errorf("wait process: %w", err) - } - - if err := <-stdinErrChan; err != nil { - return fmt.Errorf("write bundle data to stdinPipe: %w", err) - } - - if err := <-stdoutErrChan; err != nil { - return fmt.Errorf("read stdout: %w", err) - } - - if err := <-stderrErrChan; err != nil { - return fmt.Errorf("read stderr: %w", err) - } - - return nil -} diff --git a/internal/ts/init.go b/internal/ts/init.go index e582754e..79186693 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - tsbundle "github.com/werf/3p-helm/pkg/werf/ts" + "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/log" ) @@ -31,7 +31,7 @@ func EnsureGitignore(chartPath string) error { // 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, tsbundle.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, deno.ChartTSSourceDir) if _, err := os.Stat(tsDir); err == nil { return fmt.Errorf("init chart structure: typescript directory already exists: %s", tsDir) } else if !os.IsNotExist(err) { @@ -77,7 +77,7 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error // InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { - tsDir := filepath.Join(chartPath, tsbundle.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, deno.ChartTSSourceDir) srcDir := filepath.Join(tsDir, "src") if _, err := os.Stat(tsDir); err == nil { diff --git a/internal/ts/render.go b/internal/ts/render.go index 7491404a..5df842aa 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -11,37 +11,38 @@ import ( helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chartutil" - tsbundle "github.com/werf/3p-helm/pkg/werf/ts" + "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/log" ) func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath string) (map[string]string, error) { allRendered := make(map[string]string) - if err := tsbundle.BundleTSChartsRecursive(ctx, chart, chartPath, rebuildBundle); err != nil { + denoRuntime := deno.NewDenoRuntime(rebuildBundle) + if err := denoRuntime.BundleChartsRecursive(ctx, chart, chartPath); err != nil { return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) } - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, tempDirPath); err != nil { + if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, tempDirPath, denoRuntime); err != nil { return nil, err } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, tempDirPath string) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, tempDirPath string, denoRuntime *deno.DenoRuntime) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) - entrypoint, bundle := tsbundle.GetEntrypointAndBundle(chart.RuntimeFiles) + entrypoint, bundle := deno.GetEntrypointAndBundle(chart.RuntimeFiles) if entrypoint != "" && bundle != nil { - content, err := renderChart(ctx, bundle, chart, values, tempDirPath) + content, err := renderChart(ctx, bundle, chart, values, tempDirPath, denoRuntime) if err != nil { return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } if content != "" { - outputPath := path.Join(pathPrefix, tsbundle.ChartTSSourceDir, entrypoint) + outputPath := path.Join(pathPrefix, deno.ChartTSSourceDir, entrypoint) results[outputPath] = content log.Default.Debug(ctx, "Rendered output: %s", outputPath) } @@ -59,6 +60,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch path.Join(chartPath, "charts", depName), results, tempDirPath, + denoRuntime, ) if err != nil { return fmt.Errorf("render dependency %q: %w", depName, err) @@ -68,7 +70,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string) (string, error) { +func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string, denoRuntime *deno.DenoRuntime) (string, error) { renderDir := path.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) if err := os.MkdirAll(renderDir, 0o755); err != nil { return "", fmt.Errorf("create temp dir for render context: %w", err) @@ -79,12 +81,12 @@ func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.C return "", fmt.Errorf("build render context: %w", err) } - err = runApp(ctx, bundle.Data, renderDir) + err = denoRuntime.RunApp(ctx, bundle.Data, renderDir) if err != nil { return "", fmt.Errorf("run deno app: %w", err) } - resultBytes, err := os.ReadFile(path.Join(renderDir, renderOutputFileName)) + resultBytes, err := os.ReadFile(path.Join(renderDir, deno.RenderOutputFileName)) if err != nil { return "", fmt.Errorf("read output file: %w", err) } @@ -153,7 +155,7 @@ func writeInputRenderContext(renderedValues chartutil.Values, chart *helmchart.C return fmt.Errorf("marshal render context to json: %w", err) } - if err := os.WriteFile(path.Join(renderDir, renderInputFileName), yamlInput, 0o644); err != nil { + if err := os.WriteFile(path.Join(renderDir, deno.RenderInputFileName), yamlInput, 0o644); err != nil { return fmt.Errorf("write render context to file: %w", err) } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 4b0834a0..5462dd43 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -14,7 +14,7 @@ import ( helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chart/loader" "github.com/werf/3p-helm/pkg/werf/helmopts" - tsbundle "github.com/werf/3p-helm/pkg/werf/ts" + "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -53,12 +53,13 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("load chart: %w", err) } - if err = tsbundle.BundleTSChartsRecursive(ctx, chart, absPath, true); err != nil { + denoRuntime := deno.NewDenoRuntime(true) + if err = denoRuntime.BundleChartsRecursive(ctx, chart, absPath); err != nil { return fmt.Errorf("process chart: %w", err) } bundles := lo.Filter(chart.Raw, func(file *helmchart.File, _ int) bool { - return strings.Contains(file.Name, tsbundle.ChartTSBundleFile) + return strings.Contains(file.Name, deno.ChartTSBundleFile) }) if len(bundles) == 0 { diff --git a/pkg/deno/downloader.go b/pkg/deno/downloader.go new file mode 100644 index 00000000..6ff30372 --- /dev/null +++ b/pkg/deno/downloader.go @@ -0,0 +1,220 @@ +package deno + +import ( + "archive/zip" + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/samber/lo" + + "github.com/werf/3p-helm/pkg/helmpath" + "github.com/werf/nelm/internal/util" + "github.com/werf/nelm/pkg/log" +) + +const version = "2.7.1" + +var nonAlphanumRegexp = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +func downloadDeno(ctx context.Context, cacheDir, link string) error { + httpClient := util.NewRestyClient(ctx) + httpClient.SetTimeout(5 * time.Minute) + + expectedHash, err := fetchExpectedChecksum(ctx, httpClient, link) + if err != nil { + return fmt.Errorf("fetch checksum: %w", err) + } + + tmpDir, err := os.MkdirTemp(cacheDir, "download-*") + if err != nil { + return fmt.Errorf("create temp directory: %w", err) + } + + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + zipFile := filepath.Join(tmpDir, "deno.zip") + + log.Default.Debug(ctx, "Downloading Deno from %s to %s", link, zipFile) + + response, err := httpClient.R().SetContext(ctx).SetOutput(zipFile).Get(link) + if err != nil { + return fmt.Errorf("download Deno from %s: %w", link, err) + } + + if response.IsError() { + return fmt.Errorf("download Deno from %s: status code %d", link, response.StatusCode()) + } + + if err := verifyChecksum(zipFile, expectedHash); err != nil { + return err + } + + reader, err := zip.OpenReader(zipFile) + if err != nil { + return fmt.Errorf("open downloaded Deno archive: %w", err) + } + + defer func() { + _ = reader.Close() + }() + + binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") + + found := false + for _, file := range reader.File { + if file.Name != binaryName { + continue + } + + if err := unzipBinary(tmpDir, file); err != nil { + return err + } + + tmpBinaryPath := filepath.Join(tmpDir, filepath.Base(file.Name)) + finalPath := filepath.Join(cacheDir, filepath.Base(file.Name)) + + if err := os.Rename(tmpBinaryPath, finalPath); err != nil { + return fmt.Errorf("move Deno binary to cache: %w", err) + } + + found = true + + break + } + + if !found { + return fmt.Errorf("deno binary not found in archive") + } + + return nil +} + +func fetchExpectedChecksum(ctx context.Context, httpClient *resty.Client, archiveURL string) (string, error) { + checksumURL := archiveURL + ".sha256sum" + + log.Default.Debug(ctx, "Fetching Deno checksum from %s", checksumURL) + + response, err := httpClient.R().SetContext(ctx).Get(checksumURL) + if err != nil { + return "", fmt.Errorf("download checksum from %s: %w", checksumURL, err) + } + + if response.IsError() { + return "", fmt.Errorf("download checksum from %s: status code %d", checksumURL, response.StatusCode()) + } + + hash, _, _ := strings.Cut(strings.TrimSpace(response.String()), " ") + if len(hash) != 64 { + return "", fmt.Errorf("unexpected checksum format from %s", checksumURL) + } + + return hash, nil +} + +func getDenoFolder(downloadURL string) (string, error) { + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(downloadURL))) + + suffix := downloadURL + if len(suffix) > 15 { + suffix = suffix[len(suffix)-15:] + } + + slug := strings.ToLower(strings.Trim(nonAlphanumRegexp.ReplaceAllString(suffix, "-"), "-")) + + dirName := hash + "-" + slug + cacheDir := helmpath.CachePath("nelm", "deno", dirName) + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("create cache directory for Deno: %w", err) + } + + return cacheDir, nil +} + +func getDownloadLink() (string, error) { + var target string + + switch { + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + target = "x86_64-unknown-linux-gnu" + case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + target = "aarch64-unknown-linux-gnu" + case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + target = "x86_64-apple-darwin" + case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + target = "aarch64-apple-darwin" + case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + target = "x86_64-pc-windows-msvc" + case runtime.GOOS == "windows" && runtime.GOARCH == "arm64": + target = "aarch64-pc-windows-msvc" + default: + return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + url := fmt.Sprintf("https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip", version, target) + + return url, nil +} + +func unzipBinary(cacheDir string, file *zip.File) error { + destPath := filepath.Join(cacheDir, filepath.Base(file.Name)) + + denoFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("create file for Deno binary: %w", err) + } + + defer func() { + _ = denoFile.Close() + }() + + fileReader, err := file.Open() + if err != nil { + return fmt.Errorf("open file %s in Deno archive: %w", file.Name, err) + } + + limitReader := io.LimitReader(fileReader, 200*1024*1024) + if _, err := io.Copy(denoFile, limitReader); err != nil { + return fmt.Errorf("copy Deno binary to destination: %w", err) + } + + if err := os.Chmod(destPath, 0o755); err != nil { + return fmt.Errorf("chmod Deno binary: %w", err) + } + + return nil +} + +func verifyChecksum(filePath, expectedHash string) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open file for checksum verification: %w", err) + } + + defer func() { + _ = file.Close() + }() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return fmt.Errorf("calculate checksum: %w", err) + } + + actualHash := fmt.Sprintf("%x", hash.Sum(nil)) + if actualHash != expectedHash { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", filePath, expectedHash, actualHash) + } + + return nil +} diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go new file mode 100644 index 00000000..8ebbd01e --- /dev/null +++ b/pkg/deno/runtime.go @@ -0,0 +1,304 @@ +package deno + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/gofrs/flock" + "github.com/gookit/color" + "github.com/samber/lo" + + helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/nelm/pkg/log" +) + +const ( + // ChartTSBundleFile is the path to the bundle in a Helm chart. + ChartTSBundleFile = ChartTSSourceDir + "dist/bundle.js" + // ChartTSEntryPointJS is the JavaScript entry point path. + ChartTSEntryPointJS = "src/index.js" + // ChartTSEntryPointTS is the TypeScript entry point path. + ChartTSEntryPointTS = "src/index.ts" + // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. + ChartTSSourceDir = "ts/" + RenderInputFileName = "input.yaml" + RenderOutputFileName = "output.yaml" +) + +var ChartTSEntryPoints = [...]string{ChartTSEntryPointTS, ChartTSEntryPointJS} + +type DenoRuntime struct { + binPath string + rebuild bool +} + +func NewDenoRuntime(rebuild bool) *DenoRuntime { + return &DenoRuntime{rebuild: rebuild} +} + +func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { + entrypoint, bundle := GetEntrypointAndBundle(chart.RuntimeFiles) + if entrypoint == "" { + return nil + } + + if bundle == nil || rt.rebuild { + if err := rt.ensureBinary(ctx); err != nil { + return fmt.Errorf("ensure Deno is available: %w", err) + } + + bundleRes, err := rt.runDenoBundle(ctx, path, entrypoint) + if err != nil { + return fmt.Errorf("build TypeScript bundle: %w", err) + } + + if rt.rebuild && bundle != nil { + chart.RemoveRuntimeFile(ChartTSBundleFile) + } + + chart.AddRuntimeFile(ChartTSBundleFile, bundleRes) + } + + deps := chart.Dependencies() + if len(deps) == 0 { + return nil + } + + for _, dep := range deps { + depPath := filepath.Join(path, "charts", dep.Name()) + + if _, err := os.Stat(depPath); err != nil { + // Subchart loaded from .tgz or missing on disk — skip, + // deno bundle needs a real directory to work with. + continue + } + + if err := rt.BundleChartsRecursive(ctx, dep, depPath); err != nil { + return fmt.Errorf("process dependency %q: %w", dep.Name(), err) + } + } + + return nil +} + +func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { + args := []string{ + "run", + "--no-remote", + // deno permissions: allow read/write only for input and output files, deny all else. + "--allow-read=" + RenderInputFileName, + "--allow-write=" + RenderOutputFileName, + "--deny-net", + "--deny-env", + "--deny-run", + // write bundle data to Stdin + "-", + // pass input and output file names as arguments + "--input-file=" + RenderInputFileName, + "--output-file=" + RenderOutputFileName, + } + + denoBin := rt.getBinaryPath() + cmd := exec.CommandContext(ctx, denoBin, args...) + cmd.Dir = renderDir + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("get stdin pipe: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("get stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("get stderr pipe: %w", err) + } + + stdinErrChan := make(chan error, 1) + go func() { + defer func() { + _ = stdinPipe.Close() + }() + + _, writeErr := stdinPipe.Write(bundleData) + stdinErrChan <- writeErr + }() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start process: %w", err) + } + + stdoutErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + text := scanner.Text() + log.Default.Debug(ctx, text) + } + + stdoutErrChan <- scanner.Err() + }() + + stderrErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + log.Default.Debug(ctx, color.Style{color.Red}.Sprint(scanner.Text())) + } + + stderrErrChan <- scanner.Err() + }() + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("wait process: %w", err) + } + + if err := <-stdinErrChan; err != nil { + return fmt.Errorf("write bundle data to stdinPipe: %w", err) + } + + if err := <-stdoutErrChan; err != nil { + return fmt.Errorf("read stdout: %w", err) + } + + if err := <-stderrErrChan; err != nil { + return fmt.Errorf("read stderr: %w", err) + } + + return nil +} + +func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { + if denoBin, ok := os.LookupEnv("NELM_DENO_BIN"); ok && denoBin != "" { + rt.setBinaryPath(denoBin) + log.Default.Debug(ctx, "Using Deno binary from NELM_DENO_BIN environment variable: %s", denoBin) + + return nil + } + + if denoBin := rt.getBinaryPath(); denoBin != "" { + log.Default.Debug(ctx, "Using Deno binary: %s", denoBin) + + return nil + } + + link, err := getDownloadLink() + if err != nil { + return fmt.Errorf("get download link: %w", err) + } + + cacheDir, err := getDenoFolder(link) + if err != nil { + return fmt.Errorf("get Deno cache folder: %w", err) + } + + binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") + + denoPath := filepath.Join(cacheDir, binaryName) + if _, err := os.Stat(denoPath); err == nil { + rt.setBinaryPath(denoPath) + log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) + + return nil + } + + fileLock := flock.New(filepath.Join(cacheDir, "lock")) + if err := fileLock.Lock(); err != nil { + return fmt.Errorf("acquire lock on Deno cache: %w", err) + } + + defer func() { + _ = fileLock.Unlock() + }() + + if _, err := os.Stat(denoPath); err == nil { + rt.setBinaryPath(denoPath) + log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) + + return nil + } + + if err := downloadDeno(ctx, cacheDir, link); err != nil { + return err + } + + rt.setBinaryPath(denoPath) + log.Default.Debug(ctx, "Deno binary downloaded to: %s", denoPath) + + return nil +} + +func (rt *DenoRuntime) getBinaryPath() string { + return rt.binPath +} + +func (rt *DenoRuntime) runDenoBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { + denoBin := rt.getBinaryPath() + cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) + cmd.Dir = filepath.Join(chartPath, ChartTSSourceDir) + + output, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + _, _ = os.Stderr.Write(exitErr.Stderr) + } + + return nil, fmt.Errorf("get deno build output: %w", err) + } + + return output, nil +} + +func (rt *DenoRuntime) setBinaryPath(path string) { + rt.binPath = path +} + +func GetEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { + entrypoint := findEntrypointInFiles(files) + if entrypoint == "" { + return "", nil + } + + bundleFile, foundBundle := lo.Find(files, func(f *helmchart.File) bool { + return f.Name == ChartTSBundleFile + }) + + if !foundBundle { + return entrypoint, nil + } + + return entrypoint, bundleFile +} + +func findEntrypointInFiles(files []*helmchart.File) string { + sourceFiles := make(map[string][]byte) + + for _, f := range files { + if strings.HasPrefix(f.Name, ChartTSSourceDir+"src/") { + sourceFiles[strings.TrimPrefix(f.Name, ChartTSSourceDir)] = f.Data + } + } + + if len(sourceFiles) == 0 { + return "" + } + + for _, ep := range ChartTSEntryPoints { + if _, ok := sourceFiles[ep]; ok { + return ep + } + } + + return "" +} From 5c339a920e84423131b3c18b17e410f8a747b00d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 26 Feb 2026 22:55:07 +0300 Subject: [PATCH 17/30] wip: updated 3p-helm version Signed-off-by: Dmitry Mordvinov --- go.mod | 3 +-- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 2b1c0347..d3c11610 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.5.0 - github.com/werf/3p-helm v0.0.0-20260226105203-2bf4ce55336e + github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 github.com/werf/lockgate v0.1.1 @@ -94,7 +94,6 @@ 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/gobwas/glob v0.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index 1a83a6cf..70674074 100644 --- a/go.sum +++ b/go.sum @@ -420,8 +420,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= -github.com/werf/3p-helm v0.0.0-20260226105203-2bf4ce55336e h1:fwID2o1P9HH793Lfw1oWIT4ooLEoQtpxYiSqQ3lA6VU= -github.com/werf/3p-helm v0.0.0-20260226105203-2bf4ce55336e/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= +github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef h1:0nlymoaw8678mXiZUkp3tt42GGdBsDmdXTQvjxKvzyg= +github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 h1:ee55f/lNya8V9jCBsQWDhvOw6y1fB0uysop8te9aUcM= From 1b95a3c9f6ae5117c4de0a5329e38aeb0e8cb286 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 26 Feb 2026 23:45:46 +0300 Subject: [PATCH 18/30] wip: fixes for logging Signed-off-by: Dmitry Mordvinov --- internal/ts/render.go | 19 +++++++++---------- pkg/deno/downloader.go | 10 ++++++++-- pkg/deno/runtime.go | 17 ++++++++--------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/internal/ts/render.go b/internal/ts/render.go index 5df842aa..b41b1718 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path" + "path/filepath" "strings" "sigs.k8s.io/yaml" @@ -24,7 +25,7 @@ func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues cha } if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, tempDirPath, denoRuntime); err != nil { - return nil, err + return nil, fmt.Errorf("render chart recursive: %w", err) } return allRendered, nil @@ -57,7 +58,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch dep, scopeValuesForSubchart(values, depName, dep), path.Join(pathPrefix, "charts", depName), - path.Join(chartPath, "charts", depName), + filepath.Join(chartPath, "charts", depName), results, tempDirPath, denoRuntime, @@ -71,22 +72,20 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch } func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string, denoRuntime *deno.DenoRuntime) (string, error) { - renderDir := path.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) + renderDir := filepath.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) if err := os.MkdirAll(renderDir, 0o755); err != nil { return "", fmt.Errorf("create temp dir for render context: %w", err) } - err := writeInputRenderContext(renderedValues, chart, renderDir) - if err != nil { + if err := writeInputRenderContext(renderedValues, chart, renderDir); err != nil { return "", fmt.Errorf("build render context: %w", err) } - err = denoRuntime.RunApp(ctx, bundle.Data, renderDir) - if err != nil { + if err := denoRuntime.RunApp(ctx, bundle.Data, renderDir); err != nil { return "", fmt.Errorf("run deno app: %w", err) } - resultBytes, err := os.ReadFile(path.Join(renderDir, deno.RenderOutputFileName)) + resultBytes, err := os.ReadFile(filepath.Join(renderDir, deno.RenderOutputFileName)) if err != nil { return "", fmt.Errorf("read output file: %w", err) } @@ -152,10 +151,10 @@ func writeInputRenderContext(renderedValues chartutil.Values, chart *helmchart.C yamlInput, err := yaml.Marshal(renderContext) if err != nil { - return fmt.Errorf("marshal render context to json: %w", err) + return fmt.Errorf("marshal render context to yaml: %w", err) } - if err := os.WriteFile(path.Join(renderDir, deno.RenderInputFileName), yamlInput, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(renderDir, deno.RenderInputFileName), yamlInput, 0o644); err != nil { return fmt.Errorf("write render context to file: %w", err) } diff --git a/pkg/deno/downloader.go b/pkg/deno/downloader.go index 6ff30372..14e05ed8 100644 --- a/pkg/deno/downloader.go +++ b/pkg/deno/downloader.go @@ -57,7 +57,7 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } if err := verifyChecksum(zipFile, expectedHash); err != nil { - return err + return fmt.Errorf("verify checksum: %w", err) } reader, err := zip.OpenReader(zipFile) @@ -78,7 +78,7 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } if err := unzipBinary(tmpDir, file); err != nil { - return err + return fmt.Errorf("unzip binary: %w", err) } tmpBinaryPath := filepath.Join(tmpDir, filepath.Base(file.Name)) @@ -88,6 +88,8 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { return fmt.Errorf("move Deno binary to cache: %w", err) } + log.Default.Debug(ctx, "Unzipped Deno to %s", finalPath) + found = true break @@ -184,6 +186,10 @@ func unzipBinary(cacheDir string, file *zip.File) error { return fmt.Errorf("open file %s in Deno archive: %w", file.Name, err) } + defer func() { + _ = fileReader.Close() + }() + limitReader := io.LimitReader(fileReader, 200*1024*1024) if _, err := io.Copy(denoFile, limitReader); err != nil { return fmt.Errorf("copy Deno binary to destination: %w", err) diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index 8ebbd01e..20addc2a 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -179,15 +179,13 @@ func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir } func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { - if denoBin, ok := os.LookupEnv("NELM_DENO_BIN"); ok && denoBin != "" { - rt.setBinaryPath(denoBin) - log.Default.Debug(ctx, "Using Deno binary from NELM_DENO_BIN environment variable: %s", denoBin) - + if denoBin := rt.getBinaryPath(); denoBin != "" { return nil } - if denoBin := rt.getBinaryPath(); denoBin != "" { - log.Default.Debug(ctx, "Using Deno binary: %s", denoBin) + if denoBin, ok := os.LookupEnv("NELM_DENO_BIN"); ok && denoBin != "" { + rt.setBinaryPath(denoBin) + log.Default.Debug(ctx, "Using Deno binary from NELM_DENO_BIN environment variable: %s", denoBin) return nil } @@ -218,7 +216,9 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { } defer func() { - _ = fileLock.Unlock() + if err := fileLock.Unlock(); err != nil { + log.Default.Error(ctx, "release lock on Deno cache: %v", err) + } }() if _, err := os.Stat(denoPath); err == nil { @@ -229,11 +229,10 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { } if err := downloadDeno(ctx, cacheDir, link); err != nil { - return err + return fmt.Errorf("download deno: %w", err) } rt.setBinaryPath(denoPath) - log.Default.Debug(ctx, "Deno binary downloaded to: %s", denoPath) return nil } From 8b3dec7050fcaeac34e4a5701097640ea72e28b8 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 27 Feb 2026 16:32:55 +0300 Subject: [PATCH 19/30] wip: changed naming for ts package from 3p-helm Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 2 +- go.mod | 2 +- go.sum | 2 ++ pkg/deno/runtime.go | 23 +++++++++++++++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 131dedb9..5e67e29c 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -37,7 +37,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" if featgate.FeatGateTypescript.Enabled() { - tsruntime.TSRuntime = deno.NewDenoRuntime(true) + ts.DefaultBundler = deno.NewDenoRuntime(true) } if err := originalRunE(cmd, args); err != nil { diff --git a/go.mod b/go.mod index d3c11610..4aa0fe27 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.5.0 - github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef + github.com/werf/3p-helm v0.0.0-20260227125226-985df3259b6d github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 github.com/werf/lockgate v0.1.1 diff --git a/go.sum b/go.sum index 70674074..c6ea1f4e 100644 --- a/go.sum +++ b/go.sum @@ -422,6 +422,8 @@ github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef h1:0nlymoaw8678mXiZUkp3tt42GGdBsDmdXTQvjxKvzyg= github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= +github.com/werf/3p-helm v0.0.0-20260227125226-985df3259b6d h1:Sjvh9UKVevx2jTK9hbn/zsgjD9CE6SiucqYOHew9GTQ= +github.com/werf/3p-helm v0.0.0-20260227125226-985df3259b6d/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/kubedog v0.13.1-0.20260115171811-304218f24308 h1:ee55f/lNya8V9jCBsQWDhvOw6y1fB0uysop8te9aUcM= diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index 20addc2a..7101fcc0 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -16,6 +16,7 @@ import ( "github.com/samber/lo" helmchart "github.com/werf/3p-helm/pkg/chart" + "github.com/werf/3p-helm/pkg/werf/ts" "github.com/werf/nelm/pkg/log" ) @@ -27,12 +28,18 @@ const ( // ChartTSEntryPointTS is the TypeScript entry point path. ChartTSEntryPointTS = "src/index.ts" // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. - ChartTSSourceDir = "ts/" - RenderInputFileName = "input.yaml" + ChartTSSourceDir = "ts/" + // RenderInputFileName is the name of the input file with context for the Deno app. + RenderInputFileName = "input.yaml" + // RenderOutputFileName is the name of the output file with rendered manifests from the Deno app. RenderOutputFileName = "output.yaml" ) -var ChartTSEntryPoints = [...]string{ChartTSEntryPointTS, ChartTSEntryPointJS} +var ( + _ ts.Bundler = (*DenoRuntime)(nil) + + ChartTSEntryPoints = [...]string{ChartTSEntryPointTS, ChartTSEntryPointJS} +) type DenoRuntime struct { binPath string @@ -54,6 +61,8 @@ func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmcha return fmt.Errorf("ensure Deno is available: %w", err) } + log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) + bundleRes, err := rt.runDenoBundle(ctx, path, entrypoint) if err != nil { return fmt.Errorf("build TypeScript bundle: %w", err) @@ -210,7 +219,9 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { return nil } - fileLock := flock.New(filepath.Join(cacheDir, "lock")) + lockFile := filepath.Join(cacheDir, "lock") + + fileLock := flock.New(lockFile) if err := fileLock.Lock(); err != nil { return fmt.Errorf("acquire lock on Deno cache: %w", err) } @@ -219,6 +230,10 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { if err := fileLock.Unlock(); err != nil { log.Default.Error(ctx, "release lock on Deno cache: %v", err) } + + if err := os.Remove(lockFile); err != nil { + log.Default.Error(ctx, "remove Deno cache lock file: %v", err) + } }() if _, err := os.Stat(denoPath); err == nil { From 30423bf57dd25328b5af5eb9ae90e8687b1c9206 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 27 Feb 2026 20:35:20 +0300 Subject: [PATCH 20/30] wip: --rebuild-ts and --deno-bin-path flags Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart.go | 2 - cmd/nelm/chart_init.go | 81 ------------------------------ cmd/nelm/chart_lint.go | 14 ++++++ cmd/nelm/chart_pack.go | 16 +++++- cmd/nelm/chart_render.go | 14 ++++++ cmd/nelm/chart_ts_build.go | 11 +++- cmd/nelm/chart_ts_init.go | 4 +- cmd/nelm/groups.go | 1 + cmd/nelm/release_install.go | 9 +++- cmd/nelm/release_plan_install.go | 14 ++++++ internal/chart/chart_render.go | 7 +-- internal/ts/render.go | 4 +- pkg/action/chart_lint.go | 6 +++ pkg/action/chart_render.go | 6 +++ pkg/action/chart_ts_build.go | 5 +- pkg/action/release_install.go | 5 +- pkg/action/release_plan_install.go | 6 +++ pkg/deno/runtime.go | 21 +++++--- 18 files changed, 121 insertions(+), 105 deletions(-) delete mode 100644 cmd/nelm/chart_init.go diff --git a/cmd/nelm/chart.go b/cmd/nelm/chart.go index 7a488871..58786f4a 100644 --- a/cmd/nelm/chart.go +++ b/cmd/nelm/chart.go @@ -18,8 +18,6 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. cli.GroupCommandOptions{}, ) - // TODO: add chart init command when it's implemented - // 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 deleted file mode 100644 index 00506c0c..00000000 --- a/cmd/nelm/chart_init.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "cmp" - "context" - "fmt" - - "github.com/spf13/cobra" - - "github.com/werf/common-go/pkg/cli" - "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/log" -) - -type chartInitConfig struct { - ChartDirPath string - LogColorMode string - LogLevel string - TempDirPath string -} - -// newChartInitCommand -func _(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] - } - - // TODO: implement chart init logic for non-TypeScript charts - - return nil - }, - ) - - afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { - 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_lint.go b/cmd/nelm/chart_lint.go index e34fd2e1..6475747b 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -261,6 +261,20 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); 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, diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 5e67e29c..2ddc0563 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" @@ -28,6 +29,8 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cmd.Aliases = []string{} cli.SetSubCommandAnnotations(cmd, 30, chartCmdGroup) + var denoBinaryPath string + originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { helmSettings := helm_v3.Settings @@ -37,7 +40,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" if featgate.FeatGateTypescript.Enabled() { - ts.DefaultBundler = deno.NewDenoRuntime(true) + ts.DefaultBundler = deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: denoBinaryPath}) } if err := originalRunE(cmd, args); err != nil { @@ -47,5 +50,16 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return nil } + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + if err := cli.AddFlag(cmd, &denoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + return nil + } + return cmd } diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index ed5f4f81..1c933550 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -248,6 +248,20 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); 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, diff --git a/cmd/nelm/chart_ts_build.go b/cmd/nelm/chart_ts_build.go index c87c88d7..6527f47b 100644 --- a/cmd/nelm/chart_ts_build.go +++ b/cmd/nelm/chart_ts_build.go @@ -26,8 +26,8 @@ func newChartTSBuildCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[ cmd := cli.NewSubCommand( ctx, "build [PATH]", - "Build vendor for typescript chart.", - "Build vendor for typescript chart in the specified directory. If PATH is not specified, uses the current directory.", + "Build TypeScript chart.", + "Build TypeScript chart in the specified directory. If PATH is not specified, uses the current directory.", 10, // priority for ordering in help tsCmdGroup, cli.SubCommandOptions{ @@ -68,6 +68,13 @@ func newChartTSBuildCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[ return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + return nil } diff --git a/cmd/nelm/chart_ts_init.go b/cmd/nelm/chart_ts_init.go index b19c4307..1fb9af80 100644 --- a/cmd/nelm/chart_ts_init.go +++ b/cmd/nelm/chart_ts_init.go @@ -26,8 +26,8 @@ func newChartTSInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* cmd := cli.NewSubCommand( ctx, "init [PATH]", - "Initialize a new typescript chart.", - "Initialize a new typescript chart in the specified directory. If PATH is not specified, uses the current directory.", + "Initialize a new TypeScript chart.", + "Initialize a new TypeScript chart in the specified directory. If PATH is not specified, uses the current directory.", 20, // priority for ordering in help tsCmdGroup, cli.SubCommandOptions{ diff --git a/cmd/nelm/groups.go b/cmd/nelm/groups.go index 1f9f38cc..900686ca 100644 --- a/cmd/nelm/groups.go +++ b/cmd/nelm/groups.go @@ -17,6 +17,7 @@ var ( secretFlagGroup = cli.NewFlagGroup("secret", "Secret options:", 80) resourceValidationGroup = cli.NewFlagGroup("resource-validation", "Resource validation options:", 75) patchFlagGroup = cli.NewFlagGroup("patch", "Patch options:", 70) + tsFlagGroup = cli.NewFlagGroup("typescript", "TypeScript options:", 67) progressFlagGroup = cli.NewFlagGroup("progress", "Progress options:", 65) chartRepoFlagGroup = cli.NewFlagGroup("chart-repo", "Chart repository options:", 60) kubeConnectionFlagGroup = cli.NewFlagGroup("kube-connection", "Kubernetes connection options:", 50) diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 818074f1..d34d861a 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -315,7 +315,14 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: miscFlagGroup, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, }); err != nil { return fmt.Errorf("add flag: %w", err) } diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 7122ddbe..144aef2c 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -313,6 +313,20 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: tsFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.Timeout, "timeout", 0, "Fail if not finished in time", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: mainFlagGroup, diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index b806312a..af3a6cfc 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -46,6 +46,7 @@ type RenderChartOptions struct { ChartProvenanceStrategy string ChartRepoNoUpdate bool ChartVersion string + DenoBinaryPath string ExtraAPIVersions []string HelmOptions helmopts.HelmOptions LocalKubeVersion string @@ -224,7 +225,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle, opts.TempDirPath) + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle, opts.TempDirPath, opts.DenoBinaryPath) if err != nil { return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) } @@ -355,10 +356,10 @@ func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } -func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, tempDirPath string) (map[string]string, error) { +func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, tempDirPath, denoBinaryPath string) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath, tempDirPath) + result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath, tempDirPath, denoBinaryPath) if err != nil { return nil, fmt.Errorf("render TypeScript: %w", err) } diff --git a/internal/ts/render.go b/internal/ts/render.go index b41b1718..906feb0f 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -16,10 +16,10 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath string) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { allRendered := make(map[string]string) - denoRuntime := deno.NewDenoRuntime(rebuildBundle) + denoRuntime := deno.NewDenoRuntime(rebuildBundle, deno.DenoRuntimeOptions{BinaryPath: denoBinaryPath}) if err := denoRuntime.BundleChartsRecursive(ctx, chart, chartPath); err != nil { return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) } diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 8d8c6301..49585f05 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -60,6 +60,8 @@ type ChartLintOptions struct { DefaultChartVersion string // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string + // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. + DenoBinaryPath string // ExtraAPIVersions is a list of additional Kubernetes API versions to include during linting. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -98,6 +100,8 @@ type ChartLintOptions struct { // NoRemoveManualChanges, when true, preserves fields during validation that would be manually added. // Used in the validation dry-run to check resource compatibility. NoRemoveManualChanges bool + // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. + RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -265,6 +269,8 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, + RebuildTSBundle: opts.RebuildTSBundle, + DenoBinaryPath: opts.DenoBinaryPath, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 0b221da9..6711b903 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -60,6 +60,8 @@ type ChartRenderOptions struct { DefaultChartName string // DefaultChartVersion sets the default chart version when Chart.yaml doesn't specify one. DefaultChartVersion string + // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. + DenoBinaryPath string // ExtraAPIVersions is a list of additional Kubernetes API versions to include when rendering. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -95,6 +97,8 @@ type ChartRenderOptions struct { // OutputNoPrint, when true, suppresses printing the rendered manifests to stdout. // Useful when only the result data structure is needed. OutputNoPrint bool + // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. + RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -268,6 +272,8 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, + RebuildTSBundle: opts.RebuildTSBundle, + DenoBinaryPath: opts.DenoBinaryPath, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 5462dd43..e8ccc108 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -20,7 +20,8 @@ import ( ) type ChartTSBuildOptions struct { - ChartDirPath string + ChartDirPath string + DenoBinaryPath string } func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { @@ -53,7 +54,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("load chart: %w", err) } - denoRuntime := deno.NewDenoRuntime(true) + denoRuntime := deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: opts.DenoBinaryPath}) if err = denoRuntime.BundleChartsRecursive(ctx, chart, absPath); err != nil { return fmt.Errorf("process chart: %w", err) } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index a9f28f79..e290b7c7 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -76,6 +76,8 @@ type ReleaseInstallOptions struct { DefaultChartName string // DefaultChartVersion sets the default chart version when Chart.yaml doesn't specify one. DefaultChartVersion string + // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. + DenoBinaryPath string // InstallGraphPath, if specified, saves the Graphviz representation of the install plan to this file path. // Useful for debugging and visualizing the dependency graph of resource operations. InstallGraphPath string @@ -106,7 +108,7 @@ type ReleaseInstallOptions struct { PlanArtifactLifetime time.Duration // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string - // RebuildTSBundle, when true, forces rebuilding the Deno vendor bundle even if it already exists. + // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. @@ -363,6 +365,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, RebuildTSBundle: opts.RebuildTSBundle, + DenoBinaryPath: opts.DenoBinaryPath, TempDirPath: opts.TempDirPath, }) if err != nil { diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index e37e3b17..2f2a00cf 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -72,6 +72,8 @@ type ReleasePlanInstallOptions struct { DefaultChartName string // DefaultChartVersion sets the default chart version when Chart.yaml doesn't specify one. DefaultChartVersion string + // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. + DenoBinaryPath string // ErrorIfChangesPlanned, when true, returns ErrChangesPlanned if any changes are detected. // Used with --exit-code flag to return exit code 2 if changes are planned, 0 if no changes, 1 on error. ErrorIfChangesPlanned bool @@ -97,6 +99,8 @@ type ReleasePlanInstallOptions struct { NoFinalTracking bool // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string + // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. + RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -270,6 +274,8 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc Remote: true, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, + RebuildTSBundle: opts.RebuildTSBundle, + DenoBinaryPath: opts.DenoBinaryPath, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index 7101fcc0..0c57aaf2 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -46,8 +46,8 @@ type DenoRuntime struct { rebuild bool } -func NewDenoRuntime(rebuild bool) *DenoRuntime { - return &DenoRuntime{rebuild: rebuild} +func NewDenoRuntime(rebuild bool, opts DenoRuntimeOptions) *DenoRuntime { + return &DenoRuntime{binPath: opts.BinaryPath, rebuild: rebuild} } func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { @@ -98,6 +98,10 @@ func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmcha } func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { + if err := rt.ensureBinary(ctx); err != nil { + return fmt.Errorf("ensure Deno is available: %w", err) + } + args := []string{ "run", "--no-remote", @@ -189,12 +193,9 @@ func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { if denoBin := rt.getBinaryPath(); denoBin != "" { - return nil - } - - if denoBin, ok := os.LookupEnv("NELM_DENO_BIN"); ok && denoBin != "" { - rt.setBinaryPath(denoBin) - log.Default.Debug(ctx, "Using Deno binary from NELM_DENO_BIN environment variable: %s", denoBin) + if _, err := os.Stat(denoBin); err != nil { + return fmt.Errorf("deno binary not found on path %q", denoBin) + } return nil } @@ -278,6 +279,10 @@ func (rt *DenoRuntime) setBinaryPath(path string) { rt.binPath = path } +type DenoRuntimeOptions struct { + BinaryPath string +} + func GetEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { entrypoint := findEntrypointInFiles(files) if entrypoint == "" { From ef3ce5fd2c67d18c77f57772505446207d2935e4 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 6 Mar 2026 19:22:22 +0300 Subject: [PATCH 21/30] fix: review recommendations fixes Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_ts_init.go | 2 +- cmd/nelm/groups.go | 2 +- internal/chart/chart_render.go | 17 ++----- internal/ts/init.go | 10 ++-- internal/ts/init_templates.go | 8 ---- internal/ts/render.go | 44 +++++++++-------- pkg/action/chart_ts_build.go | 3 +- pkg/common/common.go | 12 +++-- pkg/deno/downloader.go | 56 ++++++++++++++-------- pkg/deno/runtime.go | 87 +++++++++++++--------------------- 10 files changed, 111 insertions(+), 130 deletions(-) diff --git a/cmd/nelm/chart_ts_init.go b/cmd/nelm/chart_ts_init.go index 1fb9af80..129b99cb 100644 --- a/cmd/nelm/chart_ts_init.go +++ b/cmd/nelm/chart_ts_init.go @@ -46,7 +46,7 @@ func newChartTSInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* } if err := action.ChartTSInit(ctx, cfg.ChartTSInitOptions); err != nil { - return fmt.Errorf("chart init: %w", err) + return fmt.Errorf("chart ts init: %w", err) } return nil diff --git a/cmd/nelm/groups.go b/cmd/nelm/groups.go index 900686ca..5f4d1a05 100644 --- a/cmd/nelm/groups.go +++ b/cmd/nelm/groups.go @@ -9,7 +9,7 @@ var ( chartCmdGroup = cli.NewCommandGroup("chart", "Chart commands:", 90) secretCmdGroup = cli.NewCommandGroup("secret", "Secret commands:", 80) dependencyCmdGroup = cli.NewCommandGroup("dependency", "Dependency commands:", 70) - tsCmdGroup = cli.NewCommandGroup("ts", "TypeScript chart commands:", 60) + tsCmdGroup = cli.NewCommandGroup("ts", "TypeScript commands:", 60) repoCmdGroup = cli.NewCommandGroup("repo", "Repo commands:", 60) miscCmdGroup = cli.NewCommandGroup("misc", "Other commands:", 0) mainFlagGroup = cli.NewFlagGroup("main", "Options:", 100) diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index af3a6cfc..94284a5d 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -225,9 +225,11 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues, opts.RebuildTSBundle, opts.TempDirPath, opts.DenoBinaryPath) + log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) + + jsRenderedTemplates, err := ts.RenderChart(ctx, chart, renderedValues, opts.RebuildTSBundle, chartPath, opts.TempDirPath, opts.DenoBinaryPath) if err != nil { - return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("render TypeScript templates for chart %q: %w", chart.Name(), err) } if len(jsRenderedTemplates) > 0 { @@ -356,17 +358,6 @@ func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } -func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, tempDirPath, denoBinaryPath string) (map[string]string, error) { - log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - - result, err := ts.RenderChart(ctx, chart, renderedValues, rebuildBundle, chartPath, tempDirPath, denoBinaryPath) - if err != nil { - return nil, fmt.Errorf("render TypeScript: %w", err) - } - - return result, nil -} - func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, releaseNamespace string, opts RenderChartOptions) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec diff --git a/internal/ts/init.go b/internal/ts/init.go index 79186693..07924297 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "github.com/werf/nelm/pkg/deno" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) @@ -31,7 +31,7 @@ func EnsureGitignore(chartPath string) error { // 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, deno.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) if _, err := os.Stat(tsDir); err == nil { return fmt.Errorf("init chart structure: typescript directory already exists: %s", tsDir) } else if !os.IsNotExist(err) { @@ -77,7 +77,7 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error // InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { - tsDir := filepath.Join(chartPath, deno.ChartTSSourceDir) + tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) srcDir := filepath.Join(tsDir, "src") if _, err := os.Stat(tsDir); err == nil { @@ -95,8 +95,8 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { {content: deploymentTSContent, path: filepath.Join(srcDir, "deployment.ts")}, {content: serviceTSContent, path: filepath.Join(srcDir, "service.ts")}, {content: tsconfigContent, path: filepath.Join(tsDir, "tsconfig.json")}, - {content: denoJSON(denoBuildScript), path: filepath.Join(tsDir, "deno.json")}, - {content: inputExample(chartName), path: filepath.Join(tsDir, "input.example.yaml")}, + {content: fmt.Sprintf(denoJSONTmpl, denoBuildScript), path: filepath.Join(tsDir, "deno.json")}, + {content: fmt.Sprintf(inputExampleContent, chartName), path: filepath.Join(tsDir, "input.example.yaml")}, } if err := os.MkdirAll(srcDir, 0o755); err != nil { diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 26a7aeb9..3a11ad95 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -239,11 +239,3 @@ service: func chartYaml(chartName string) string { return fmt.Sprintf(chartYamlTmpl, chartName) } - -func denoJSON(scriptPath string) string { - return fmt.Sprintf(denoJSONTmpl, scriptPath) -} - -func inputExample(chartName string) string { - return fmt.Sprintf(inputExampleContent, chartName) -} diff --git a/internal/ts/render.go b/internal/ts/render.go index 906feb0f..22c0442f 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -8,67 +8,69 @@ import ( "path/filepath" "strings" + "github.com/samber/lo" "sigs.k8s.io/yaml" helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chartutil" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/log" ) func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { - allRendered := make(map[string]string) - denoRuntime := deno.NewDenoRuntime(rebuildBundle, deno.DenoRuntimeOptions{BinaryPath: denoBinaryPath}) if err := denoRuntime.BundleChartsRecursive(ctx, chart, chartPath); err != nil { - return nil, fmt.Errorf("process chart for TypeScript rendering: %w", err) + return nil, fmt.Errorf("bundle chart recursive: %w", err) } - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, allRendered, tempDirPath, denoRuntime); err != nil { + allRendered, err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, tempDirPath, denoRuntime) + if err != nil { return nil, fmt.Errorf("render chart recursive: %w", err) } return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath string, results map[string]string, tempDirPath string, denoRuntime *deno.DenoRuntime) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath, tempDirPath string, denoRuntime *deno.DenoRuntime) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) + results := make(map[string]string) entrypoint, bundle := deno.GetEntrypointAndBundle(chart.RuntimeFiles) - if entrypoint != "" && bundle != nil { + if bundle != nil { content, err := renderChart(ctx, bundle, chart, values, tempDirPath, denoRuntime) if err != nil { - return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } if content != "" { - outputPath := path.Join(pathPrefix, deno.ChartTSSourceDir, entrypoint) + outputPath := path.Join(pathPrefix, common.ChartTSSourceDir, lo.CoalesceOrEmpty(entrypoint, bundle.Name)) results[outputPath] = content - log.Default.Debug(ctx, "Rendered output: %s", outputPath) + log.Default.Debug(ctx, "Rendered TypeScript output for chart %s: %s", chart.Name(), outputPath) } } for _, dep := range chart.Dependencies() { - depName := dep.Name() - log.Default.Debug(ctx, "Processing dependency %q for chart %q", depName, chart.Name()) + log.Default.Debug(ctx, "Processing dependency %q for chart %q", dep.Name(), chart.Name()) - err := renderChartRecursive( + depResults, err := renderChartRecursive( ctx, dep, - scopeValuesForSubchart(values, depName, dep), - path.Join(pathPrefix, "charts", depName), - filepath.Join(chartPath, "charts", depName), - results, + scopeValuesForSubchart(values, dep.Name(), dep), + path.Join(pathPrefix, "charts", dep.Name()), + filepath.Join(chartPath, "charts", dep.Name()), tempDirPath, denoRuntime, ) if err != nil { - return fmt.Errorf("render dependency %q: %w", depName, err) + return nil, fmt.Errorf("render dependency %q: %w", dep.Name(), err) } + + results = lo.Assign(results, depResults) } - return nil + return results, nil } func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string, denoRuntime *deno.DenoRuntime) (string, error) { @@ -78,14 +80,14 @@ func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.C } if err := writeInputRenderContext(renderedValues, chart, renderDir); err != nil { - return "", fmt.Errorf("build render context: %w", err) + return "", fmt.Errorf("write input render context: %w", err) } if err := denoRuntime.RunApp(ctx, bundle.Data, renderDir); err != nil { return "", fmt.Errorf("run deno app: %w", err) } - resultBytes, err := os.ReadFile(filepath.Join(renderDir, deno.RenderOutputFileName)) + resultBytes, err := os.ReadFile(filepath.Join(renderDir, common.ChartTSOutputFile)) if err != nil { return "", fmt.Errorf("read output file: %w", err) } @@ -154,7 +156,7 @@ func writeInputRenderContext(renderedValues chartutil.Values, chart *helmchart.C return fmt.Errorf("marshal render context to yaml: %w", err) } - if err := os.WriteFile(filepath.Join(renderDir, deno.RenderInputFileName), yamlInput, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(renderDir, common.ChartTSInputFile), yamlInput, 0o644); err != nil { return fmt.Errorf("write render context to file: %w", err) } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index e8ccc108..79d32bd9 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -14,6 +14,7 @@ import ( helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/chart/loader" "github.com/werf/3p-helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" @@ -60,7 +61,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { } bundles := lo.Filter(chart.Raw, func(file *helmchart.File, _ int) bool { - return strings.Contains(file.Name, deno.ChartTSBundleFile) + return strings.Contains(file.Name, common.ChartTSBundleFile) }) if len(bundles) == 0 { diff --git a/pkg/common/common.go b/pkg/common/common.go index 220a660a..0d2cacfd 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -77,15 +77,19 @@ const ( StoreAsHook StoreAs = "hook" StoreAsRegular StoreAs = "regular" + // ChartTSBundleFile is the path to the bundle in a Helm chart. + ChartTSBundleFile = ChartTSSourceDir + "dist/bundle.js" // ChartTSEntryPointJS is the JavaScript entry point path. ChartTSEntryPointJS = "src/index.js" // ChartTSEntryPointTS is the TypeScript entry point path. ChartTSEntryPointTS = "src/index.ts" + // ChartTSInputFile is the name of the input file with context for the Deno app. + ChartTSInputFile = "input.yaml" + // ChartTSOutputFile is the name of the output file with rendered manifests from the Deno app. + ChartTSOutputFile = "output.yaml" // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. - ChartTSSourceDir = "ts/" - // ChartTSVendorBundleFile is the path to the vendor bundle file in a Helm chart. - ChartTSVendorBundleFile = ChartTSSourceDir + "vendor/libs.js" - DefaultBurstLimit = 100 + ChartTSSourceDir = "ts/" + DefaultBurstLimit = 100 // TODO(major): switch to if-possible DefaultChartProvenanceStrategy = "never" // TODO(major): reconsider? diff --git a/pkg/deno/downloader.go b/pkg/deno/downloader.go index 14e05ed8..a3debe62 100644 --- a/pkg/deno/downloader.go +++ b/pkg/deno/downloader.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "fmt" + "hash/fnv" "io" "os" "path/filepath" @@ -23,11 +24,9 @@ import ( const version = "2.7.1" -var nonAlphanumRegexp = regexp.MustCompile(`[^a-zA-Z0-9]+`) - func downloadDeno(ctx context.Context, cacheDir, link string) error { httpClient := util.NewRestyClient(ctx) - httpClient.SetTimeout(5 * time.Minute) + httpClient.SetTimeout(15 * time.Minute) expectedHash, err := fetchExpectedChecksum(ctx, httpClient, link) if err != nil { @@ -40,7 +39,9 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } defer func() { - _ = os.RemoveAll(tmpDir) + if err = os.RemoveAll(tmpDir); err != nil { + log.Default.Error(ctx, "failed to remove temporary directory %s: %s", tmpDir, err) + } }() zipFile := filepath.Join(tmpDir, "deno.zip") @@ -53,10 +54,10 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } if response.IsError() { - return fmt.Errorf("download Deno from %s: status code %d", link, response.StatusCode()) + return fmt.Errorf("download Deno from %s: %s", link, response.Status()) } - if err := verifyChecksum(zipFile, expectedHash); err != nil { + if err := verifyChecksum(ctx, zipFile, expectedHash); err != nil { return fmt.Errorf("verify checksum: %w", err) } @@ -66,18 +67,20 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } defer func() { - _ = reader.Close() + if err = reader.Close(); err != nil { + log.Default.Error(ctx, "close downloaded Deno archive: %s", err) + } }() binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") - found := false + var binaryFound bool for _, file := range reader.File { if file.Name != binaryName { continue } - if err := unzipBinary(tmpDir, file); err != nil { + if err := unzipBinary(ctx, tmpDir, file); err != nil { return fmt.Errorf("unzip binary: %w", err) } @@ -90,12 +93,12 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { log.Default.Debug(ctx, "Unzipped Deno to %s", finalPath) - found = true + binaryFound = true break } - if !found { + if !binaryFound { return fmt.Errorf("deno binary not found in archive") } @@ -113,28 +116,33 @@ func fetchExpectedChecksum(ctx context.Context, httpClient *resty.Client, archiv } if response.IsError() { - return "", fmt.Errorf("download checksum from %s: status code %d", checksumURL, response.StatusCode()) + return "", fmt.Errorf("download checksum from %s: %s", checksumURL, response.Status()) } hash, _, _ := strings.Cut(strings.TrimSpace(response.String()), " ") if len(hash) != 64 { - return "", fmt.Errorf("unexpected checksum format from %s", checksumURL) + return "", fmt.Errorf("unexpected checksum format from %s: %s", checksumURL, hash) } return hash, nil } func getDenoFolder(downloadURL string) (string, error) { - hash := fmt.Sprintf("%x", sha256.Sum256([]byte(downloadURL))) + hash := fnv.New32a() + if _, err := hash.Write([]byte(downloadURL)); err != nil { + return "", fmt.Errorf("calculate hash for Deno cache directory: %w", err) + } + + hashStr := fmt.Sprintf("%x", hash.Sum32()) suffix := downloadURL if len(suffix) > 15 { suffix = suffix[len(suffix)-15:] } - slug := strings.ToLower(strings.Trim(nonAlphanumRegexp.ReplaceAllString(suffix, "-"), "-")) + slug := strings.ToLower(strings.Trim(regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(suffix, "-"), "-")) - dirName := hash + "-" + slug + dirName := hashStr + "-" + slug cacheDir := helmpath.CachePath("nelm", "deno", dirName) if err := os.MkdirAll(cacheDir, 0o755); err != nil { @@ -169,7 +177,7 @@ func getDownloadLink() (string, error) { return url, nil } -func unzipBinary(cacheDir string, file *zip.File) error { +func unzipBinary(ctx context.Context, cacheDir string, file *zip.File) error { destPath := filepath.Join(cacheDir, filepath.Base(file.Name)) denoFile, err := os.Create(destPath) @@ -178,7 +186,9 @@ func unzipBinary(cacheDir string, file *zip.File) error { } defer func() { - _ = denoFile.Close() + if err = denoFile.Close(); err != nil { + log.Default.Error(ctx, "close file for Deno binary: %s", err) + } }() fileReader, err := file.Open() @@ -187,7 +197,9 @@ func unzipBinary(cacheDir string, file *zip.File) error { } defer func() { - _ = fileReader.Close() + if err = fileReader.Close(); err != nil { + log.Default.Error(ctx, "close file %s in Deno archive: %s", file.Name, err) + } }() limitReader := io.LimitReader(fileReader, 200*1024*1024) @@ -202,14 +214,16 @@ func unzipBinary(cacheDir string, file *zip.File) error { return nil } -func verifyChecksum(filePath, expectedHash string) error { +func verifyChecksum(ctx context.Context, filePath, expectedHash string) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file for checksum verification: %w", err) } defer func() { - _ = file.Close() + if err = file.Close(); err != nil { + log.Default.Error(ctx, "close file for checksum verification: %s", err) + } }() hash := sha256.New() diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index 0c57aaf2..aee9cc9b 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -12,33 +12,18 @@ import ( "strings" "github.com/gofrs/flock" - "github.com/gookit/color" "github.com/samber/lo" helmchart "github.com/werf/3p-helm/pkg/chart" "github.com/werf/3p-helm/pkg/werf/ts" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) -const ( - // ChartTSBundleFile is the path to the bundle in a Helm chart. - ChartTSBundleFile = ChartTSSourceDir + "dist/bundle.js" - // ChartTSEntryPointJS is the JavaScript entry point path. - ChartTSEntryPointJS = "src/index.js" - // ChartTSEntryPointTS is the TypeScript entry point path. - ChartTSEntryPointTS = "src/index.ts" - // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. - ChartTSSourceDir = "ts/" - // RenderInputFileName is the name of the input file with context for the Deno app. - RenderInputFileName = "input.yaml" - // RenderOutputFileName is the name of the output file with rendered manifests from the Deno app. - RenderOutputFileName = "output.yaml" -) - var ( _ ts.Bundler = (*DenoRuntime)(nil) - ChartTSEntryPoints = [...]string{ChartTSEntryPointTS, ChartTSEntryPointJS} + ChartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} ) type DenoRuntime struct { @@ -50,29 +35,29 @@ func NewDenoRuntime(rebuild bool, opts DenoRuntimeOptions) *DenoRuntime { return &DenoRuntime{binPath: opts.BinaryPath, rebuild: rebuild} } -func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { +func (r *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { entrypoint, bundle := GetEntrypointAndBundle(chart.RuntimeFiles) if entrypoint == "" { return nil } - if bundle == nil || rt.rebuild { - if err := rt.ensureBinary(ctx); err != nil { + if bundle == nil || r.rebuild { + if err := r.ensureBinary(ctx); err != nil { return fmt.Errorf("ensure Deno is available: %w", err) } log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) - bundleRes, err := rt.runDenoBundle(ctx, path, entrypoint) + bundleRes, err := r.runDenoBundle(ctx, path, entrypoint) if err != nil { return fmt.Errorf("build TypeScript bundle: %w", err) } - if rt.rebuild && bundle != nil { - chart.RemoveRuntimeFile(ChartTSBundleFile) + if bundle != nil { + chart.RemoveRuntimeFile(common.ChartTSBundleFile) } - chart.AddRuntimeFile(ChartTSBundleFile, bundleRes) + chart.AddRuntimeFile(common.ChartTSBundleFile, bundleRes) } deps := chart.Dependencies() @@ -89,7 +74,7 @@ func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmcha continue } - if err := rt.BundleChartsRecursive(ctx, dep, depPath); err != nil { + if err := r.BundleChartsRecursive(ctx, dep, depPath); err != nil { return fmt.Errorf("process dependency %q: %w", dep.Name(), err) } } @@ -97,8 +82,8 @@ func (rt *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmcha return nil } -func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { - if err := rt.ensureBinary(ctx); err != nil { +func (r *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { + if err := r.ensureBinary(ctx); err != nil { return fmt.Errorf("ensure Deno is available: %w", err) } @@ -106,19 +91,19 @@ func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir "run", "--no-remote", // deno permissions: allow read/write only for input and output files, deny all else. - "--allow-read=" + RenderInputFileName, - "--allow-write=" + RenderOutputFileName, + "--allow-read=" + common.ChartTSInputFile, + "--allow-write=" + common.ChartTSOutputFile, "--deny-net", "--deny-env", "--deny-run", // write bundle data to Stdin "-", // pass input and output file names as arguments - "--input-file=" + RenderInputFileName, - "--output-file=" + RenderOutputFileName, + "--input-file=" + common.ChartTSInputFile, + "--output-file=" + common.ChartTSOutputFile, } - denoBin := rt.getBinaryPath() + denoBin := r.binPath cmd := exec.CommandContext(ctx, denoBin, args...) cmd.Dir = renderDir @@ -166,7 +151,7 @@ func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir go func() { scanner := bufio.NewScanner(stderrPipe) for scanner.Scan() { - log.Default.Debug(ctx, color.Style{color.Red}.Sprint(scanner.Text())) + log.Default.Error(ctx, "error from deno app: %s", scanner.Text()) } stderrErrChan <- scanner.Err() @@ -191,10 +176,10 @@ func (rt *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir return nil } -func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { - if denoBin := rt.getBinaryPath(); denoBin != "" { - if _, err := os.Stat(denoBin); err != nil { - return fmt.Errorf("deno binary not found on path %q", denoBin) +func (r *DenoRuntime) ensureBinary(ctx context.Context) error { + if r.binPath != "" { + if _, err := os.Stat(r.binPath); err != nil { + return fmt.Errorf("deno binary not found on path %q", r.binPath) } return nil @@ -214,7 +199,7 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { denoPath := filepath.Join(cacheDir, binaryName) if _, err := os.Stat(denoPath); err == nil { - rt.setBinaryPath(denoPath) + r.binPath = denoPath log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) return nil @@ -238,7 +223,7 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { }() if _, err := os.Stat(denoPath); err == nil { - rt.setBinaryPath(denoPath) + r.binPath = denoPath log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) return nil @@ -248,25 +233,21 @@ func (rt *DenoRuntime) ensureBinary(ctx context.Context) error { return fmt.Errorf("download deno: %w", err) } - rt.setBinaryPath(denoPath) + r.binPath = denoPath return nil } -func (rt *DenoRuntime) getBinaryPath() string { - return rt.binPath -} - -func (rt *DenoRuntime) runDenoBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { - denoBin := rt.getBinaryPath() +func (r *DenoRuntime) runDenoBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { + denoBin := r.binPath cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) - cmd.Dir = filepath.Join(chartPath, ChartTSSourceDir) + cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) output, err := cmd.Output() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { - _, _ = os.Stderr.Write(exitErr.Stderr) + log.Default.Error(ctx, "run deno bundle error: %s", exitErr) } return nil, fmt.Errorf("get deno build output: %w", err) @@ -275,10 +256,6 @@ func (rt *DenoRuntime) runDenoBundle(ctx context.Context, chartPath, entryPoint return output, nil } -func (rt *DenoRuntime) setBinaryPath(path string) { - rt.binPath = path -} - type DenoRuntimeOptions struct { BinaryPath string } @@ -290,7 +267,7 @@ func GetEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { } bundleFile, foundBundle := lo.Find(files, func(f *helmchart.File) bool { - return f.Name == ChartTSBundleFile + return f.Name == common.ChartTSBundleFile }) if !foundBundle { @@ -304,8 +281,8 @@ func findEntrypointInFiles(files []*helmchart.File) string { sourceFiles := make(map[string][]byte) for _, f := range files { - if strings.HasPrefix(f.Name, ChartTSSourceDir+"src/") { - sourceFiles[strings.TrimPrefix(f.Name, ChartTSSourceDir)] = f.Data + if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"src/") { + sourceFiles[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data } } From bf3aeab6059ce6906d9ff3b49d9f2e3a512fbd10 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 6 Mar 2026 23:23:18 +0300 Subject: [PATCH 22/30] fix: changed ts rebuild flag Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 2 +- cmd/nelm/chart_render.go | 2 +- cmd/nelm/chart_ts_build.go | 8 ++++++++ cmd/nelm/common.go | 2 ++ cmd/nelm/release_install.go | 2 +- cmd/nelm/release_plan_install.go | 2 +- internal/chart/chart_render.go | 4 ++-- pkg/action/chart_lint.go | 6 +++--- pkg/action/chart_render.go | 6 +++--- pkg/action/chart_ts_build.go | 1 + pkg/action/release_install.go | 6 +++--- pkg/action/release_plan_install.go | 6 +++--- 12 files changed, 29 insertions(+), 18 deletions(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index 6475747b..07802af3 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -261,7 +261,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 1c933550..e632f53c 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -248,7 +248,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/cmd/nelm/chart_ts_build.go b/cmd/nelm/chart_ts_build.go index 6527f47b..261b2146 100644 --- a/cmd/nelm/chart_ts_build.go +++ b/cmd/nelm/chart_ts_build.go @@ -75,6 +75,14 @@ func newChartTSBuildCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[ 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) + } + return nil } diff --git a/cmd/nelm/common.go b/cmd/nelm/common.go index 7c2d8ad0..3b0fe133 100644 --- a/cmd/nelm/common.go +++ b/cmd/nelm/common.go @@ -9,6 +9,8 @@ import ( "github.com/werf/nelm/pkg/log" ) +const IgnoreBundleJSFlagDescription = "Do not use the existing bundle.js file. Requires TypeScript source files and Deno to rebuild." + var helmRootCmd *cobra.Command func allowedLogColorModesHelp() string { diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index d34d861a..0a702c30 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -313,7 +313,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 144aef2c..04eb4036 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -313,7 +313,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RebuildTSBundle, "rebuild-ts", false, "Rebuild the typescript bundle even if it already exists.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index 94284a5d..2d3bb2be 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -49,9 +49,9 @@ type RenderChartOptions struct { DenoBinaryPath string ExtraAPIVersions []string HelmOptions helmopts.HelmOptions + IgnoreBundleJS bool LocalKubeVersion string NoStandaloneCRDs bool - RebuildTSBundle bool Remote bool SubchartNotes bool TempDirPath string @@ -227,7 +227,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s if featgate.FeatGateTypescript.Enabled() { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) - jsRenderedTemplates, err := ts.RenderChart(ctx, chart, renderedValues, opts.RebuildTSBundle, chartPath, opts.TempDirPath, opts.DenoBinaryPath) + jsRenderedTemplates, err := ts.RenderChart(ctx, chart, renderedValues, opts.IgnoreBundleJS, chartPath, opts.TempDirPath, opts.DenoBinaryPath) if err != nil { return nil, fmt.Errorf("render TypeScript templates for chart %q: %w", chart.Name(), err) } diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 49585f05..445693a5 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -80,6 +80,8 @@ type ChartLintOptions struct { // ForceAdoption, when true, allows adopting resources during validation that belong to a different Helm release. // Used during the validation phase to check if resources could be adopted. ForceAdoption bool + // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. + IgnoreBundleJS bool // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. LegacyChartType helmopts.ChartType @@ -100,8 +102,6 @@ type ChartLintOptions struct { // NoRemoveManualChanges, when true, preserves fields during validation that would be manually added. // Used in the validation dry-run to check resource compatibility. NoRemoveManualChanges bool - // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. - RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -269,7 +269,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, - RebuildTSBundle: opts.RebuildTSBundle, + IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 6711b903..5dbea8f8 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -77,6 +77,8 @@ type ChartRenderOptions struct { // ForceAdoption is currently unused in chart rendering. // TODO(major): remove this useless field. ForceAdoption bool + // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. + IgnoreBundleJS bool // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. LegacyChartType helmopts.ChartType @@ -97,8 +99,6 @@ type ChartRenderOptions struct { // OutputNoPrint, when true, suppresses printing the rendered manifests to stdout. // Useful when only the result data structure is needed. OutputNoPrint bool - // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. - RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -272,7 +272,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, - RebuildTSBundle: opts.RebuildTSBundle, + IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 79d32bd9..28503c56 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -23,6 +23,7 @@ import ( type ChartTSBuildOptions struct { ChartDirPath string DenoBinaryPath string + TempDirPath string } func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index e290b7c7..81b9f956 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -78,6 +78,8 @@ type ReleaseInstallOptions struct { DefaultChartVersion string // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string + // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. + IgnoreBundleJS bool // InstallGraphPath, if specified, saves the Graphviz representation of the install plan to this file path. // Useful for debugging and visualizing the dependency graph of resource operations. InstallGraphPath string @@ -108,8 +110,6 @@ type ReleaseInstallOptions struct { PlanArtifactLifetime time.Duration // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string - // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. - RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -364,7 +364,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re Remote: true, SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, - RebuildTSBundle: opts.RebuildTSBundle, + IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, TempDirPath: opts.TempDirPath, }) diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 2f2a00cf..4dac28d2 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -77,6 +77,8 @@ type ReleasePlanInstallOptions struct { // ErrorIfChangesPlanned, when true, returns ErrChangesPlanned if any changes are detected. // Used with --exit-code flag to return exit code 2 if changes are planned, 0 if no changes, 1 on error. ErrorIfChangesPlanned bool + // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. + IgnoreBundleJS bool // InstallGraphPath, if specified, saves the Graphviz representation of the install plan to this file path. // Useful for debugging and visualizing the dependency graph of resource operations. InstallGraphPath string @@ -99,8 +101,6 @@ type ReleasePlanInstallOptions struct { NoFinalTracking bool // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string - // RebuildTSBundle, when true, forces rebuilding the Deno bundle even if it already exists. - RebuildTSBundle bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. // Used for authenticating to OCI registries when pulling charts. @@ -274,7 +274,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc Remote: true, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, - RebuildTSBundle: opts.RebuildTSBundle, + IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, }) if err != nil { From 2a5a401b4341a47c0dc1a7f1cb0d6796cb427a72 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 10 Mar 2026 20:38:42 +0300 Subject: [PATCH 23/30] fix: changes for chart ts init, small refactor for BundleChartRecursive/ensureDeno, disabled rule for decompress bomb Signed-off-by: Dmitry Mordvinov --- .golangci.yaml | 1 + cmd/nelm/chart_ts_init.go | 4 +- go.mod | 2 + go.sum | 6 ++- internal/ts/init.go | 74 ++++++++++++++++++++---------- internal/ts/init_test.go | 13 ------ pkg/deno/downloader.go | 9 ++-- pkg/deno/runtime.go | 96 +++++++++++++++++++++++---------------- 8 files changed, 120 insertions(+), 85 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 3f863f58..45b2ca24 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -76,6 +76,7 @@ linters: gosec: excludes: - G306 + - G110 # Potential DoS vulnerability via decompression bomb wsl_v5: allow-whole-block: true wrapcheck: diff --git a/cmd/nelm/chart_ts_init.go b/cmd/nelm/chart_ts_init.go index 129b99cb..9c6e18c9 100644 --- a/cmd/nelm/chart_ts_init.go +++ b/cmd/nelm/chart_ts_init.go @@ -26,8 +26,8 @@ func newChartTSInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* cmd := cli.NewSubCommand( ctx, "init [PATH]", - "Initialize a new TypeScript chart.", - "Initialize a new TypeScript chart in the specified directory. If PATH is not specified, uses the current directory.", + "Initialize the TypeScript directory for chart.", + "Initialize the TypeScript directory for chart. If PATH is not specified, uses the current directory.", 20, // priority for ordering in help tsCmdGroup, cli.SubCommandOptions{ diff --git a/go.mod b/go.mod index 4aa0fe27..a91e06a3 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/gookit/color v1.5.4 + github.com/gosimple/slug v1.15.0 github.com/hofstadter-io/cinful v1.0.0 github.com/jedib0t/go-pretty/v6 v6.5.5 github.com/jellydator/ttlcache/v3 v3.1.1 @@ -103,6 +104,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.1 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index c6ea1f4e..c2461fe0 100644 --- a/go.sum +++ b/go.sum @@ -205,6 +205,10 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -420,8 +424,6 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= -github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef h1:0nlymoaw8678mXiZUkp3tt42GGdBsDmdXTQvjxKvzyg= -github.com/werf/3p-helm v0.0.0-20260226193844-1e01262a5cef/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/3p-helm v0.0.0-20260227125226-985df3259b6d h1:Sjvh9UKVevx2jTK9hbn/zsgjD9CE6SiucqYOHew9GTQ= github.com/werf/3p-helm v0.0.0-20260227125226-985df3259b6d/go.mod h1:UAmQvGZhiUULXQpigm1yqcp57s097kpAHz2EvFtKCSk= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= diff --git a/internal/ts/init.go b/internal/ts/init.go index 07924297..07c90448 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -38,43 +38,71 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error return fmt.Errorf("stat %s: %w", tsDir, err) } - skipIfExists := []struct { - content string - path string - }{ - {content: chartYaml(chartName), path: filepath.Join(chartPath, "Chart.yaml")}, - {content: valuesYamlContent, path: filepath.Join(chartPath, "values.yaml")}, + if err := ensureValuesFile(ctx, chartPath); err != nil { + return fmt.Errorf("ensure values.yaml: %w", err) } - for _, f := range skipIfExists { - _, err := os.Stat(f.path) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("stat %s: %w", f.path, err) - } + // Handle .helmignore: create or enrich + helmignorePath := filepath.Join(chartPath, ".helmignore") + if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/vendor/", "ts/node_modules/"}); err != nil { + return fmt.Errorf("ensure helmignore entries: %w", err) + } - if err == nil { - log.Default.Debug(ctx, "Skipping existing file %s", f.path) - continue - } + log.Default.Debug(ctx, "Updated %s", helmignorePath) - if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil { - return fmt.Errorf("write %s: %w", f.path, err) + return nil +} + +func ensureValuesFile(ctx context.Context, chartPath string) error { + valuesPath := filepath.Join(chartPath, "values.yaml") + + exists, err := fileExists(valuesPath) + if err != nil { + return fmt.Errorf("stat %s: %w", valuesPath, err) + } + + if !exists { + if err := os.WriteFile(valuesPath, []byte(valuesYamlContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", valuesPath, err) } - log.Default.Debug(ctx, "Created %s", f.path) + log.Default.Debug(ctx, "Created %s", valuesPath) + + return nil } - // Handle .helmignore: create or enrich - helmignorePath := filepath.Join(chartPath, ".helmignore") - if err := ensureFileEntries(helmignorePath, helmignoreContent, []string{"ts/vendor/", "ts/node_modules/"}); err != nil { - return fmt.Errorf("ensure helmignore entries: %w", err) + examplePath := filepath.Join(chartPath, "values-ts-example.yaml") + + exists, err = fileExists(examplePath) + if err != nil { + return fmt.Errorf("stat %s: %w", examplePath, err) } - log.Default.Debug(ctx, "Updated %s", helmignorePath) + if exists { + log.Default.Debug(ctx, "Skipping existing file %s", examplePath) + return nil + } + + if err := os.WriteFile(examplePath, []byte(valuesYamlContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", examplePath, err) + } + + log.Default.Warn(ctx, "values.yaml already exists, created values-ts-example.yaml instead") return nil } +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + // InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) diff --git a/internal/ts/init_test.go b/internal/ts/init_test.go index 93f6f597..1223032d 100644 --- a/internal/ts/init_test.go +++ b/internal/ts/init_test.go @@ -85,7 +85,6 @@ func TestInitChartStructure(t *testing.T) { err := ts.InitChartStructure(context.Background(), chartPath, "ts-only-chart") require.NoError(t, err) - assert.FileExists(t, filepath.Join(chartPath, "Chart.yaml")) assert.FileExists(t, filepath.Join(chartPath, "values.yaml")) assert.FileExists(t, filepath.Join(chartPath, ".helmignore")) }) @@ -110,18 +109,6 @@ func TestInitChartStructure(t *testing.T) { assert.NoDirExists(t, filepath.Join(chartPath, "templates")) }) - t.Run("substitutes chart name in Chart.yaml", func(t *testing.T) { - chartPath := filepath.Join(t.TempDir(), "my-ts-chart") - require.NoError(t, os.MkdirAll(chartPath, 0o755)) - - err := ts.InitChartStructure(context.Background(), chartPath, "my-ts-chart") - require.NoError(t, err) - - content, err := os.ReadFile(filepath.Join(chartPath, "Chart.yaml")) - require.NoError(t, err) - assert.Contains(t, string(content), "name: my-ts-chart") - }) - t.Run("includes TS entries in .helmignore", func(t *testing.T) { chartPath := filepath.Join(t.TempDir(), "ts-only-chart") require.NoError(t, os.MkdirAll(chartPath, 0o755)) diff --git a/pkg/deno/downloader.go b/pkg/deno/downloader.go index a3debe62..f265d09f 100644 --- a/pkg/deno/downloader.go +++ b/pkg/deno/downloader.go @@ -9,12 +9,12 @@ import ( "io" "os" "path/filepath" - "regexp" "runtime" "strings" "time" "github.com/go-resty/resty/v2" + "github.com/gosimple/slug" "github.com/samber/lo" "github.com/werf/3p-helm/pkg/helmpath" @@ -140,9 +140,7 @@ func getDenoFolder(downloadURL string) (string, error) { suffix = suffix[len(suffix)-15:] } - slug := strings.ToLower(strings.Trim(regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(suffix, "-"), "-")) - - dirName := hashStr + "-" + slug + dirName := hashStr + "-" + slug.Make(suffix) cacheDir := helmpath.CachePath("nelm", "deno", dirName) if err := os.MkdirAll(cacheDir, 0o755); err != nil { @@ -202,8 +200,7 @@ func unzipBinary(ctx context.Context, cacheDir string, file *zip.File) error { } }() - limitReader := io.LimitReader(fileReader, 200*1024*1024) - if _, err := io.Copy(denoFile, limitReader); err != nil { + if _, err := io.Copy(denoFile, fileReader); err != nil { return fmt.Errorf("copy Deno binary to destination: %w", err) } diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index aee9cc9b..d1b85172 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -36,50 +36,15 @@ func NewDenoRuntime(rebuild bool, opts DenoRuntimeOptions) *DenoRuntime { } func (r *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { - entrypoint, bundle := GetEntrypointAndBundle(chart.RuntimeFiles) - if entrypoint == "" { + if !hasTSFiles(chart) { return nil } - if bundle == nil || r.rebuild { - if err := r.ensureBinary(ctx); err != nil { - return fmt.Errorf("ensure Deno is available: %w", err) - } - - log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) - - bundleRes, err := r.runDenoBundle(ctx, path, entrypoint) - if err != nil { - return fmt.Errorf("build TypeScript bundle: %w", err) - } - - if bundle != nil { - chart.RemoveRuntimeFile(common.ChartTSBundleFile) - } - - chart.AddRuntimeFile(common.ChartTSBundleFile, bundleRes) - } - - deps := chart.Dependencies() - if len(deps) == 0 { - return nil - } - - for _, dep := range deps { - depPath := filepath.Join(path, "charts", dep.Name()) - - if _, err := os.Stat(depPath); err != nil { - // Subchart loaded from .tgz or missing on disk — skip, - // deno bundle needs a real directory to work with. - continue - } - - if err := r.BundleChartsRecursive(ctx, dep, depPath); err != nil { - return fmt.Errorf("process dependency %q: %w", dep.Name(), err) - } + if err := r.ensureBinary(ctx); err != nil { + return fmt.Errorf("ensure Deno is available: %w", err) } - return nil + return r.bundleChartsRecursive(ctx, chart, path) } func (r *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { @@ -176,6 +141,44 @@ func (r *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir s return nil } +func (r *DenoRuntime) bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { + entrypoint, bundle := GetEntrypointAndBundle(chart.RuntimeFiles) + if entrypoint == "" { + return nil + } + + if bundle == nil || r.rebuild { + log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) + + bundleRes, err := r.runDenoBundle(ctx, path, entrypoint) + if err != nil { + return fmt.Errorf("build TypeScript bundle: %w", err) + } + + if bundle != nil { + chart.RemoveRuntimeFile(common.ChartTSBundleFile) + } + + chart.AddRuntimeFile(common.ChartTSBundleFile, bundleRes) + } + + for _, dep := range chart.Dependencies() { + depPath := filepath.Join(path, "charts", dep.Name()) + + if _, err := os.Stat(depPath); err != nil { + // Subchart loaded from .tgz or missing on disk — skip, + // deno bundle needs a real directory to work with. + continue + } + + if err := r.bundleChartsRecursive(ctx, dep, depPath); err != nil { + return fmt.Errorf("process dependency %q: %w", dep.Name(), err) + } + } + + return nil +} + func (r *DenoRuntime) ensureBinary(ctx context.Context) error { if r.binPath != "" { if _, err := os.Stat(r.binPath); err != nil { @@ -277,6 +280,21 @@ func GetEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { return entrypoint, bundleFile } +func hasTSFiles(chart *helmchart.Chart) bool { + entrypoint := findEntrypointInFiles(chart.RuntimeFiles) + if entrypoint != "" { + return true + } + + for _, dep := range chart.Dependencies() { + if hasTSFiles(dep) { + return true + } + } + + return false +} + func findEntrypointInFiles(files []*helmchart.File) string { sourceFiles := make(map[string][]byte) From 9bbdee15fa6409bbd0bb2d7b60c72252868b737b Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 10 Mar 2026 20:43:17 +0300 Subject: [PATCH 24/30] fix: format fixes Signed-off-by: Dmitry Mordvinov --- internal/ts/init.go | 103 +++++++++++++++++----------------- internal/ts/init_templates.go | 10 ---- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/internal/ts/init.go b/internal/ts/init.go index 07c90448..9124b4e3 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -53,56 +53,6 @@ func InitChartStructure(ctx context.Context, chartPath, chartName string) error return nil } -func ensureValuesFile(ctx context.Context, chartPath string) error { - valuesPath := filepath.Join(chartPath, "values.yaml") - - exists, err := fileExists(valuesPath) - if err != nil { - return fmt.Errorf("stat %s: %w", valuesPath, err) - } - - if !exists { - if err := os.WriteFile(valuesPath, []byte(valuesYamlContent), 0o644); err != nil { - return fmt.Errorf("write %s: %w", valuesPath, err) - } - - log.Default.Debug(ctx, "Created %s", valuesPath) - - return nil - } - - examplePath := filepath.Join(chartPath, "values-ts-example.yaml") - - exists, err = fileExists(examplePath) - if err != nil { - return fmt.Errorf("stat %s: %w", examplePath, err) - } - - if exists { - log.Default.Debug(ctx, "Skipping existing file %s", examplePath) - return nil - } - - if err := os.WriteFile(examplePath, []byte(valuesYamlContent), 0o644); err != nil { - return fmt.Errorf("write %s: %w", examplePath, err) - } - - log.Default.Warn(ctx, "values.yaml already exists, created values-ts-example.yaml instead") - - return nil -} - -func fileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - return false, err -} - // InitTSBoilerplate creates TypeScript boilerplate files in ts/ directory. func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) @@ -142,6 +92,46 @@ func InitTSBoilerplate(ctx context.Context, chartPath, chartName string) error { return nil } +func ensureValuesFile(ctx context.Context, chartPath string) error { + valuesPath := filepath.Join(chartPath, "values.yaml") + + exists, err := fileExists(valuesPath) + if err != nil { + return err + } + + if !exists { + if err := os.WriteFile(valuesPath, []byte(valuesYamlContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", valuesPath, err) + } + + log.Default.Debug(ctx, "Created %s", valuesPath) + + return nil + } + + examplePath := filepath.Join(chartPath, "values-ts-example.yaml") + + exists, err = fileExists(examplePath) + if err != nil { + return err + } + + if exists { + log.Default.Debug(ctx, "Skipping existing file %s", examplePath) + + return nil + } + + if err := os.WriteFile(examplePath, []byte(valuesYamlContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", examplePath, err) + } + + log.Default.Warn(ctx, "values.yaml already exists, created values-ts-example.yaml instead") + + return nil +} + // ensureFileEntries ensures a file contains all required entries. // If file doesn't exist, creates it with defaultContent. // If file exists, appends any missing entries. @@ -178,3 +168,16 @@ func ensureFileEntries(filePath, defaultContent string, requiredEntries []string return nil } + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, fmt.Errorf("stat %s: %w", path, err) +} diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 3a11ad95..1426f192 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -1,12 +1,6 @@ package ts -import "fmt" - const ( - chartYamlTmpl = `apiVersion: v2 -name: %s -version: 0.1.0 -` denoJSONTmpl = `{ "tasks": { "build": "%s" @@ -235,7 +229,3 @@ service: port: 80 ` ) - -func chartYaml(chartName string) string { - return fmt.Sprintf(chartYamlTmpl, chartName) -} From 920e4b9e67de8d3a7462d55ad3c9120bfe9c93fc Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 11 Mar 2026 22:13:09 +0300 Subject: [PATCH 25/30] fix: changed comment for InitChartStructure Signed-off-by: Dmitry Mordvinov --- internal/ts/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ts/init.go b/internal/ts/init.go index 9124b4e3..c5dcdade 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -27,8 +27,8 @@ func EnsureGitignore(chartPath string) error { ) } -// InitChartStructure creates Chart.yaml and values.yaml if they don't exist. -// For .helmignore: creates if missing, or appends TS entries if exists. +// InitChartStructure creates values.yaml and .helmignore if they don't exist. +// If values.yaml already exists, creates values-ts-example.yaml instead. // Returns error if ts/ directory already exists. func InitChartStructure(ctx context.Context, chartPath, chartName string) error { tsDir := filepath.Join(chartPath, common.ChartTSSourceDir) From de00522b6ba29199547dba0118f65d28c6764854 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 12 Mar 2026 15:01:04 +0300 Subject: [PATCH 26/30] fix: moved changes from 3p-helm Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 11 ++------ internal/helm/pkg/action/package.go | 12 ++++++++- internal/helm/pkg/chart/chart.go | 37 ++++++++++++++++++++++++-- internal/helm/pkg/chart/loader/load.go | 6 ++--- internal/ts/deno.go | 3 +++ pkg/action/chart_ts_build.go | 6 ++--- pkg/deno/downloader.go | 2 +- pkg/deno/runtime.go | 9 ++----- 8 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 internal/ts/deno.go diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 2d65f429..e9057a1c 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -11,8 +11,7 @@ import ( "github.com/werf/common-go/pkg/cli" helm_v3 "github.com/werf/nelm/internal/helm/cmd/helm" "github.com/werf/nelm/internal/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/deno" - "github.com/werf/nelm/pkg/featgate" + "github.com/werf/nelm/internal/ts" "github.com/werf/nelm/pkg/log" ) @@ -28,8 +27,6 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cmd.Aliases = []string{} cli.SetSubCommandAnnotations(cmd, 30, chartCmdGroup) - var denoBinaryPath string - originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { helmSettings := helm_v3.Settings @@ -38,10 +35,6 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co loader.NoChartLockWarning = "" - if featgate.FeatGateTypescript.Enabled() { - ts.DefaultBundler = deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: denoBinaryPath}) - } - if err := originalRunE(cmd, args); err != nil { return err } @@ -50,7 +43,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co } afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { - if err := cli.AddFlag(cmd, &denoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &ts.DefaultDenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/internal/helm/pkg/action/package.go b/internal/helm/pkg/action/package.go index 96ca6c76..9e0c0e4a 100644 --- a/internal/helm/pkg/action/package.go +++ b/internal/helm/pkg/action/package.go @@ -18,18 +18,22 @@ package action import ( "bufio" + "context" "fmt" "os" "syscall" "github.com/Masterminds/semver/v3" "github.com/pkg/errors" + "github.com/werf/nelm/internal/ts" "golang.org/x/term" "github.com/werf/nelm/internal/helm/pkg/chart/loader" "github.com/werf/nelm/internal/helm/pkg/chartutil" "github.com/werf/nelm/internal/helm/pkg/provenance" "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/deno" + "github.com/werf/nelm/pkg/featgate" ) // Package is the action for packaging a chart. @@ -61,6 +65,12 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO return "", err } + if featgate.FeatGateTypescript.Enabled() { + if err := deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: ts.DefaultDenoBinaryPath}).BundleChartsRecursive(context.Background(), ch, path); err != nil { + return "", errors.Wrap(err, "unable to process TypeScript files in chart") + } + } + // If version is set, modify the version. if p.Version != "" { ch.Metadata.Version = p.Version @@ -137,7 +147,7 @@ func (p *Package) Clearsign(filename string, opts helmopts.HelmOptions) error { return err } - return os.WriteFile(filename+".prov", []byte(sig), 0644) + return os.WriteFile(filename+".prov", []byte(sig), 0o644) } // promptUser implements provenance.PassphraseFetcher diff --git a/internal/helm/pkg/chart/chart.go b/internal/helm/pkg/chart/chart.go index d03f9d1b..a3794f4e 100644 --- a/internal/helm/pkg/chart/chart.go +++ b/internal/helm/pkg/chart/chart.go @@ -20,6 +20,8 @@ import ( "regexp" "strings" + "github.com/samber/lo" + "github.com/werf/nelm/internal/helm/pkg/werf/secrets/runtimedata" ) @@ -55,8 +57,6 @@ type Chart struct { Files []*File `json:"files" copy:"shallow"` // Files that are used at runtime, but should not be saved to secret/configmap. RuntimeFiles []*File `json:"-" copy:"shallow"` - // Dependencies for RuntimeFiles that are used at runtime, but should not be saved to secret/configmap and not added to packaged chart. - RuntimeDepsFiles []*File `json:"-" copy:"shallow"` parent *Chart dependencies []*Chart @@ -180,3 +180,36 @@ func hasManifestExtension(fname string) bool { ext := filepath.Ext(fname) return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json") } + +func (ch *Chart) AddRuntimeFile(name string, data []byte) { + ch.Raw = append(ch.Raw, &File{Name: name, Data: data}) + + ch.RuntimeFiles = append(ch.RuntimeFiles, &File{Name: name, Data: data}) + if !ch.IsRoot() { + root := ch.Root() + rawName := getRootRawFileName(ch, name) + root.Raw = append(root.Raw, &File{Name: rawName, Data: data}) + } +} + +func (ch *Chart) RemoveRuntimeFile(name string) { + ch.Raw = lo.Reject(ch.Raw, func(f *File, _ int) bool { + return f.Name == name + }) + + ch.RuntimeFiles = lo.Reject(ch.RuntimeFiles, func(f *File, _ int) bool { + return f.Name == name + }) + + if !ch.IsRoot() { + root := ch.Root() + rawName := getRootRawFileName(ch, name) + root.Raw = lo.Reject(root.Raw, func(f *File, _ int) bool { + return f.Name == rawName + }) + } +} + +func getRootRawFileName(ch *Chart, name string) string { + return filepath.Join(strings.TrimPrefix(ch.ChartFullPath(), ch.Root().Name()+"/"), name) +} diff --git a/internal/helm/pkg/chart/loader/load.go b/internal/helm/pkg/chart/loader/load.go index 1e924db7..b5211d28 100644 --- a/internal/helm/pkg/chart/loader/load.go +++ b/internal/helm/pkg/chart/loader/load.go @@ -28,13 +28,13 @@ import ( "github.com/pkg/errors" "sigs.k8s.io/yaml" + "github.com/werf/common-go/pkg/secrets_manager" "github.com/werf/nelm/internal/helm/pkg/chart" "github.com/werf/nelm/internal/helm/pkg/werf/chartextender" "github.com/werf/nelm/internal/helm/pkg/werf/file" "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" "github.com/werf/nelm/internal/helm/pkg/werf/secrets" "github.com/werf/nelm/internal/helm/pkg/werf/secrets/runtimedata" - "github.com/werf/common-go/pkg/secrets_manager" ) // ChartLoader loads a chart. @@ -173,9 +173,7 @@ func LoadFiles(files []*BufferedFile, opts helmopts.HelmOptions) (*chart.Chart, fname := strings.TrimPrefix(f.Name, "charts/") cname := strings.SplitN(fname, "/", 2)[0] subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) - case strings.HasPrefix(f.Name, "ts/node_modules/"): - c.RuntimeDepsFiles = append(c.RuntimeDepsFiles, &chart.File{Name: f.Name, Data: f.Data}) - case strings.HasPrefix(f.Name, "ts/"): + case strings.HasPrefix(f.Name, "ts/") && !strings.HasPrefix(f.Name, "ts/node_modules/"): c.RuntimeFiles = append(c.RuntimeFiles, &chart.File{Name: f.Name, Data: f.Data}) default: c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) diff --git a/internal/ts/deno.go b/internal/ts/deno.go new file mode 100644 index 00000000..2f3e6ce2 --- /dev/null +++ b/internal/ts/deno.go @@ -0,0 +1,3 @@ +package ts + +var DefaultDenoBinaryPath string diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 28503c56..84f5b121 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -10,10 +10,10 @@ import ( "github.com/dustin/go-humanize" "github.com/gookit/color" "github.com/samber/lo" + helmchart "github.com/werf/nelm/internal/helm/pkg/chart" + "github.com/werf/nelm/internal/helm/pkg/chart/loader" + "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" - helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/3p-helm/pkg/chart/loader" - "github.com/werf/3p-helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/featgate" diff --git a/pkg/deno/downloader.go b/pkg/deno/downloader.go index f265d09f..579bf9d1 100644 --- a/pkg/deno/downloader.go +++ b/pkg/deno/downloader.go @@ -16,8 +16,8 @@ import ( "github.com/go-resty/resty/v2" "github.com/gosimple/slug" "github.com/samber/lo" + "github.com/werf/nelm/internal/helm/pkg/helmpath" - "github.com/werf/3p-helm/pkg/helmpath" "github.com/werf/nelm/internal/util" "github.com/werf/nelm/pkg/log" ) diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go index d1b85172..1403f070 100644 --- a/pkg/deno/runtime.go +++ b/pkg/deno/runtime.go @@ -14,17 +14,12 @@ import ( "github.com/gofrs/flock" "github.com/samber/lo" - helmchart "github.com/werf/3p-helm/pkg/chart" - "github.com/werf/3p-helm/pkg/werf/ts" + helmchart "github.com/werf/nelm/internal/helm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) -var ( - _ ts.Bundler = (*DenoRuntime)(nil) - - ChartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} -) +var ChartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} type DenoRuntime struct { binPath string From 1cf3d0e585004bf68c7816cd19c352ae19054832 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 12 Mar 2026 19:45:15 +0300 Subject: [PATCH 27/30] refactor: use separate functions instead of DenoRuntime class Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 2 +- internal/helm/pkg/action/package.go | 5 +- internal/ts/deno.go | 294 +++++++++++++++++++++- {pkg/deno => internal/ts}/downloader.go | 8 +- internal/ts/render.go | 27 +- pkg/action/chart_ts_build.go | 7 +- pkg/deno/runtime.go | 313 ------------------------ pkg/ts/bundle.go | 5 + 8 files changed, 325 insertions(+), 336 deletions(-) rename {pkg/deno => internal/ts}/downloader.go (98%) delete mode 100644 pkg/deno/runtime.go create mode 100644 pkg/ts/bundle.go diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 88001c06..e848d66d 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -237,7 +237,7 @@ tasks: - task: test:ginkgo vars: paths: '{{.paths | default "./internal ./pkg ./cmd"}}' - skipPackage: 'internal/helm{{if .skipPackage}},{{.skipPackage}}{{end}}' + skipPackage: "internal/helm{{if .skipPackage}},{{.skipPackage}}{{end}}" parallel: "{{.parallel}}" verify:binaries:dist:all: diff --git a/internal/helm/pkg/action/package.go b/internal/helm/pkg/action/package.go index 9e0c0e4a..47717998 100644 --- a/internal/helm/pkg/action/package.go +++ b/internal/helm/pkg/action/package.go @@ -25,14 +25,13 @@ import ( "github.com/Masterminds/semver/v3" "github.com/pkg/errors" - "github.com/werf/nelm/internal/ts" "golang.org/x/term" "github.com/werf/nelm/internal/helm/pkg/chart/loader" "github.com/werf/nelm/internal/helm/pkg/chartutil" "github.com/werf/nelm/internal/helm/pkg/provenance" "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" - "github.com/werf/nelm/pkg/deno" + "github.com/werf/nelm/internal/ts" "github.com/werf/nelm/pkg/featgate" ) @@ -66,7 +65,7 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO } if featgate.FeatGateTypescript.Enabled() { - if err := deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: ts.DefaultDenoBinaryPath}).BundleChartsRecursive(context.Background(), ch, path); err != nil { + if err := ts.BundleChartsRecursive(context.Background(), ch, path, true, ts.DefaultDenoBinaryPath); err != nil { return "", errors.Wrap(err, "unable to process TypeScript files in chart") } } diff --git a/internal/ts/deno.go b/internal/ts/deno.go index 2f3e6ce2..b377be0c 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -1,3 +1,295 @@ package ts -var DefaultDenoBinaryPath string +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/gofrs/flock" + "github.com/samber/lo" + + helmchart "github.com/werf/nelm/internal/helm/pkg/chart" + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/log" +) + +var ( + DefaultDenoBinaryPath string + + chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} +) + +func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuild bool, binaryPath string) error { + if !hasTSFiles(chart) { + return nil + } + + denoBin, err := getDenoBinary(ctx, binaryPath) + if err != nil { + return fmt.Errorf("ensure Deno is available: %w", err) + } + + return bundleChartsRecursive(ctx, chart, path, rebuild, denoBin) +} + +func bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuild bool, denoBin string) error { + entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) + if entrypoint == "" { + return nil + } + + if bundle == nil || rebuild { + log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) + + bundleRes, err := runDenoBundle(ctx, path, entrypoint, denoBin) + if err != nil { + return fmt.Errorf("build TypeScript bundle: %w", err) + } + + if bundle != nil { + chart.RemoveRuntimeFile(common.ChartTSBundleFile) + } + + chart.AddRuntimeFile(common.ChartTSBundleFile, bundleRes) + } + + for _, dep := range chart.Dependencies() { + depPath := filepath.Join(path, "charts", dep.Name()) + + if _, err := os.Stat(depPath); err != nil { + // Subchart loaded from .tgz or missing on disk — skip, + // deno bundle needs a real directory to work with. + continue + } + + if err := bundleChartsRecursive(ctx, dep, depPath, rebuild, denoBin); err != nil { + return fmt.Errorf("process dependency %q: %w", dep.Name(), err) + } + } + + return nil +} + +func getEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { + entrypoint := findEntrypointInFiles(files) + if entrypoint == "" { + return "", nil + } + + bundleFile, foundBundle := lo.Find(files, func(f *helmchart.File) bool { + return f.Name == common.ChartTSBundleFile + }) + + if !foundBundle { + return entrypoint, nil + } + + return entrypoint, bundleFile +} + +func hasTSFiles(chart *helmchart.Chart) bool { + entrypoint := findEntrypointInFiles(chart.RuntimeFiles) + if entrypoint != "" { + return true + } + + for _, dep := range chart.Dependencies() { + if hasTSFiles(dep) { + return true + } + } + + return false +} + +func findEntrypointInFiles(files []*helmchart.File) string { + sourceFiles := make(map[string][]byte) + + for _, f := range files { + if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"src/") { + sourceFiles[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data + } + } + + if len(sourceFiles) == 0 { + return "" + } + + for _, ep := range chartTSEntryPoints { + if _, ok := sourceFiles[ep]; ok { + return ep + } + } + + return "" +} + +func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { + if binaryPath != "" { + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("deno binary not found on path %q", binaryPath) + } + + return binaryPath, nil + } + + link, err := getDownloadLink() + if err != nil { + return "", fmt.Errorf("get download link: %w", err) + } + + cacheDir, err := getDenoFolder(link) + if err != nil { + return "", fmt.Errorf("get Deno cache folder: %w", err) + } + + binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") + + denoPath := filepath.Join(cacheDir, binaryName) + if _, err := os.Stat(denoPath); err == nil { + log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) + + return denoPath, nil + } + + lockFile := filepath.Join(cacheDir, "lock") + + fileLock := flock.New(lockFile) + if err := fileLock.Lock(); err != nil { + return "", fmt.Errorf("acquire lock on Deno cache: %w", err) + } + + defer func() { + if err := fileLock.Unlock(); err != nil { + log.Default.Error(ctx, "release lock on Deno cache: %v", err) + } + + if err := os.Remove(lockFile); err != nil { + log.Default.Error(ctx, "remove Deno cache lock file: %v", err) + } + }() + + if _, err := os.Stat(denoPath); err == nil { + log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) + + return denoPath, nil + } + + if err := downloadDeno(ctx, cacheDir, link); err != nil { + return "", fmt.Errorf("download deno: %w", err) + } + + return denoPath, nil +} + +func runApp(ctx context.Context, bundleData []byte, renderDir, denoBin string) error { + args := []string{ + "run", + "--no-remote", + // deno permissions: allow read/write only for input and output files, deny all else. + "--allow-read=" + common.ChartTSInputFile, + "--allow-write=" + common.ChartTSOutputFile, + "--deny-net", + "--deny-env", + "--deny-run", + // write bundle data to Stdin + "-", + // pass input and output file names as arguments + "--input-file=" + common.ChartTSInputFile, + "--output-file=" + common.ChartTSOutputFile, + } + + cmd := exec.CommandContext(ctx, denoBin, args...) + cmd.Dir = renderDir + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("get stdin pipe: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("get stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("get stderr pipe: %w", err) + } + + stdinErrChan := make(chan error, 1) + go func() { + defer func() { + _ = stdinPipe.Close() + }() + + _, writeErr := stdinPipe.Write(bundleData) + stdinErrChan <- writeErr + }() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start process: %w", err) + } + + stdoutErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + text := scanner.Text() + log.Default.Debug(ctx, text) + } + + stdoutErrChan <- scanner.Err() + }() + + stderrErrChan := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + log.Default.Error(ctx, "error from deno app: %s", scanner.Text()) + } + + stderrErrChan <- scanner.Err() + }() + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("wait process: %w", err) + } + + if err := <-stdinErrChan; err != nil { + return fmt.Errorf("write bundle data to stdinPipe: %w", err) + } + + if err := <-stdoutErrChan; err != nil { + return fmt.Errorf("read stdout: %w", err) + } + + if err := <-stderrErrChan; err != nil { + return fmt.Errorf("read stderr: %w", err) + } + + return nil +} + +func runDenoBundle(ctx context.Context, chartPath, entryPoint, denoBin string) ([]uint8, error) { + cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) + cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) + + output, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + log.Default.Error(ctx, "run deno bundle error: %s", exitErr) + } + + return nil, fmt.Errorf("get deno build output: %w", err) + } + + return output, nil +} diff --git a/pkg/deno/downloader.go b/internal/ts/downloader.go similarity index 98% rename from pkg/deno/downloader.go rename to internal/ts/downloader.go index 579bf9d1..068b1748 100644 --- a/pkg/deno/downloader.go +++ b/internal/ts/downloader.go @@ -1,4 +1,4 @@ -package deno +package ts import ( "archive/zip" @@ -16,13 +16,13 @@ import ( "github.com/go-resty/resty/v2" "github.com/gosimple/slug" "github.com/samber/lo" - "github.com/werf/nelm/internal/helm/pkg/helmpath" + "github.com/werf/nelm/internal/helm/pkg/helmpath" "github.com/werf/nelm/internal/util" "github.com/werf/nelm/pkg/log" ) -const version = "2.7.1" +const denoVersion = "2.7.1" func downloadDeno(ctx context.Context, cacheDir, link string) error { httpClient := util.NewRestyClient(ctx) @@ -170,7 +170,7 @@ func getDownloadLink() (string, error) { return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) } - url := fmt.Sprintf("https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip", version, target) + url := fmt.Sprintf("https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip", denoVersion, target) return url, nil } diff --git a/internal/ts/render.go b/internal/ts/render.go index 6d319227..0cd500c1 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -14,17 +14,24 @@ import ( helmchart "github.com/werf/nelm/internal/helm/pkg/chart" "github.com/werf/nelm/internal/helm/pkg/chartutil" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/log" ) func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { - denoRuntime := deno.NewDenoRuntime(rebuildBundle, deno.DenoRuntimeOptions{BinaryPath: denoBinaryPath}) - if err := denoRuntime.BundleChartsRecursive(ctx, chart, chartPath); err != nil { + if !hasTSFiles(chart) { + return map[string]string{}, nil + } + + denoBin, err := getDenoBinary(ctx, denoBinaryPath) + if err != nil { + return nil, fmt.Errorf("ensure Deno is available: %w", err) + } + + if err := bundleChartsRecursive(ctx, chart, chartPath, rebuildBundle, denoBin); err != nil { return nil, fmt.Errorf("bundle chart recursive: %w", err) } - allRendered, err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, tempDirPath, denoRuntime) + allRendered, err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, tempDirPath, denoBin) if err != nil { return nil, fmt.Errorf("render chart recursive: %w", err) } @@ -32,14 +39,14 @@ func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues cha return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath, tempDirPath string, denoRuntime *deno.DenoRuntime) (map[string]string, error) { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix, chartPath, tempDirPath, denoBin string) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) results := make(map[string]string) - entrypoint, bundle := deno.GetEntrypointAndBundle(chart.RuntimeFiles) + entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) if bundle != nil { - content, err := renderChart(ctx, bundle, chart, values, tempDirPath, denoRuntime) + content, err := renderChart(ctx, bundle, chart, values, tempDirPath, denoBin) if err != nil { return nil, fmt.Errorf("render files for chart %q: %w", chart.Name(), err) } @@ -61,7 +68,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch path.Join(pathPrefix, "charts", dep.Name()), filepath.Join(chartPath, "charts", dep.Name()), tempDirPath, - denoRuntime, + denoBin, ) if err != nil { return nil, fmt.Errorf("render dependency %q: %w", dep.Name(), err) @@ -73,7 +80,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return results, nil } -func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath string, denoRuntime *deno.DenoRuntime) (string, error) { +func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.Chart, renderedValues chartutil.Values, tempDirPath, denoBin string) (string, error) { renderDir := filepath.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) if err := os.MkdirAll(renderDir, 0o755); err != nil { return "", fmt.Errorf("create temp dir for render context: %w", err) @@ -83,7 +90,7 @@ func renderChart(ctx context.Context, bundle *helmchart.File, chart *helmchart.C return "", fmt.Errorf("write input render context: %w", err) } - if err := denoRuntime.RunApp(ctx, bundle.Data, renderDir); err != nil { + if err := runApp(ctx, bundle.Data, renderDir, denoBin); err != nil { return "", fmt.Errorf("run deno app: %w", err) } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 84f5b121..a5a3f135 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -10,12 +10,12 @@ import ( "github.com/dustin/go-humanize" "github.com/gookit/color" "github.com/samber/lo" + helmchart "github.com/werf/nelm/internal/helm/pkg/chart" "github.com/werf/nelm/internal/helm/pkg/chart/loader" "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" - + "github.com/werf/nelm/internal/ts" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/deno" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -56,8 +56,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("load chart: %w", err) } - denoRuntime := deno.NewDenoRuntime(true, deno.DenoRuntimeOptions{BinaryPath: opts.DenoBinaryPath}) - if err = denoRuntime.BundleChartsRecursive(ctx, chart, absPath); err != nil { + if err = ts.BundleChartsRecursive(ctx, chart, absPath, true, opts.DenoBinaryPath); err != nil { return fmt.Errorf("process chart: %w", err) } diff --git a/pkg/deno/runtime.go b/pkg/deno/runtime.go deleted file mode 100644 index 1403f070..00000000 --- a/pkg/deno/runtime.go +++ /dev/null @@ -1,313 +0,0 @@ -package deno - -import ( - "bufio" - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/gofrs/flock" - "github.com/samber/lo" - - helmchart "github.com/werf/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/log" -) - -var ChartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} - -type DenoRuntime struct { - binPath string - rebuild bool -} - -func NewDenoRuntime(rebuild bool, opts DenoRuntimeOptions) *DenoRuntime { - return &DenoRuntime{binPath: opts.BinaryPath, rebuild: rebuild} -} - -func (r *DenoRuntime) BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { - if !hasTSFiles(chart) { - return nil - } - - if err := r.ensureBinary(ctx); err != nil { - return fmt.Errorf("ensure Deno is available: %w", err) - } - - return r.bundleChartsRecursive(ctx, chart, path) -} - -func (r *DenoRuntime) RunApp(ctx context.Context, bundleData []byte, renderDir string) error { - if err := r.ensureBinary(ctx); err != nil { - return fmt.Errorf("ensure Deno is available: %w", err) - } - - args := []string{ - "run", - "--no-remote", - // deno permissions: allow read/write only for input and output files, deny all else. - "--allow-read=" + common.ChartTSInputFile, - "--allow-write=" + common.ChartTSOutputFile, - "--deny-net", - "--deny-env", - "--deny-run", - // write bundle data to Stdin - "-", - // pass input and output file names as arguments - "--input-file=" + common.ChartTSInputFile, - "--output-file=" + common.ChartTSOutputFile, - } - - denoBin := r.binPath - cmd := exec.CommandContext(ctx, denoBin, args...) - cmd.Dir = renderDir - - stdinPipe, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("get stdin pipe: %w", err) - } - - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("get stdout pipe: %w", err) - } - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("get stderr pipe: %w", err) - } - - stdinErrChan := make(chan error, 1) - go func() { - defer func() { - _ = stdinPipe.Close() - }() - - _, writeErr := stdinPipe.Write(bundleData) - stdinErrChan <- writeErr - }() - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start process: %w", err) - } - - stdoutErrChan := make(chan error, 1) - go func() { - scanner := bufio.NewScanner(stdoutPipe) - for scanner.Scan() { - text := scanner.Text() - log.Default.Debug(ctx, text) - } - - stdoutErrChan <- scanner.Err() - }() - - stderrErrChan := make(chan error, 1) - go func() { - scanner := bufio.NewScanner(stderrPipe) - for scanner.Scan() { - log.Default.Error(ctx, "error from deno app: %s", scanner.Text()) - } - - stderrErrChan <- scanner.Err() - }() - - if err := cmd.Wait(); err != nil { - return fmt.Errorf("wait process: %w", err) - } - - if err := <-stdinErrChan; err != nil { - return fmt.Errorf("write bundle data to stdinPipe: %w", err) - } - - if err := <-stdoutErrChan; err != nil { - return fmt.Errorf("read stdout: %w", err) - } - - if err := <-stderrErrChan; err != nil { - return fmt.Errorf("read stderr: %w", err) - } - - return nil -} - -func (r *DenoRuntime) bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string) error { - entrypoint, bundle := GetEntrypointAndBundle(chart.RuntimeFiles) - if entrypoint == "" { - return nil - } - - if bundle == nil || r.rebuild { - log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) - - bundleRes, err := r.runDenoBundle(ctx, path, entrypoint) - if err != nil { - return fmt.Errorf("build TypeScript bundle: %w", err) - } - - if bundle != nil { - chart.RemoveRuntimeFile(common.ChartTSBundleFile) - } - - chart.AddRuntimeFile(common.ChartTSBundleFile, bundleRes) - } - - for _, dep := range chart.Dependencies() { - depPath := filepath.Join(path, "charts", dep.Name()) - - if _, err := os.Stat(depPath); err != nil { - // Subchart loaded from .tgz or missing on disk — skip, - // deno bundle needs a real directory to work with. - continue - } - - if err := r.bundleChartsRecursive(ctx, dep, depPath); err != nil { - return fmt.Errorf("process dependency %q: %w", dep.Name(), err) - } - } - - return nil -} - -func (r *DenoRuntime) ensureBinary(ctx context.Context) error { - if r.binPath != "" { - if _, err := os.Stat(r.binPath); err != nil { - return fmt.Errorf("deno binary not found on path %q", r.binPath) - } - - return nil - } - - link, err := getDownloadLink() - if err != nil { - return fmt.Errorf("get download link: %w", err) - } - - cacheDir, err := getDenoFolder(link) - if err != nil { - return fmt.Errorf("get Deno cache folder: %w", err) - } - - binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") - - denoPath := filepath.Join(cacheDir, binaryName) - if _, err := os.Stat(denoPath); err == nil { - r.binPath = denoPath - log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) - - return nil - } - - lockFile := filepath.Join(cacheDir, "lock") - - fileLock := flock.New(lockFile) - if err := fileLock.Lock(); err != nil { - return fmt.Errorf("acquire lock on Deno cache: %w", err) - } - - defer func() { - if err := fileLock.Unlock(); err != nil { - log.Default.Error(ctx, "release lock on Deno cache: %v", err) - } - - if err := os.Remove(lockFile); err != nil { - log.Default.Error(ctx, "remove Deno cache lock file: %v", err) - } - }() - - if _, err := os.Stat(denoPath); err == nil { - r.binPath = denoPath - log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) - - return nil - } - - if err := downloadDeno(ctx, cacheDir, link); err != nil { - return fmt.Errorf("download deno: %w", err) - } - - r.binPath = denoPath - - return nil -} - -func (r *DenoRuntime) runDenoBundle(ctx context.Context, chartPath, entryPoint string) ([]uint8, error) { - denoBin := r.binPath - cmd := exec.CommandContext(ctx, denoBin, "bundle", entryPoint) - cmd.Dir = filepath.Join(chartPath, common.ChartTSSourceDir) - - output, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Default.Error(ctx, "run deno bundle error: %s", exitErr) - } - - return nil, fmt.Errorf("get deno build output: %w", err) - } - - return output, nil -} - -type DenoRuntimeOptions struct { - BinaryPath string -} - -func GetEntrypointAndBundle(files []*helmchart.File) (string, *helmchart.File) { - entrypoint := findEntrypointInFiles(files) - if entrypoint == "" { - return "", nil - } - - bundleFile, foundBundle := lo.Find(files, func(f *helmchart.File) bool { - return f.Name == common.ChartTSBundleFile - }) - - if !foundBundle { - return entrypoint, nil - } - - return entrypoint, bundleFile -} - -func hasTSFiles(chart *helmchart.Chart) bool { - entrypoint := findEntrypointInFiles(chart.RuntimeFiles) - if entrypoint != "" { - return true - } - - for _, dep := range chart.Dependencies() { - if hasTSFiles(dep) { - return true - } - } - - return false -} - -func findEntrypointInFiles(files []*helmchart.File) string { - sourceFiles := make(map[string][]byte) - - for _, f := range files { - if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"src/") { - sourceFiles[strings.TrimPrefix(f.Name, common.ChartTSSourceDir)] = f.Data - } - } - - if len(sourceFiles) == 0 { - return "" - } - - for _, ep := range ChartTSEntryPoints { - if _, ok := sourceFiles[ep]; ok { - return ep - } - } - - return "" -} diff --git a/pkg/ts/bundle.go b/pkg/ts/bundle.go new file mode 100644 index 00000000..87fe7138 --- /dev/null +++ b/pkg/ts/bundle.go @@ -0,0 +1,5 @@ +package ts + +import "github.com/werf/nelm/internal/ts" + +var BundleChartsRecursive = ts.BundleChartsRecursive From fa7d4bd1b62f7c6d442a60f7fb0cfe0fd8501644 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 12 Mar 2026 23:48:52 +0300 Subject: [PATCH 28/30] wip: updated version of npm package for init ts, changed description for ts initialization Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_ts_init.go | 4 ++-- internal/ts/deno.go | 10 +++++----- internal/ts/init_templates.go | 2 +- pkg/action/chart_ts_init.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/nelm/chart_ts_init.go b/cmd/nelm/chart_ts_init.go index 9c6e18c9..1ef62980 100644 --- a/cmd/nelm/chart_ts_init.go +++ b/cmd/nelm/chart_ts_init.go @@ -26,8 +26,8 @@ func newChartTSInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* cmd := cli.NewSubCommand( ctx, "init [PATH]", - "Initialize the TypeScript directory for chart.", - "Initialize the TypeScript directory for chart. If PATH is not specified, uses the current directory.", + "Initialize the files needed to render manifests using TypeScript.", + "Initialize the files needed to render manifests using TypeScript. If PATH is not specified, uses the current directory.", 20, // priority for ordering in help tsCmdGroup, cli.SubCommandOptions{ diff --git a/internal/ts/deno.go b/internal/ts/deno.go index b377be0c..b201fdd0 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -25,7 +25,7 @@ var ( chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} ) -func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuild bool, binaryPath string) error { +func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuildBundle bool, binaryPath string) error { if !hasTSFiles(chart) { return nil } @@ -35,16 +35,16 @@ func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path str return fmt.Errorf("ensure Deno is available: %w", err) } - return bundleChartsRecursive(ctx, chart, path, rebuild, denoBin) + return bundleChartsRecursive(ctx, chart, path, rebuildBundle, denoBin) } -func bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuild bool, denoBin string) error { +func bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuildBundle bool, denoBin string) error { entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) if entrypoint == "" { return nil } - if bundle == nil || rebuild { + if bundle == nil || rebuildBundle { log.Default.Info(ctx, "Bundle TypeScript for chart %q (entrypoint: %s)", chart.Name(), entrypoint) bundleRes, err := runDenoBundle(ctx, path, entrypoint, denoBin) @@ -68,7 +68,7 @@ func bundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path str continue } - if err := bundleChartsRecursive(ctx, dep, depPath, rebuild, denoBin); err != nil { + if err := bundleChartsRecursive(ctx, dep, depPath, rebuildBundle, denoBin); err != nil { return fmt.Errorf("process dependency %q: %w", dep.Name(), err) } } diff --git a/internal/ts/init_templates.go b/internal/ts/init_templates.go index 1426f192..273b0777 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -6,7 +6,7 @@ const ( "build": "%s" }, "imports": { - "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.2" + "@nelm/chart-ts-sdk": "npm:@nelm/chart-ts-sdk@^0.1.3" } } ` diff --git a/pkg/action/chart_ts_init.go b/pkg/action/chart_ts_init.go index a33c9099..ca64026b 100644 --- a/pkg/action/chart_ts_init.go +++ b/pkg/action/chart_ts_init.go @@ -55,7 +55,7 @@ func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { return fmt.Errorf("ensure .gitignore: %w", err) } - log.Default.Info(ctx, "Initialized TypeScript chart in %s", absPath) + log.Default.Info(ctx, "Initialized TypeScript files in %s", absPath) return nil } From c8b7f0abdba3c8daae404a24c41ae23375601c79 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 13 Mar 2026 13:14:19 +0300 Subject: [PATCH 29/30] wip: changed the way to pass DenoBinaryPath Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 5 +++- internal/helm/cmd/helm/package.go | 6 +++++ internal/helm/pkg/action/package.go | 2 +- .../helm/pkg/werf/helmopts/helmoptions.go | 7 +++++- internal/ts/deno.go | 6 +---- internal/ts/options.go | 24 +++++++++++++++++++ 6 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 internal/ts/options.go diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index e9057a1c..467c3178 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -16,6 +16,7 @@ import ( ) func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + opts := ts.ChartTSOptions{} cmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "package") })) @@ -32,6 +33,8 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co helmSettings := helm_v3.Settings ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = ts.NewContextWithTSOptions(ctx, opts) + cmd.SetContext(ctx) loader.NoChartLockWarning = "" @@ -43,7 +46,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co } afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { - if err := cli.AddFlag(cmd, &ts.DefaultDenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &opts.DenoBinaryPath, "deno-binary-path", "", "Path to the Deno binary to use instead of auto-downloading.", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: tsFlagGroup, }); err != nil { diff --git a/internal/helm/cmd/helm/package.go b/internal/helm/cmd/helm/package.go index b0b44fc6..c653a23a 100644 --- a/internal/helm/cmd/helm/package.go +++ b/internal/helm/cmd/helm/package.go @@ -30,6 +30,7 @@ import ( "github.com/werf/nelm/internal/helm/pkg/downloader" "github.com/werf/nelm/internal/helm/pkg/getter" "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" + "github.com/werf/nelm/internal/ts" ) const packageDesc = ` @@ -69,10 +70,15 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } } + tsOpts := ts.GetTSOptionsFromContext(cmd.Context()) + opts := helmopts.HelmOptions{ ChartLoadOpts: helmopts.ChartLoadOptions{ NoSecrets: true, }, + TypeScriptOpts: helmopts.TypeScriptOptions{ + DenoBinaryPath: tsOpts.DenoBinaryPath, + }, } client.RepositoryConfig = settings.RepositoryConfig diff --git a/internal/helm/pkg/action/package.go b/internal/helm/pkg/action/package.go index 47717998..4b0216e3 100644 --- a/internal/helm/pkg/action/package.go +++ b/internal/helm/pkg/action/package.go @@ -65,7 +65,7 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO } if featgate.FeatGateTypescript.Enabled() { - if err := ts.BundleChartsRecursive(context.Background(), ch, path, true, ts.DefaultDenoBinaryPath); err != nil { + if err := ts.BundleChartsRecursive(context.Background(), ch, path, true, opts.TypeScriptOpts.DenoBinaryPath); err != nil { return "", errors.Wrap(err, "unable to process TypeScript files in chart") } } diff --git a/internal/helm/pkg/werf/helmopts/helmoptions.go b/internal/helm/pkg/werf/helmopts/helmoptions.go index 25872266..c9486266 100644 --- a/internal/helm/pkg/werf/helmopts/helmoptions.go +++ b/internal/helm/pkg/werf/helmopts/helmoptions.go @@ -1,7 +1,8 @@ package helmopts type HelmOptions struct { - ChartLoadOpts ChartLoadOptions + ChartLoadOpts ChartLoadOptions + TypeScriptOpts TypeScriptOptions } type ChartLoadOptions struct { @@ -21,6 +22,10 @@ type ChartLoadOptions struct { DefaultRootContext map[string]interface{} } +type TypeScriptOptions struct { + DenoBinaryPath string +} + type ChartType string const ( diff --git a/internal/ts/deno.go b/internal/ts/deno.go index b201fdd0..cfb2bd73 100644 --- a/internal/ts/deno.go +++ b/internal/ts/deno.go @@ -19,11 +19,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -var ( - DefaultDenoBinaryPath string - - chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} -) +var chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuildBundle bool, binaryPath string) error { if !hasTSFiles(chart) { diff --git a/internal/ts/options.go b/internal/ts/options.go new file mode 100644 index 00000000..cb94f977 --- /dev/null +++ b/internal/ts/options.go @@ -0,0 +1,24 @@ +package ts + +import "context" + +var tsOptionsKey chartTSOptionsKey + +type chartTSOptionsKey struct{} + +type ChartTSOptions struct { + DenoBinaryPath string +} + +func GetTSOptionsFromContext(ctx context.Context) ChartTSOptions { + opts, ok := ctx.Value(tsOptionsKey).(ChartTSOptions) + if !ok { + return ChartTSOptions{} + } + + return opts +} + +func NewContextWithTSOptions(ctx context.Context, opts ChartTSOptions) context.Context { + return context.WithValue(ctx, tsOptionsKey, opts) +} From 7064989c83c53e394d31cad626d2e0ec65ac8033 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 13 Mar 2026 13:21:16 +0300 Subject: [PATCH 30/30] wip: changed the way to pass DenoBinaryPath Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 3 ++- internal/ts/options.go | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 467c3178..ed9da5e1 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -11,12 +11,13 @@ import ( "github.com/werf/common-go/pkg/cli" helm_v3 "github.com/werf/nelm/internal/helm/cmd/helm" "github.com/werf/nelm/internal/helm/pkg/chart/loader" + "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" "github.com/werf/nelm/internal/ts" "github.com/werf/nelm/pkg/log" ) func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { - opts := ts.ChartTSOptions{} + opts := helmopts.TypeScriptOptions{} cmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "package") })) diff --git a/internal/ts/options.go b/internal/ts/options.go index cb94f977..7aa656cf 100644 --- a/internal/ts/options.go +++ b/internal/ts/options.go @@ -1,24 +1,24 @@ package ts -import "context" +import ( + "context" + + "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" +) var tsOptionsKey chartTSOptionsKey type chartTSOptionsKey struct{} -type ChartTSOptions struct { - DenoBinaryPath string -} - -func GetTSOptionsFromContext(ctx context.Context) ChartTSOptions { - opts, ok := ctx.Value(tsOptionsKey).(ChartTSOptions) +func GetTSOptionsFromContext(ctx context.Context) helmopts.TypeScriptOptions { + opts, ok := ctx.Value(tsOptionsKey).(helmopts.TypeScriptOptions) if !ok { - return ChartTSOptions{} + return helmopts.TypeScriptOptions{} } return opts } -func NewContextWithTSOptions(ctx context.Context, opts ChartTSOptions) context.Context { +func NewContextWithTSOptions(ctx context.Context, opts helmopts.TypeScriptOptions) context.Context { return context.WithValue(ctx, tsOptionsKey, opts) }