From faadf781444f048f7bf3a25647152152b2e607c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Feb 2026 18:25:40 +0000 Subject: [PATCH] Add HTTP input support for ctrlc apply Co-authored-by: justin --- cmd/ctrlc/root/apply/cmd.go | 38 ++++++++++++++---- cmd/ctrlc/root/apply/cmd_test.go | 30 ++++++++++++++ cmd/ctrlc/root/apply/input.go | 63 ++++++++++++++++++++++++++++++ cmd/ctrlc/root/apply/input_test.go | 53 +++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 cmd/ctrlc/root/apply/input.go create mode 100644 cmd/ctrlc/root/apply/input_test.go diff --git a/cmd/ctrlc/root/apply/cmd.go b/cmd/ctrlc/root/apply/cmd.go index 15b0788..98345fa 100644 --- a/cmd/ctrlc/root/apply/cmd.go +++ b/cmd/ctrlc/root/apply/cmd.go @@ -31,6 +31,9 @@ func NewApplyCmd() *cobra.Command { # Apply a multi-document file with systems, deployments, and environments $ ctrlc apply -f config.yaml + # Apply a remote YAML file + $ ctrlc apply -f https://example.com/config.yaml + # Apply all YAML files matching a glob pattern $ ctrlc apply -f "**/*.ctrlc.yaml" @@ -52,13 +55,13 @@ func NewApplyCmd() *cobra.Command { }, } - cmd.Flags().StringArrayVarP(&filePatterns, "file", "f", nil, "Path or glob pattern to YAML files (can be specified multiple times, prefix with ! to exclude)") + cmd.Flags().StringArrayVarP(&filePatterns, "file", "f", nil, "Path, glob pattern, or URL to YAML files (can be specified multiple times, prefix with ! to exclude)") cmd.MarkFlagRequired("file") return cmd } -// expandGlob expands glob patterns to file paths, supporting ** for recursive matching +// expandGlob expands glob patterns to file paths or URLs, supporting ** for recursive matching // It follows git-style pattern matching where later patterns override earlier ones // and ! prefix negates (excludes) a pattern func expandGlob(patterns []string) ([]string, error) { @@ -69,21 +72,32 @@ func expandGlob(patterns []string) ([]string, error) { type patternRule struct { pattern string include bool // true = include, false = exclude + isURL bool } var rules []patternRule for _, p := range patterns { + include := true + pattern := p if strings.HasPrefix(p, "!") { - rules = append(rules, patternRule{strings.TrimPrefix(p, "!"), false}) - } else { - rules = append(rules, patternRule{p, true}) + include = false + pattern = strings.TrimPrefix(p, "!") } + rules = append(rules, patternRule{ + pattern: pattern, + include: include, + isURL: isHTTPURL(pattern), + }) } // First, collect all potential files from include patterns candidateFiles := make(map[string]bool) for _, rule := range rules { if rule.include { + if rule.isURL { + candidateFiles[rule.pattern] = true + continue + } matches, err := doublestar.FilepathGlob(rule.pattern) if err != nil { return nil, fmt.Errorf("invalid glob pattern '%s': %w", rule.pattern, err) @@ -101,7 +115,17 @@ func expandGlob(patterns []string) ([]string, error) { // For each candidate file, evaluate all rules in order - last match wins for filePath := range candidateFiles { included := false + targetIsURL := isHTTPURL(filePath) for _, rule := range rules { + if rule.isURL != targetIsURL { + continue + } + if rule.isURL { + if rule.pattern == filePath { + included = rule.include + } + continue + } matched, err := doublestar.PathMatch(rule.pattern, filePath) if err != nil { return nil, fmt.Errorf("invalid pattern '%s': %w", rule.pattern, err) @@ -146,9 +170,9 @@ func runApply(ctx context.Context, filePatterns []string) error { var documents []Document for _, filePath := range files { - docs, err := ParseFile(filePath) + docs, err := ParseInput(ctx, filePath) if err != nil { - return fmt.Errorf("failed to parse file %s: %w", filePath, err) + return fmt.Errorf("failed to parse input %s: %w", filePath, err) } documents = append(documents, docs...) } diff --git a/cmd/ctrlc/root/apply/cmd_test.go b/cmd/ctrlc/root/apply/cmd_test.go index d68e3e0..b6d3788 100644 --- a/cmd/ctrlc/root/apply/cmd_test.go +++ b/cmd/ctrlc/root/apply/cmd_test.go @@ -440,3 +440,33 @@ func TestExpandGlob_ExcludeThenReinclude(t *testing.T) { t.Error("test_app.yaml should be excluded") } } + +func TestExpandGlob_URLInclude(t *testing.T) { + url := "https://example.com/config.yaml" + + files, err := expandGlob([]string{url}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d: %v", len(files), files) + } + + if files[0] != url { + t.Errorf("expected %s, got %s", url, files[0]) + } +} + +func TestExpandGlob_URLExclude(t *testing.T) { + url := "https://example.com/config.yaml" + + _, err := expandGlob([]string{url, "!" + url}) + if err == nil { + t.Fatal("expected error when URL is excluded") + } + + if err.Error() != "no files matched patterns" { + t.Errorf("unexpected error message: %v", err) + } +} diff --git a/cmd/ctrlc/root/apply/input.go b/cmd/ctrlc/root/apply/input.go new file mode 100644 index 0000000..8c06d17 --- /dev/null +++ b/cmd/ctrlc/root/apply/input.go @@ -0,0 +1,63 @@ +package apply + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const defaultHTTPTimeout = 30 * time.Second + +func isHTTPURL(input string) bool { + parsed, err := url.Parse(input) + if err != nil { + return false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return false + } + return parsed.Host != "" +} + +// ParseInput reads a local file or HTTP(S) URL and returns parsed documents. +func ParseInput(ctx context.Context, input string) ([]Document, error) { + if isHTTPURL(input) { + data, err := fetchHTTP(ctx, input) + if err != nil { + return nil, err + } + return ParseYAML(data) + } + return ParseFile(input) +} + +func fetchHTTP(ctx context.Context, input string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, input, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for %s: %w", input, err) + } + + client := &http.Client{ + Timeout: defaultHTTPTimeout, + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", input, err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("failed to fetch %s: status %s", input, response.Status) + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", input, err) + } + + return data, nil +} diff --git a/cmd/ctrlc/root/apply/input_test.go b/cmd/ctrlc/root/apply/input_test.go new file mode 100644 index 0000000..aa31ea2 --- /dev/null +++ b/cmd/ctrlc/root/apply/input_test.go @@ -0,0 +1,53 @@ +package apply + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseInput_HTTP(t *testing.T) { + payload := `type: Resource +name: test-resource +identifier: test-id +kind: Cluster +version: v1 +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, payload) + })) + defer server.Close() + + documents, err := ParseInput(context.Background(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(documents) != 1 { + t.Fatalf("expected 1 document, got %d", len(documents)) + } + + if _, ok := documents[0].(*ResourceDocument); !ok { + t.Fatalf("expected ResourceDocument, got %T", documents[0]) + } +} + +func TestParseInput_HTTPStatusError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + _, err := ParseInput(context.Background(), server.URL) + if err == nil { + t.Fatal("expected error for non-2xx response") + } + + if !strings.Contains(err.Error(), "status 404") { + t.Fatalf("unexpected error: %v", err) + } +}