commands/project/publish/catalog/publish.go (204 lines of code) (raw):

package catalog import ( "fmt" "net/http" "net/url" "os" "path/filepath" "sort" "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/commands/cmdutils" "gitlab.com/gitlab-org/cli/internal/glrepo" "gitlab.com/gitlab-org/cli/pkg/iostreams" "gopkg.in/yaml.v3" ) const ( publishToCatalogApiPath = "projects/%s/catalog/publish" templatesDirName = "templates" templateFileExt = ".yml" templateFileName = "template.yml" ) type publishToCatalogRequest struct { Version string `json:"version"` Metadata map[string]any `json:"metadata"` } type publishToCatalogResponse struct { CatalogUrl string `json:"catalog_url"` } type Options struct { TagName string HTTPClient func() (*gitlab.Client, error) BaseRepo func() (glrepo.Interface, error) IO *iostreams.IOStreams } func NewCmdPublishCatalog(f *cmdutils.Factory) *cobra.Command { opts := &Options{ IO: f.IO, BaseRepo: f.BaseRepo, } publishCatalogCmd := &cobra.Command{ Use: "catalog <tag-name>", Short: `[EXPERIMENTAL] Publishes CI/CD components to the catalog.`, Long: heredoc.Docf(`[EXPERIMENTAL] Publishes CI/CD components in the project to the CI/CD catalog using the provided tag name. Requires the feature flag %[1]sci_release_cli_catalog_publish_option%[1]s to be enabled for this project in your GitLab instance. Requires the same user as the release author. - It retrieves components from the current repository by searching for %[1]syml%[1]s files within the "templates" directory and its subdirectories. - It fails if the feature flag %[1]sci_release_cli_catalog_publish_option%[1]s is not enabled for this project in your GitLab instance. Components can be defined: - In single files ending in %[1]s.yml%[1]s for each component, like %[1]stemplates/secret-detection.yml%[1]s. - In subdirectories containing %[1]stemplate.yml%[1]s files as entry points, for components that bundle together multiple related files. For example, %[1]stemplates/secret-detection/template.yml%[1]s. `, "`"), Example: heredoc.Doc(` - glab repo publish catalog v1.2.3 `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.HTTPClient = f.HttpClient opts.BaseRepo = f.BaseRepo repo, err := opts.BaseRepo() if err != nil { return err } apiClient, err := opts.HTTPClient() if err != nil { return err } opts.TagName = args[0] _, _, err = apiClient.Tags.GetTag(repo.FullName(), opts.TagName) if err != nil { return &cmdutils.FlagError{Err: fmt.Errorf("Invalid tag %s.", opts.TagName)} } return Publish(opts.IO, apiClient, repo.FullName(), opts.TagName) }, } return publishCatalogCmd } func Publish(io *iostreams.IOStreams, client *gitlab.Client, repoName string, tagName string) error { color := io.Color() io.Logf("%s Publishing release %s=%s to the GitLab CI/CD catalog for %s=%s...\n", color.ProgressIcon(), color.Blue("tag"), tagName, color.Blue("repo"), repoName) body, err := publishToCatalogRequestBody(tagName) if err != nil { return cmdutils.WrapError(err, "failed to create a request body.") } path := fmt.Sprintf(publishToCatalogApiPath, url.PathEscape(repoName)) request, err := client.NewRequest(http.MethodPost, path, body, nil) if err != nil { return cmdutils.WrapError(err, "failed to create a request.") } var response publishToCatalogResponse _, err = client.Do(request, &response) if err != nil { return err } io.Logf("%s Release published: %s=%s\n", color.GreenCheck(), color.Blue("url"), response.CatalogUrl) return nil } func publishToCatalogRequestBody(version string) (*publishToCatalogRequest, error) { baseDir, err := os.Getwd() if err != nil { return nil, cmdutils.WrapError(err, "failed to get working directory") } components, err := fetchTemplates(baseDir) if err != nil { return nil, cmdutils.WrapError(err, "failed to fetch components") } metadata := make(map[string]any) componentsData := make([]map[string]any, 0, len(components)) for name, path := range components { spec, err := extractSpec(path) if err != nil { return nil, cmdutils.WrapError(err, "failed to extract spec") } componentsData = append(componentsData, map[string]any{ "name": name, "spec": spec, "component_type": "template", }) } sort.Slice(componentsData, func(i, j int) bool { return componentsData[i]["name"].(string) < componentsData[j]["name"].(string) }) metadata["components"] = componentsData return &publishToCatalogRequest{ Version: version, Metadata: metadata, }, nil } // fetchTemplates returns a map of component names to their paths. // The component name is either the name of the file without the extension in the "templates" directory of the project // or the name of the directory containing a "template.yml" file in the "templates" directory. // More information: https://docs.gitlab.com/ci/components/#directory-structure func fetchTemplates(baseDir string) (map[string]string, error) { templates := make(map[string]string) paths, err := fetchTemplatePaths(baseDir) if err != nil { return nil, cmdutils.WrapError(err, "failed to fetch template paths") } for _, path := range paths { componentName, err := extractComponentName(baseDir, path) if err != nil { return nil, cmdutils.WrapError(err, "failed to extract component name") } if componentName != "" { templates[componentName] = path } } return templates, nil } // fetchTemplatePaths returns a list of the possible component paths to the YAML files in the "templates" directory. func fetchTemplatePaths(baseDir string) ([]string, error) { templatesDir := filepath.Join(baseDir, templatesDirName) var yamlFiles []string err := filepath.WalkDir(templatesDir, func(path string, d os.DirEntry, err error) error { if err != nil { return cmdutils.WrapError(err, "failed to walk directory") } if filepath.Ext(d.Name()) == templateFileExt { yamlFiles = append(yamlFiles, path) } return nil }) if err != nil { return nil, err } return yamlFiles, nil } // extractComponentName returns the valid component name from the path if it is a valid component path. // valid component paths: // 1. All YAML files in the "templates" directory. // 2. All "template.yml" files in the subdirectories of the "templates" directory. func extractComponentName(baseDir string, path string) (string, error) { relativePath, err := filepath.Rel(baseDir, path) if err != nil { return "", err } dirname := filepath.Dir(relativePath) fileExt := filepath.Ext(relativePath) filename := filepath.Base(relativePath) filenameWithoutExt := filename[:len(filename)-len(fileExt)] // All YAML files in the "templates" directory. if dirname == templatesDirName { return filenameWithoutExt, nil } // All "template.yml" files in the subdirectories of the "templates" directory. if filename == templateFileName { return filepath.Base(dirname), nil } else { return "", nil } } type specDef struct { Spec map[string]any `yaml:"spec"` } // extractSpec returns the spec from the component file. func extractSpec(componentPath string) (map[string]any, error) { content, err := os.ReadFile(componentPath) if err != nil { return nil, cmdutils.WrapError(err, "failed to read file") } var spec specDef err = yaml.Unmarshal(content, &spec) if err != nil { return nil, cmdutils.WrapError(err, "failed to unmarshal YAML") } return spec.Spec, nil }