github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/publish/publish.go (about)

     1  package publish
     2  
     3  import (
     4  	"errors"
     5  	"net/http"
     6  	"path/filepath"
     7  	"regexp"
     8  	"strconv"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/ActiveState/cli/internal/captain"
    13  	"github.com/ActiveState/cli/internal/errs"
    14  	"github.com/ActiveState/cli/internal/fileutils"
    15  	"github.com/ActiveState/cli/internal/gqlclient"
    16  	"github.com/ActiveState/cli/internal/locale"
    17  	"github.com/ActiveState/cli/internal/logging"
    18  	"github.com/ActiveState/cli/internal/osutils"
    19  	"github.com/ActiveState/cli/internal/output"
    20  	"github.com/ActiveState/cli/internal/primer"
    21  	"github.com/ActiveState/cli/internal/prompt"
    22  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    23  	"github.com/ActiveState/cli/pkg/platform/api"
    24  	graphModel "github.com/ActiveState/cli/pkg/platform/api/graphql/model"
    25  	"github.com/ActiveState/cli/pkg/platform/api/graphql/request"
    26  	"github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_client/inventory_operations"
    27  	"github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_models"
    28  	"github.com/ActiveState/cli/pkg/platform/authentication"
    29  	"github.com/ActiveState/cli/pkg/platform/model"
    30  	"github.com/ActiveState/cli/pkg/project"
    31  	"github.com/ActiveState/graphql"
    32  	"github.com/go-openapi/strfmt"
    33  	"gopkg.in/yaml.v3"
    34  )
    35  
    36  type Params struct {
    37  	Name           string
    38  	Version        string
    39  	Namespace      string
    40  	Owner          string
    41  	Description    string
    42  	Authors        captain.UsersValue
    43  	Depends        captain.PackagesValue
    44  	DependsRuntime captain.PackagesValue
    45  	DependsBuild   captain.PackagesValue
    46  	DependsTest    captain.PackagesValue
    47  	Features       captain.PackagesValue
    48  	Filepath       string
    49  	MetaFilepath   string
    50  	Edit           bool
    51  	Editor         bool
    52  }
    53  
    54  type Runner struct {
    55  	auth    *authentication.Auth
    56  	out     output.Outputer
    57  	prompt  prompt.Prompter
    58  	project *project.Project
    59  	client  *gqlclient.Client
    60  }
    61  
    62  type primeable interface {
    63  	primer.Outputer
    64  	primer.Auther
    65  	primer.Projecter
    66  	primer.Prompter
    67  }
    68  
    69  func New(prime primeable) *Runner {
    70  	client := gqlclient.NewWithOpts(
    71  		api.GetServiceURL(api.ServiceBuildPlanner).String(), 0,
    72  		graphql.WithHTTPClient(http.DefaultClient),
    73  		graphql.UseMultipartForm(),
    74  	)
    75  	client.SetTokenProvider(prime.Auth())
    76  	client.EnableDebugLog()
    77  	return &Runner{auth: prime.Auth(), out: prime.Output(), prompt: prime.Prompt(), project: prime.Project(), client: client}
    78  }
    79  
    80  type ParentIngredient struct {
    81  	IngredientID        strfmt.UUID
    82  	IngredientVersionID strfmt.UUID
    83  	Version             string
    84  	Dependencies        []inventory_models.Dependency `json:"dependencies"`
    85  }
    86  
    87  var nameRegexp = regexp.MustCompile(`\w+([_-]\w+)*`)
    88  
    89  func (r *Runner) Run(params *Params) error {
    90  	if !r.auth.Authenticated() {
    91  		return locale.NewInputError("err_auth_required")
    92  	}
    93  
    94  	if params.Filepath != "" {
    95  		if !fileutils.FileExists(params.Filepath) {
    96  			return locale.NewInputError("err_uploadingredient_file_not_found", "File not found: {{.V0}}", params.Filepath)
    97  		}
    98  		if !strings.HasSuffix(strings.ToLower(params.Filepath), ".zip") &&
    99  			!strings.HasSuffix(strings.ToLower(params.Filepath), ".tar.gz") {
   100  			return locale.NewInputError("err_uploadingredient_file_not_supported", "Expected file extension to be either .zip or .tar.gz: '{{.V0}}'", params.Filepath)
   101  		}
   102  	} else if !params.Edit {
   103  		return locale.NewInputError("err_uploadingredient_file_required", "You have to supply the source archive unless editing.")
   104  	}
   105  
   106  	reqVars := request.PublishVariables{}
   107  
   108  	// Pass input from meta file
   109  	if params.MetaFilepath != "" {
   110  		if !fileutils.TargetExists(params.MetaFilepath) {
   111  			return locale.NewInputError("err_uploadingredient_metafile_not_found", "Meta file not found: {{.V0}}", params.MetaFilepath)
   112  		}
   113  
   114  		b, err := fileutils.ReadFile(params.MetaFilepath)
   115  		if err != nil {
   116  			return locale.WrapExternalError(err, "err_uploadingredient_file_read", "Could not read file: {{.V0}}", params.MetaFilepath)
   117  		}
   118  
   119  		if err := yaml.Unmarshal(b, &reqVars); err != nil {
   120  			return locale.WrapExternalError(err, "err_uploadingredient_file_read", "Failed to unmarshal meta file, error received: {{.V0}}", err.Error())
   121  		}
   122  	}
   123  
   124  	// Namespace
   125  	if params.Namespace != "" {
   126  		reqVars.Namespace = params.Namespace
   127  	} else if reqVars.Namespace == "" && r.project != nil && r.project.Owner() != "" {
   128  		reqVars.Namespace = model.NewOrgNamespace(r.project.Owner()).String()
   129  	}
   130  
   131  	// Name
   132  	if params.Name != "" { // Validate & Set name
   133  		reqVars.Name = params.Name
   134  	} else if reqVars.Name == "" {
   135  		// Attempt to extract a usable name from the filename.
   136  		name := filepath.Base(params.Filepath)
   137  		if ext := filepath.Ext(params.Filepath); ext != "" {
   138  			name = name[:len(name)-len(ext)] // strip extension
   139  		}
   140  		name = versionRegexp.ReplaceAllString(name, "") // strip version number
   141  		if matches := nameRegexp.FindAllString(name, 1); matches != nil {
   142  			name = matches[0] // extract name-part
   143  		}
   144  		reqVars.Name = name
   145  	}
   146  
   147  	var ingredient *ParentIngredient
   148  
   149  	latestRevisionTime, err := model.FetchLatestRevisionTimeStamp(r.auth)
   150  	if err != nil {
   151  		return errs.Wrap(err, "Unable to determine latest revision time")
   152  	}
   153  
   154  	isRevision := false
   155  	if params.Version != "" {
   156  		// Attempt to get the version if it already exists, it not existing is not an error though
   157  		i, err := model.GetIngredientByNameAndVersion(reqVars.Namespace, reqVars.Name, params.Version, &latestRevisionTime, r.auth)
   158  		if err != nil {
   159  			var notFound *inventory_operations.GetNamespaceIngredientVersionNotFound
   160  			if !errors.As(err, &notFound) {
   161  				return errs.Wrap(err, "could not get ingredient version")
   162  			}
   163  		} else {
   164  			ingredient = &ParentIngredient{*i.IngredientID, *i.IngredientVersionID, *i.Version, i.Dependencies}
   165  			isRevision = true
   166  		}
   167  	}
   168  
   169  	if ingredient == nil {
   170  		// Attempt to find the existing ingredient, if we didn't already get it from the version specific call above
   171  		ingredients, err := model.SearchIngredientsStrict(reqVars.Namespace, reqVars.Name, true, false, &latestRevisionTime, r.auth)
   172  		if err != nil && !errs.Matches(err, &model.ErrSearch404{}) { // 404 means either the ingredient or the namespace was not found, which is fine
   173  			return locale.WrapError(err, "err_uploadingredient_search", "Could not search for ingredient")
   174  		}
   175  		if len(ingredients) > 0 {
   176  			i := ingredients[0].LatestVersion
   177  			ingredient = &ParentIngredient{*i.IngredientID, *i.IngredientVersionID, *i.Version, i.Dependencies}
   178  			if params.Version == "" {
   179  				isRevision = true
   180  			}
   181  		}
   182  	}
   183  
   184  	if params.Edit {
   185  		if ingredient == nil {
   186  			return locale.NewInputError("err_uploadingredient_edit_not_found",
   187  				"Could not find ingredient to edit with name: '[ACTIONABLE]{{.V0}}[/RESET]', namespace: '[ACTIONABLE]{{.V1}}[/RESET]'.",
   188  				reqVars.Name, reqVars.Namespace)
   189  		}
   190  		if err := prepareEditRequest(ingredient, &reqVars, isRevision, r.auth); err != nil {
   191  			return errs.Wrap(err, "Could not prepare edit request")
   192  		}
   193  	} else {
   194  		if isRevision {
   195  			return locale.NewInputError("err_uploadingredient_exists",
   196  				"Ingredient with namespace '[ACTIONABLE]{{.V0}}[/RESET]' and name '[ACTIONABLE]{{.V1}}[/RESET]' already exists. "+
   197  					"To edit an existing ingredient you need to pass the '[ACTIONABLE]--edit[/RESET]' flag.",
   198  				reqVars.Namespace, reqVars.Name)
   199  		}
   200  	}
   201  
   202  	if err := prepareRequestFromParams(&reqVars, params, isRevision); err != nil {
   203  		return errs.Wrap(err, "Could not prepare request from params")
   204  	}
   205  
   206  	if params.Editor {
   207  		if !r.out.Config().Interactive {
   208  			return locale.NewInputError("err_uploadingredient_editor_not_supported", "Opening in editor is not supported in non-interactive mode")
   209  		}
   210  		if err := r.OpenInEditor(&reqVars); err != nil {
   211  			return err
   212  		}
   213  	}
   214  
   215  	if reqVars.Namespace == "" {
   216  		return locale.NewInputError("err_uploadingredient_namespace_required", "You have to supply the namespace when working outside of a project context")
   217  	}
   218  
   219  	b, err := reqVars.MarshalYaml(false)
   220  	if err != nil {
   221  		return errs.Wrap(err, "Could not marshal publish variables")
   222  	}
   223  
   224  	cont, err := r.prompt.Confirm(
   225  		"",
   226  		locale.Tl("uploadingredient_confirm", `Prepared the following ingredient:
   227  
   228  {{.V0}}
   229  
   230  Do you want to publish this ingredient?
   231  `, string(b)),
   232  		ptr.To(true),
   233  	)
   234  	if err != nil {
   235  		return errs.Wrap(err, "Confirmation failed")
   236  	}
   237  	if !cont {
   238  		r.out.Print(locale.Tl("uploadingredient_cancel", "Publish cancelled"))
   239  		return nil
   240  	}
   241  
   242  	r.out.Notice(locale.Tl("uploadingredient_uploading", "Publishing ingredient..."))
   243  
   244  	pr, err := request.Publish(reqVars, params.Filepath)
   245  	if err != nil {
   246  		return locale.WrapError(err, "err_uploadingredient_publish", "Could not create publish request")
   247  	}
   248  	result := graphModel.PublishResult{}
   249  
   250  	if err := r.client.Run(pr, &result); err != nil {
   251  		return locale.WrapError(err, "err_uploadingredient_publish", "", err.Error())
   252  	}
   253  
   254  	if result.Publish.Error != "" {
   255  		return locale.NewError("err_uploadingredient_publish_api", "API responded with error: {{.V0}}", result.Publish.Error)
   256  	}
   257  
   258  	logging.Debug("Published ingredient ID: %s", result.Publish.IngredientID)
   259  	logging.Debug("Published ingredient version ID: %s", result.Publish.IngredientVersionID)
   260  	logging.Debug("Published ingredient revision: %d", result.Publish.Revision)
   261  
   262  	ingredientID := strfmt.UUID(result.Publish.IngredientID)
   263  	publishedIngredient, err := model.FetchIngredient(&ingredientID, r.auth)
   264  	if err != nil {
   265  		return locale.WrapError(err, "err_uploadingredient_fetch", "Unable to fetch newly published ingredient")
   266  	}
   267  	versionID := strfmt.UUID(result.Publish.IngredientVersionID)
   268  
   269  	latestTime, err := model.FetchLatestRevisionTimeStamp(r.auth)
   270  	if err != nil {
   271  		return locale.WrapError(err, "err_uploadingingredient_fetch_timestamp", "Unable to fetch latest revision timestamp")
   272  	}
   273  
   274  	publishedVersion, err := model.FetchIngredientVersion(&ingredientID, &versionID, true, ptr.To(strfmt.DateTime(latestTime)), r.auth)
   275  	if err != nil {
   276  		return locale.WrapError(err, "err_uploadingingredient_fetch_version", "Unable to fetch newly published ingredient version")
   277  	}
   278  
   279  	ingTime, err := time.Parse(time.RFC3339, publishedVersion.RevisionTimestamp.String())
   280  	if err != nil {
   281  		return errs.Wrap(err, "Ingredient timestamp invalid")
   282  	}
   283  
   284  	// Increment time by 1 second to work around API precision issue where same second comparisons can fall on either side
   285  	ingTime = ingTime.Add(time.Second)
   286  
   287  	r.out.Print(output.Prepare(
   288  		locale.Tl(
   289  			"uploadingredient_success", "",
   290  			publishedIngredient.NormalizedName,
   291  			*publishedIngredient.PrimaryNamespace,
   292  			*publishedVersion.Version,
   293  			strconv.Itoa(int(*publishedVersion.Revision)),
   294  			ingTime.Format(time.RFC3339),
   295  		),
   296  		result.Publish,
   297  	))
   298  
   299  	return nil
   300  }
   301  
   302  var versionRegexp = regexp.MustCompile(`\d+\.\d+(\.\d+)?`)
   303  
   304  func prepareRequestFromParams(r *request.PublishVariables, params *Params, isRevision bool) error {
   305  	if params.Version != "" {
   306  		r.Version = params.Version
   307  	}
   308  	if r.Version == "" {
   309  		r.Version = "0.0.1"
   310  		if matches := versionRegexp.FindAllString(params.Filepath, 1); matches != nil {
   311  			r.Version = matches[0]
   312  		}
   313  	}
   314  
   315  	if params.Description != "" {
   316  		r.Description = params.Description
   317  	}
   318  	if r.Description == "" && !params.Edit {
   319  		r.Description = "Not Provided"
   320  	}
   321  
   322  	if len(params.Authors) != 0 {
   323  		r.Authors = []request.PublishVariableAuthor{}
   324  		for _, author := range params.Authors {
   325  			r.Authors = append(r.Authors, request.PublishVariableAuthor{
   326  				Name:  author.Name,
   327  				Email: author.Email,
   328  			})
   329  		}
   330  	}
   331  
   332  	// User input trumps inheritance from previous ingredient
   333  	if len(params.Depends) != 0 || len(params.DependsRuntime) != 0 || len(params.DependsBuild) != 0 || len(params.DependsTest) != 0 {
   334  		r.Dependencies = []request.PublishVariableDep{}
   335  	}
   336  
   337  	if len(params.Depends) != 0 {
   338  		for _, dep := range params.Depends {
   339  			r.Dependencies = append(
   340  				r.Dependencies,
   341  				request.PublishVariableDep{
   342  					Dependency: request.Dependency{Name: dep.Name, Namespace: dep.Namespace, VersionRequirements: dep.Version},
   343  				},
   344  			)
   345  		}
   346  	}
   347  
   348  	if len(params.DependsRuntime) != 0 {
   349  		for _, dep := range params.DependsRuntime {
   350  			r.Dependencies = append(
   351  				r.Dependencies,
   352  				request.PublishVariableDep{
   353  					Dependency: request.Dependency{Name: dep.Name, Namespace: dep.Namespace, VersionRequirements: dep.Version, Type: request.DependencyTypeRuntime},
   354  				},
   355  			)
   356  		}
   357  	}
   358  
   359  	if len(params.DependsBuild) != 0 {
   360  		for _, dep := range params.DependsBuild {
   361  			r.Dependencies = append(
   362  				r.Dependencies,
   363  				request.PublishVariableDep{
   364  					Dependency: request.Dependency{Name: dep.Name, Namespace: dep.Namespace, VersionRequirements: dep.Version, Type: request.DependencyTypeBuild},
   365  				},
   366  			)
   367  		}
   368  	}
   369  
   370  	if len(params.DependsTest) != 0 {
   371  		for _, dep := range params.DependsTest {
   372  			r.Dependencies = append(
   373  				r.Dependencies,
   374  				request.PublishVariableDep{
   375  					Dependency: request.Dependency{Name: dep.Name, Namespace: dep.Namespace, VersionRequirements: dep.Version, Type: request.DependencyTypeTest},
   376  				},
   377  			)
   378  		}
   379  	}
   380  
   381  	if len(params.Features) != 0 {
   382  		r.Features = []request.PublishVariableFeature{}
   383  		for _, feature := range params.Features {
   384  			r.Features = append(
   385  				r.Features,
   386  				request.PublishVariableFeature{Name: feature.Name, Namespace: feature.Namespace, Version: feature.Version},
   387  			)
   388  		}
   389  	}
   390  
   391  	return nil
   392  }
   393  
   394  // prepareEditRequest inherits meta data from the previous ingredient revision if it exists. This should really happen
   395  // on the API, but at the time of implementation we did this client side as the API side requires significant refactorings
   396  // to enable this behavior.
   397  func prepareEditRequest(ingredient *ParentIngredient, r *request.PublishVariables, isRevision bool, auth *authentication.Auth) error {
   398  	r.Version = ingredient.Version
   399  
   400  	if !isRevision {
   401  		authors, err := model.FetchAuthors(&ingredient.IngredientID, &ingredient.IngredientVersionID, auth)
   402  		if err != nil {
   403  			return locale.WrapError(err, "err_uploadingredient_fetch_authors", "Could not fetch authors for ingredient")
   404  		}
   405  		if len(authors) > 0 {
   406  			r.Authors = []request.PublishVariableAuthor{}
   407  			for _, author := range authors {
   408  				var websites []string
   409  				for _, w := range author.Websites {
   410  					websites = append(websites, w.String())
   411  				}
   412  				r.Authors = append(r.Authors, request.PublishVariableAuthor{
   413  					Name:     ptr.From(author.Name, ""),
   414  					Email:    author.Email.String(),
   415  					Websites: websites,
   416  				})
   417  			}
   418  		}
   419  	}
   420  
   421  	if len(ingredient.Dependencies) > 0 {
   422  		r.Dependencies = []request.PublishVariableDep{}
   423  		for _, dep := range ingredient.Dependencies {
   424  			r.Dependencies = append(
   425  				r.Dependencies,
   426  				request.PublishVariableDep{request.Dependency{
   427  					Name:                ptr.From(dep.Feature, ""),
   428  					Namespace:           ptr.From(dep.Namespace, ""),
   429  					VersionRequirements: model.InventoryRequirementsToString(dep.Requirements),
   430  				}, []request.Dependency{}},
   431  			)
   432  		}
   433  	}
   434  
   435  	return nil
   436  }
   437  
   438  func (r *Runner) OpenInEditor(pr *request.PublishVariables) error {
   439  	// Prepare file for editing
   440  	b, err := pr.MarshalYaml(true)
   441  	if err != nil {
   442  		return locale.WrapError(err, "err_uploadingredient_publish", "Could not marshal publish request")
   443  	}
   444  	b = append([]byte("# Edit the following file and confirm in your terminal when done\n"), b...)
   445  	fn, err := fileutils.WriteTempFile("*.ingredient.yaml", b)
   446  	if err != nil {
   447  		return locale.WrapError(err, "err_uploadingredient_publish", "Could not write publish request to file")
   448  	}
   449  
   450  	r.out.Notice(locale.Tr("uploadingredient_editor_opening", fn))
   451  
   452  	// Open file
   453  	if err := osutils.OpenEditor(fn); err != nil {
   454  		return locale.WrapError(err, "err_uploadingredient_publish", "Could not open publish request file")
   455  	}
   456  
   457  	// Wait for confirmation
   458  	if _, err := r.prompt.Input("", locale.Tl("uploadingredient_edit_confirm", "Press enter when done editing"), ptr.To("")); err != nil {
   459  		return errs.Wrap(err, "Confirmation failed")
   460  	}
   461  
   462  	eb, err := fileutils.ReadFile(fn)
   463  	if err != nil {
   464  		return errs.Wrap(err, "Could not read file")
   465  	}
   466  
   467  	// Write changes to request
   468  	if err := pr.UnmarshalYaml(eb); err != nil {
   469  		return locale.WrapError(err, "err_uploadingredient_publish", "Could not unmarshal publish request")
   470  	}
   471  
   472  	return nil
   473  }