diff --git a/cmd/src/blueprint.go b/cmd/src/blueprint.go new file mode 100644 index 0000000000..1a94555487 --- /dev/null +++ b/cmd/src/blueprint.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "fmt" +) + +const defaultBlueprintRepo = "https://github.com/sourcegraph-community/blueprints" + +var blueprintCommands commander + +func init() { + usage := `INTERNAL USE ONLY: 'src blueprint' manages blueprints on a Sourcegraph instance. + +Usage: + src blueprint command [command options] + +The commands are: + + list lists blueprints from a remote repository or local path + import imports blueprints from a remote repository or local path + +Use "src blueprint [command] -h" for more information about a command. + +` + + flagset := flag.NewFlagSet("blueprint", flag.ExitOnError) + handler := func(args []string) error { + blueprintCommands.run(flagset, "src blueprint", usage, args) + return nil + } + + // Register the command. + commands = append(commands, &command{ + flagSet: flagset, + handler: handler, + usageFunc: func() { fmt.Println(usage) }, + }) +} diff --git a/cmd/src/blueprint_import.go b/cmd/src/blueprint_import.go new file mode 100644 index 0000000000..16f0cd4196 --- /dev/null +++ b/cmd/src/blueprint_import.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/blueprint" +) + +type multiStringFlag []string + +func (f *multiStringFlag) String() string { + return strings.Join(*f, ", ") +} + +func (f *multiStringFlag) Set(value string) error { + *f = append(*f, value) + return nil +} + +func (f *multiStringFlag) ToMap() map[string]string { + result := make(map[string]string) + for _, v := range *f { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + } + return result +} + +type blueprintImportOpts struct { + client api.Client + out io.Writer + repo string + rev string + subdir string + namespace string + vars map[string]string + dryRun bool + continueOnError bool +} + +func init() { + usage := ` +'src blueprint import' imports a blueprint from a Git repository or local directory and executes its resources. + +Usage: + + src blueprint import -repo [flags] + +Examples: + + Import a blueprint from the community repository (default): + + $ src blueprint import -subdir monitor/cve-2025-55182 + + Import a specific branch or tag: + + $ src blueprint import -rev v1.0.0 -subdir monitor/cve-2025-55182 + + Import from a local directory: + + $ src blueprint import -repo ./my-blueprints -subdir monitor/cve-2025-55182 + + Import from an absolute path: + + $ src blueprint import -repo /path/to/blueprints + + Import with custom variables: + + $ src blueprint import -subdir monitor/cve-2025-55182 -var webhookUrl=https://example.com/hook + + Dry run to validate without executing: + + $ src blueprint import -subdir monitor/cve-2025-55182 -dry-run + +` + + flagSet := flag.NewFlagSet("import", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src blueprint %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + repoFlag = flagSet.String("repo", defaultBlueprintRepo, "Repository URL (HTTPS) or local path to blueprint") + revFlag = flagSet.String("rev", "", "Git revision, branch, or tag to checkout (ignored for local paths)") + subdirFlag = flagSet.String("subdir", "", "Subdirectory in repo containing blueprint.yaml") + namespaceFlag = flagSet.String("namespace", "", "User or org namespace for mutations (defaults to current user)") + dryRunFlag = flagSet.Bool("dry-run", false, "Parse and validate only; do not execute any mutations") + continueOnError = flagSet.Bool("continue-on-error", false, "Continue applying resources even if one fails") + varFlags = multiStringFlag{} + apiFlags = api.NewFlags(flagSet) + ) + flagSet.Var(&varFlags, "var", "Variable in the form key=value; can be repeated") + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := cfg.apiClient(apiFlags, flagSet.Output()) + + opts := blueprintImportOpts{ + client: client, + out: flagSet.Output(), + repo: *repoFlag, + rev: *revFlag, + subdir: *subdirFlag, + namespace: *namespaceFlag, + vars: varFlags.ToMap(), + dryRun: *dryRunFlag, + continueOnError: *continueOnError, + } + + return runBlueprintImport(context.Background(), opts) + } + + blueprintCommands = append(blueprintCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} + +func runBlueprintImport(ctx context.Context, opts blueprintImportOpts) error { + var src blueprint.BlueprintSource + var err error + + if opts.subdir == "" { + src, err = blueprint.ResolveRootSource(opts.repo, opts.rev) + } else { + src, err = blueprint.ResolveBlueprintSource(opts.repo, opts.rev, opts.subdir) + } + if err != nil { + return err + } + + blueprintDir, cleanup, err := src.Prepare(ctx) + if cleanup != nil { + defer func() { _ = cleanup() }() + } + if err != nil { + return err + } + + if opts.subdir == "" { + return runBlueprintImportAll(ctx, opts, blueprintDir) + } + + return runBlueprintImportSingle(ctx, opts, blueprintDir) +} + +func runBlueprintImportAll(ctx context.Context, opts blueprintImportOpts, rootDir string) error { + found, err := blueprint.FindBlueprints(rootDir) + if err != nil { + return err + } + + if len(found) == 0 { + fmt.Fprintf(opts.out, "No blueprints found in repository\n") + return nil + } + + fmt.Fprintf(opts.out, "Found %d blueprint(s) in repository\n\n", len(found)) + + exec := blueprint.NewExecutor(blueprint.ExecutorOpts{ + Client: opts.client, + Out: opts.out, + Vars: opts.vars, + DryRun: opts.dryRun, + ContinueOnError: opts.continueOnError, + }) + + var lastErr error + for _, bp := range found { + subdir, _ := filepath.Rel(rootDir, bp.Dir) + if subdir == "." { + subdir = "" + } + + fmt.Fprintf(opts.out, "--- Importing blueprint: %s", bp.Name) + if subdir != "" { + fmt.Fprintf(opts.out, " (%s)", subdir) + } + fmt.Fprintf(opts.out, "\n") + + summary, err := exec.Execute(ctx, bp, bp.Dir) + blueprint.PrintExecutionSummary(opts.out, summary, opts.dryRun) + + if err != nil { + lastErr = err + if !opts.continueOnError { + return err + } + } + fmt.Fprintf(opts.out, "\n") + } + + return lastErr +} + +func runBlueprintImportSingle(ctx context.Context, opts blueprintImportOpts, blueprintDir string) error { + bp, err := blueprint.Load(blueprintDir) + if err != nil { + return err + } + + fmt.Fprintf(opts.out, "Loaded blueprint: %s\n", bp.Name) + if bp.Title != "" { + fmt.Fprintf(opts.out, " Title: %s\n", bp.Title) + } + if len(bp.BatchSpecs) > 0 { + fmt.Fprintf(opts.out, " Batch specs: %d\n", len(bp.BatchSpecs)) + } + if len(bp.Monitors) > 0 { + fmt.Fprintf(opts.out, " Monitors: %d\n", len(bp.Monitors)) + } + if len(bp.Insights) > 0 { + fmt.Fprintf(opts.out, " Insights: %d\n", len(bp.Insights)) + } + if len(bp.Dashboards) > 0 { + fmt.Fprintf(opts.out, " Dashboards: %d\n", len(bp.Dashboards)) + } + + exec := blueprint.NewExecutor(blueprint.ExecutorOpts{ + Client: opts.client, + Out: opts.out, + Vars: opts.vars, + DryRun: opts.dryRun, + ContinueOnError: opts.continueOnError, + }) + + summary, err := exec.Execute(ctx, bp, blueprintDir) + blueprint.PrintExecutionSummary(opts.out, summary, opts.dryRun) + + return err +} diff --git a/cmd/src/blueprint_list.go b/cmd/src/blueprint_list.go new file mode 100644 index 0000000000..9835979422 --- /dev/null +++ b/cmd/src/blueprint_list.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "flag" + "fmt" + "path/filepath" + + "github.com/sourcegraph/src-cli/internal/blueprint" +) + +func init() { + usage := ` +Examples: + + List blueprints from the default community repository: + + $ src blueprint list + + List blueprints from a GitHub repository: + + $ src blueprint list -repo https://github.com/org/blueprints + + List blueprints from a specific branch or tag: + + $ src blueprint list -repo https://github.com/org/blueprints -rev v1.0.0 + + List blueprints from a local directory: + + $ src blueprint list -repo ./my-blueprints + + Print JSON description of all blueprints: + + $ src blueprint list -f '{{.|json}}' + + List just blueprint names and subdirs: + + $ src blueprint list -f '{{.Subdir}}: {{.Name}}' + +` + + flagSet := flag.NewFlagSet("list", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src blueprint %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + repoFlag = flagSet.String("repo", defaultBlueprintRepo, "Repository URL (HTTPS) or local path to blueprints") + revFlag = flagSet.String("rev", "", "Git revision, branch, or tag to checkout (ignored for local paths)") + formatFlag = flagSet.String("f", "{{.Title}}\t{{.Summary}}\t{{.Subdir}}", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.|json}}")`) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + src, err := blueprint.ResolveRootSource(*repoFlag, *revFlag) + if err != nil { + return err + } + + rootDir, cleanup, err := src.Prepare(context.Background()) + if cleanup != nil { + defer func() { _ = cleanup() }() + } + if err != nil { + return err + } + + found, err := blueprint.FindBlueprints(rootDir) + if err != nil { + return err + } + + for _, bp := range found { + subdir, _ := filepath.Rel(rootDir, bp.Dir) + if subdir == "." { + subdir = "" + } + data := struct { + *blueprint.Blueprint + Subdir string + }{bp, subdir} + if err := execTemplate(tmpl, data); err != nil { + return err + } + } + return nil + } + + blueprintCommands = append(blueprintCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/internal/blueprint/doc.go b/internal/blueprint/doc.go new file mode 100644 index 0000000000..f2f38f4424 --- /dev/null +++ b/internal/blueprint/doc.go @@ -0,0 +1,6 @@ +// Package blueprint provides parsing and validation for Sourcegraph blueprints. +// +// A blueprint is a collection of Sourcegraph resources (batch specs, monitors, +// insights, dashboards) defined in a blueprint.yaml file that can be imported +// and applied to a Sourcegraph instance. +package blueprint diff --git a/internal/blueprint/executor.go b/internal/blueprint/executor.go new file mode 100644 index 0000000000..5f88ed4351 --- /dev/null +++ b/internal/blueprint/executor.go @@ -0,0 +1,460 @@ +package blueprint + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/api" +) + +// ResourceKind identifies the type of resource managed by a blueprint. +type ResourceKind string + +const ( + KindDashboard ResourceKind = "dashboard" + KindMonitor ResourceKind = "monitor" + KindInsight ResourceKind = "insight" + KindBatchSpec ResourceKind = "batch-spec" + KindLink ResourceKind = "link" +) + +// ResourceResult captures the outcome of executing a single resource within a blueprint. +type ResourceResult struct { + Kind ResourceKind + Name string + Succeeded bool + Skipped bool + Err error +} + +// ExecutionSummary contains the results of executing all resources in a blueprint. +type ExecutionSummary struct { + Resources []ResourceResult +} + +// FailureCount returns the number of resources that failed during execution. +func (s *ExecutionSummary) FailureCount() int { + count := 0 + for _, r := range s.Resources { + if r.Err != nil { + count++ + } + } + return count +} + +// ExecutorOpts configures an Executor. +type ExecutorOpts struct { + // Client is used to execute GraphQL requests against Sourcegraph. + Client api.Client + // Out is where human-readable progress and summary output will be written. + // If nil, NewExecutor defaults it to os.Stdout. + Out io.Writer + // Vars contains template variables that are passed to GraphQL queries. + Vars map[string]string + // DryRun, if true, skips all mutations but still validates and builds variables. + DryRun bool + // ContinueOnError, if true, continues executing other resources after a failure. + ContinueOnError bool +} + +// Executor executes a blueprint against a Sourcegraph instance. +type Executor struct { + client api.Client + out io.Writer + vars map[string]string + dryRun bool + continueOnError bool + currentUserID *string + dashboardIDs map[string]string + insightIDs map[string]string +} + +// NewExecutor constructs an Executor from options. +func NewExecutor(opts ExecutorOpts) *Executor { + out := opts.Out + if out == nil { + out = os.Stdout + } + return &Executor{ + client: opts.Client, + out: out, + vars: opts.Vars, + dryRun: opts.DryRun, + continueOnError: opts.ContinueOnError, + dashboardIDs: make(map[string]string), + insightIDs: make(map[string]string), + } +} + +// Execute executes all resources defined in the given blueprint. +func (e *Executor) Execute(ctx context.Context, bp *Blueprint, blueprintDir string) (*ExecutionSummary, error) { + summary := &ExecutionSummary{} + + if len(bp.Dashboards) > 0 { + fmt.Fprintf(e.out, "\nExecuting dashboard mutations...\n") + for _, ref := range bp.Dashboards { + result := e.executeDashboard(ctx, ref, blueprintDir) + summary.Resources = append(summary.Resources, result) + e.printResult(result) + if result.Err != nil && !e.continueOnError { + return summary, result.Err + } + } + } + + if len(bp.Monitors) > 0 { + fmt.Fprintf(e.out, "\nExecuting monitor mutations...\n") + for _, ref := range bp.Monitors { + result := e.executeMonitor(ctx, ref, blueprintDir) + summary.Resources = append(summary.Resources, result) + e.printResult(result) + if result.Err != nil && !e.continueOnError { + return summary, result.Err + } + } + } + + if len(bp.Insights) > 0 { + fmt.Fprintf(e.out, "\nExecuting insight mutations...\n") + for _, ref := range bp.Insights { + result := e.executeInsight(ctx, ref, blueprintDir) + summary.Resources = append(summary.Resources, result) + e.printResult(result) + if result.Err != nil && !e.continueOnError { + return summary, result.Err + } + } + } + + if err := e.linkInsightsToDashboards(ctx, bp.Insights, summary); err != nil && !e.continueOnError { + return summary, err + } + + if failed := summary.FailureCount(); failed > 0 { + return summary, errors.Newf("%d resource(s) failed", failed) + } + + return summary, nil +} + +func (e *Executor) printResult(result ResourceResult) { + if result.Err != nil { + fmt.Fprintf(e.out, " ✗ %s %q: %v\n", result.Kind, result.Name, result.Err) + } else if result.Skipped { + fmt.Fprintf(e.out, " ○ %s %q (skipped - dry run)\n", result.Kind, result.Name) + } else { + fmt.Fprintf(e.out, " ✓ %s %q\n", result.Kind, result.Name) + } +} + +func (e *Executor) executeDashboard(ctx context.Context, ref DashboardRef, blueprintDir string) ResourceResult { + result := ResourceResult{Kind: KindDashboard, Name: ref.Name} + + query, err := loadGQLFile(ref.Path(blueprintDir)) + if err != nil { + result.Err = errors.Wrapf(err, "loading %s", ref.Path(blueprintDir)) + return result + } + + vars, err := e.buildVariables(ctx, query) + if err != nil { + result.Err = errors.Wrap(err, "building variables") + return result + } + + if e.dryRun { + result.Skipped = true + result.Succeeded = true + return result + } + + var response struct { + CreateInsightsDashboard struct { + Dashboard struct { + ID string `json:"id"` + } `json:"dashboard"` + } `json:"createInsightsDashboard"` + } + + ok, err := e.client.NewRequest(query, vars).Do(ctx, &response) + if err != nil { + result.Err = formatGraphQLError(err, KindDashboard, ref.Name) + return result + } + if !ok { + result.Err = errors.Newf("executing dashboard mutation %q: no data returned", ref.Name) + return result + } + + if id := response.CreateInsightsDashboard.Dashboard.ID; id != "" { + e.dashboardIDs[ref.Name] = id + } + + result.Succeeded = true + return result +} + +func (e *Executor) executeMonitor(ctx context.Context, ref MonitorRef, blueprintDir string) ResourceResult { + result := ResourceResult{Kind: KindMonitor, Name: ref.Name} + + query, err := loadGQLFile(ref.Path(blueprintDir)) + if err != nil { + result.Err = errors.Wrapf(err, "loading %s", ref.Path(blueprintDir)) + return result + } + + vars, err := e.buildVariables(ctx, query) + if err != nil { + result.Err = errors.Wrap(err, "building variables") + return result + } + + if e.dryRun { + result.Skipped = true + result.Succeeded = true + return result + } + + var response any + ok, err := e.client.NewRequest(query, vars).Do(ctx, &response) + if err != nil { + result.Err = formatGraphQLError(err, KindMonitor, ref.Name) + return result + } + if !ok { + result.Err = errors.Newf("executing monitor mutation %q: no data returned", ref.Name) + return result + } + + result.Succeeded = true + return result +} + +func (e *Executor) executeInsight(ctx context.Context, ref InsightRef, blueprintDir string) ResourceResult { + result := ResourceResult{Kind: KindInsight, Name: ref.Name} + + query, err := loadGQLFile(ref.Path(blueprintDir)) + if err != nil { + result.Err = errors.Wrapf(err, "loading %s", ref.Path(blueprintDir)) + return result + } + + vars, err := e.buildVariables(ctx, query) + if err != nil { + result.Err = errors.Wrap(err, "building variables") + return result + } + + if e.dryRun { + result.Skipped = true + result.Succeeded = true + return result + } + + var response struct { + CreateLineChartSearchInsight struct { + View struct { + ID string `json:"id"` + } `json:"view"` + } `json:"createLineChartSearchInsight"` + } + + ok, err := e.client.NewRequest(query, vars).Do(ctx, &response) + if err != nil { + result.Err = formatGraphQLError(err, KindInsight, ref.Name) + return result + } + if !ok { + result.Err = errors.Newf("executing insight mutation %q: no data returned", ref.Name) + return result + } + + if id := response.CreateLineChartSearchInsight.View.ID; id != "" { + e.insightIDs[ref.Name] = id + } + + result.Succeeded = true + return result +} + +func (e *Executor) linkInsightsToDashboards(ctx context.Context, insights []InsightRef, summary *ExecutionSummary) error { + var hasLinks bool + for _, insight := range insights { + if len(insight.Dashboards) > 0 { + hasLinks = true + break + } + } + if !hasLinks || e.dryRun { + return nil + } + + fmt.Fprintf(e.out, "\nLinking insights to dashboards...\n") + + const addInsightQuery = `mutation AddInsightViewToDashboard($input: AddInsightViewToDashboardInput!) { + addInsightViewToDashboard(input: $input) { + dashboard { id } + } + }` + + for _, insight := range insights { + insightID, ok := e.insightIDs[insight.Name] + if !ok { + continue + } + + for _, dashboardName := range insight.Dashboards { + dashboardID, ok := e.dashboardIDs[dashboardName] + if !ok { + fmt.Fprintf(e.out, " ⚠ dashboard %q not found for insight %q\n", dashboardName, insight.Name) + continue + } + + vars := map[string]any{ + "input": map[string]any{ + "insightViewId": insightID, + "dashboardId": dashboardID, + }, + } + + var response any + _, err := e.client.NewRequest(addInsightQuery, vars).Do(ctx, &response) + if err != nil { + result := ResourceResult{ + Kind: KindLink, + Name: fmt.Sprintf("%s → %s", insight.Name, dashboardName), + Err: errors.Wrapf(err, "linking insight %q to dashboard %q", insight.Name, dashboardName), + } + summary.Resources = append(summary.Resources, result) + fmt.Fprintf(e.out, " ✗ %s → %s: %v\n", insight.Name, dashboardName, err) + if !e.continueOnError { + return result.Err + } + } else { + fmt.Fprintf(e.out, " ✓ %s → %s\n", insight.Name, dashboardName) + } + } + } + + return nil +} + +func loadGQLFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return stripComments(string(data)), nil +} + +func stripComments(content string) string { + var lines []string + for line := range strings.SplitSeq(content, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#") { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} + +func (e *Executor) buildVariables(ctx context.Context, query string) (map[string]any, error) { + vars := make(map[string]any) + + for k, v := range e.vars { + vars[k] = v + } + + if strings.Contains(query, "$namespaceId") { + if _, provided := vars["namespaceId"]; !provided { + if e.dryRun { + vars["namespaceId"] = "" + } else { + nsID, err := e.resolveCurrentUserID(ctx) + if err != nil { + return nil, errors.Wrap(err, "resolving namespace") + } + vars["namespaceId"] = nsID + } + } + } + + return vars, nil +} + +func (e *Executor) resolveCurrentUserID(ctx context.Context) (string, error) { + if e.currentUserID != nil { + return *e.currentUserID, nil + } + + const query = `query { currentUser { id } }` + var result struct { + CurrentUser struct { + ID string `json:"id"` + } `json:"currentUser"` + } + + ok, err := e.client.NewQuery(query).Do(ctx, &result) + if err != nil { + return "", errors.Wrap(err, "getCurrentUser query failed") + } + if !ok { + return "", errors.New("getCurrentUser: no data returned") + } + if result.CurrentUser.ID == "" { + return "", errors.New("getCurrentUser: not authenticated") + } + + e.currentUserID = &result.CurrentUser.ID + return result.CurrentUser.ID, nil +} + +func formatGraphQLError(err error, kind ResourceKind, name string) error { + var gqlErrs api.GraphQlErrors + if errors.As(err, &gqlErrs) { + msgs := make([]string, 0, len(gqlErrs)) + for _, ge := range gqlErrs { + msgs = append(msgs, ge.Error()) + } + return errors.Newf("%s %q failed: %s", kind, name, strings.Join(msgs, "; ")) + } + return errors.Wrapf(err, "%s %q failed", kind, name) +} + +// PrintExecutionSummary writes a human-readable summary of execution results. +func PrintExecutionSummary(out io.Writer, s *ExecutionSummary, dryRun bool) { + if s == nil || len(s.Resources) == 0 { + return + } + + if dryRun { + fmt.Fprintf(out, "\nDry run complete. No mutations were executed.\n") + } + + counts := make(map[ResourceKind]struct{ total, ok, failed int }) + for _, r := range s.Resources { + c := counts[r.Kind] + c.total++ + if r.Succeeded { + c.ok++ + } else if r.Err != nil { + c.failed++ + } + counts[r.Kind] = c + } + + fmt.Fprintf(out, "\nSummary:\n") + for _, kind := range []ResourceKind{KindDashboard, KindMonitor, KindInsight, KindBatchSpec, KindLink} { + c := counts[kind] + if c.total == 0 { + continue + } + fmt.Fprintf(out, " %s: %d total, %d succeeded, %d failed\n", kind, c.total, c.ok, c.failed) + } +} diff --git a/internal/blueprint/executor_test.go b/internal/blueprint/executor_test.go new file mode 100644 index 0000000000..b69540264f --- /dev/null +++ b/internal/blueprint/executor_test.go @@ -0,0 +1,796 @@ +package blueprint + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" +) + +type mockRequest struct { + query string + vars map[string]any + response any + ok bool + err error +} + +func (r *mockRequest) Do(ctx context.Context, result any) (bool, error) { + if r.err != nil { + return false, r.err + } + if r.response != nil { + copyResponse(r.response, result) + } + return r.ok, nil +} + +func (r *mockRequest) DoRaw(ctx context.Context, result any) (bool, error) { + return r.Do(ctx, result) +} + +type mockClient struct { + requests []*mockRequest + responses map[string]mockResponse + callCount int + lastQuery string + lastVars map[string]any + currentUserID string +} + +type mockResponse struct { + response any + ok bool + err error +} + +func (c *mockClient) NewQuery(query string) api.Request { + return c.NewRequest(query, nil) +} + +func (c *mockClient) NewRequest(query string, vars map[string]any) api.Request { + c.lastQuery = query + c.lastVars = vars + c.callCount++ + + if strings.Contains(query, "currentUser") { + return &mockRequest{ + query: query, + vars: vars, + response: map[string]any{ + "currentUser": map[string]any{ + "id": c.currentUserID, + }, + }, + ok: true, + } + } + + for pattern, resp := range c.responses { + if strings.Contains(query, pattern) { + return &mockRequest{ + query: query, + vars: vars, + response: resp.response, + ok: resp.ok, + err: resp.err, + } + } + } + + if c.callCount <= len(c.requests) { + req := c.requests[c.callCount-1] + req.query = query + req.vars = vars + return req + } + + return &mockRequest{query: query, vars: vars, ok: true} +} + +func (c *mockClient) NewHTTPRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + return nil, nil +} + +func (c *mockClient) Do(req *http.Request) (*http.Response, error) { + return nil, nil +} + +func copyResponse(src, dst any) { + switch d := dst.(type) { + case *struct { + CreateInsightsDashboard struct { + Dashboard struct { + ID string `json:"id"` + } `json:"dashboard"` + } `json:"createInsightsDashboard"` + }: + if m, ok := src.(map[string]any); ok { + if cid, ok := m["createInsightsDashboard"].(map[string]any); ok { + if dash, ok := cid["dashboard"].(map[string]any); ok { + if id, ok := dash["id"].(string); ok { + d.CreateInsightsDashboard.Dashboard.ID = id + } + } + } + } + case *struct { + CreateLineChartSearchInsight struct { + View struct { + ID string `json:"id"` + } `json:"view"` + } `json:"createLineChartSearchInsight"` + }: + if m, ok := src.(map[string]any); ok { + if cli, ok := m["createLineChartSearchInsight"].(map[string]any); ok { + if view, ok := cli["view"].(map[string]any); ok { + if id, ok := view["id"].(string); ok { + d.CreateLineChartSearchInsight.View.ID = id + } + } + } + } + } +} + +func setupTestBlueprint(t *testing.T) (string, *Blueprint) { + t.Helper() + dir := t.TempDir() + + resourcesDir := filepath.Join(dir, "resources") + for _, subdir := range []string{"dashboards", "monitors", "insights"} { + if err := os.MkdirAll(filepath.Join(resourcesDir, subdir), 0o755); err != nil { + t.Fatal(err) + } + } + + dashboardGQL := `mutation CreateDashboard($input: CreateInsightsDashboardInput!) { + createInsightsDashboard(input: $input) { + dashboard { id } + } + }` + if err := os.WriteFile(filepath.Join(resourcesDir, "dashboards", "test-dashboard.gql"), []byte(dashboardGQL), 0o644); err != nil { + t.Fatal(err) + } + + monitorGQL := `mutation CreateMonitor($input: MonitorInput!) { + createCodeMonitor(input: $input) { id } + }` + if err := os.WriteFile(filepath.Join(resourcesDir, "monitors", "test-monitor.gql"), []byte(monitorGQL), 0o644); err != nil { + t.Fatal(err) + } + + insightGQL := `mutation CreateInsight($input: LineChartSearchInsightInput!) { + createLineChartSearchInsight(input: $input) { + view { id } + } + }` + if err := os.WriteFile(filepath.Join(resourcesDir, "insights", "test-insight.gql"), []byte(insightGQL), 0o644); err != nil { + t.Fatal(err) + } + + bp := &Blueprint{ + Version: 1, + Name: "test-blueprint", + Dashboards: []DashboardRef{ + {Name: "test-dashboard"}, + }, + Monitors: []MonitorRef{ + {Name: "test-monitor"}, + }, + Insights: []InsightRef{ + {Name: "test-insight", Dashboards: []string{"test-dashboard"}}, + }, + } + + return dir, bp +} + +func TestExecutor_DryRun(t *testing.T) { + dir, bp := setupTestBlueprint(t) + out := &bytes.Buffer{} + + client := &mockClient{currentUserID: "user-123"} + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: out, + DryRun: true, + }) + + summary, err := executor.Execute(context.Background(), bp, dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if client.callCount != 0 { + t.Errorf("expected 0 API calls in dry-run mode, got %d", client.callCount) + } + + for _, r := range summary.Resources { + if !r.Skipped { + t.Errorf("resource %s %q should be skipped in dry-run", r.Kind, r.Name) + } + if !r.Succeeded { + t.Errorf("resource %s %q should succeed in dry-run", r.Kind, r.Name) + } + } + + if !strings.Contains(out.String(), "skipped - dry run") { + t.Errorf("output should mention dry run: %s", out.String()) + } +} + +func TestExecutor_Success(t *testing.T) { + dir, bp := setupTestBlueprint(t) + out := &bytes.Buffer{} + + client := &mockClient{ + currentUserID: "user-123", + responses: map[string]mockResponse{ + "createInsightsDashboard": { + response: map[string]any{ + "createInsightsDashboard": map[string]any{ + "dashboard": map[string]any{"id": "dash-id-1"}, + }, + }, + ok: true, + }, + "createCodeMonitor": { + response: map[string]any{"createCodeMonitor": map[string]any{"id": "mon-id-1"}}, + ok: true, + }, + "createLineChartSearchInsight": { + response: map[string]any{ + "createLineChartSearchInsight": map[string]any{ + "view": map[string]any{"id": "insight-id-1"}, + }, + }, + ok: true, + }, + "addInsightViewToDashboard": { + response: map[string]any{}, + ok: true, + }, + }, + } + + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: out, + }) + + summary, err := executor.Execute(context.Background(), bp, dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if summary.FailureCount() != 0 { + t.Errorf("expected 0 failures, got %d", summary.FailureCount()) + } + + dashboardCount := 0 + monitorCount := 0 + insightCount := 0 + for _, r := range summary.Resources { + switch r.Kind { + case KindDashboard: + dashboardCount++ + case KindMonitor: + monitorCount++ + case KindInsight: + insightCount++ + } + if !r.Succeeded { + t.Errorf("resource %s %q should have succeeded", r.Kind, r.Name) + } + } + + if dashboardCount != 1 { + t.Errorf("expected 1 dashboard, got %d", dashboardCount) + } + if monitorCount != 1 { + t.Errorf("expected 1 monitor, got %d", monitorCount) + } + if insightCount != 1 { + t.Errorf("expected 1 insight, got %d", insightCount) + } +} + +func TestExecutor_ContinueOnError(t *testing.T) { + dir, bp := setupTestBlueprint(t) + out := &bytes.Buffer{} + + client := &mockClient{ + currentUserID: "user-123", + responses: map[string]mockResponse{ + "createInsightsDashboard": { + err: api.GraphQlErrors{&api.GraphQlError{}}, + }, + "createCodeMonitor": { + response: map[string]any{"createCodeMonitor": map[string]any{"id": "mon-id-1"}}, + ok: true, + }, + "createLineChartSearchInsight": { + response: map[string]any{ + "createLineChartSearchInsight": map[string]any{ + "view": map[string]any{"id": "insight-id-1"}, + }, + }, + ok: true, + }, + }, + } + + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: out, + ContinueOnError: true, + }) + + summary, err := executor.Execute(context.Background(), bp, dir) + if err == nil { + t.Fatal("expected error due to failed dashboard") + } + + if summary.FailureCount() != 1 { + t.Errorf("expected 1 failure, got %d", summary.FailureCount()) + } + + if len(summary.Resources) != 3 { + t.Errorf("expected 3 resources (continued after error), got %d", len(summary.Resources)) + } +} + +func TestExecutor_StopOnError(t *testing.T) { + dir, bp := setupTestBlueprint(t) + out := &bytes.Buffer{} + + client := &mockClient{ + currentUserID: "user-123", + responses: map[string]mockResponse{ + "createInsightsDashboard": { + err: api.GraphQlErrors{&api.GraphQlError{}}, + }, + }, + } + + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: out, + ContinueOnError: false, + }) + + summary, err := executor.Execute(context.Background(), bp, dir) + if err == nil { + t.Fatal("expected error") + } + + if len(summary.Resources) != 1 { + t.Errorf("expected 1 resource (stopped after error), got %d", len(summary.Resources)) + } +} + +func TestExecutor_MissingGQLFile(t *testing.T) { + dir := t.TempDir() + bp := &Blueprint{ + Version: 1, + Name: "test", + Dashboards: []DashboardRef{{Name: "nonexistent"}}, + } + + client := &mockClient{currentUserID: "user-123"} + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: &bytes.Buffer{}, + }) + + summary, err := executor.Execute(context.Background(), bp, dir) + if err == nil { + t.Fatal("expected error for missing file") + } + + if len(summary.Resources) != 1 { + t.Fatalf("expected 1 resource result, got %d", len(summary.Resources)) + } + if summary.Resources[0].Err == nil { + t.Error("expected error in resource result") + } +} + +func TestStripComments(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no comments", + input: "query { user { id } }", + want: "query { user { id } }", + }, + { + name: "line comment removed", + input: "# comment\nquery { user { id } }", + want: "query { user { id } }", + }, + { + name: "multiple comments removed", + input: "# first\n# second\nquery { user { id } }\n# trailing", + want: "query { user { id } }", + }, + { + name: "indented comment removed", + input: "query {\n # comment\n user { id }\n}", + want: "query {\n user { id }\n}", + }, + { + name: "preserves non-comment lines", + input: "mutation {\n test\n}", + want: "mutation {\n test\n}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripComments(tt.input) + if got != tt.want { + t.Errorf("stripComments() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadGQLFile(t *testing.T) { + dir := t.TempDir() + content := "# comment\nmutation Test { test }" + path := filepath.Join(dir, "test.gql") + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got, err := loadGQLFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(got, "# comment") { + t.Errorf("comments should be stripped: %q", got) + } + if !strings.Contains(got, "mutation Test") { + t.Errorf("query content should be preserved: %q", got) + } +} + +func TestLoadGQLFile_NotFound(t *testing.T) { + _, err := loadGQLFile("/nonexistent/path.gql") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestFormatGraphQLError(t *testing.T) { + t.Run("GraphQL errors", func(t *testing.T) { + gqlErr := api.GraphQlErrors{&api.GraphQlError{}} + err := formatGraphQLError(gqlErr, KindDashboard, "test-dash") + + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "dashboard") { + t.Errorf("error should mention resource kind: %v", err) + } + if !strings.Contains(err.Error(), "test-dash") { + t.Errorf("error should mention resource name: %v", err) + } + }) + + t.Run("other errors", func(t *testing.T) { + otherErr := &testError{msg: "network failure"} + err := formatGraphQLError(otherErr, KindMonitor, "test-mon") + + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "network failure") { + t.Errorf("error should contain original message: %v", err) + } + }) +} + +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} + +func TestExecutionSummary_FailureCount(t *testing.T) { + tests := []struct { + name string + resources []ResourceResult + want int + }{ + { + name: "empty", + resources: nil, + want: 0, + }, + { + name: "all succeeded", + resources: []ResourceResult{ + {Kind: KindDashboard, Succeeded: true}, + {Kind: KindMonitor, Succeeded: true}, + }, + want: 0, + }, + { + name: "one failure", + resources: []ResourceResult{ + {Kind: KindDashboard, Succeeded: true}, + {Kind: KindMonitor, Err: &testError{msg: "failed"}}, + }, + want: 1, + }, + { + name: "multiple failures", + resources: []ResourceResult{ + {Kind: KindDashboard, Err: &testError{msg: "failed"}}, + {Kind: KindMonitor, Err: &testError{msg: "failed"}}, + {Kind: KindInsight, Succeeded: true}, + }, + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &ExecutionSummary{Resources: tt.resources} + if got := s.FailureCount(); got != tt.want { + t.Errorf("FailureCount() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestPrintExecutionSummary(t *testing.T) { + t.Run("nil summary", func(t *testing.T) { + out := &bytes.Buffer{} + PrintExecutionSummary(out, nil, false) + if out.Len() != 0 { + t.Errorf("expected no output for nil summary") + } + }) + + t.Run("empty resources", func(t *testing.T) { + out := &bytes.Buffer{} + PrintExecutionSummary(out, &ExecutionSummary{}, false) + if out.Len() != 0 { + t.Errorf("expected no output for empty resources") + } + }) + + t.Run("dry run message", func(t *testing.T) { + out := &bytes.Buffer{} + summary := &ExecutionSummary{ + Resources: []ResourceResult{ + {Kind: KindDashboard, Succeeded: true, Skipped: true}, + }, + } + PrintExecutionSummary(out, summary, true) + if !strings.Contains(out.String(), "Dry run complete") { + t.Errorf("output should mention dry run: %s", out.String()) + } + }) + + t.Run("summary counts", func(t *testing.T) { + out := &bytes.Buffer{} + summary := &ExecutionSummary{ + Resources: []ResourceResult{ + {Kind: KindDashboard, Succeeded: true}, + {Kind: KindDashboard, Succeeded: true}, + {Kind: KindMonitor, Err: &testError{msg: "failed"}}, + {Kind: KindInsight, Succeeded: true}, + }, + } + PrintExecutionSummary(out, summary, false) + output := out.String() + + if !strings.Contains(output, "dashboard: 2 total, 2 succeeded, 0 failed") { + t.Errorf("expected dashboard counts in output: %s", output) + } + if !strings.Contains(output, "monitor: 1 total, 0 succeeded, 1 failed") { + t.Errorf("expected monitor counts in output: %s", output) + } + }) +} + +func TestExecutor_BuildVariables_NamespaceID(t *testing.T) { + t.Run("dry run uses placeholder", func(t *testing.T) { + client := &mockClient{currentUserID: "user-123"} + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: &bytes.Buffer{}, + DryRun: true, + }) + + query := "mutation($namespaceId: ID!) { test }" + vars, err := executor.buildVariables(context.Background(), query) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if vars["namespaceId"] != "" { + t.Errorf("expected placeholder namespace ID, got %v", vars["namespaceId"]) + } + + if client.callCount != 0 { + t.Errorf("should not call API in dry run mode") + } + }) + + t.Run("live mode resolves user ID", func(t *testing.T) { + client := &userMockClient{userID: "user-456"} + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: &bytes.Buffer{}, + DryRun: false, + }) + + query := "mutation($namespaceId: ID!) { test }" + vars, err := executor.buildVariables(context.Background(), query) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if vars["namespaceId"] != "user-456" { + t.Errorf("expected user-456, got %v", vars["namespaceId"]) + } + }) + + t.Run("provided namespaceId not overwritten", func(t *testing.T) { + client := &mockClient{currentUserID: "user-123"} + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: &bytes.Buffer{}, + Vars: map[string]string{"namespaceId": "custom-ns"}, + }) + + query := "mutation($namespaceId: ID!) { test }" + vars, err := executor.buildVariables(context.Background(), query) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if vars["namespaceId"] != "custom-ns" { + t.Errorf("expected custom-ns, got %v", vars["namespaceId"]) + } + }) +} + +func TestExecutor_InsightDashboardLinking(t *testing.T) { + dir, _ := setupTestBlueprint(t) + out := &bytes.Buffer{} + + bp := &Blueprint{ + Version: 1, + Name: "test", + Dashboards: []DashboardRef{ + {Name: "test-dashboard"}, + }, + Insights: []InsightRef{ + {Name: "test-insight", Dashboards: []string{"test-dashboard"}}, + }, + } + + client := &trackingMockClient{ + mockClient: mockClient{ + currentUserID: "user-123", + responses: map[string]mockResponse{ + "createInsightsDashboard": { + response: map[string]any{ + "createInsightsDashboard": map[string]any{ + "dashboard": map[string]any{"id": "dash-123"}, + }, + }, + ok: true, + }, + "createLineChartSearchInsight": { + response: map[string]any{ + "createLineChartSearchInsight": map[string]any{ + "view": map[string]any{"id": "insight-456"}, + }, + }, + ok: true, + }, + "addInsightViewToDashboard": { + response: map[string]any{}, + ok: true, + }, + }, + }, + } + + executor := NewExecutor(ExecutorOpts{ + Client: client, + Out: out, + }) + + _, err := executor.Execute(context.Background(), bp, dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !client.linkCalled { + t.Error("expected addInsightViewToDashboard to be called") + } + + if !strings.Contains(out.String(), "Linking insights to dashboards") { + t.Errorf("output should mention linking: %s", out.String()) + } +} + +type trackingMockClient struct { + mockClient + linkCalled bool +} + +type userMockClient struct { + userID string +} + +func (c *userMockClient) NewQuery(query string) api.Request { + return c.NewRequest(query, nil) +} + +func (c *userMockClient) NewRequest(query string, vars map[string]any) api.Request { + if strings.Contains(query, "currentUser") { + return &userMockRequest{userID: c.userID} + } + return &mockRequest{ok: true} +} + +func (c *userMockClient) NewHTTPRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + return nil, nil +} + +func (c *userMockClient) Do(req *http.Request) (*http.Response, error) { + return nil, nil +} + +type userMockRequest struct { + userID string +} + +func (r *userMockRequest) Do(ctx context.Context, result any) (bool, error) { + if ptr, ok := result.(*struct { + CurrentUser struct { + ID string `json:"id"` + } `json:"currentUser"` + }); ok { + ptr.CurrentUser.ID = r.userID + } + return true, nil +} + +func (r *userMockRequest) DoRaw(ctx context.Context, result any) (bool, error) { + return r.Do(ctx, result) +} + +func (c *trackingMockClient) NewRequest(query string, vars map[string]any) api.Request { + if strings.Contains(query, "addInsightViewToDashboard") { + c.linkCalled = true + } + return c.mockClient.NewRequest(query, vars) +} + +func (c *trackingMockClient) NewQuery(query string) api.Request { + return c.NewRequest(query, nil) +} diff --git a/internal/blueprint/find.go b/internal/blueprint/find.go new file mode 100644 index 0000000000..8ea52cd70b --- /dev/null +++ b/internal/blueprint/find.go @@ -0,0 +1,39 @@ +package blueprint + +import ( + "io/fs" + "path/filepath" +) + +// FindBlueprints recursively searches rootDir for blueprint.yaml files and +// returns the successfully loaded blueprints. Invalid or malformed blueprints +// are silently skipped to allow discovery to continue. +func FindBlueprints(rootDir string) ([]*Blueprint, error) { + var results []*Blueprint + + err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if d.Name() != "blueprint.yaml" { + return nil + } + + blueprintDir := filepath.Dir(path) + bp, loadErr := Load(blueprintDir) + if loadErr != nil { + return nil + } + + results = append(results, bp) + + return nil + }) + + return results, err +} diff --git a/internal/blueprint/find_test.go b/internal/blueprint/find_test.go new file mode 100644 index 0000000000..4aabfbd615 --- /dev/null +++ b/internal/blueprint/find_test.go @@ -0,0 +1,141 @@ +package blueprint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindBlueprints(t *testing.T) { + tmpDir := t.TempDir() + + bp1Dir := filepath.Join(tmpDir, "bp1") + bp2Dir := filepath.Join(tmpDir, "nested", "bp2") + emptyDir := filepath.Join(tmpDir, "empty") + + for _, dir := range []string{bp1Dir, bp2Dir, emptyDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + bp1Content := `version: 1 +name: blueprint-one +title: Blueprint One +summary: First test blueprint +monitors: + - name: mon1 +` + if err := os.WriteFile(filepath.Join(bp1Dir, "blueprint.yaml"), []byte(bp1Content), 0o644); err != nil { + t.Fatal(err) + } + + bp2Content := `version: 1 +name: blueprint-two +title: Blueprint Two +summary: Second test blueprint +insights: + - name: insight1 + - name: insight2 +dashboards: + - name: dash1 +` + if err := os.WriteFile(filepath.Join(bp2Dir, "blueprint.yaml"), []byte(bp2Content), 0o644); err != nil { + t.Fatal(err) + } + + found, err := FindBlueprints(tmpDir) + if err != nil { + t.Fatalf("FindBlueprints returned error: %v", err) + } + + if len(found) != 2 { + t.Fatalf("expected 2 blueprints, got %d", len(found)) + } + + byName := make(map[string]*Blueprint) + for _, bp := range found { + byName[bp.Name] = bp + } + + bp1, ok := byName["blueprint-one"] + if !ok { + t.Fatal("blueprint-one not found") + } + if bp1.Dir != bp1Dir { + t.Errorf("expected Dir %q, got %q", bp1Dir, bp1.Dir) + } + if bp1.Title != "Blueprint One" { + t.Errorf("expected title 'Blueprint One', got %q", bp1.Title) + } + if len(bp1.Monitors) != 1 { + t.Errorf("expected 1 monitor, got %d", len(bp1.Monitors)) + } + + bp2, ok := byName["blueprint-two"] + if !ok { + t.Fatal("blueprint-two not found") + } + if bp2.Dir != bp2Dir { + t.Errorf("expected Dir %q, got %q", bp2Dir, bp2.Dir) + } + if len(bp2.Insights) != 2 { + t.Errorf("expected 2 insights, got %d", len(bp2.Insights)) + } + if len(bp2.Dashboards) != 1 { + t.Errorf("expected 1 dashboard, got %d", len(bp2.Dashboards)) + } +} + +func TestFindBlueprints_EmptyDir(t *testing.T) { + tmpDir := t.TempDir() + + found, err := FindBlueprints(tmpDir) + if err != nil { + t.Fatalf("FindBlueprints returned error: %v", err) + } + + if len(found) != 0 { + t.Errorf("expected 0 blueprints, got %d", len(found)) + } +} + +func TestFindBlueprints_InvalidBlueprintSkipped(t *testing.T) { + tmpDir := t.TempDir() + + validDir := filepath.Join(tmpDir, "valid") + invalidDir := filepath.Join(tmpDir, "invalid") + + for _, dir := range []string{validDir, invalidDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + validContent := `version: 1 +name: valid-blueprint +` + if err := os.WriteFile(filepath.Join(validDir, "blueprint.yaml"), []byte(validContent), 0o644); err != nil { + t.Fatal(err) + } + + invalidContent := `version: 2 +name: invalid-version +` + if err := os.WriteFile(filepath.Join(invalidDir, "blueprint.yaml"), []byte(invalidContent), 0o644); err != nil { + t.Fatal(err) + } + + found, err := FindBlueprints(tmpDir) + if err != nil { + t.Fatalf("FindBlueprints returned error: %v", err) + } + + if len(found) != 1 { + t.Fatalf("expected 1 blueprint (invalid skipped), got %d", len(found)) + } + + if found[0].Name != "valid-blueprint" { + t.Errorf("expected valid-blueprint, got %q", found[0].Name) + } +} diff --git a/internal/blueprint/load.go b/internal/blueprint/load.go new file mode 100644 index 0000000000..c6f5c453f1 --- /dev/null +++ b/internal/blueprint/load.go @@ -0,0 +1,44 @@ +package blueprint + +import ( + "os" + "path/filepath" + + "github.com/sourcegraph/sourcegraph/lib/errors" + "gopkg.in/yaml.v3" +) + +// Load reads and validates a blueprint.yaml from blueprintDir. +func Load(blueprintDir string) (*Blueprint, error) { + blueprintPath := filepath.Join(blueprintDir, "blueprint.yaml") + + data, err := os.ReadFile(blueprintPath) + if err != nil { + return nil, errors.Wrap(err, "reading blueprint.yaml") + } + + var bp Blueprint + if err := yaml.Unmarshal(data, &bp); err != nil { + return nil, errors.Wrap(err, "parsing blueprint.yaml") + } + + if err := Validate(&bp); err != nil { + return nil, err + } + + bp.Dir = blueprintDir + return &bp, nil +} + +// Validate checks that the blueprint has a supported version and required fields. +func Validate(bp *Blueprint) error { + if bp.Version != 1 { + return errors.Newf("unsupported blueprint version: %d (expected 1)", bp.Version) + } + + if bp.Name == "" { + return errors.New("blueprint.yaml missing required field: name") + } + + return nil +} diff --git a/internal/blueprint/load_test.go b/internal/blueprint/load_test.go new file mode 100644 index 0000000000..12ea73ed13 --- /dev/null +++ b/internal/blueprint/load_test.go @@ -0,0 +1,148 @@ +package blueprint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr bool + errContains string + check func(t *testing.T, bp *Blueprint) + }{ + { + name: "valid blueprint with all fields", + yaml: `version: 1 +name: test-blueprint +title: Test Blueprint +summary: A test blueprint +description: This is a test blueprint for unit testing +category: security +tags: + - security + - testing +batchSpecs: + - name: fix-cve +monitors: + - name: security-alert +insights: + - name: vulnerability-count + dashboards: + - security-dashboard +dashboards: + - name: security-dashboard +`, + check: func(t *testing.T, bp *Blueprint) { + if bp.Version != 1 { + t.Errorf("Version = %d, want 1", bp.Version) + } + if bp.Name != "test-blueprint" { + t.Errorf("Name = %q, want %q", bp.Name, "test-blueprint") + } + if bp.Title != "Test Blueprint" { + t.Errorf("Title = %q, want %q", bp.Title, "Test Blueprint") + } + if len(bp.BatchSpecs) != 1 || bp.BatchSpecs[0].Name != "fix-cve" { + t.Errorf("BatchSpecs = %v, want [{fix-cve}]", bp.BatchSpecs) + } + if len(bp.Monitors) != 1 || bp.Monitors[0].Name != "security-alert" { + t.Errorf("Monitors = %v, want [{security-alert}]", bp.Monitors) + } + if len(bp.Insights) != 1 || bp.Insights[0].Name != "vulnerability-count" { + t.Errorf("Insights = %v, want [{vulnerability-count [...]}]", bp.Insights) + } + if len(bp.Insights[0].Dashboards) != 1 || bp.Insights[0].Dashboards[0] != "security-dashboard" { + t.Errorf("Insights[0].Dashboards = %v, want [security-dashboard]", bp.Insights[0].Dashboards) + } + if len(bp.Dashboards) != 1 || bp.Dashboards[0].Name != "security-dashboard" { + t.Errorf("Dashboards = %v, want [{security-dashboard}]", bp.Dashboards) + } + }, + }, + { + name: "minimal valid blueprint", + yaml: `version: 1 +name: minimal +`, + check: func(t *testing.T, bp *Blueprint) { + if bp.Name != "minimal" { + t.Errorf("Name = %q, want %q", bp.Name, "minimal") + } + }, + }, + { + name: "missing name", + yaml: `version: 1`, + wantErr: true, + errContains: "missing required field: name", + }, + { + name: "unsupported version", + yaml: `version: 2 +name: test +`, + wantErr: true, + errContains: "unsupported blueprint version", + }, + { + name: "invalid yaml", + yaml: `version: [invalid`, + wantErr: true, + errContains: "parsing blueprint.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "blueprint.yaml"), []byte(tt.yaml), 0644); err != nil { + t.Fatal(err) + } + + bp, err := Load(dir) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, bp) + } + }) + } +} + +func TestLoad_MissingFile(t *testing.T) { + dir := t.TempDir() + _, err := Load(dir) + if err == nil { + t.Fatal("expected error for missing blueprint.yaml") + } + if !contains(err.Error(), "reading blueprint.yaml") { + t.Errorf("error %q does not mention reading blueprint.yaml", err.Error()) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/blueprint/source.go b/internal/blueprint/source.go new file mode 100644 index 0000000000..9e5f13badc --- /dev/null +++ b/internal/blueprint/source.go @@ -0,0 +1,112 @@ +package blueprint + +import ( + "context" + "net/url" + "path/filepath" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// BlueprintSource provides access to blueprint files from various sources. +type BlueprintSource interface { + // Prepare makes the blueprint directory available and returns its path. + // The cleanup function MUST be called when done (may be nil for sources + // that don't require cleanup). + Prepare(ctx context.Context) (blueprintDir string, cleanup func() error, err error) +} + +// ResolveBlueprintSource returns a BlueprintSource for the given repository or path and subdirectory. +// rawRepo may be an HTTPS Git URL or a local filesystem path. rev may be empty to use the default branch. +func ResolveBlueprintSource(rawRepo, rev, subdir string) (BlueprintSource, error) { + if err := validateSubdir(subdir); err != nil { + return nil, err + } + + if isLocalPath(rawRepo) { + path := rawRepo + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, errors.Wrapf(err, "resolving absolute path for %q", path) + } + path = absPath + } + return &LocalBlueprintSource{ + Path: path, + Subdir: subdir, + }, nil + } + + u, err := url.Parse(rawRepo) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, errors.Newf("invalid repository URL %q: must be an HTTPS Git URL or local path", rawRepo) + } + + if u.Scheme != "https" { + return nil, errors.Newf("unsupported URL scheme %q: only HTTPS is allowed", u.Scheme) + } + + return &GitBlueprintSource{ + RepoURL: rawRepo, + Rev: rev, + Subdir: subdir, + }, nil +} + +func isLocalPath(s string) bool { + if filepath.IsAbs(s) { + return true + } + if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") || s == "." || s == ".." { + return true + } + return false +} + +// ResolveRootSource returns a BlueprintSource that provides the repository root +// without requiring a blueprint.yaml file. Used for listing all blueprints in a repository. +func ResolveRootSource(rawRepo, rev string) (BlueprintSource, error) { + if isLocalPath(rawRepo) { + path := rawRepo + if !filepath.IsAbs(path) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, errors.Wrapf(err, "resolving absolute path for %q", path) + } + path = absPath + } + return &LocalRootSource{Path: path}, nil + } + + u, err := url.Parse(rawRepo) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, errors.Newf("invalid repository URL %q: must be an HTTPS Git URL or local path", rawRepo) + } + + if u.Scheme != "https" { + return nil, errors.Newf("unsupported URL scheme %q: only HTTPS is allowed", u.Scheme) + } + + return &GitRootSource{ + RepoURL: rawRepo, + Rev: rev, + }, nil +} + +func validateSubdir(subdir string) error { + if subdir == "" { + return nil + } + + cleaned := filepath.Clean(subdir) + if filepath.IsAbs(cleaned) { + return errors.Newf("subdir must be a relative path, got %q", subdir) + } + if strings.HasPrefix(cleaned, "..") { + return errors.Newf("subdir must not escape the repository root, got %q", subdir) + } + + return nil +} diff --git a/internal/blueprint/source_git.go b/internal/blueprint/source_git.go new file mode 100644 index 0000000000..a863f6446d --- /dev/null +++ b/internal/blueprint/source_git.go @@ -0,0 +1,156 @@ +package blueprint + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// GitBlueprintSource clones a Git repository over HTTPS and provides access +// to the blueprint files within it. +type GitBlueprintSource struct { + RepoURL string + Rev string + Subdir string + GitBinary string // defaults to "git" if empty +} + +func (s *GitBlueprintSource) Prepare(ctx context.Context) (string, func() error, error) { + git := s.GitBinary + if git == "" { + git = "git" + } + + if _, err := exec.LookPath(git); err != nil { + return "", nil, errors.New("git CLI not found; please install git to use 'src blueprint import'") + } + + tmpDir, err := os.MkdirTemp("", "src-blueprint-*") + if err != nil { + return "", nil, errors.Wrap(err, "creating temporary directory for blueprint clone") + } + + cleanup := func() error { + return os.RemoveAll(tmpDir) + } + + if err := s.clone(ctx, git, tmpDir); err != nil { + _ = cleanup() + return "", nil, err + } + + if s.Rev != "" { + if err := s.checkout(ctx, git, tmpDir); err != nil { + _ = cleanup() + return "", nil, err + } + } + + blueprintDir := tmpDir + if s.Subdir != "" { + blueprintDir = filepath.Join(tmpDir, filepath.Clean(s.Subdir)) + } + + if _, err := os.Stat(filepath.Join(blueprintDir, "blueprint.yaml")); err != nil { + _ = cleanup() + return "", nil, errors.Wrap(err, "blueprint.yaml not found in cloned repository") + } + + return blueprintDir, cleanup, nil +} + +func (s *GitBlueprintSource) clone(ctx context.Context, git, targetDir string) error { + args := []string{"clone"} + if s.Rev == "" { + args = append(args, "--branch", "main") + } + args = append(args, "--", s.RepoURL, targetDir) + cmd := exec.CommandContext(ctx, git, args...) + cmd.Env = gitEnv() + cmd.Stdout = io.Discard + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return errors.Wrapf(err, "git clone %q failed: %s", s.RepoURL, strings.TrimSpace(stderr.String())) + } + + return nil +} + +func (s *GitBlueprintSource) checkout(ctx context.Context, git, repoDir string) error { + args := []string{"-C", repoDir, "checkout", "--detach", s.Rev} + cmd := exec.CommandContext(ctx, git, args...) + cmd.Env = gitEnv() + cmd.Stdout = io.Discard + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return errors.Wrapf(err, "git checkout %q failed: %s", s.Rev, strings.TrimSpace(stderr.String())) + } + + return nil +} + +func gitEnv() []string { + env := os.Environ() + env = append(env, "GIT_TERMINAL_PROMPT=0") + return env +} + +// GitRootSource clones a Git repository and provides access to the root +// without requiring a blueprint.yaml file. Used for listing all blueprints. +type GitRootSource struct { + RepoURL string + Rev string + GitBinary string +} + +func (s *GitRootSource) Prepare(ctx context.Context) (string, func() error, error) { + git := s.GitBinary + if git == "" { + git = "git" + } + + if _, err := exec.LookPath(git); err != nil { + return "", nil, errors.New("git CLI not found; please install git to use 'src blueprint list'") + } + + tmpDir, err := os.MkdirTemp("", "src-blueprint-*") + if err != nil { + return "", nil, errors.Wrap(err, "creating temporary directory for blueprint clone") + } + + cleanup := func() error { + return os.RemoveAll(tmpDir) + } + + src := &GitBlueprintSource{ + RepoURL: s.RepoURL, + Rev: s.Rev, + GitBinary: git, + } + + if err := src.clone(ctx, git, tmpDir); err != nil { + _ = cleanup() + return "", nil, err + } + + if s.Rev != "" { + if err := src.checkout(ctx, git, tmpDir); err != nil { + _ = cleanup() + return "", nil, err + } + } + + return tmpDir, cleanup, nil +} diff --git a/internal/blueprint/source_local.go b/internal/blueprint/source_local.go new file mode 100644 index 0000000000..01f34cc7bd --- /dev/null +++ b/internal/blueprint/source_local.go @@ -0,0 +1,54 @@ +package blueprint + +import ( + "context" + "os" + "path/filepath" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// LocalBlueprintSource provides access to blueprint files from a local directory. +type LocalBlueprintSource struct { + Path string + Subdir string +} + +func (s *LocalBlueprintSource) Prepare(ctx context.Context) (string, func() error, error) { + blueprintDir := s.Path + if s.Subdir != "" { + blueprintDir = filepath.Join(s.Path, filepath.Clean(s.Subdir)) + } + + info, err := os.Stat(blueprintDir) + if err != nil { + return "", nil, errors.Wrapf(err, "blueprint directory %q not accessible", blueprintDir) + } + if !info.IsDir() { + return "", nil, errors.Newf("blueprint path %q is not a directory", blueprintDir) + } + + if _, err := os.Stat(filepath.Join(blueprintDir, "blueprint.yaml")); err != nil { + return "", nil, errors.Wrap(err, "blueprint.yaml not found in directory") + } + + return blueprintDir, nil, nil +} + +// LocalRootSource provides access to a local directory without requiring +// a blueprint.yaml file at the root. Used for listing all blueprints. +type LocalRootSource struct { + Path string +} + +func (s *LocalRootSource) Prepare(ctx context.Context) (string, func() error, error) { + info, err := os.Stat(s.Path) + if err != nil { + return "", nil, errors.Wrapf(err, "directory %q not accessible", s.Path) + } + if !info.IsDir() { + return "", nil, errors.Newf("path %q is not a directory", s.Path) + } + + return s.Path, nil, nil +} diff --git a/internal/blueprint/source_test.go b/internal/blueprint/source_test.go new file mode 100644 index 0000000000..7cc83dbc7e --- /dev/null +++ b/internal/blueprint/source_test.go @@ -0,0 +1,166 @@ +package blueprint + +import ( + "testing" +) + +func TestResolveBlueprintSource(t *testing.T) { + tests := []struct { + name string + repo string + rev string + subdir string + wantErr string + wantType string + }{ + { + name: "valid https url", + repo: "https://github.com/org/blueprints", + wantType: "git", + }, + { + name: "valid https url with rev", + repo: "https://github.com/org/blueprints", + rev: "v1.0.0", + wantType: "git", + }, + { + name: "valid https url with subdir", + repo: "https://github.com/org/blueprints", + subdir: "monitors/cve-2025-1234", + wantType: "git", + }, + { + name: "ssh url rejected", + repo: "git@github.com:org/blueprints.git", + wantErr: "invalid repository URL", + }, + { + name: "http url rejected", + repo: "http://github.com/org/blueprints", + wantErr: "unsupported URL scheme", + }, + { + name: "absolute local path", + repo: "/path/to/local/repo", + wantType: "local", + }, + { + name: "relative path with dot slash", + repo: "./local/repo", + wantType: "local", + }, + { + name: "relative path with parent", + repo: "../other/repo", + wantType: "local", + }, + { + name: "current directory", + repo: ".", + wantType: "local", + }, + { + name: "parent directory", + repo: "..", + wantType: "local", + }, + { + name: "absolute subdir rejected", + repo: "https://github.com/org/blueprints", + subdir: "/etc/passwd", + wantErr: "subdir must be a relative path", + }, + { + name: "parent escape subdir rejected", + repo: "https://github.com/org/blueprints", + subdir: "../../../etc/passwd", + wantErr: "subdir must not escape the repository root", + }, + { + name: "hidden parent escape rejected", + repo: "https://github.com/org/blueprints", + subdir: "foo/../../bar", + wantErr: "subdir must not escape the repository root", + }, + { + name: "local path with absolute subdir rejected", + repo: "/path/to/repo", + subdir: "/etc/passwd", + wantErr: "subdir must be a relative path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src, err := ResolveBlueprintSource(tt.repo, tt.rev, tt.subdir) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + switch tt.wantType { + case "git": + gitSrc, ok := src.(*GitBlueprintSource) + if !ok { + t.Fatalf("expected *GitBlueprintSource, got %T", src) + } + if gitSrc.RepoURL != tt.repo { + t.Errorf("RepoURL = %q, want %q", gitSrc.RepoURL, tt.repo) + } + if gitSrc.Rev != tt.rev { + t.Errorf("Rev = %q, want %q", gitSrc.Rev, tt.rev) + } + if gitSrc.Subdir != tt.subdir { + t.Errorf("Subdir = %q, want %q", gitSrc.Subdir, tt.subdir) + } + case "local": + localSrc, ok := src.(*LocalBlueprintSource) + if !ok { + t.Fatalf("expected *LocalBlueprintSource, got %T", src) + } + if localSrc.Subdir != tt.subdir { + t.Errorf("Subdir = %q, want %q", localSrc.Subdir, tt.subdir) + } + default: + t.Fatalf("unknown wantType %q", tt.wantType) + } + }) + } +} + +func TestValidateSubdir(t *testing.T) { + tests := []struct { + subdir string + wantErr bool + }{ + {"", false}, + {"monitors", false}, + {"monitors/cve-2025", false}, + {"./monitors", false}, + {"/absolute", true}, + {"..", true}, + {"../escape", true}, + {"foo/../..", true}, + {"foo/bar/../../..", true}, + } + + for _, tt := range tests { + t.Run(tt.subdir, func(t *testing.T) { + err := validateSubdir(tt.subdir) + if (err != nil) != tt.wantErr { + t.Errorf("validateSubdir(%q) error = %v, wantErr = %v", tt.subdir, err, tt.wantErr) + } + }) + } +} diff --git a/internal/blueprint/types.go b/internal/blueprint/types.go new file mode 100644 index 0000000000..f553317654 --- /dev/null +++ b/internal/blueprint/types.go @@ -0,0 +1,61 @@ +package blueprint + +import "path/filepath" + +// Blueprint represents a collection of Sourcegraph resources defined in a +// blueprint.yaml file. +type Blueprint struct { + Dir string `yaml:"-"` + Version int `yaml:"version"` + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Category string `yaml:"category"` + Tags []string `yaml:"tags"` + BatchSpecs []BatchSpecRef `yaml:"batchSpecs"` + Monitors []MonitorRef `yaml:"monitors"` + Insights []InsightRef `yaml:"insights"` + Dashboards []DashboardRef `yaml:"dashboards"` +} + +// BatchSpecRef references a batch spec resource within a blueprint. +type BatchSpecRef struct { + Name string `yaml:"name"` +} + +// MonitorRef references a code monitor resource within a blueprint. +type MonitorRef struct { + Name string `yaml:"name"` +} + +// InsightRef references a code insight resource within a blueprint. +type InsightRef struct { + Name string `yaml:"name"` + Dashboards []string `yaml:"dashboards"` +} + +// DashboardRef references an insights dashboard resource within a blueprint. +type DashboardRef struct { + Name string `yaml:"name"` +} + +// Path returns the filesystem path to the batch spec YAML for this reference. +func (r BatchSpecRef) Path(blueprintDir string) string { + return filepath.Join(blueprintDir, "resources", "batch-spec", r.Name+".yaml") +} + +// Path returns the filesystem path to the monitor GraphQL file for this reference. +func (r MonitorRef) Path(blueprintDir string) string { + return filepath.Join(blueprintDir, "resources", "monitors", r.Name+".gql") +} + +// Path returns the filesystem path to the insight GraphQL file for this reference. +func (r InsightRef) Path(blueprintDir string) string { + return filepath.Join(blueprintDir, "resources", "insights", r.Name+".gql") +} + +// Path returns the filesystem path to the dashboard GraphQL file for this reference. +func (r DashboardRef) Path(blueprintDir string) string { + return filepath.Join(blueprintDir, "resources", "dashboards", r.Name+".gql") +}