Skip to content

Commit aa8779f

Browse files
committed
feat: add pipe sync command and refactor key bindings
- Introduced a new command for syncing via pipe, designed for one-time execution without interval support. - Refactored key bindings in the UI package for improved readability by aligning the formatting of key binding declarations. - Updated model handling in the UI to enhance clarity and maintainability.
1 parent 582d819 commit aa8779f

5 files changed

Lines changed: 180 additions & 17 deletions

File tree

cmd/ctrlc/root/sync/pipe/pipe.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package pipe
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strings"
10+
11+
"github.com/MakeNowJust/heredoc/v2"
12+
"github.com/charmbracelet/log"
13+
"github.com/ctrlplanedev/cli/internal/api"
14+
"github.com/ctrlplanedev/cli/internal/cliutil"
15+
"github.com/ctrlplanedev/cli/pkg/resourceprovider"
16+
"github.com/spf13/cobra"
17+
"github.com/spf13/viper"
18+
)
19+
20+
func NewSyncPipeCmd() *cobra.Command {
21+
var providerName string
22+
23+
cmd := &cobra.Command{
24+
Use: "pipe",
25+
Short: "Sync resources from stdin into Ctrlplane",
26+
Example: heredoc.Doc(`
27+
# One-shot sync from a script
28+
$ ./discover-databases.sh | ctrlc sync pipe --provider "custom-db"
29+
30+
# Inline JSON
31+
$ echo '[{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","config":{},"metadata":{}}]' \
32+
| ctrlc sync pipe --provider "my-servers"
33+
34+
# Single resource (no array wrapper needed)
35+
$ echo '{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server"}' \
36+
| ctrlc sync pipe --provider "my-servers"
37+
38+
# From curl with jq transformation
39+
$ curl -s https://cmdb.internal/api/servers \
40+
| jq '[.[] | {name, identifier: .id, version: "cmdb/v1", kind: "Server", config: ., metadata: {}}]' \
41+
| ctrlc sync pipe --provider "cmdb"
42+
`),
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
// Detect piped stdin
45+
stat, err := os.Stdin.Stat()
46+
if err != nil {
47+
return fmt.Errorf("failed to stat stdin: %w", err)
48+
}
49+
if (stat.Mode() & os.ModeCharDevice) != 0 {
50+
return fmt.Errorf("no piped input detected -- pipe JSON resources to this command")
51+
}
52+
53+
// Read all stdin
54+
data, err := io.ReadAll(os.Stdin)
55+
if err != nil {
56+
return fmt.Errorf("failed to read stdin: %w", err)
57+
}
58+
if len(data) == 0 {
59+
return fmt.Errorf("stdin is empty -- expected JSON resource array")
60+
}
61+
62+
// Parse JSON -- try array first, then single object
63+
resources, err := parseResources(data)
64+
if err != nil {
65+
return err
66+
}
67+
68+
// Validate required fields
69+
if err := validateResources(resources); err != nil {
70+
return err
71+
}
72+
73+
log.Info("Syncing resources from stdin", "count", len(resources), "provider", providerName)
74+
75+
// Create API client
76+
apiURL := viper.GetString("url")
77+
apiKey := viper.GetString("api-key")
78+
workspace := viper.GetString("workspace")
79+
ctrlplaneClient, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey)
80+
if err != nil {
81+
return fmt.Errorf("failed to create API client: %w", err)
82+
}
83+
84+
// Upsert resource provider
85+
rp, err := resourceprovider.New(ctrlplaneClient, workspace, providerName)
86+
if err != nil {
87+
return fmt.Errorf("failed to create resource provider: %w", err)
88+
}
89+
90+
// Upsert resources
91+
ctx := context.Background()
92+
upsertResp, err := rp.UpsertResource(ctx, resources)
93+
if err != nil {
94+
return fmt.Errorf("failed to upsert resources: %w", err)
95+
}
96+
97+
log.Info("Response from upserting resources", "status", upsertResp.Status)
98+
99+
return cliutil.HandleResponseOutput(cmd, upsertResp)
100+
},
101+
}
102+
103+
cmd.Flags().StringVarP(&providerName, "provider", "p", "", "Resource provider name")
104+
cmd.MarkFlagRequired("provider")
105+
106+
return cmd
107+
}
108+
109+
// parseResources attempts to parse the raw JSON data as either an array of
110+
// resources or a single resource object. A single object is normalized to a
111+
// one-element array.
112+
func parseResources(data []byte) ([]api.ResourceProviderResource, error) {
113+
// Try array first
114+
var resources []api.ResourceProviderResource
115+
if err := json.Unmarshal(data, &resources); err == nil {
116+
return resources, nil
117+
}
118+
119+
// Try single object
120+
var single api.ResourceProviderResource
121+
if err := json.Unmarshal(data, &single); err == nil {
122+
return []api.ResourceProviderResource{single}, nil
123+
}
124+
125+
// Show a snippet of the input for debugging
126+
snippet := string(data)
127+
if len(snippet) > 200 {
128+
snippet = snippet[:200] + "..."
129+
}
130+
return nil, fmt.Errorf("invalid JSON input: %s", snippet)
131+
}
132+
133+
// validateResources checks that each resource has the required fields:
134+
// Name, Identifier, Version, Kind.
135+
func validateResources(resources []api.ResourceProviderResource) error {
136+
var errs []string
137+
for i, r := range resources {
138+
var missing []string
139+
if r.Name == "" {
140+
missing = append(missing, "name")
141+
}
142+
if r.Identifier == "" {
143+
missing = append(missing, "identifier")
144+
}
145+
if r.Version == "" {
146+
missing = append(missing, "version")
147+
}
148+
if r.Kind == "" {
149+
missing = append(missing, "kind")
150+
}
151+
if len(missing) > 0 {
152+
errs = append(errs, fmt.Sprintf("resource[%d]: missing required field(s) '%s'", i, strings.Join(missing, "', '")))
153+
}
154+
}
155+
if len(errs) > 0 {
156+
return fmt.Errorf("validation failed:\n %s", strings.Join(errs, "\n "))
157+
}
158+
return nil
159+
}

cmd/ctrlc/root/sync/sync.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/google"
1010
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/helm"
1111
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/kubernetes"
12+
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/pipe"
1213
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/salesforce"
1314
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/tailscale"
1415
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/terraform"
@@ -43,5 +44,9 @@ func NewSyncCmd() *cobra.Command {
4344
cmd.AddCommand(cliutil.AddIntervalSupport(github.NewSyncGitHubCmd(), ""))
4445
cmd.AddCommand(cliutil.AddIntervalSupport(salesforce.NewSalesforceCmd(), ""))
4546

47+
// pipe is intentionally not wrapped with AddIntervalSupport -- it is
48+
// one-shot by design; the OS scheduler (cron, systemd) handles repetition.
49+
cmd.AddCommand(pipe.NewSyncPipeCmd())
50+
4651
return cmd
4752
}

cmd/ctrlc/root/ui/keys.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ package ui
33
import "github.com/charmbracelet/bubbles/key"
44

55
type keyMap struct {
6-
Up key.Binding
7-
Down key.Binding
8-
Enter key.Binding
9-
Back key.Binding
10-
Quit key.Binding
11-
Refresh key.Binding
12-
Filter key.Binding
13-
Command key.Binding
14-
Help key.Binding
6+
Up key.Binding
7+
Down key.Binding
8+
Enter key.Binding
9+
Back key.Binding
10+
Quit key.Binding
11+
Refresh key.Binding
12+
Filter key.Binding
13+
Command key.Binding
14+
Help key.Binding
1515
Describe key.Binding
1616
}
1717

cmd/ctrlc/root/ui/model.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import (
88
"github.com/charmbracelet/bubbles/key"
99
tea "github.com/charmbracelet/bubbletea"
1010
"github.com/charmbracelet/lipgloss"
11-
"github.com/ctrlplanedev/cli/internal/api"
1211
"github.com/ctrlplanedev/cli/cmd/ctrlc/root/version"
12+
"github.com/ctrlplanedev/cli/internal/api"
1313
)
1414

1515
// tickMsg is emitted by the auto-refresh ticker
@@ -294,9 +294,9 @@ func (m Model) handleDrillDown() (tea.Model, tea.Cmd) {
294294
return m, nil
295295
}
296296
jobFrame := viewFrame{
297-
title: depItem.Deployment.Name + " > Jobs",
298-
resource: resourceTypeDeployments,
299-
table: newTableModel(columnsForDrillDown("deployment-jobs")),
297+
title: depItem.Deployment.Name + " > Jobs",
298+
resource: resourceTypeDeployments,
299+
table: newTableModel(columnsForDrillDown("deployment-jobs")),
300300
drillKind: "deployment-jobs",
301301
drill: &drillContext{
302302
deploymentID: depItem.Deployment.Id,
@@ -317,9 +317,9 @@ func (m Model) handleDrillDown() (tea.Model, tea.Cmd) {
317317
return m, nil
318318
}
319319
depFrame := viewFrame{
320-
title: resItem.Name + " > Deployments",
321-
resource: resourceTypeResources,
322-
table: newTableModel(columnsForDrillDown("resource-deployments")),
320+
title: resItem.Name + " > Deployments",
321+
resource: resourceTypeResources,
322+
table: newTableModel(columnsForDrillDown("resource-deployments")),
323323
drillKind: "resource-deployments",
324324
drill: &drillContext{
325325
resourceIdentifier: resItem.Identifier,

cmd/ctrlc/root/ui/styles.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,4 @@ var (
8181
resourceIndicatorStyle = lipgloss.NewStyle().
8282
Foreground(primaryColor).
8383
Bold(true)
84-
8584
)

0 commit comments

Comments
 (0)