diff --git a/.golangci.yaml b/.golangci.yaml index 13588fc1..0dad0abf 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.go b/cmd/nelm/chart.go index 25a9ebbd..58786f4a 100644 --- a/cmd/nelm/chart.go +++ b/cmd/nelm/chart.go @@ -18,7 +18,6 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. cli.GroupCommandOptions{}, ) - cmd.AddCommand(newChartInitCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartRenderCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDependencyCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDownloadCommand(ctx, afterAllCommandsBuiltFuncs)) @@ -26,6 +25,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_lint.go b/cmd/nelm/chart_lint.go index e34fd2e1..07802af3 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.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, 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 9bac68ec..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/featgate" "github.com/werf/nelm/pkg/log" ) func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + opts := helmopts.TypeScriptOptions{} cmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "package") })) @@ -33,17 +34,11 @@ 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 = "" - if featgate.FeatGateTypescript.Enabled() { - for _, chartPath := range args { - if err := ts.BuildVendorBundleToDir(ctx, chartPath); err != nil { - return fmt.Errorf("build TypeScript vendor bundle in %q: %w", chartPath, err) - } - } - } - if err := originalRunE(cmd, args); err != nil { return err } @@ -51,5 +46,16 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return nil } + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + 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 { + 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..e632f53c 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.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, 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.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_init.go b/cmd/nelm/chart_ts_build.go similarity index 74% rename from cmd/nelm/chart_init.go rename to cmd/nelm/chart_ts_build.go index d5aa8ef2..261b2146 100644 --- a/cmd/nelm/chart_init.go +++ b/cmd/nelm/chart_ts_build.go @@ -13,23 +13,23 @@ import ( "github.com/werf/nelm/pkg/log" ) -type chartInitConfig struct { - action.ChartInitOptions +type chartTSBuildConfig struct { + action.ChartTSBuildOptions LogColorMode string LogLevel string } -func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { - cfg := &chartInitConfig{} +func newChartTSBuildCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cfg := &chartTSBuildConfig{} 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.", + "build [PATH]", + "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 - chartCmdGroup, + tsCmdGroup, cli.SubCommandOptions{ Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -45,8 +45,8 @@ 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) + if err := action.ChartTSBuild(ctx, cfg.ChartTSBuildOptions); err != nil { + return fmt.Errorf("chart build: %w", err) } return nil @@ -54,30 +54,31 @@ func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co ) afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { - if err := cli.AddFlag(cmd, &cfg.TS, "ts", false, "Initialize TypeScript chart", cli.AddFlagOptions{ - Group: mainFlagGroup, + 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.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, + if err := cli.AddFlag(cmd, &cfg.LogLevel, "log-level", string(log.InfoLevel), "Set log level. "+allowedLogLevelsHelp(), cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, 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{ + 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: miscFlagGroup, + Group: tsFlagGroup, }); 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, + 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) } diff --git a/cmd/nelm/chart_ts_init.go b/cmd/nelm/chart_ts_init.go new file mode 100644 index 00000000..1ef62980 --- /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 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{ + 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 ts 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/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/groups.go b/cmd/nelm/groups.go index 86ccec0a..5f4d1a05 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 commands:", 60) repoCmdGroup = cli.NewCommandGroup("repo", "Repo commands:", 60) miscCmdGroup = cli.NewCommandGroup("misc", "Other commands:", 0) mainFlagGroup = cli.NewFlagGroup("main", "Options:", 100) @@ -16,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 84ba24fc..0a702c30 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -313,6 +313,20 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, 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.PlanArtifactPath, "use-plan", "", "Use the gzip-compressed JSON plan file from the specified path during release install", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagLocalEnvVarRegexes, Group: mainFlagGroup, diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 7122ddbe..04eb4036 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.IgnoreBundleJS, "ignore-bundle-js", false, IgnoreBundleJSFlagDescription, 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/go.mod b/go.mod index be65cf01..2a439087 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,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/foxcpp/go-mockdns v1.0.0 github.com/go-resty/resty/v2 v2.17.1 @@ -36,6 +34,7 @@ require ( github.com/gookit/color v1.5.4 github.com/gosuri/uitable v0.0.4 github.com/hashicorp/go-multierror v1.1.1 + 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 @@ -118,18 +117,18 @@ 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-sql-driver/mysql v1.7.1 // 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/handlers v1.5.2 // 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/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 71efa2de..fb3acad9 100644 --- a/go.sum +++ b/go.sum @@ -115,16 +115,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= @@ -158,8 +154,6 @@ 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= @@ -214,6 +208,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= diff --git a/internal/chart/chart_render.go b/internal/chart/chart_render.go index 52d978a1..38c45358 100644 --- a/internal/chart/chart_render.go +++ b/internal/chart/chart_render.go @@ -46,12 +46,15 @@ type RenderChartOptions struct { ChartProvenanceStrategy string ChartRepoNoUpdate bool ChartVersion string + DenoBinaryPath string ExtraAPIVersions []string HelmOptions helmopts.HelmOptions + IgnoreBundleJS bool LocalKubeVersion string NoStandaloneCRDs bool Remote bool SubchartNotes bool + TempDirPath string TemplatesAllowDNS bool } @@ -222,9 +225,11 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues) + log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) + + jsRenderedTemplates, err := ts.RenderChart(ctx, chart, renderedValues, opts.IgnoreBundleJS, 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 { @@ -363,17 +368,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) (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) - 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/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 96ca6c76..4b0216e3 100644 --- a/internal/helm/pkg/action/package.go +++ b/internal/helm/pkg/action/package.go @@ -18,6 +18,7 @@ package action import ( "bufio" + "context" "fmt" "os" "syscall" @@ -30,6 +31,8 @@ import ( "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/internal/ts" + "github.com/werf/nelm/pkg/featgate" ) // Package is the action for packaging a chart. @@ -61,6 +64,12 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO return "", err } + if featgate.FeatGateTypescript.Enabled() { + 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") + } + } + // If version is set, modify the version. if p.Version != "" { ch.Metadata.Version = p.Version @@ -137,7 +146,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/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/bundle.go b/internal/ts/bundle.go deleted file mode 100644 index e7720a1a..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/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/internal/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 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 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 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 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 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 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 827e1ff4..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/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/internal/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 new file mode 100644 index 00000000..cfb2bd73 --- /dev/null +++ b/internal/ts/deno.go @@ -0,0 +1,291 @@ +package ts + +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} + +func BundleChartsRecursive(ctx context.Context, chart *helmchart.Chart, path string, rebuildBundle 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, rebuildBundle, denoBin) +} + +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 || rebuildBundle { + 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, rebuildBundle, 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/internal/ts/downloader.go b/internal/ts/downloader.go new file mode 100644 index 00000000..068b1748 --- /dev/null +++ b/internal/ts/downloader.go @@ -0,0 +1,237 @@ +package ts + +import ( + "archive/zip" + "context" + "crypto/sha256" + "fmt" + "hash/fnv" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "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/util" + "github.com/werf/nelm/pkg/log" +) + +const denoVersion = "2.7.1" + +func downloadDeno(ctx context.Context, cacheDir, link string) error { + httpClient := util.NewRestyClient(ctx) + httpClient.SetTimeout(15 * 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() { + 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") + + 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: %s", link, response.Status()) + } + + if err := verifyChecksum(ctx, zipFile, expectedHash); err != nil { + return fmt.Errorf("verify checksum: %w", err) + } + + reader, err := zip.OpenReader(zipFile) + if err != nil { + return fmt.Errorf("open downloaded Deno archive: %w", err) + } + + defer func() { + 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") + + var binaryFound bool + for _, file := range reader.File { + if file.Name != binaryName { + continue + } + + if err := unzipBinary(ctx, tmpDir, file); err != nil { + return fmt.Errorf("unzip binary: %w", 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) + } + + log.Default.Debug(ctx, "Unzipped Deno to %s", finalPath) + + binaryFound = true + + break + } + + if !binaryFound { + 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: %s", checksumURL, response.Status()) + } + + hash, _, _ := strings.Cut(strings.TrimSpace(response.String()), " ") + if len(hash) != 64 { + return "", fmt.Errorf("unexpected checksum format from %s: %s", checksumURL, hash) + } + + return hash, nil +} + +func getDenoFolder(downloadURL string) (string, error) { + 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:] + } + + dirName := hashStr + "-" + slug.Make(suffix) + 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", denoVersion, target) + + return url, nil +} + +func unzipBinary(ctx context.Context, 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() { + if err = denoFile.Close(); err != nil { + log.Default.Error(ctx, "close file for Deno binary: %s", err) + } + }() + + fileReader, err := file.Open() + if err != nil { + return fmt.Errorf("open file %s in Deno archive: %w", file.Name, err) + } + + defer func() { + if err = fileReader.Close(); err != nil { + log.Default.Error(ctx, "close file %s in Deno archive: %s", file.Name, err) + } + }() + + if _, err := io.Copy(denoFile, fileReader); 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(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() { + if err = file.Close(); err != nil { + log.Default.Error(ctx, "close file for checksum verification: %s", err) + } + }() + + 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/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..0e66a69a 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 ChartTSBuildScript = denoBuildScript diff --git a/internal/ts/files.go b/internal/ts/files.go deleted file mode 100644 index 69daff18..00000000 --- a/internal/ts/files.go +++ /dev/null @@ -1,51 +0,0 @@ -package ts - -import ( - "strings" - - helmchart "github.com/werf/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/internal/helm/pkg/werf/file" - "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 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 findEntrypointInFiles(files map[string][]byte) string { - for _, ep := range common.ChartTSEntryPoints { - if _, ok := files[ep]; ok { - return ep - } - } - - return "" -} - -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 4de71117..c5dcdade 100644 --- a/internal/ts/init.go +++ b/internal/ts/init.go @@ -11,12 +11,13 @@ import ( "github.com/werf/nelm/pkg/log" ) +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/", - "ts/dist/", } return ensureFileEntries( @@ -26,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) @@ -37,35 +38,13 @@ 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")}, - } - - for _, f := range skipIfExists { - _, err := os.Stat(f.path) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("stat %s: %w", f.path, err) - } - - if err == nil { - log.Default.Debug(ctx, "Skipping existing file %s", f.path) - continue - } - - if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil { - return fmt.Errorf("write %s: %w", f.path, err) - } - - log.Default.Debug(ctx, "Created %s", f.path) + if err := ensureValuesFile(ctx, chartPath); err != nil { + return fmt.Errorf("ensure values.yaml: %w", err) } // 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/", "ts/node_modules/"}); err != nil { return fmt.Errorf("ensure helmignore entries: %w", err) } @@ -94,7 +73,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: packageJSON(chartName), path: filepath.Join(tsDir, "package.json")}, + {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 { @@ -112,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. @@ -148,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 2ad99976..273b0777 100644 --- a/internal/ts/init_templates.go +++ b/internal/ts/init_templates.go @@ -1,14 +1,17 @@ package ts -import "fmt" - 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.3" + } +} ` 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 +56,8 @@ export function newDeployment($: RenderContext): object { # negation (prefixed with !). Only one pattern per line. # TypeScript chart files -ts/dist/ +ts/vendor/ +ts/node_modules/ ` helpersTSContent = `import type { RenderContext } from '@nelm/chart-ts-sdk'; @@ -97,11 +101,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 +116,61 @@ 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" - } -} + inputExampleContent = `Capabilities: + APIVersions: + - v1 + HelmVersion: + go_version: go1.25.0 + version: v3.20 + KubeVersion: + Major: "1" + Minor: "35" + Version: v1.35.0 +Chart: + APIVersion: v2 + Annotations: + 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: + - %[1]s + Maintainers: + - Email: john@example.com + Name: john + URL: https://example.com/john + Name: %[1]s + Sources: + - https://example.org/%[1]s + Tags: %[1]s + Type: application + Version: 0.1.0 +Files: + myfile: "content" +Release: + IsInstall: false + IsUpgrade: true + Name: %[1]s + Namespace: %[1]s + Revision: 2 + 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'; +import { getFullname, getLabels, getSelectorLabels } from './helpers.ts'; export function newService($: RenderContext): object { return { @@ -175,10 +207,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 @@ -193,11 +229,3 @@ service: port: 80 ` ) - -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/init_test.go b/internal/ts/init_test.go index 0f37afd4..1223032d 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" @@ -24,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) { @@ -43,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) { @@ -75,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/") }) } @@ -87,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")) }) @@ -112,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)) @@ -133,7 +118,8 @@ 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/") + assert.Contains(t, string(content), "ts/node_modules/") }) t.Run("skips existing Chart.yaml", func(t *testing.T) { @@ -180,7 +166,8 @@ 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/") + assert.Contains(t, string(content), "ts/node_modules/") }) } @@ -200,7 +187,8 @@ 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")) + assert.FileExists(t, filepath.Join(chartPath, "ts", "input.example.yaml")) }) t.Run("creates correct directory structure", func(t *testing.T) { @@ -214,17 +202,17 @@ 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), fmt.Sprintf(`"build": "%s"`, ts.ChartTSBuildScript)) + assert.Contains(t, string(content), `"@nelm/chart-ts-sdk"`) }) t.Run("includes render function in index.ts", func(t *testing.T) { @@ -236,9 +224,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 +267,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"`) }) @@ -298,6 +287,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") diff --git a/internal/ts/options.go b/internal/ts/options.go new file mode 100644 index 00000000..7aa656cf --- /dev/null +++ b/internal/ts/options.go @@ -0,0 +1,24 @@ +package ts + +import ( + "context" + + "github.com/werf/nelm/internal/helm/pkg/werf/helmopts" +) + +var tsOptionsKey chartTSOptionsKey + +type chartTSOptionsKey struct{} + +func GetTSOptionsFromContext(ctx context.Context) helmopts.TypeScriptOptions { + opts, ok := ctx.Value(tsOptionsKey).(helmopts.TypeScriptOptions) + if !ok { + return helmopts.TypeScriptOptions{} + } + + return opts +} + +func NewContextWithTSOptions(ctx context.Context, opts helmopts.TypeScriptOptions) context.Context { + return context.WithValue(ctx, tsOptionsKey, opts) +} 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 e49a3ee6..0cd500c1 100644 --- a/internal/ts/render.go +++ b/internal/ts/render.go @@ -3,10 +3,12 @@ package ts import ( "context" "fmt" + "os" "path" - "slices" + "path/filepath" "strings" + "github.com/samber/lo" "sigs.k8s.io/yaml" helmchart "github.com/werf/nelm/internal/helm/pkg/chart" @@ -15,128 +17,89 @@ import ( "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { - allRendered := make(map[string]string) +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { + 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) + } - if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), allRendered); err != nil { - return nil, err + allRendered, err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), chartPath, tempDirPath, denoBin) + 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 string, results 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) - rendered, err := renderFiles(ctx, chart, values) - if err != nil { - return fmt.Errorf("render files for chart %q: %w", chart.Name(), err) - } + results := make(map[string]string) + entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) + + if bundle != nil { + 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) + } - for filename, content := range rendered { - outputPath := path.Join(pathPrefix, filename) - results[outputPath] = content - log.Default.Debug(ctx, "Rendered output: %s", outputPath) + if content != "" { + outputPath := path.Join(pathPrefix, common.ChartTSSourceDir, lo.CoalesceOrEmpty(entrypoint, bundle.Name)) + results[outputPath] = content + 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), - results, + scopeValuesForSubchart(values, dep.Name(), dep), + path.Join(pathPrefix, "charts", dep.Name()), + filepath.Join(chartPath, "charts", dep.Name()), + tempDirPath, + denoBin, ) if err != nil { - return fmt.Errorf("render dependency %q: %w", depName, err) + return nil, fmt.Errorf("render dependency %q: %w", dep.Name(), err) } - } - - return nil -} - -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (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) - } - - sourceFiles := extractSourceFiles(mergedFiles) - if len(sourceFiles) == 0 { - return map[string]string{}, nil - } - - entrypoint := findEntrypointInFiles(sourceFiles) - if entrypoint == "" { - 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)) - if err != nil { - return nil, fmt.Errorf("run bundle: %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 + results = lo.Assign(results, depResults) } - return map[string]string{ - path.Join(common.ChartTSSourceDir, entrypoint): yamlOutput, - }, nil + return results, nil } -func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) map[string]any { - renderContext := renderedValues.AsMap() - - if valuesInterface, ok := renderContext["Values"]; ok { - if chartValues, ok := valuesInterface.(chartutil.Values); ok { - renderContext["Values"] = chartValues.AsMap() - } +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) } - renderContext["Chart"] = buildChartMetadata(chart) - - files := make(map[string]any, len(chart.Files)) - for _, file := range chart.Files { - files[file.Name] = file.Data + if err := writeInputRenderContext(renderedValues, chart, renderDir); err != nil { + return "", fmt.Errorf("write input render context: %w", err) } - renderContext["Files"] = files - - return renderContext -} - -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) + if err := runApp(ctx, bundle.Data, renderDir, denoBin); err != nil { + return "", fmt.Errorf("run deno app: %w", err) } - manifests, exists := resultMap["manifests"] - if !exists { - return "", fmt.Errorf("convert render result to yaml: missing 'manifests' field") + resultBytes, err := os.ReadFile(filepath.Join(renderDir, common.ChartTSOutputFile)) + if err != nil { + return "", fmt.Errorf("read output file: %w", err) } - return marshalManifests(manifests) + return strings.TrimSpace(string(resultBytes)), nil } func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, subchart *helmchart.Chart) chartutil.Values { @@ -177,6 +140,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 yaml: %w", err) + } + + if err := os.WriteFile(filepath.Join(renderDir, common.ChartTSInputFile), 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(), @@ -216,31 +209,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/internal/ts/render_ai_test.go b/internal/ts/render_ai_test.go deleted file mode 100644 index af0239aa..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/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/internal/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 6c4d956c..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/nelm/internal/helm/pkg/chart" - "github.com/werf/nelm/internal/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_lint.go b/pkg/action/chart_lint.go index 202707ea..6baef1fd 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 @@ -78,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 @@ -264,6 +268,9 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { LocalKubeVersion: opts.LocalKubeVersion, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 17876cf3..02118602 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 @@ -75,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 @@ -267,6 +271,9 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu LocalKubeVersion: opts.LocalKubeVersion, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + 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 new file mode 100644 index 00000000..a5a3f135 --- /dev/null +++ b/pkg/action/chart_ts_build.go @@ -0,0 +1,89 @@ +package action + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "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/featgate" + "github.com/werf/nelm/pkg/log" +) + +type ChartTSBuildOptions struct { + ChartDirPath string + DenoBinaryPath string + TempDirPath 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") + } + + log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Run bundle for ")+"%s", absPath) + + helmOpts := helmopts.HelmOptions{ + ChartLoadOpts: helmopts.ChartLoadOptions{ + ChartType: helmopts.ChartTypeChart, + }, + } + + chart, err := loader.Load(absPath, helmOpts) + if err != nil { + return fmt.Errorf("load chart: %w", err) + } + + if err = ts.BundleChartsRecursive(ctx, chart, absPath, true, opts.DenoBinaryPath); 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, common.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", bundle.Name, humanize.Bytes(uint64(len(bundle.Data)))) + } + + log.Default.Info(ctx, "TypeScript chart bundled successfully") + + return nil +} diff --git a/pkg/action/chart_init.go b/pkg/action/chart_ts_init.go similarity index 80% rename from pkg/action/chart_init.go rename to pkg/action/chart_ts_init.go index acc405db..ca64026b 100644 --- a/pkg/action/chart_init.go +++ b/pkg/action/chart_ts_init.go @@ -11,13 +11,13 @@ import ( "github.com/werf/nelm/pkg/log" ) -type ChartInitOptions struct { +type ChartTSInitOptions struct { ChartDirPath string - TS bool + ChartName string TempDirPath string } -func ChartInit(ctx context.Context, opts ChartInitOptions) error { +func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { chartPath := opts.ChartDirPath if chartPath == "" { chartPath = "." @@ -29,9 +29,8 @@ 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 opts.ChartName != "" { + chartName = opts.ChartName } if !featgate.FeatGateTypescript.Enabled() { @@ -56,7 +55,7 @@ func ChartInit(ctx context.Context, opts ChartInitOptions) 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 } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index c1c93534..7f2339a5 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -76,6 +76,10 @@ 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 + // 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 @@ -360,6 +364,9 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re Remote: true, SubchartNotes: opts.ShowSubchartNotes, TemplatesAllowDNS: opts.TemplatesAllowDNS, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, + 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 6a38c95f..146b4a7c 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -72,9 +72,13 @@ 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 + // 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 @@ -269,6 +273,9 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, Remote: true, TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, }) if err != nil { return fmt.Errorf("render chart: %w", err) diff --git a/pkg/common/common.go b/pkg/common/common.go index 56d0eb74..ae99d4b8 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/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