From 8f9452f41b9dae8002631ab678f3eaf5d216f1e8 Mon Sep 17 00:00:00 2001 From: Hoang Ngo Date: Fri, 17 Apr 2026 15:46:00 +0700 Subject: [PATCH 1/5] feat: generate plugin scaffold Signed-off-by: Hoang Ngo --- hack/plugin-scaffold/main.go | 285 ++++++++++++++++++ .../templates/config/application.go.tmpl | 25 ++ .../templates/config/deploy_target.go.tmpl | 20 ++ .../templates/config/plugin.go.tmpl | 19 ++ .../templates/deployment/pipeline.go.tmpl | 73 +++++ .../templates/deployment/plugin.go.tmpl | 92 ++++++ .../templates/deployment/stage.go.tmpl | 32 ++ hack/plugin-scaffold/templates/go.mod.tmpl | 7 + .../templates/livestate/plugin.go.tmpl | 36 +++ hack/plugin-scaffold/templates/main.go.tmpl | 47 +++ .../templates/planpreview/plugin.go.tmpl | 36 +++ 11 files changed, 672 insertions(+) create mode 100644 hack/plugin-scaffold/main.go create mode 100644 hack/plugin-scaffold/templates/config/application.go.tmpl create mode 100644 hack/plugin-scaffold/templates/config/deploy_target.go.tmpl create mode 100644 hack/plugin-scaffold/templates/config/plugin.go.tmpl create mode 100644 hack/plugin-scaffold/templates/deployment/pipeline.go.tmpl create mode 100644 hack/plugin-scaffold/templates/deployment/plugin.go.tmpl create mode 100644 hack/plugin-scaffold/templates/deployment/stage.go.tmpl create mode 100644 hack/plugin-scaffold/templates/go.mod.tmpl create mode 100644 hack/plugin-scaffold/templates/livestate/plugin.go.tmpl create mode 100644 hack/plugin-scaffold/templates/main.go.tmpl create mode 100644 hack/plugin-scaffold/templates/planpreview/plugin.go.tmpl diff --git a/hack/plugin-scaffold/main.go b/hack/plugin-scaffold/main.go new file mode 100644 index 0000000000..bca7f989f1 --- /dev/null +++ b/hack/plugin-scaffold/main.go @@ -0,0 +1,285 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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. + +// plugin-scaffold generates scaffolding for a new PipeCD deployment plugin. +// +// Usage: +// +// go run ./hack/plugin-scaffold \ +// --name myplatform \ +// --module github.com/my-org/my-plugin \ +// --stages "MY_DEPLOY:Deploy resources,MY_PROMOTE:Promote to production" \ +// --rollback MY_ROLLBACK \ +// --output ./output +package main + +import ( + "bytes" + "embed" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "unicode" +) + +//go:embed templates +var tmplFS embed.FS + +// Stage holds parsed information about a single stage. +type Stage struct { + // Name is the constant value, e.g. "MY_DEPLOY" + Name string + // Description is the human-readable description + Description string + // IsRollback marks this stage as the rollback stage + IsRollback bool +} + +// PluginData holds all data needed to render plugin templates. +type PluginData struct { + // PluginName is the raw plugin name, e.g. "myplatform" + PluginName string + // PluginTitle is the title-cased plugin name, e.g. "Myplatform" + PluginTitle string + // Module is the Go module path, e.g. "github.com/my-org/my-plugin" + Module string + // Stages is the full list of stages including rollback + Stages []Stage + // DeployStages is stages excluding rollback + DeployStages []Stage + // RollbackStage is the rollback stage (may be zero value if none) + RollbackStage *Stage + // HasRollback is true if a rollback stage was specified + HasRollback bool + // HasLivestate is true if --livestate flag was set + HasLivestate bool + // HasPlanPreview is true if --planpreview flag was set + HasPlanPreview bool +} + +func main() { + var ( + name = flag.String("name", "", "Plugin name (lowercase, e.g. myplatform)") + module = flag.String("module", "", "Go module path (e.g. github.com/my-org/my-plugin)") + stagesRaw = flag.String("stages", "", "Comma-separated stages: NAME or NAME:Description") + rollback = flag.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") + livestate = flag.Bool("livestate", false, "Generate livestate/plugin.go stub") + planpreview = flag.Bool("planpreview", false, "Generate planpreview/plugin.go stub") + output = flag.String("output", ".", "Output directory") + force = flag.Bool("force", false, "Overwrite existing output directory") + ) + flag.Parse() + + if err := run(*name, *module, *stagesRaw, *rollback, *output, *livestate, *planpreview, *force); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run(name, module, stagesRaw, rollbackName, output string, hasLivestate, hasPlanPreview, force bool) error { + if name == "" { + return errors.New("--name is required") + } + if module == "" { + return errors.New("--module is required") + } + if stagesRaw == "" { + return errors.New("--stages is required") + } + + deployStages, err := parseStages(stagesRaw) + if err != nil { + return fmt.Errorf("parse stages: %w", err) + } + + data := PluginData{ + PluginName: name, + PluginTitle: titleCase(name), + Module: module, + DeployStages: deployStages, + Stages: deployStages, + HasLivestate: hasLivestate, + HasPlanPreview: hasPlanPreview, + } + + if rollbackName != "" { + rb := Stage{ + Name: rollbackName, + Description: "Rollback to the previous version", + IsRollback: true, + } + data.RollbackStage = &rb + data.HasRollback = true + data.Stages = append(deployStages, rb) + } + + root := filepath.Join(output, name) + if _, err := os.Stat(root); err == nil { + if !force { + return fmt.Errorf("output directory %q already exists; use --force to overwrite", root) + } + } + return scaffold(root, data) +} + +func scaffold(root string, data PluginData) error { + staticFiles := map[string]string{ + "main.go": "templates/main.go.tmpl", + "go.mod": "templates/go.mod.tmpl", + "config/plugin.go": "templates/config/plugin.go.tmpl", + "config/application.go": "templates/config/application.go.tmpl", + "config/deploy_target.go": "templates/config/deploy_target.go.tmpl", + "deployment/plugin.go": "templates/deployment/plugin.go.tmpl", + "deployment/pipeline.go": "templates/deployment/pipeline.go.tmpl", + } + + for outPath, tmplPath := range staticFiles { + if err := renderFile(filepath.Join(root, outPath), tmplPath, data); err != nil { + return fmt.Errorf("render %s: %w", outPath, err) + } + } + + for _, stage := range data.DeployStages { + stageData := struct { + PluginData + Stage Stage + }{data, stage} + outPath := filepath.Join(root, "deployment", stageNameToSnake(stage.Name)+".go") + if err := renderFile(outPath, "templates/deployment/stage.go.tmpl", stageData); err != nil { + return fmt.Errorf("render stage file %s: %w", outPath, err) + } + } + + if data.HasLivestate { + outPath := filepath.Join(root, "livestate", "plugin.go") + if err := renderFile(outPath, "templates/livestate/plugin.go.tmpl", data); err != nil { + return fmt.Errorf("render livestate/plugin.go: %w", err) + } + } + + if data.HasPlanPreview { + outPath := filepath.Join(root, "planpreview", "plugin.go") + if err := renderFile(outPath, "templates/planpreview/plugin.go.tmpl", data); err != nil { + return fmt.Errorf("render planpreview/plugin.go: %w", err) + } + } + + fmt.Printf("Plugin scaffolding generated at: %s\n", root) + fmt.Println("\nFiles created:") + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + rel, _ := filepath.Rel(root, path) + fmt.Printf(" %s\n", rel) + } + return nil + }) +} + +func renderFile(outPath, tmplPath string, data any) error { + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + tmplContent, err := tmplFS.ReadFile(tmplPath) + if err != nil { + return fmt.Errorf("read template %s: %w", tmplPath, err) + } + funcMap := template.FuncMap{ + "titleStage": stageNameToTitle, + "snakeStage": stageNameToSnake, + "lower": strings.ToLower, + } + t, err := template.New(tmplPath).Funcs(funcMap).Parse(string(tmplContent)) + if err != nil { + return fmt.Errorf("parse template %s: %w", tmplPath, err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return fmt.Errorf("execute template %s: %w", tmplPath, err) + } + return os.WriteFile(outPath, buf.Bytes(), 0644) +} + +// parseStages parses "NAME:Desc,NAME2:Desc2" into Stage slices. +func parseStages(raw string) ([]Stage, error) { + parts := strings.Split(raw, ",") + stages := make([]Stage, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + before, after, ok := strings.Cut(p, ":") + var name, desc string + if ok { + name = strings.TrimSpace(before) + desc = strings.TrimSpace(after) + } else { + name = p + desc = fmt.Sprintf("Execute %s stage", strings.ToLower(strings.ReplaceAll(p, "_", " "))) + } + if name == "" { + return nil, fmt.Errorf("empty stage name in %q", p) + } + stages = append(stages, Stage{Name: name, Description: desc}) + } + if len(stages) == 0 { + return nil, errors.New("at least one stage is required") + } + return stages, nil +} + +// titleCase converts "myplatform" or "my-platform" to "Myplatform" / "MyPlatform". +func titleCase(s string) string { + var b strings.Builder + upper := true + for _, r := range s { + if r == '-' || r == '_' { + upper = true + continue + } + if upper { + b.WriteRune(unicode.ToUpper(r)) + upper = false + } else { + b.WriteRune(r) + } + } + return b.String() +} + +// stageNameToTitle converts "MY_SYNC" to "MySync". +func stageNameToTitle(s string) string { + parts := strings.Split(s, "_") + var b strings.Builder + for _, p := range parts { + if len(p) == 0 { + continue + } + b.WriteString(strings.ToUpper(p[:1])) + b.WriteString(strings.ToLower(p[1:])) + } + return b.String() +} + +// stageNameToSnake converts "MY_SYNC" to "my_sync". +func stageNameToSnake(s string) string { + return strings.ToLower(s) +} diff --git a/hack/plugin-scaffold/templates/config/application.go.tmpl b/hack/plugin-scaffold/templates/config/application.go.tmpl new file mode 100644 index 0000000000..dd70e84680 --- /dev/null +++ b/hack/plugin-scaffold/templates/config/application.go.tmpl @@ -0,0 +1,25 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 config + +// {{.PluginTitle}}ApplicationSpec defines the application specification. +type {{.PluginTitle}}ApplicationSpec struct { + Input {{.PluginTitle}}DeploymentInput `json:"input"` +} + +// {{.PluginTitle}}DeploymentInput defines the deployment input configuration. +type {{.PluginTitle}}DeploymentInput struct { + // TODO: Add deployment input fields specific to your platform. +} diff --git a/hack/plugin-scaffold/templates/config/deploy_target.go.tmpl b/hack/plugin-scaffold/templates/config/deploy_target.go.tmpl new file mode 100644 index 0000000000..e433edabc5 --- /dev/null +++ b/hack/plugin-scaffold/templates/config/deploy_target.go.tmpl @@ -0,0 +1,20 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 config + +// {{.PluginTitle}}DeployTargetConfig defines the configuration for a deploy target. +type {{.PluginTitle}}DeployTargetConfig struct { + // TODO: Add deploy target fields (e.g. API endpoint, credentials). +} diff --git a/hack/plugin-scaffold/templates/config/plugin.go.tmpl b/hack/plugin-scaffold/templates/config/plugin.go.tmpl new file mode 100644 index 0000000000..289272abc9 --- /dev/null +++ b/hack/plugin-scaffold/templates/config/plugin.go.tmpl @@ -0,0 +1,19 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 config + +// {{.PluginTitle}}PluginConfig defines the global configuration for the {{.PluginTitle}} plugin. +type {{.PluginTitle}}PluginConfig struct { +} diff --git a/hack/plugin-scaffold/templates/deployment/pipeline.go.tmpl b/hack/plugin-scaffold/templates/deployment/pipeline.go.tmpl new file mode 100644 index 0000000000..baa9a6221c --- /dev/null +++ b/hack/plugin-scaffold/templates/deployment/pipeline.go.tmpl @@ -0,0 +1,73 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 deployment + +import ( + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +const ( +{{range .Stages}} Stage{{titleStage .Name}} = "{{.Name}}" +{{end}}) + +const ( +{{range .Stages}} Stage{{titleStage .Name}}Description = "{{.Description}}" +{{end}}) + +var allStages = []string{ +{{range .Stages}} Stage{{titleStage .Name}}, +{{end}}} + +func buildQuickSyncPipeline(autoRollback bool) []sdk.QuickSyncStage { + out := []sdk.QuickSyncStage{ + { + Name: Stage{{titleStage (index .DeployStages 0).Name}}, + Description: Stage{{titleStage (index .DeployStages 0).Name}}Description, + Rollback: false, + }, + } +{{if .HasRollback}} + if autoRollback { + out = append(out, sdk.QuickSyncStage{ + Name: Stage{{titleStage .RollbackStage.Name}}, + Description: Stage{{titleStage .RollbackStage.Name}}Description, + Rollback: true, + }) + } +{{end}} + return out +} + +func buildPipelineStages(input *sdk.BuildPipelineSyncStagesInput) []sdk.PipelineStage { + stages := input.Request.Stages + out := make([]sdk.PipelineStage, 0, len(stages)+1) + for _, s := range stages { + out = append(out, sdk.PipelineStage{ + Name: s.Name, + Index: s.Index, + Rollback: false, + }) + } +{{if .HasRollback}} + if input.Request.Rollback { + out = append(out, sdk.PipelineStage{ + Name: Stage{{titleStage .RollbackStage.Name}}, + Index: len(stages) + 1, + Rollback: true, + }) + } +{{end}} + return out +} diff --git a/hack/plugin-scaffold/templates/deployment/plugin.go.tmpl b/hack/plugin-scaffold/templates/deployment/plugin.go.tmpl new file mode 100644 index 0000000000..ce213b4687 --- /dev/null +++ b/hack/plugin-scaffold/templates/deployment/plugin.go.tmpl @@ -0,0 +1,92 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 deployment + +import ( + "context" + "errors" + + cfg "{{.Module}}/config" + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +var _ sdk.DeploymentPlugin[cfg.{{.PluginTitle}}PluginConfig, cfg.{{.PluginTitle}}DeployTargetConfig, cfg.{{.PluginTitle}}ApplicationSpec] = (*{{.PluginTitle}}Plugin)(nil) + +var ErrUnsupportedStage = errors.New("unsupported stage") + +type {{.PluginTitle}}Plugin struct{} + +func (p *{{.PluginTitle}}Plugin) FetchDefinedStages() []string { + return allStages +} + +func (p *{{.PluginTitle}}Plugin) BuildQuickSyncStages( + _ context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + input *sdk.BuildQuickSyncStagesInput, +) (*sdk.BuildQuickSyncStagesResponse, error) { + return &sdk.BuildQuickSyncStagesResponse{ + Stages: buildQuickSyncPipeline(input.Request.Rollback), + }, nil +} + +func (p *{{.PluginTitle}}Plugin) BuildPipelineSyncStages( + _ context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + input *sdk.BuildPipelineSyncStagesInput, +) (*sdk.BuildPipelineSyncStagesResponse, error) { + return &sdk.BuildPipelineSyncStagesResponse{ + Stages: buildPipelineStages(input), + }, nil +} + +func (p *{{.PluginTitle}}Plugin) ExecuteStage( + ctx context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + deployTargets []*sdk.DeployTarget[cfg.{{.PluginTitle}}DeployTargetConfig], + input *sdk.ExecuteStageInput[cfg.{{.PluginTitle}}ApplicationSpec], +) (*sdk.ExecuteStageResponse, error) { + switch input.Request.StageName { +{{range .DeployStages}} case Stage{{titleStage .Name}}: + return &sdk.ExecuteStageResponse{ + Status: p.execute{{titleStage .Name}}Stage(ctx, input, deployTargets[0]), + }, nil +{{end}} default: + return nil, ErrUnsupportedStage + } +} + +func (p *{{.PluginTitle}}Plugin) DetermineVersions( + _ context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + _ *sdk.DetermineVersionsInput[cfg.{{.PluginTitle}}ApplicationSpec], +) (*sdk.DetermineVersionsResponse, error) { + return &sdk.DetermineVersionsResponse{ + Versions: []sdk.ArtifactVersion{ + {Version: "unknown", Name: "{{.PluginName}}"}, + }, + }, nil +} + +func (p *{{.PluginTitle}}Plugin) DetermineStrategy( + _ context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + _ *sdk.DetermineStrategyInput[cfg.{{.PluginTitle}}ApplicationSpec], +) (*sdk.DetermineStrategyResponse, error) { + return &sdk.DetermineStrategyResponse{ + Strategy: sdk.SyncStrategyQuickSync, + Summary: "Use quick sync strategy", + }, nil +} diff --git a/hack/plugin-scaffold/templates/deployment/stage.go.tmpl b/hack/plugin-scaffold/templates/deployment/stage.go.tmpl new file mode 100644 index 0000000000..59f143128f --- /dev/null +++ b/hack/plugin-scaffold/templates/deployment/stage.go.tmpl @@ -0,0 +1,32 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 deployment + +import ( + "context" + + cfg "{{.Module}}/config" + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +func (p *{{.PluginTitle}}Plugin) execute{{titleStage .Stage.Name}}Stage( + ctx context.Context, + input *sdk.ExecuteStageInput[cfg.{{.PluginTitle}}ApplicationSpec], + target *sdk.DeployTarget[cfg.{{.PluginTitle}}DeployTargetConfig], +) sdk.StageStatus { + // TODO: Implement {{.Stage.Name}} stage logic. + input.Logger.Info("executing {{.Stage.Name}} stage") + return sdk.StageStatusSuccess +} diff --git a/hack/plugin-scaffold/templates/go.mod.tmpl b/hack/plugin-scaffold/templates/go.mod.tmpl new file mode 100644 index 0000000000..09154a2ff6 --- /dev/null +++ b/hack/plugin-scaffold/templates/go.mod.tmpl @@ -0,0 +1,7 @@ +module {{.Module}} + +go 1.24.0 + +require ( + github.com/pipe-cd/piped-plugin-sdk-go v0.3.0 +) diff --git a/hack/plugin-scaffold/templates/livestate/plugin.go.tmpl b/hack/plugin-scaffold/templates/livestate/plugin.go.tmpl new file mode 100644 index 0000000000..0f573ddb2d --- /dev/null +++ b/hack/plugin-scaffold/templates/livestate/plugin.go.tmpl @@ -0,0 +1,36 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 livestate + +import ( + "context" + + cfg "{{.Module}}/config" + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +var _ sdk.LivestatePlugin[cfg.{{.PluginTitle}}PluginConfig, cfg.{{.PluginTitle}}DeployTargetConfig, cfg.{{.PluginTitle}}ApplicationSpec] = (*Plugin)(nil) + +type Plugin struct{} + +func (p *Plugin) GetLivestate( + ctx context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + deployTargets []*sdk.DeployTarget[cfg.{{.PluginTitle}}DeployTargetConfig], + input *sdk.GetLivestateInput[cfg.{{.PluginTitle}}ApplicationSpec], +) (*sdk.GetLivestateResponse, error) { + // TODO: Implement GetLivestate. + return &sdk.GetLivestateResponse{}, nil +} diff --git a/hack/plugin-scaffold/templates/main.go.tmpl b/hack/plugin-scaffold/templates/main.go.tmpl new file mode 100644 index 0000000000..2a6ef992f7 --- /dev/null +++ b/hack/plugin-scaffold/templates/main.go.tmpl @@ -0,0 +1,47 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 main + +import ( + "log" + + "{{.Module}}/deployment" +{{- if .HasLivestate}} + "{{.Module}}/livestate" +{{- end}} +{{- if .HasPlanPreview}} + "{{.Module}}/planpreview" +{{- end}} + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +func main() { + plugin, err := sdk.NewPlugin( + "0.0.1", + sdk.WithDeploymentPlugin(&deployment.{{.PluginTitle}}Plugin{}), +{{- if .HasLivestate}} + sdk.WithLivestatePlugin(&livestate.Plugin{}), +{{- end}} +{{- if .HasPlanPreview}} + sdk.WithPlanPreviewPlugin(&planpreview.Plugin{}), +{{- end}} + ) + if err != nil { + log.Fatalf("failed to create plugin: %v", err) + } + if err := plugin.Run(); err != nil { + log.Fatalf("plugin execution failed: %v", err) + } +} diff --git a/hack/plugin-scaffold/templates/planpreview/plugin.go.tmpl b/hack/plugin-scaffold/templates/planpreview/plugin.go.tmpl new file mode 100644 index 0000000000..12935d93d1 --- /dev/null +++ b/hack/plugin-scaffold/templates/planpreview/plugin.go.tmpl @@ -0,0 +1,36 @@ +// Copyright 2026 The PipeCD Authors. +// +// 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 planpreview + +import ( + "context" + + cfg "{{.Module}}/config" + sdk "github.com/pipe-cd/piped-plugin-sdk-go" +) + +var _ sdk.PlanPreviewPlugin[cfg.{{.PluginTitle}}PluginConfig, cfg.{{.PluginTitle}}DeployTargetConfig, cfg.{{.PluginTitle}}ApplicationSpec] = (*Plugin)(nil) + +type Plugin struct{} + +func (p *Plugin) GetPlanPreview( + ctx context.Context, + _ *cfg.{{.PluginTitle}}PluginConfig, + deployTargets []*sdk.DeployTarget[cfg.{{.PluginTitle}}DeployTargetConfig], + input *sdk.GetPlanPreviewInput[cfg.{{.PluginTitle}}ApplicationSpec], +) (*sdk.GetPlanPreviewResponse, error) { + // TODO: Implement GetPlanPreview. + return &sdk.GetPlanPreviewResponse{}, nil +} From e1da2117f74044534d91a57cd423bba866f7a2a5 Mon Sep 17 00:00:00 2001 From: Hoang Ngo Date: Fri, 17 Apr 2026 22:13:58 +0700 Subject: [PATCH 2/5] chore: allow config go version and plugin sdk version Signed-off-by: Hoang Ngo --- hack/plugin-scaffold/main.go | 12 ++++++++++-- hack/plugin-scaffold/templates/go.mod.tmpl | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hack/plugin-scaffold/main.go b/hack/plugin-scaffold/main.go index bca7f989f1..5d49976cc4 100644 --- a/hack/plugin-scaffold/main.go +++ b/hack/plugin-scaffold/main.go @@ -70,6 +70,10 @@ type PluginData struct { HasLivestate bool // HasPlanPreview is true if --planpreview flag was set HasPlanPreview bool + // GoVersion is the Go version used in go.mod, e.g. "1.24.0" + GoVersion string + // SDKVersion is the piped-plugin-sdk-go version, e.g. "v0.3.0" + SDKVersion string } func main() { @@ -80,18 +84,20 @@ func main() { rollback = flag.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") livestate = flag.Bool("livestate", false, "Generate livestate/plugin.go stub") planpreview = flag.Bool("planpreview", false, "Generate planpreview/plugin.go stub") + goVersion = flag.String("go-version", "1.24.0", "Go version for go.mod") + sdkVersion = flag.String("sdk-version", "v0.3.0", "piped-plugin-sdk-go version for go.mod") output = flag.String("output", ".", "Output directory") force = flag.Bool("force", false, "Overwrite existing output directory") ) flag.Parse() - if err := run(*name, *module, *stagesRaw, *rollback, *output, *livestate, *planpreview, *force); err != nil { + if err := run(*name, *module, *stagesRaw, *rollback, *output, *goVersion, *sdkVersion, *livestate, *planpreview, *force); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } -func run(name, module, stagesRaw, rollbackName, output string, hasLivestate, hasPlanPreview, force bool) error { +func run(name, module, stagesRaw, rollbackName, output, goVersion, sdkVersion string, hasLivestate, hasPlanPreview, force bool) error { if name == "" { return errors.New("--name is required") } @@ -115,6 +121,8 @@ func run(name, module, stagesRaw, rollbackName, output string, hasLivestate, has Stages: deployStages, HasLivestate: hasLivestate, HasPlanPreview: hasPlanPreview, + GoVersion: goVersion, + SDKVersion: sdkVersion, } if rollbackName != "" { diff --git a/hack/plugin-scaffold/templates/go.mod.tmpl b/hack/plugin-scaffold/templates/go.mod.tmpl index 09154a2ff6..a9be233423 100644 --- a/hack/plugin-scaffold/templates/go.mod.tmpl +++ b/hack/plugin-scaffold/templates/go.mod.tmpl @@ -1,7 +1,7 @@ module {{.Module}} -go 1.24.0 +go {{.GoVersion}} require ( - github.com/pipe-cd/piped-plugin-sdk-go v0.3.0 + github.com/pipe-cd/piped-plugin-sdk-go {{.SDKVersion}} ) From a0c29ec9523620f2e5ed9a411b5dfee13a031052 Mon Sep 17 00:00:00 2001 From: Hoang Ngo Date: Fri, 17 Apr 2026 22:15:56 +0700 Subject: [PATCH 3/5] chore: use the latest go version as default value Signed-off-by: Hoang Ngo --- hack/plugin-scaffold/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hack/plugin-scaffold/main.go b/hack/plugin-scaffold/main.go index 5d49976cc4..b924293624 100644 --- a/hack/plugin-scaffold/main.go +++ b/hack/plugin-scaffold/main.go @@ -84,7 +84,7 @@ func main() { rollback = flag.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") livestate = flag.Bool("livestate", false, "Generate livestate/plugin.go stub") planpreview = flag.Bool("planpreview", false, "Generate planpreview/plugin.go stub") - goVersion = flag.String("go-version", "1.24.0", "Go version for go.mod") + goVersion = flag.String("go-version", "1.26.2", "Go version for go.mod") sdkVersion = flag.String("sdk-version", "v0.3.0", "piped-plugin-sdk-go version for go.mod") output = flag.String("output", ".", "Output directory") force = flag.Bool("force", false, "Overwrite existing output directory") From 04c8bbd234dce2c5d439d618167ef321f3ac0285 Mon Sep 17 00:00:00 2001 From: Hoang Ngo Date: Fri, 17 Apr 2026 22:43:55 +0700 Subject: [PATCH 4/5] use runtime.Version() as the default value for Go version Signed-off-by: Hoang Ngo --- hack/plugin-scaffold/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hack/plugin-scaffold/main.go b/hack/plugin-scaffold/main.go index b924293624..c57a0a2279 100644 --- a/hack/plugin-scaffold/main.go +++ b/hack/plugin-scaffold/main.go @@ -32,6 +32,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "text/template" "unicode" @@ -84,7 +85,7 @@ func main() { rollback = flag.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") livestate = flag.Bool("livestate", false, "Generate livestate/plugin.go stub") planpreview = flag.Bool("planpreview", false, "Generate planpreview/plugin.go stub") - goVersion = flag.String("go-version", "1.26.2", "Go version for go.mod") + goVersion = flag.String("go-version", strings.TrimPrefix(runtime.Version(), "go"), "Go version for go.mod (default: current toolchain)") sdkVersion = flag.String("sdk-version", "v0.3.0", "piped-plugin-sdk-go version for go.mod") output = flag.String("output", ".", "Output directory") force = flag.Bool("force", false, "Overwrite existing output directory") From 7e74e8a480189965fbdbe9c08113605260100a69 Mon Sep 17 00:00:00 2001 From: Hoang Ngo Date: Wed, 22 Apr 2026 14:13:18 +0700 Subject: [PATCH 5/5] feat: command "add-stage" adds stage to exist plugin Signed-off-by: Hoang Ngo --- hack/plugin-scaffold/main.go | 129 +++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/hack/plugin-scaffold/main.go b/hack/plugin-scaffold/main.go index c57a0a2279..86c810d5a4 100644 --- a/hack/plugin-scaffold/main.go +++ b/hack/plugin-scaffold/main.go @@ -78,26 +78,127 @@ type PluginData struct { } func main() { - var ( - name = flag.String("name", "", "Plugin name (lowercase, e.g. myplatform)") - module = flag.String("module", "", "Go module path (e.g. github.com/my-org/my-plugin)") - stagesRaw = flag.String("stages", "", "Comma-separated stages: NAME or NAME:Description") - rollback = flag.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") - livestate = flag.Bool("livestate", false, "Generate livestate/plugin.go stub") - planpreview = flag.Bool("planpreview", false, "Generate planpreview/plugin.go stub") - goVersion = flag.String("go-version", strings.TrimPrefix(runtime.Version(), "go"), "Go version for go.mod (default: current toolchain)") - sdkVersion = flag.String("sdk-version", "v0.3.0", "piped-plugin-sdk-go version for go.mod") - output = flag.String("output", ".", "Output directory") - force = flag.Bool("force", false, "Overwrite existing output directory") - ) - flag.Parse() + if len(os.Args) < 2 || os.Args[1] == "help" { + printUsage() + os.Exit(0) + } + + var err error + switch os.Args[1] { + case "new": + err = runNew(os.Args[2:]) + case "add-stage": + err = runAddStage(os.Args[2:]) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1]) + printUsage() + os.Exit(1) + } - if err := run(*name, *module, *stagesRaw, *rollback, *output, *goVersion, *sdkVersion, *livestate, *planpreview, *force); err != nil { + if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } +func printUsage() { + fmt.Println(`plugin-scaffold - generate scaffolding for a PipeCD plugin + +Commands: + new Create a new plugin + add-stage Add a stage to an existing plugin + help Show this message + +Run "go run ./hack/plugin-scaffold -help" for command flags.`) +} + +func runNew(args []string) error { + fs := flag.NewFlagSet("new", flag.ExitOnError) + name := fs.String("name", "", "Plugin name (lowercase, e.g. myplatform)") + module := fs.String("module", "", "Go module path (e.g. github.com/my-org/my-plugin)") + stagesRaw := fs.String("stages", "", "Comma-separated stages: NAME or NAME:Description") + rollback := fs.String("rollback", "", "Rollback stage name (optional, e.g. MY_ROLLBACK)") + livestate := fs.Bool("livestate", false, "Generate livestate/plugin.go stub") + planpreview := fs.Bool("planpreview", false, "Generate planpreview/plugin.go stub") + goVersion := fs.String("go-version", strings.TrimPrefix(runtime.Version(), "go"), "Go version for go.mod (default: current toolchain)") + sdkVersion := fs.String("sdk-version", "v0.3.0", "piped-plugin-sdk-go version for go.mod") + output := fs.String("output", ".", "Output directory") + force := fs.Bool("force", false, "Overwrite existing output directory") + fs.Parse(args) + + return run(*name, *module, *stagesRaw, *rollback, *output, *goVersion, *sdkVersion, *livestate, *planpreview, *force) +} + +func runAddStage(args []string) error { + fs := flag.NewFlagSet("add-stage", flag.ExitOnError) + pluginDir := fs.String("plugin-dir", "", "Path to the existing plugin directory") + module := fs.String("module", "", "Go module path of the plugin (e.g. github.com/my-org/my-plugin)") + stageRaw := fs.String("stage", "", "Stage to add: NAME or NAME:Description") + fs.Parse(args) + + if *pluginDir == "" { + return errors.New("-plugin-dir is required") + } + if *module == "" { + return errors.New("-module is required") + } + if *stageRaw == "" { + return errors.New("-stage is required") + } + + stages, err := parseStages(*stageRaw) + if err != nil { + return err + } + stage := stages[0] + pluginTitle := titleCase(filepath.Base(*pluginDir)) + + stageFile := filepath.Join(*pluginDir, "deployment", stageNameToSnake(stage.Name)+".go") + if _, err := os.Stat(stageFile); err == nil { + return fmt.Errorf("stage file %q already exists", stageFile) + } + + data := struct { + PluginData + Stage Stage + }{ + PluginData: PluginData{ + PluginTitle: pluginTitle, + Module: *module, + }, + Stage: stage, + } + if err := renderFile(stageFile, "templates/deployment/stage.go.tmpl", data); err != nil { + return fmt.Errorf("generate stage file: %w", err) + } + + fmt.Printf("Created: %s\n", stageFile) + fmt.Printf(` +Next, add the following to deployment/pipeline.go: + + In the const block: + Stage%s = "%s" + Stage%sDescription = "%s" + + In allStages: + Stage%s, + +Then add to the ExecuteStage switch in deployment/plugin.go: + + case Stage%s: + return &sdk.ExecuteStageResponse{ + Status: p.execute%sStage(ctx, input, deployTargets[0]), + }, nil +`, + stageNameToTitle(stage.Name), stage.Name, + stageNameToTitle(stage.Name), stage.Description, + stageNameToTitle(stage.Name), + stageNameToTitle(stage.Name), + stageNameToTitle(stage.Name), + ) + return nil +} + func run(name, module, stagesRaw, rollbackName, output, goVersion, sdkVersion string, hasLivestate, hasPlanPreview, force bool) error { if name == "" { return errors.New("--name is required")