Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions api/holodeck/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ type EnvironmentSpec struct {

// +optional
LoadBalancer *LoadBalancer `json:"loadBalancer,omitempty"`

// CustomTemplates defines user-provided scripts to execute during provisioning.
// +optional
CustomTemplates []CustomTemplate `json:"customTemplates,omitempty"`
}

type Provider string
Expand Down Expand Up @@ -997,3 +1001,61 @@ type Kernel struct {

Version string `json:"version,omitempty"`
}

// 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"`
Comment on lines +1028 to +1031
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+kubebuilder:default=post-install only affects CRD defaulting; Holodeck's local YAML parsing via sigs.k8s.io/yaml won't apply it, so omitted phase will deserialize as the empty string. Consider adding explicit defaulting in the config load path (or documenting/handling empty Phase as post-install in consumers) to make the default reliable for CLI usage.

Copilot uses AI. Check for mistakes.

// 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.
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL is documented as an "HTTPS location", but the schema doesn't currently constrain the scheme (e.g., it will accept http:// or other schemes). If HTTPS-only is a security requirement, add a kubebuilder validation (pattern / XValidation) or adjust the field comment to match what will actually be accepted and validated at runtime.

Suggested change
// URL is a remote HTTPS location to fetch the script from.
// URL is a remote HTTPS location to fetch the script from.
// +kubebuilder:validation:Pattern=`^https://`

Copilot uses AI. Check for mistakes.
// +optional
URL string `json:"url,omitempty"`

// Checksum is an optional SHA256 checksum for verification.
// Format: "sha256:<hex-digest>"
// +optional
Checksum string `json:"checksum,omitempty"`

// Timeout in seconds for script execution (default: 600).
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says Timeout defaults to 600 seconds, but there is no +kubebuilder:default=600 marker and plain JSON/YAML unmarshalling will leave it as 0. Either add an explicit default marker (and/or validation like minimum 1) or clarify that 0 means "use default" and ensure consumers implement that behavior.

Suggested change
// Timeout in seconds for script execution (default: 600).
// Timeout in seconds for script execution (default: 600).
// +kubebuilder:default=600

Copilot uses AI. Check for mistakes.
// +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"`
}
182 changes: 182 additions & 0 deletions api/holodeck/v1alpha1/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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_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
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:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
}

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")
}
}
34 changes: 34 additions & 0 deletions api/holodeck/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading