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, ¬Found) { 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 }