@@ -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
176194func (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
195310func (f * project ) dealVarsFiles (p * kkprojectv1.Play , basePlaybook string ) error {
196311 for _ , varsFileStr := range p .VarsFiles {
0 commit comments