Skip to content

Commit 0549f6a

Browse files
authored
Introduce pipectl transfer command (#6692)
1 parent 99acf82 commit 0549f6a

7 files changed

Lines changed: 659 additions & 0 deletions

File tree

cmd/pipectl/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/piped"
2626
"github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/planpreview"
2727
"github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/plugin"
28+
"github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/transfer"
2829
"github.com/pipe-cd/pipecd/pkg/cli"
2930
)
3031

@@ -43,6 +44,7 @@ func main() {
4344
encrypt.NewCommand(),
4445
migrate.NewCommand(),
4546
plugin.NewCommand(),
47+
transfer.NewCommand(),
4648
)
4749

4850
if err := app.Run(); err != nil {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright 2026 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package transfer
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"os"
22+
"time"
23+
24+
"github.com/spf13/cobra"
25+
"go.uber.org/zap"
26+
27+
"github.com/pipe-cd/pipecd/pkg/app/server/service/apiservice"
28+
"github.com/pipe-cd/pipecd/pkg/cli"
29+
"github.com/pipe-cd/pipecd/pkg/model"
30+
)
31+
32+
type backup struct {
33+
root *command
34+
35+
outputFile string
36+
}
37+
38+
func newBackupCommand(root *command) *cobra.Command {
39+
c := &backup{
40+
root: root,
41+
}
42+
cmd := &cobra.Command{
43+
Use: "backup",
44+
Short: "Backup piped and application data from the source control plane to a local file.",
45+
Long: `Backup exports all pipeds (discovered via their applications) and all applications
46+
from the source control plane into a single JSON file. Use the parent --address and --api-key
47+
flags to point at the source control plane.
48+
49+
Note: deployment history is not included because the API does not expose a write endpoint for deployments.`,
50+
RunE: cli.WithContext(c.run),
51+
}
52+
53+
cmd.Flags().StringVar(&c.outputFile, "output-file", c.outputFile, "The path of the output JSON file to save the backup data.")
54+
cmd.MarkFlagRequired("output-file")
55+
56+
return cmd
57+
}
58+
59+
func (c *backup) run(ctx context.Context, input cli.Input) error {
60+
input.Logger.Info("Starting control plane backup...")
61+
62+
cli, err := c.root.clientOptions.NewClient(ctx)
63+
if err != nil {
64+
return fmt.Errorf("failed to initialize client: %w", err)
65+
}
66+
defer cli.Close()
67+
68+
// Collect all applications via paginated ListApplications calls.
69+
applications, err := listAllApplications(ctx, cli, input.Logger)
70+
if err != nil {
71+
return fmt.Errorf("failed to list applications: %w", err)
72+
}
73+
input.Logger.Info(fmt.Sprintf("Found %d application(s)", len(applications)))
74+
75+
// Discover unique piped IDs from the application list, then fetch piped details.
76+
pipeds, err := fetchPipeds(ctx, cli, applications, input.Logger)
77+
if err != nil {
78+
return fmt.Errorf("failed to fetch pipeds: %w", err)
79+
}
80+
input.Logger.Info(fmt.Sprintf("Found %d piped(s)", len(pipeds)))
81+
82+
data := &BackupData{
83+
Version: "1",
84+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
85+
Pipeds: pipeds,
86+
Applications: applications,
87+
}
88+
89+
if err := writeBackupFile(c.outputFile, data); err != nil {
90+
return fmt.Errorf("failed to write backup file: %w", err)
91+
}
92+
93+
input.Logger.Info("Backup completed successfully", zap.String("output-file", c.outputFile))
94+
return nil
95+
}
96+
97+
// listAllApplications fetches all applications (both enabled and disabled) from the control plane
98+
// using cursor-based pagination. The ListApplications API filters by the disabled field as a strict
99+
// equality match, so two separate paginated sweeps are required.
100+
func listAllApplications(ctx context.Context, cli apiservice.Client, logger *zap.Logger) ([]*model.Application, error) {
101+
enabled, err := listApplicationsByDisabledStatus(ctx, cli, false, logger)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to list enabled applications: %w", err)
104+
}
105+
disabled, err := listApplicationsByDisabledStatus(ctx, cli, true, logger)
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to list disabled applications: %w", err)
108+
}
109+
all := append(enabled, disabled...)
110+
return all, nil
111+
}
112+
113+
// listApplicationsByDisabledStatus paginates through ListApplications for a given disabled status.
114+
func listApplicationsByDisabledStatus(ctx context.Context, cli apiservice.Client, disabled bool, logger *zap.Logger) ([]*model.Application, error) {
115+
var (
116+
all []*model.Application
117+
cursor string
118+
)
119+
for {
120+
resp, err := cli.ListApplications(ctx, &apiservice.ListApplicationsRequest{
121+
Disabled: disabled,
122+
Cursor: cursor,
123+
Limit: 500,
124+
})
125+
if err != nil {
126+
return nil, err
127+
}
128+
all = append(all, resp.Applications...)
129+
if resp.Cursor == "" || len(resp.Applications) == 0 {
130+
break
131+
}
132+
cursor = resp.Cursor
133+
}
134+
label := "enabled"
135+
if disabled {
136+
label = "disabled"
137+
}
138+
logger.Info(fmt.Sprintf("Fetched %d %s application(s)", len(all), label))
139+
return all, nil
140+
}
141+
142+
// fetchPipeds collects the unique piped IDs from the applications and fetches each piped's details.
143+
func fetchPipeds(ctx context.Context, cli apiservice.Client, applications []*model.Application, logger *zap.Logger) ([]*model.Piped, error) {
144+
seen := make(map[string]struct{})
145+
pipeds := make([]*model.Piped, 0, 10)
146+
147+
for _, app := range applications {
148+
if _, ok := seen[app.PipedId]; ok {
149+
continue
150+
}
151+
seen[app.PipedId] = struct{}{}
152+
153+
resp, err := cli.GetPiped(ctx, &apiservice.GetPipedRequest{PipedId: app.PipedId})
154+
if err != nil {
155+
logger.Warn("failed to fetch piped, skipping", zap.String("piped-id", app.PipedId), zap.Error(err))
156+
continue
157+
}
158+
pipeds = append(pipeds, resp.Piped)
159+
}
160+
if len(pipeds) == 0 {
161+
return nil, fmt.Errorf("no piped for backup")
162+
}
163+
return pipeds, nil
164+
}
165+
166+
// writeBackupFile serialises data to JSON and writes it to the given path.
167+
func writeBackupFile(path string, data *BackupData) error {
168+
b, err := json.MarshalIndent(data, "", " ")
169+
if err != nil {
170+
return fmt.Errorf("failed to marshal backup data: %w", err)
171+
}
172+
return os.WriteFile(path, b, 0o600)
173+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2026 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package transfer
16+
17+
import (
18+
"github.com/spf13/cobra"
19+
)
20+
21+
func newRestoreCommand(root *command) *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "restore",
24+
Short: "Restore piped and application data to the target control plane.",
25+
Long: `Restore re-creates pipeds and applications on the target control plane from a backup file.
26+
27+
The restore process requires two steps because the control plane validates that each
28+
application's Git repository is registered in the target piped before the application
29+
can be created. Repository registration only happens after the piped agent connects.
30+
31+
Two-step workflow:
32+
33+
Step 1 - Register pipeds:
34+
pipectl transfer restore piped --input-file=backup.json --output-file=mapping.json
35+
Update each piped config with the new ID and key from mapping.json, then restart the piped agents.
36+
37+
Step 2 - Restore applications (after pipeds have connected and registered their repos):
38+
pipectl transfer restore application --input-file=backup.json --piped-id-mapping-file=mapping.json`,
39+
}
40+
41+
cmd.AddCommand(newRestorePipedCommand(root))
42+
cmd.AddCommand(newRestoreApplicationCommand(root))
43+
44+
return cmd
45+
}

0 commit comments

Comments
 (0)