-
Notifications
You must be signed in to change notification settings - Fork 70
feat: add blueprints command #1244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) }, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 <repository-url-or-path> [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 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not always return a valid cleanup? then you don't have to check it the whole time and can just do |
||||||
| 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") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| return nil | ||||||
| } | ||||||
|
|
||||||
| fmt.Fprintf(opts.out, "Found %d blueprint(s) in repository\n\n", len(found)) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
unless the extra |
||||||
|
|
||||||
| 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 != "" { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can roll all this fmt.Fprintln(opts.out, bp.String()) |
||||||
| 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 | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe this distinction should be made in a top level
blueprint.NewSource/blueprint.ResolveSourcemethod? We then unexportResolveRootSource/ResolveBlueprintSourceand just have a singleResolvemethod exposed that returns the correct source for us.