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
}