diff --git a/cmd/seitask/runner.go b/cmd/seitask/runner.go index 5a29feb..70c8767 100644 --- a/cmd/seitask/runner.go +++ b/cmd/seitask/runner.go @@ -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 @@ -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"), @@ -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}, diff --git a/internal/runner/apply.go b/internal/runner/apply.go index 84c99c9..417fcd8 100644 --- a/internal/runner/apply.go +++ b/internal/runner/apply.go @@ -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. @@ -44,16 +51,18 @@ type DefaultRenderer struct{} // "--" (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)) @@ -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) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 773888d..96b023d 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -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" @@ -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") @@ -75,7 +78,7 @@ 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)) } @@ -83,14 +86,69 @@ spec: 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")) } @@ -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())