Skip to content

Commit 733d4c2

Browse files
committed
feat(project): inject custom playbooks at arbitrary positions via playbooks order (#3191)
Hard-coding an import slot in every builtin playbook was brittle and could not place custom playbooks at arbitrary positions. Generalize injection so users can insert custom playbooks anywhere in the top-level source playbook by weight. - Add a `playbooks` config list; each entry has `order` (float, can be fractional or negative) and `path` (template-rendered). Original plays get weights 0,1,2,... by document order; injected items are merged and sorted by a deterministic comparator: order ascending -> config items precede file plays -> definition order (duplicate order does not error). - Empty or unset `path` is skipped silently; a set path that does not exist still errors. Injection applies only to the top-level (first) source playbook. - Remove the hard-coded `inject_playbooks_path` import slot from builtin playbooks; injection now goes exclusively through the `playbooks` order list. - Add unit tests for order-based injection (incl. empty-path skip and pure import-directive anchors) and zh/en docs (framework + reference page). Signed-off-by: redscholar <blacktiledhouse@gmail.com>
1 parent 6304c20 commit 733d4c2

14 files changed

Lines changed: 494 additions & 1 deletion

File tree

docs/en/framework/002-playbook.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,44 @@ A playbook file can execute multiple plays in the defined order; each play speci
5555
- **Multiple plays**: Execute in defined order; `import_playbook` expands to the corresponding play first.
5656
- **Within the same play**: `pre_tasks` → `roles` → `tasks` → `post_tasks`.
5757
- Any task failure (without `ignore_errors`) results in play failure.
58+
59+
## Inject Playbooks
60+
61+
Besides hardcoding `import_playbook` inside a playbook file, you can declare a `playbooks`
62+
list in the playbook's config spec to inject a **custom playbook at any position of the
63+
top-level (first) source playbook**. This lets you override/modify parameters after the
64+
default parameters are loaded, without touching builtin playbooks or role code.
65+
66+
### Configuration
67+
68+
```yaml
69+
spec:
70+
playbooks:
71+
- order: 1.5 # insert between the 1st and 2nd original plays
72+
path: hook/inject_playbooks.yaml # playbook to inject (relative to project root, templated)
73+
- order: 0 # 0 means insert at the very front (before the 1st play)
74+
path: hook/pre_custom.yaml
75+
```
76+
77+
### Rules
78+
79+
- **Original plays get weights `1, 2, 3, ...` automatically** (by document order, 1-based).
80+
- **Injected items use an explicit `order`** (float, can be fractional or negative) to
81+
position the insertion; e.g. `order: 1.5` inserts between the 1st and 2nd original plays,
82+
`order: 0` inserts at the very front.
83+
- **Sort comparator (deterministic; duplicate `order` does not error)**:
84+
1. `order` ascending;
85+
2. `playbooks` config items take precedence over file plays;
86+
3. within the same source, by definition order (config list order / file order).
87+
- **`path` is rendered through templates**:
88+
- rendered to empty / unset variable → that item is **skipped** (no error);
89+
- path set but file not found → **errors** (as usual), to catch typos.
90+
- This mechanism applies **only to the top-level (first) source playbook.yaml**; imported
91+
sub-playbooks are not injected again.
92+
93+
### Example
94+
95+
The builtin package ships an example playbook `hook/inject_playbooks.yaml` (bundled, directly
96+
referenced via `path: hook/inject_playbooks.yaml`). It is a no-op by default; uncomment to
97+
override variables. For the full example and field reference, see
98+
[Inject Playbook (inject_playbooks.yaml)](../reference/playbooks/inject_playbooks.md).
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Inject Playbook (inject_playbooks.yaml)
2+
3+
![architecture](../../images/architecture.png)
4+
5+
`inject_playbooks.yaml` is a builtin **example playbook for injection**. It is a no-op by
6+
default, used to override/modify parameters after the default parameters are loaded, without
7+
changing builtin playbooks or role code.
8+
9+
The file is bundled with the builtin package at `builtin/core/playbooks/hook/inject_playbooks.yaml`.
10+
Reference it directly via the
11+
[`playbooks` injection mechanism](../framework/002-playbook.md#inject-playbooks):
12+
13+
```yaml
14+
spec:
15+
playbooks:
16+
- order: 1.5 # insert between the 1st and 2nd original plays
17+
path: hook/inject_playbooks.yaml # this file, bundled with the builtin package
18+
```
19+
20+
## What you can do
21+
22+
- Use `set_fact` to override runtime variables of the current host (for a single host play).
23+
- Use `add_hostvars` to write/override variables on multiple hosts in bulk (like `set_fact`
24+
but across hosts).
25+
- Use `debug` to print variables and verify the injection took effect.
26+
27+
## Full example
28+
29+
The file content is commented out by default; uncomment the relevant block to enable it:
30+
31+
```yaml
32+
- name: Inject | Inject and override playbook variables
33+
hosts:
34+
- all
35+
tasks:
36+
# Example 1: override a single variable (applies to all hosts of the current play)
37+
# - name: Inject | Override a single variable via set_fact
38+
# set_fact:
39+
# .kubernetes.version: "v1.31.0"
40+
41+
# Example 2: override variables by group / host condition
42+
# - name: Inject | Override variables for a specific group
43+
# set_fact:
44+
# .kubernetes.container_manager: "containerd"
45+
# when:
46+
# - .groups.k8s_cluster | default list | has .inventory_hostname
47+
48+
# Example 3: inject variables to multiple hosts in bulk (add_hostvars)
49+
# - name: Inject | Add host variables to multiple hosts
50+
# add_hostvars:
51+
# hosts: ["all"]
52+
# vars:
53+
# custom_label: "injected-by-hook"
54+
55+
# Example 4: debug — confirm variable values before injection
56+
# - name: Inject | Debug current variables
57+
# debug:
58+
# msg: "kubernetes version is {{ .kubernetes.version }}"
59+
```
60+
61+
## Notes
62+
63+
- Injected variables are only valid **during the current playbook run** (runtime variables)
64+
and are not written back to your config file. For persistent customization, prefer
65+
declaring it in the config spec.
66+
- Only enable the example when you truly need "recompute / conditional override after the
67+
default parameters are loaded".
68+
- **Builtin playbooks** can reference the bundled `hook/inject_playbooks.yaml` directly;
69+
**local projects** (non-builtin playbooks) should create this file under their own project
70+
directory and reference it by relative path.
71+
- For `order` positioning, sorting rules, empty-path skipping, etc., see
72+
[Inject Playbooks](../framework/002-playbook.md#inject-playbooks).

docs/zh/framework/002-playbook.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,41 @@
5555
- **多个 play**:按定义顺序执行;`import_playbook` 会先展开为对应 play。
5656
- **同一 play 内**:`pre_tasks` → `roles` → `tasks` → `post_tasks`。
5757
- 任一 task 失败(且未 `ignore_errors`)则 play 失败。
58+
59+
## 注入自定义 Playbook(Inject Playbooks)
60+
61+
除在 playbook 文件内写死 `import_playbook` 外,还可以通过 playbook 的 config spec 声明
62+
`playbooks` 列表,把**自定义 playbook 注入到顶层(第一个)源 playbook 的任意位置**。
63+
这样无需改动内置 playbook 与 role 代码,就能在默认参数加载完成后覆盖 / 修改参数。
64+
65+
### 配置
66+
67+
```yaml
68+
spec:
69+
playbooks:
70+
- order: 1.5 # 插在第 1、2 个原始 play 之间
71+
path: hook/inject_playbooks.yaml # 要注入的 playbook(相对项目根,支持模板渲染)
72+
- order: 0 # 0 表示插在最前面(第 1 个 play 之前)
73+
path: hook/pre_custom.yaml
74+
```
75+
76+
### 规则
77+
78+
- **原始 play 自动获得权重 `1, 2, 3, ...`**(按文档顺序,1-based)。
79+
- **注入项给显式 `order`**(float,可小数、可负数),用于定位插入位置;例如
80+
`order: 1.5` 表示插在第 1 个与第 2 个原始 play 之间,`order: 0` 表示插在最前。
81+
- **排序比较器(确定性裁决,order 相同不报错)**:
82+
1. `order` 升序;
83+
2. `playbooks` 配置项优先于文件内 play;
84+
3. 同来源内按定义顺序(配置列表顺序 / 文件内顺序)。
85+
- **`path` 走模板渲染**:
86+
- 渲染为空串 / 未设置变量 → 该项被**跳过**(不报错);
87+
- 设置了路径但文件不存在 → 按原逻辑**报错**,便于发现路径拼写错误。
88+
- 该机制**仅作用于顶层(第一个)源 playbook.yaml**;被导入的子 playbook 不再二次注入。
89+
90+
### 示例
91+
92+
内置包已提供一个示例 playbook `hook/inject_playbooks.yaml`(随内置包发布,可直接用
93+
`path: hook/inject_playbooks.yaml` 引用)。它默认是空操作,取消注释即可覆盖变量。
94+
完整示例与字段说明见参考页:[注入 Playbook(inject_playbooks.yaml)](../reference/playbooks/inject_playbooks.md)。
95+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# 注入 Playbook(inject_playbooks.yaml)
2+
3+
![architecture](../../images/architecture.png)
4+
5+
`inject_playbooks.yaml` 是 KubeKey 内置的一个**注入用示例 playbook**。它本身默认是空操作,
6+
用于在不改动内置 playbook 与 role 代码的前提下,在默认参数加载完成后覆盖 / 修改参数。
7+
8+
该文件随内置包发布,位于 `builtin/core/playbooks/hook/inject_playbooks.yaml`。可直接通过
9+
[`playbooks` 注入机制](../framework/002-playbook.md#注入自定义-playbookinject-playbooks) 引用:
10+
11+
```yaml
12+
spec:
13+
playbooks:
14+
- order: 1.5 # 插在第 1、2 个原始 play 之间
15+
path: hook/inject_playbooks.yaml # 本文件,已随内置包提供
16+
```
17+
18+
## 你能做什么
19+
20+
- 用 `set_fact` 覆盖当前主机的运行时变量(针对单个 host play)。
21+
- 用 `add_hostvars` 批量给多台主机写入 / 覆盖变量(类似 `set_fact` 但作用于多主机)。
22+
- 用 `debug` 打印变量,方便排查注入是否正确生效。
23+
24+
## 完整示例
25+
26+
文件内容默认全部以注释形式给出,取消对应注释即可生效:
27+
28+
```yaml
29+
- name: Inject | Inject and override playbook variables
30+
hosts:
31+
- all
32+
tasks:
33+
# 示例 1:覆盖单一变量(针对当前 play 的所有主机生效)
34+
# - name: Inject | Override a single variable via set_fact
35+
# set_fact:
36+
# .kubernetes.version: "v1.31.0"
37+
38+
# 示例 2:按分组 / 主机条件覆盖变量
39+
# - name: Inject | Override variables for a specific group
40+
# set_fact:
41+
# .kubernetes.container_manager: "containerd"
42+
# when:
43+
# - .groups.k8s_cluster | default list | has .inventory_hostname
44+
45+
# 示例 3:批量给多台主机注入变量(add_hostvars)
46+
# - name: Inject | Add host variables to multiple hosts
47+
# add_hostvars:
48+
# hosts: ["all"]
49+
# vars:
50+
# custom_label: "injected-by-hook"
51+
52+
# 示例 4:调试 —— 确认注入前的变量取值
53+
# - name: Inject | Debug current variables
54+
# debug:
55+
# msg: "kubernetes version is {{ .kubernetes.version }}"
56+
```
57+
58+
## 注意事项
59+
60+
- 注入的变量仅在「当前 playbook 运行期间」有效(运行时变量),不会写回你的 config 文件。
61+
如需持久化定制,应当优先在 config 的 spec 中声明。
62+
- 仅当确有「默认参数加载后仍需二次计算 / 条件覆盖」的需求时才启用示例。
63+
- **内置 playbook** 可直接引用随包提供的 `hook/inject_playbooks.yaml`;
64+
**本地项目**(非内置 playbook)请在自己的项目目录下创建该文件,并用相对路径引用。
65+
- 关于 `order` 定位、排序规则、空路径跳过等行为,详见
66+
[注入自定义 Playbook](../framework/002-playbook.md#注入自定义-playbookinject-playbooks)。

pkg/project/project.go

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ import (
2323
"io/fs"
2424
"os"
2525
"path/filepath"
26+
"sort"
2627
"strings"
2728

2829
"github.com/cockroachdb/errors"
2930
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
3031
kkcorev1alpha1 "github.com/kubesphere/kubekey/api/core/v1alpha1"
3132
kkprojectv1 "github.com/kubesphere/kubekey/api/project/v1"
3233
"gopkg.in/yaml.v3"
34+
"k8s.io/klog/v2"
3335

3436
_const "github.com/kubesphere/kubekey/v4/pkg/const"
3537
"github.com/kubesphere/kubekey/v4/pkg/converter/tmpl"
@@ -134,6 +136,15 @@ func (f *project) loadPlaybook(fromPlayBook, basePlaybook string) error {
134136
return errors.Wrapf(err, "failed to unmarshal playbook %q", basePlaybook)
135137
}
136138

139+
// Inject configured playbooks at their orders for the top-level playbook
140+
// only. Imported playbooks keep their own content.
141+
if fromPlayBook == "" {
142+
plays, err = f.injectPlaybooks(plays)
143+
if err != nil {
144+
return err
145+
}
146+
}
147+
137148
for _, p := range plays {
138149
if err := f.dealImportPlaybook(p, basePlaybook); err != nil {
139150
return err
@@ -166,6 +177,13 @@ func (f *project) loadPlaybook(fromPlayBook, basePlaybook string) error {
166177
}
167178
}
168179

180+
// A play that is purely an import_playbook directive carries no
181+
// standalone content; skip appending it so it does not show up as an
182+
// empty play in the final playbook. Its imported plays are already
183+
// loaded by dealImportPlaybook above.
184+
if p.ImportPlaybook != "" {
185+
continue
186+
}
169187
f.Play = append(f.Play, p)
170188
}
171189

@@ -175,7 +193,22 @@ func (f *project) loadPlaybook(fromPlayBook, basePlaybook string) error {
175193
// dealImportPlaybook handles the "import_playbook" argument in a play
176194
func (f *project) dealImportPlaybook(p kkprojectv1.Play, basePlaybook string) error {
177195
if p.ImportPlaybook != "" {
178-
importPlaybook, _ := f.getPath(GetImportPlaybookRelPath(basePlaybook, p.ImportPlaybook))
196+
// Render the import path with template syntax so that it can be
197+
// customized via variables and the `playbooks` order injection.
198+
importPlaybookPath, err := tmpl.ParseFunc(f.config, p.ImportPlaybook, tmpl.StringFunc)
199+
if err != nil {
200+
return errors.Wrapf(err, "failed to parse import_playbook %q", p.ImportPlaybook)
201+
}
202+
// An import_playbook that renders to an empty string is treated as a
203+
// no-op and skipped silently. This makes variable-driven imports
204+
// opt-in: reference them as `{{ .var | default "" }}`, leave the
205+
// variable unset (or set it to "") to disable the import, and point it
206+
// at a path to enable it.
207+
if strings.TrimSpace(importPlaybookPath) == "" {
208+
klog.V(5).InfoS("skip empty import_playbook", "import_playbook", p.ImportPlaybook, "base", basePlaybook)
209+
return nil
210+
}
211+
importPlaybook, _ := f.getPath(GetImportPlaybookRelPath(basePlaybook, importPlaybookPath))
179212
if importPlaybook == "" {
180213
return errors.Errorf("failed to find import_playbook %q base on %q. it's should be:\n %s", p.ImportPlaybook, basePlaybook, PathFormatImportPlaybook)
181214
}
@@ -191,6 +224,88 @@ func (f *project) dealImportPlaybook(p kkprojectv1.Play, basePlaybook string) er
191224
return nil
192225
}
193226

227+
// playbookInjection defines a playbook to inject into the top-level playbook at
228+
// a specific position. Order is a sort weight: original (file) plays get
229+
// 1,2,3,... by their document order (1-based), while injected (config) plays
230+
// keep their explicit order.
231+
type playbookInjection struct {
232+
Order float64 `yaml:"order" json:"order"`
233+
Path string `yaml:"path" json:"path"`
234+
}
235+
236+
// injectPlaybooks merges configured playbook injections into the play list and
237+
// returns the combined list sorted by order. The sort contract is:
238+
// - original (file) plays get orders 1,2,3,... by document order (1-based);
239+
// - injected (config) plays keep their explicit order (float, may be negative
240+
// or fractional);
241+
// - on a tie, config plays win over file plays, then definition order
242+
// (config list order / file document order).
243+
//
244+
// The injected paths are rendered as templates; an empty rendered path is
245+
// skipped. Only the top-level playbook is injected (see loadPlaybook).
246+
func (f *project) injectPlaybooks(plays []kkprojectv1.Play) ([]kkprojectv1.Play, error) {
247+
raw, ok := f.config["playbooks"]
248+
if !ok || raw == nil {
249+
return plays, nil
250+
}
251+
// Round-trip through YAML so we don't depend on the exact map types
252+
// produced by the config JSON decoder.
253+
buf, err := yaml.Marshal(raw)
254+
if err != nil {
255+
return nil, errors.Wrap(err, "failed to marshal playbooks injection config")
256+
}
257+
var injections []playbookInjection
258+
if err := yaml.Unmarshal(buf, &injections); err != nil {
259+
return nil, errors.Wrap(err, "failed to unmarshal playbooks injection config")
260+
}
261+
262+
type entry struct {
263+
order float64
264+
fromCfg bool
265+
seq int
266+
play kkprojectv1.Play
267+
}
268+
entries := make([]entry, 0, len(plays)+len(injections))
269+
for i, p := range plays {
270+
// Original plays are ordered 1,2,3,... (1-based), so that order: 0 is a
271+
// natural "insert at the very front" sentinel.
272+
entries = append(entries, entry{order: float64(i + 1), fromCfg: false, seq: i, play: p})
273+
}
274+
for i, inj := range injections {
275+
path, err := tmpl.ParseFunc(f.config, inj.Path, tmpl.StringFunc)
276+
if err != nil {
277+
return nil, errors.Wrapf(err, "failed to parse injected playbook path %q", inj.Path)
278+
}
279+
if strings.TrimSpace(path) == "" {
280+
klog.V(5).InfoS("skip empty injected playbook", "order", inj.Order, "path", inj.Path)
281+
continue
282+
}
283+
entries = append(entries, entry{
284+
order: inj.Order,
285+
fromCfg: true,
286+
seq: i,
287+
play: kkprojectv1.Play{ImportPlaybook: path},
288+
})
289+
}
290+
291+
sort.SliceStable(entries, func(a, b int) bool {
292+
if entries[a].order != entries[b].order {
293+
return entries[a].order < entries[b].order
294+
}
295+
// Config injections win over file plays on a tie.
296+
if entries[a].fromCfg != entries[b].fromCfg {
297+
return entries[a].fromCfg
298+
}
299+
return entries[a].seq < entries[b].seq
300+
})
301+
302+
out := make([]kkprojectv1.Play, len(entries))
303+
for i, e := range entries {
304+
out[i] = e.play
305+
}
306+
return out, nil
307+
}
308+
194309
// dealVarsFiles handles the "vars_files" argument in a play
195310
func (f *project) dealVarsFiles(p *kkprojectv1.Play, basePlaybook string) error {
196311
for _, varsFileStr := range p.VarsFiles {

0 commit comments

Comments
 (0)