From 0cce01ba0d9a74040a5c96d601b7e851c7b8d984 Mon Sep 17 00:00:00 2001 From: Carlos Eduardo Arango Gutierrez Date: Wed, 4 Mar 2026 18:32:58 +0100 Subject: [PATCH 1/2] feat(api): add CustomTemplate type and TemplatePhase enum (#565) Add CustomTemplate struct with inline/file/URL sources, execution phases, checksum verification, and environment variables. Add TemplatePhase enum with five provisioning phases. Add CustomTemplates field to EnvironmentSpec. Update deepcopy methods. Includes JSON round-trip and constant tests. Signed-off-by: Carlos Eduardo Arango Gutierrez --- api/holodeck/v1alpha1/types.go | 62 ++++++++ api/holodeck/v1alpha1/types_test.go | 143 ++++++++++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 29 ++++ 3 files changed, 234 insertions(+) create mode 100644 api/holodeck/v1alpha1/types_test.go diff --git a/api/holodeck/v1alpha1/types.go b/api/holodeck/v1alpha1/types.go index fec7bf509..4d52968b6 100644 --- a/api/holodeck/v1alpha1/types.go +++ b/api/holodeck/v1alpha1/types.go @@ -37,6 +37,10 @@ type EnvironmentSpec struct { NVIDIAContainerToolkit NVIDIAContainerToolkit `json:"nvidiaContainerToolkit"` // +optional Kubernetes Kubernetes `json:"kubernetes"` + + // CustomTemplates defines user-provided scripts to execute during provisioning. + // +optional + CustomTemplates []CustomTemplate `json:"customTemplates,omitempty"` } type Instance struct { @@ -195,3 +199,61 @@ type NVIDIAContainerToolkit struct { // +optional Version string `json:"version"` } + +// TemplatePhase determines when a custom template is executed during provisioning. +// +kubebuilder:validation:Enum=pre-install;post-runtime;post-toolkit;post-kubernetes;post-install +type TemplatePhase string + +const ( + // TemplatePhasePreInstall runs before any Holodeck components + TemplatePhasePreInstall TemplatePhase = "pre-install" + // TemplatePhasePostRuntime runs after container runtime installation + TemplatePhasePostRuntime TemplatePhase = "post-runtime" + // TemplatePhasePostToolkit runs after NVIDIA Container Toolkit installation + TemplatePhasePostToolkit TemplatePhase = "post-toolkit" + // TemplatePhasePostKubernetes runs after Kubernetes is ready + TemplatePhasePostKubernetes TemplatePhase = "post-kubernetes" + // TemplatePhasePostInstall runs after all Holodeck components (default) + TemplatePhasePostInstall TemplatePhase = "post-install" +) + +// CustomTemplate defines a user-provided script to execute during provisioning. +type CustomTemplate struct { + // Name is a human-readable identifier for the template. + // +required + Name string `json:"name"` + + // Phase determines when the template is executed. + // +kubebuilder:default=post-install + // +optional + Phase TemplatePhase `json:"phase,omitempty"` + + // Inline contains the script content directly. + // +optional + Inline string `json:"inline,omitempty"` + + // File is a path to a local script file. + // +optional + File string `json:"file,omitempty"` + + // URL is a remote HTTPS location to fetch the script from. + // +optional + URL string `json:"url,omitempty"` + + // Checksum is an optional SHA256 checksum for verification. + // Format: "sha256:" + // +optional + Checksum string `json:"checksum,omitempty"` + + // Timeout in seconds for script execution (default: 600). + // +optional + Timeout int `json:"timeout,omitempty"` + + // ContinueOnError allows provisioning to continue if this script fails. + // +optional + ContinueOnError bool `json:"continueOnError,omitempty"` + + // Env are environment variables to set for the script. + // +optional + Env map[string]string `json:"env,omitempty"` +} diff --git a/api/holodeck/v1alpha1/types_test.go b/api/holodeck/v1alpha1/types_test.go new file mode 100644 index 000000000..f10056b1e --- /dev/null +++ b/api/holodeck/v1alpha1/types_test.go @@ -0,0 +1,143 @@ +/* + * 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 v1alpha1 + +import ( + "encoding/json" + "testing" +) + +func TestCustomTemplate_JSONRoundTrip(t *testing.T) { + tpl := CustomTemplate{ + Name: "install-monitoring", + Phase: TemplatePhasePostKubernetes, + Inline: "#!/bin/bash\necho hello", + } + + data, err := json.Marshal(tpl) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var got CustomTemplate + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if got.Name != tpl.Name { + t.Errorf("Name: got %q, want %q", got.Name, tpl.Name) + } + if got.Phase != tpl.Phase { + t.Errorf("Phase: got %q, want %q", got.Phase, tpl.Phase) + } + if got.Inline != tpl.Inline { + t.Errorf("Inline: got %q, want %q", got.Inline, tpl.Inline) + } +} + +func TestCustomTemplate_AllSources(t *testing.T) { + tests := []struct { + name string + tpl CustomTemplate + }{ + { + name: "inline source", + tpl: CustomTemplate{ + Name: "inline-test", + Inline: "echo hello", + }, + }, + { + name: "file source", + tpl: CustomTemplate{ + Name: "file-test", + File: "./scripts/test.sh", + }, + }, + { + name: "url source", + tpl: CustomTemplate{ + Name: "url-test", + URL: "https://example.com/script.sh", + Checksum: "sha256:abc123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.tpl) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var got CustomTemplate + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if got.Name != tt.tpl.Name { + t.Errorf("Name: got %q, want %q", got.Name, tt.tpl.Name) + } + }) + } +} + +func TestTemplatePhase_Constants(t *testing.T) { + phases := []TemplatePhase{ + TemplatePhasePreInstall, + TemplatePhasePostRuntime, + TemplatePhasePostToolkit, + TemplatePhasePostKubernetes, + TemplatePhasePostInstall, + } + expected := []string{ + "pre-install", + "post-runtime", + "post-toolkit", + "post-kubernetes", + "post-install", + } + for i, phase := range phases { + if string(phase) != expected[i] { + t.Errorf("phase %d: got %q, want %q", i, phase, expected[i]) + } + } +} + +func TestEnvironmentSpec_CustomTemplatesField(t *testing.T) { + spec := EnvironmentSpec{ + CustomTemplates: []CustomTemplate{ + {Name: "test", Phase: TemplatePhasePostInstall, Inline: "echo ok"}, + }, + } + + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var got EnvironmentSpec + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if len(got.CustomTemplates) != 1 { + t.Fatalf("expected 1 custom template, got %d", len(got.CustomTemplates)) + } + if got.CustomTemplates[0].Name != "test" { + t.Errorf("Name: got %q, want %q", got.CustomTemplates[0].Name, "test") + } +} diff --git a/api/holodeck/v1alpha1/zz_generated.deepcopy.go b/api/holodeck/v1alpha1/zz_generated.deepcopy.go index 2c5f56b67..b232a4837 100644 --- a/api/holodeck/v1alpha1/zz_generated.deepcopy.go +++ b/api/holodeck/v1alpha1/zz_generated.deepcopy.go @@ -40,6 +40,28 @@ func (in *Auth) DeepCopy() *Auth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomTemplate) DeepCopyInto(out *CustomTemplate) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomTemplate. +func (in *CustomTemplate) DeepCopy() *CustomTemplate { + if in == nil { + return nil + } + out := new(CustomTemplate) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContainerRuntime) DeepCopyInto(out *ContainerRuntime) { *out = *in @@ -123,6 +145,13 @@ func (in *EnvironmentSpec) DeepCopyInto(out *EnvironmentSpec) { out.ContainerRuntime = in.ContainerRuntime out.NVIDIAContainerToolkit = in.NVIDIAContainerToolkit in.Kubernetes.DeepCopyInto(&out.Kubernetes) + if in.CustomTemplates != nil { + in, out := &in.CustomTemplates, &out.CustomTemplates + *out = make([]CustomTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvironmentSpec. From c9273c2b5878b8db859cba653def1f92ce29c9d9 Mon Sep 17 00:00:00 2001 From: Carlos Eduardo Arango Gutierrez Date: Wed, 4 Mar 2026 18:54:33 +0100 Subject: [PATCH 2/2] fix(api): address PR #701 review feedback - R1: Reorder CustomTemplate deepcopy to alphabetical position (between ContainerRuntime and Environment) matching controller-gen convention - N1: Add TestCustomTemplate_JSONRoundTrip_AllFields covering Env map, Timeout, and ContinueOnError serialization - N2: Use realistic SHA256 digest in URL test case checksum Signed-off-by: Carlos Eduardo Arango Gutierrez --- api/holodeck/v1alpha1/types_test.go | 41 ++++++++++++++++++- .../v1alpha1/zz_generated.deepcopy.go | 30 +++++++------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/api/holodeck/v1alpha1/types_test.go b/api/holodeck/v1alpha1/types_test.go index f10056b1e..eee521264 100644 --- a/api/holodeck/v1alpha1/types_test.go +++ b/api/holodeck/v1alpha1/types_test.go @@ -49,6 +49,45 @@ func TestCustomTemplate_JSONRoundTrip(t *testing.T) { } } +func TestCustomTemplate_JSONRoundTrip_AllFields(t *testing.T) { + tpl := CustomTemplate{ + Name: "full-template", + Phase: TemplatePhasePostInstall, + Inline: "#!/bin/bash\necho hello", + Timeout: 300, + ContinueOnError: true, + Env: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + } + + data, err := json.Marshal(tpl) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var got CustomTemplate + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if got.Timeout != tpl.Timeout { + t.Errorf("Timeout: got %d, want %d", got.Timeout, tpl.Timeout) + } + if got.ContinueOnError != tpl.ContinueOnError { + t.Errorf("ContinueOnError: got %v, want %v", got.ContinueOnError, tpl.ContinueOnError) + } + if len(got.Env) != len(tpl.Env) { + t.Fatalf("Env length: got %d, want %d", len(got.Env), len(tpl.Env)) + } + for k, v := range tpl.Env { + if got.Env[k] != v { + t.Errorf("Env[%q]: got %q, want %q", k, got.Env[k], v) + } + } +} + func TestCustomTemplate_AllSources(t *testing.T) { tests := []struct { name string @@ -73,7 +112,7 @@ func TestCustomTemplate_AllSources(t *testing.T) { tpl: CustomTemplate{ Name: "url-test", URL: "https://example.com/script.sh", - Checksum: "sha256:abc123", + Checksum: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", }, }, } diff --git a/api/holodeck/v1alpha1/zz_generated.deepcopy.go b/api/holodeck/v1alpha1/zz_generated.deepcopy.go index b232a4837..dc77f1c18 100644 --- a/api/holodeck/v1alpha1/zz_generated.deepcopy.go +++ b/api/holodeck/v1alpha1/zz_generated.deepcopy.go @@ -41,38 +41,38 @@ func (in *Auth) DeepCopy() *Auth { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CustomTemplate) DeepCopyInto(out *CustomTemplate) { +func (in *ContainerRuntime) DeepCopyInto(out *ContainerRuntime) { *out = *in - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomTemplate. -func (in *CustomTemplate) DeepCopy() *CustomTemplate { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerRuntime. +func (in *ContainerRuntime) DeepCopy() *ContainerRuntime { if in == nil { return nil } - out := new(CustomTemplate) + out := new(ContainerRuntime) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ContainerRuntime) DeepCopyInto(out *ContainerRuntime) { +func (in *CustomTemplate) DeepCopyInto(out *CustomTemplate) { *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerRuntime. -func (in *ContainerRuntime) DeepCopy() *ContainerRuntime { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomTemplate. +func (in *CustomTemplate) DeepCopy() *CustomTemplate { if in == nil { return nil } - out := new(ContainerRuntime) + out := new(CustomTemplate) in.DeepCopyInto(out) return out }