-
Notifications
You must be signed in to change notification settings - Fork 14
feat(templates): implement custom template loader and executor (#565) #702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
73ef0ab
bbd5d9b
9d9eecb
2a9c3e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,188 @@ | ||||||||||||||||||||
| /* | ||||||||||||||||||||
| * Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||||||||||||||
| * you may not use this file except in compliance with the License. | ||||||||||||||||||||
| * You may obtain a copy of the License at | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||
| * | ||||||||||||||||||||
| * Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||||||||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||||||
| * See the License for the specific language governing permissions and | ||||||||||||||||||||
| * limitations under the License. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| package templates | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import ( | ||||||||||||||||||||
| "bytes" | ||||||||||||||||||||
| "crypto/sha256" | ||||||||||||||||||||
| "fmt" | ||||||||||||||||||||
| "io" | ||||||||||||||||||||
| "net/http" | ||||||||||||||||||||
| "os" | ||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||
| "regexp" | ||||||||||||||||||||
| "strings" | ||||||||||||||||||||
| "time" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| "github.com/NVIDIA/holodeck/api/holodeck/v1alpha1" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // envKeyPattern matches valid POSIX shell variable names. | ||||||||||||||||||||
| var envKeyPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // shellSafeNamePattern matches characters unsafe for interpolation into shell strings. | ||||||||||||||||||||
| var shellSafeNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]`) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // shellQuote produces a single-quoted shell string, escaping embedded single quotes. | ||||||||||||||||||||
| func shellQuote(s string) string { | ||||||||||||||||||||
| return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // sanitizeName strips shell-unsafe characters from a template name for use in shell output. | ||||||||||||||||||||
| func sanitizeName(s string) string { | ||||||||||||||||||||
| return shellSafeNamePattern.ReplaceAllString(s, "_") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const maxURLResponseBytes = 10 * 1024 * 1024 // 10MB | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // sha256Hex computes the hex-encoded SHA256 hash of data. | ||||||||||||||||||||
| func sha256Hex(data []byte) string { | ||||||||||||||||||||
| h := sha256.Sum256(data) | ||||||||||||||||||||
| return fmt.Sprintf("%x", h) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // LoadCustomTemplate loads script content from the appropriate source. | ||||||||||||||||||||
| // baseDir is used to resolve relative file paths (typically the directory | ||||||||||||||||||||
| // containing the Holodeck config file). | ||||||||||||||||||||
| func LoadCustomTemplate(tpl v1alpha1.CustomTemplate, baseDir string) ([]byte, error) { | ||||||||||||||||||||
| var content []byte | ||||||||||||||||||||
| var err error | ||||||||||||||||||||
|
|
||||||||||||||||||||
| switch { | ||||||||||||||||||||
| case tpl.Inline != "": | ||||||||||||||||||||
| content = []byte(tpl.Inline) | ||||||||||||||||||||
| case tpl.File != "": | ||||||||||||||||||||
| path := tpl.File | ||||||||||||||||||||
| if !filepath.IsAbs(path) { | ||||||||||||||||||||
| path = filepath.Join(baseDir, path) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| content, err = os.ReadFile(filepath.Clean(path)) //nolint:gosec // path is validated by caller | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: failed to read file %q: %w", tpl.Name, path, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| case tpl.URL != "": | ||||||||||||||||||||
| content, err = fetchURL(tpl.URL, tpl.Name) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, err | ||||||||||||||||||||
| } | ||||||||||||||||||||
| default: | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: no source specified", tpl.Name) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+65
to
+84
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| // Verify checksum if provided | ||||||||||||||||||||
| if tpl.Checksum != "" { | ||||||||||||||||||||
| expected := strings.TrimPrefix(tpl.Checksum, "sha256:") | ||||||||||||||||||||
| actual := sha256Hex(content) | ||||||||||||||||||||
| if actual != expected { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: checksum mismatch: expected %s, got %s", tpl.Name, expected, actual) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+86
to
+93
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| return content, nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // fetchURL downloads content from a URL with a timeout. | ||||||||||||||||||||
| func fetchURL(url, name string) ([]byte, error) { | ||||||||||||||||||||
| client := &http.Client{Timeout: 60 * time.Second} | ||||||||||||||||||||
| resp, err := client.Get(url) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: failed to fetch %q: %w", name, url, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| defer func() { _ = resp.Body.Close() }() | ||||||||||||||||||||
|
Comment on lines
+98
to
+105
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| if resp.StatusCode != http.StatusOK { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: URL %q returned status %d", name, url, resp.StatusCode) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Read one extra byte to detect truncation | ||||||||||||||||||||
| content, err := io.ReadAll(io.LimitReader(resp.Body, maxURLResponseBytes+1)) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: failed to read response: %w", name, err) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if len(content) > maxURLResponseBytes { | ||||||||||||||||||||
| return nil, fmt.Errorf("custom template %q: URL response exceeds 10MB limit", name) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+107
to
+118
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| return content, nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // CustomTemplateExecutor generates the bash script for a custom template. | ||||||||||||||||||||
| type CustomTemplateExecutor struct { | ||||||||||||||||||||
| Name string | ||||||||||||||||||||
| Phase v1alpha1.TemplatePhase | ||||||||||||||||||||
| Content []byte | ||||||||||||||||||||
| Env map[string]string | ||||||||||||||||||||
| ContinueOnError bool | ||||||||||||||||||||
| Timeout int | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // NewCustomTemplateExecutor creates an executor for a loaded custom template. | ||||||||||||||||||||
| func NewCustomTemplateExecutor(tpl v1alpha1.CustomTemplate, content []byte) *CustomTemplateExecutor { | ||||||||||||||||||||
| timeout := tpl.Timeout | ||||||||||||||||||||
| if timeout <= 0 { | ||||||||||||||||||||
| timeout = 600 | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return &CustomTemplateExecutor{ | ||||||||||||||||||||
| Name: tpl.Name, | ||||||||||||||||||||
| Phase: tpl.Phase, | ||||||||||||||||||||
| Content: content, | ||||||||||||||||||||
| Env: tpl.Env, | ||||||||||||||||||||
| ContinueOnError: tpl.ContinueOnError, | ||||||||||||||||||||
| Timeout: timeout, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Execute writes the custom template script to the buffer. | ||||||||||||||||||||
| func (ct *CustomTemplateExecutor) Execute(tpl *bytes.Buffer, _ v1alpha1.Environment) error { | ||||||||||||||||||||
| // Sanitize name and phase for safe shell interpolation (defense-in-depth) | ||||||||||||||||||||
| safeName := sanitizeName(ct.Name) | ||||||||||||||||||||
| safePhase := sanitizeName(string(ct.Phase)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Validate env var keys before generating any output (prevent key injection) | ||||||||||||||||||||
| for k := range ct.Env { | ||||||||||||||||||||
| if !envKeyPattern.MatchString(k) { | ||||||||||||||||||||
| return fmt.Errorf("custom template %q: invalid environment variable name %q", ct.Name, k) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Log header | ||||||||||||||||||||
| fmt.Fprintf(tpl, "\n# === [CUSTOM] Template: %s (phase: %s) ===\n", safeName, safePhase) | ||||||||||||||||||||
| fmt.Fprintf(tpl, `holodeck_log "INFO" "custom" "[CUSTOM] Running template '%s' (phase: %s)"`+"\n", safeName, safePhase) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Export environment variables with single-quote shell quoting (prevent value injection) | ||||||||||||||||||||
| for k, v := range ct.Env { | ||||||||||||||||||||
| fmt.Fprintf(tpl, "export %s=%s\n", k, shellQuote(v)) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Write the script content with error handling | ||||||||||||||||||||
| if ct.ContinueOnError { | ||||||||||||||||||||
| fmt.Fprintf(tpl, "# continueOnError=true: failures will be logged but not halt provisioning\n") | ||||||||||||||||||||
| fmt.Fprintf(tpl, "set +e\n") | ||||||||||||||||||||
| tpl.Write(ct.Content) | ||||||||||||||||||||
| fmt.Fprintf(tpl, "\n_custom_rc=$?\nset -e\n") | ||||||||||||||||||||
|
Comment on lines
+174
to
+176
|
||||||||||||||||||||
| fmt.Fprintf(tpl, "set +e\n") | |
| tpl.Write(ct.Content) | |
| fmt.Fprintf(tpl, "\n_custom_rc=$?\nset -e\n") | |
| // Run the custom content in a subshell so that 'exit' only terminates | |
| // the subshell and not the main provisioning script. | |
| fmt.Fprintf(tpl, "(\n") | |
| fmt.Fprintf(tpl, " set +e\n") | |
| tpl.Write(ct.Content) | |
| fmt.Fprintf(tpl, "\n)\n_custom_rc=$?\n") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description says env var values are exported using shell-quoting with
%q, but the implementation uses a custom single-quote escaping function (shellQuote). Either update the PR description to match, or switch to the described quoting approach so the documentation and code stay aligned.