Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions cmd/ctrlc/root/apply/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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...)
}
Expand Down
30 changes: 30 additions & 0 deletions cmd/ctrlc/root/apply/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
63 changes: 63 additions & 0 deletions cmd/ctrlc/root/apply/input.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions cmd/ctrlc/root/apply/input_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}