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
16 changes: 15 additions & 1 deletion cmd/seitask/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"k8s.io/client-go/tools/clientcmd"

"github.com/sei-protocol/sei-k8s-controller/internal/runner"
"github.com/sei-protocol/sei-k8s-controller/internal/taskruntime"
)

// newRunnerCommand wires the legacy seitask-runner CLI as a subcommand of
Expand Down Expand Up @@ -101,6 +102,19 @@ func runRunner(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("create dynamic client: %w", err)
}

// Load the parent Workflow's identity so applied SeiNodeTask CRs
// carry an ownerRef to it — deleting the Workflow then cascades the
// per-step SeiNodeTasks. Matches the keygen / provision-snd pattern.
cliClient, err := kubeClientFromEnv()
if err != nil {
return err
}
wf, err := taskruntime.LoadWorkflowIdentity(ctx, cliClient)
if err != nil {
return err
}
ownerRef := wf.OwnerRef()

r := &runner.Run{
Opts: runner.Options{
TemplatePath: cmd.String("template"),
Expand All @@ -116,7 +130,7 @@ func runRunner(ctx context.Context, cmd *cli.Command) error {
},
Stdout: os.Stdout,
Stderr: os.Stderr,
Renderer: runner.DefaultRenderer{},
Renderer: runner.DefaultRenderer{OwnerRef: &ownerRef},
Applier: runner.DynamicApplier{Client: dyn},
Poller: runner.DynamicPoller{Client: dyn},
Lister: runner.DynamicNodeLister{Client: dyn},
Expand Down
25 changes: 20 additions & 5 deletions internal/runner/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ const (
// DefaultRenderer renders Go text/template files. The resulting manifest is
// parsed back to assert it is a SeiNodeTask, and metadata.name is
// rewritten to a deterministic value derived from (kind, vars, NODE) so
// re-applies hit the same CR.
type DefaultRenderer struct{}
// re-applies hit the same CR. When OwnerRef is non-nil, it replaces (not
// merges) ownerReferences so the rendered SeiNodeTask cascades on parent
// Workflow deletion.
type DefaultRenderer struct {
// OwnerRef, when non-nil, is stamped onto the rendered manifest as
// the sole entry of metadata.ownerReferences. The runner subcommand
// populates it from taskruntime.LoadWorkflowIdentity at startup.
OwnerRef *metav1.OwnerReference
}

// Render parses templatePath as a Go text/template and executes it against
// vars. The template author can use {{ .NODE }}, {{ .PROPOSAL_ID }}, etc.
Expand All @@ -44,16 +51,18 @@ type DefaultRenderer struct{}
// "<kind-kebab>-<NODE>-<short-hash>" (NODE omitted if empty), where the hash
// covers the template content + sorted vars. This guarantees re-applies
// with identical inputs target the same CR (Workflow restart idempotency).
func (DefaultRenderer) Render(templatePath string, vars map[string]string) ([]byte, string, error) {
func (r DefaultRenderer) Render(templatePath string, vars map[string]string) ([]byte, string, error) {
raw, err := os.ReadFile(templatePath) //nolint:gosec // path is operator-controlled CLI arg
if err != nil {
return nil, "", fmt.Errorf("read template: %w", err)
}
return RenderBytes(templatePath, raw, vars)
return RenderBytes(templatePath, raw, vars, r.OwnerRef)
}

// RenderBytes is the byte-input variant of Render, exposed for tests.
func RenderBytes(name string, raw []byte, vars map[string]string) ([]byte, string, error) {
// When ownerRef is non-nil, it replaces (not merges) ownerReferences on
// the rendered manifest.
func RenderBytes(name string, raw []byte, vars map[string]string, ownerRef *metav1.OwnerReference) ([]byte, string, error) {
tmpl, err := template.New(name).
Option("missingkey=error").
Parse(string(raw))
Expand Down Expand Up @@ -83,6 +92,12 @@ func RenderBytes(name string, raw []byte, vars map[string]string) ([]byte, strin
deterministic := DeterministicName(specKind, vars, raw)
obj.SetName(deterministic)

// Replace (not merge) ownerReferences so a template that smuggles a
// bogus ref can't leak through. Mirrors provisionsnd.stampMetadata.
if ownerRef != nil {
obj.SetOwnerReferences([]metav1.OwnerReference{*ownerRef})
}

out, err := yaml.Marshal(obj.Object)
if err != nil {
return nil, "", fmt.Errorf("re-marshal manifest: %w", err)
Expand Down
70 changes: 64 additions & 6 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
"time"

. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/yaml"

"github.com/sei-protocol/sei-k8s-controller/internal/runner"
Expand Down Expand Up @@ -63,9 +66,9 @@ spec:
`)
vars := map[string]string{tNodeKey: tValidator0, tChainIDKey: tChainID, tPropIDKey: "47"}

manifest1, name1, err := runner.RenderBytes("t.tmpl", tmpl, vars)
manifest1, name1, err := runner.RenderBytes("t.tmpl", tmpl, vars, nil)
g.Expect(err).NotTo(HaveOccurred())
manifest2, name2, err := runner.RenderBytes("t.tmpl", tmpl, vars)
manifest2, name2, err := runner.RenderBytes("t.tmpl", tmpl, vars, nil)
g.Expect(err).NotTo(HaveOccurred())

g.Expect(name1).To(Equal(name2), "name must be deterministic for identical inputs")
Expand All @@ -75,22 +78,77 @@ spec:

// Re-render with a different var should change the hash.
vars[tPropIDKey] = "48"
_, name3, err := runner.RenderBytes("t.tmpl", tmpl, vars)
_, name3, err := runner.RenderBytes("t.tmpl", tmpl, vars, nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(name3).NotTo(Equal(name1))
}

func TestRenderBytes_RejectsNonSeiNodeTask(t *testing.T) {
g := NewWithT(t)
tmpl := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata: {name: PLACEHOLDER}\n")
_, _, err := runner.RenderBytes("t.tmpl", tmpl, nil)
_, _, err := runner.RenderBytes("t.tmpl", tmpl, nil, nil)
g.Expect(err).To(MatchError(ContainSubstring("rendered manifest is ConfigMap")))
}

func TestRenderBytes_StampsOwnerRef(t *testing.T) {
g := NewWithT(t)
tmpl := []byte(`apiVersion: sei.io/v1alpha1
kind: SeiNodeTask
metadata:
name: PLACEHOLDER
ownerReferences:
- apiVersion: rogue.example.com/v1
kind: Impostor
name: smuggled
uid: 00000000-0000-0000-0000-000000000000
spec:
kind: GovVote
target:
nodeRef:
name: {{ .NODE }}
govVote:
chainId: c
keyName: k
proposalId: "1"
option: yes
fees: 0usei
gas: 0
`)
ctrlF := false
blockF := false
ownerRef := &metav1.OwnerReference{
APIVersion: "chaos-mesh.org/v1alpha1",
Kind: "Workflow",
Name: "release-test-20260521",
UID: types.UID("abcd-uid"),
Controller: &ctrlF,
BlockOwnerDeletion: &blockF,
}

manifest, _, err := runner.RenderBytes("t.tmpl", tmpl, map[string]string{tNodeKey: tValidator0}, ownerRef)
g.Expect(err).NotTo(HaveOccurred())

obj := &unstructured.Unstructured{}
g.Expect(yaml.Unmarshal(manifest, &obj.Object)).To(Succeed())
refs := obj.GetOwnerReferences()
g.Expect(refs).To(HaveLen(1), "render must REPLACE ownerReferences so a template-smuggled ref can't leak through")
g.Expect(refs[0].Kind).To(Equal("Workflow"))
g.Expect(refs[0].Name).To(Equal("release-test-20260521"))
g.Expect(refs[0].UID).To(Equal(types.UID("abcd-uid")))

// Nil ownerRef leaves template-declared refs alone (no-stamp path).
manifestNil, _, err := runner.RenderBytes("t.tmpl", tmpl, map[string]string{tNodeKey: tValidator0}, nil)
g.Expect(err).NotTo(HaveOccurred())
objNil := &unstructured.Unstructured{}
g.Expect(yaml.Unmarshal(manifestNil, &objNil.Object)).To(Succeed())
g.Expect(objNil.GetOwnerReferences()).To(HaveLen(1))
g.Expect(objNil.GetOwnerReferences()[0].Kind).To(Equal("Impostor"))
}

func TestRenderBytes_MissingKeyIsError(t *testing.T) {
g := NewWithT(t)
tmpl := []byte("apiVersion: sei.io/v1alpha1\nkind: SeiNodeTask\nmetadata: {name: PLACEHOLDER}\nspec:\n kind: GovVote\n target: {nodeRef: {name: {{ .NODE }}}}\n")
_, _, err := runner.RenderBytes("t.tmpl", tmpl, map[string]string{})
_, _, err := runner.RenderBytes("t.tmpl", tmpl, map[string]string{}, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("execute template"))
}
Expand Down Expand Up @@ -459,7 +517,7 @@ func TestEmbeddedTemplates_Render(t *testing.T) {
g := NewWithT(t)
raw, err := os.ReadFile(filepath.Join(dir, c.file))
g.Expect(err).NotTo(HaveOccurred(), "read template")
manifest, name, err := runner.RenderBytes(c.file, raw, c.vars)
manifest, name, err := runner.RenderBytes(c.file, raw, c.vars, nil)
g.Expect(err).NotTo(HaveOccurred(), "render template")
g.Expect(name).NotTo(BeEmpty())

Expand Down
Loading