github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/addons.go (about) 1 package ddevapp 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "os" 8 "path/filepath" 9 "strings" 10 "text/template" 11 12 "github.com/ddev/ddev/pkg/exec" 13 "github.com/ddev/ddev/pkg/fileutil" 14 github2 "github.com/ddev/ddev/pkg/github" 15 "github.com/ddev/ddev/pkg/globalconfig" 16 "github.com/ddev/ddev/pkg/nodeps" 17 "github.com/ddev/ddev/pkg/util" 18 "github.com/google/go-github/v52/github" 19 "gopkg.in/yaml.v3" 20 ) 21 22 const AddonMetadataDir = "addon-metadata" 23 24 // Format of install.yaml 25 type InstallDesc struct { 26 // Name must be unique in a project; it will overwrite any existing add-on with the same name. 27 Name string `yaml:"name"` 28 ProjectFiles []string `yaml:"project_files"` 29 GlobalFiles []string `yaml:"global_files,omitempty"` 30 Dependencies []string `yaml:"dependencies,omitempty"` 31 PreInstallActions []string `yaml:"pre_install_actions,omitempty"` 32 PostInstallActions []string `yaml:"post_install_actions,omitempty"` 33 RemovalActions []string `yaml:"removal_actions,omitempty"` 34 YamlReadFiles map[string]string `yaml:"yaml_read_files"` 35 } 36 37 // format of the add-on manifest file 38 type AddonManifest struct { 39 Name string `yaml:"name"` 40 Repository string `yaml:"repository"` 41 Version string `yaml:"version"` 42 Dependencies []string `yaml:"dependencies,omitempty"` 43 InstallDate string `yaml:"install_date"` 44 ProjectFiles []string `yaml:"project_files"` 45 GlobalFiles []string `yaml:"global_files"` 46 RemovalActions []string `yaml:"removal_actions"` 47 } 48 49 // GetInstalledAddons returns a list of the installed add-ons 50 func GetInstalledAddons(app *DdevApp) []AddonManifest { 51 metadataDir := app.GetConfigPath(AddonMetadataDir) 52 err := os.MkdirAll(metadataDir, 0755) 53 if err != nil { 54 util.Failed("Error creating metadata directory: %v", err) 55 } 56 // Read the contents of the .ddev/addon-metadata directory (directories) 57 dirs, err := os.ReadDir(metadataDir) 58 if err != nil { 59 util.Failed("Error reading metadata directory: %v", err) 60 } 61 manifests := []AddonManifest{} 62 63 // Loop through the directories in the .ddev/addon-metadata directory 64 for _, d := range dirs { 65 // Check if the file is a directory 66 if d.IsDir() { 67 // Read the contents of the manifest file 68 manifestFile := filepath.Join(metadataDir, d.Name(), "manifest.yaml") 69 manifestBytes, err := os.ReadFile(manifestFile) 70 if err != nil { 71 util.Warning("No manifest file found at %s: %v", manifestFile, err) 72 continue 73 } 74 75 // Parse the manifest file 76 var manifest AddonManifest 77 err = yaml.Unmarshal(manifestBytes, &manifest) 78 if err != nil { 79 util.Failed("Unable to parse manifest file: %v", err) 80 } 81 manifests = append(manifests, manifest) 82 } 83 } 84 return manifests 85 } 86 87 // GetInstalledAddonNames returns a list of the names of installed add-ons 88 func GetInstalledAddonNames(app *DdevApp) []string { 89 manifests := GetInstalledAddons(app) 90 names := []string{} 91 for _, manifest := range manifests { 92 names = append(names, manifest.Name) 93 } 94 return names 95 } 96 97 // ProcessAddonAction takes a stanza from yaml exec section and executes it. 98 func ProcessAddonAction(action string, dict map[string]interface{}, bashPath string, verbose bool) error { 99 action = "set -eu -o pipefail\n" + action 100 t, err := template.New("ProcessAddonAction").Funcs(getTemplateFuncMap()).Parse(action) 101 if err != nil { 102 return fmt.Errorf("could not parse action '%s': %v", action, err) 103 } 104 105 var doc bytes.Buffer 106 err = t.Execute(&doc, dict) 107 if err != nil { 108 return fmt.Errorf("could not parse/execute action '%s': %v", action, err) 109 } 110 action = doc.String() 111 112 desc := GetAddonDdevDescription(action) 113 if verbose { 114 action = "set -x; " + action 115 } 116 out, err := exec.RunHostCommand(bashPath, "-c", action) 117 if len(out) > 0 { 118 util.Warning(out) 119 } 120 if err != nil { 121 util.Warning("%c %s", '\U0001F44E', desc) 122 return fmt.Errorf("Unable to run action %v: %v, output=%s", action, err, out) 123 } 124 if desc != "" { 125 util.Success("%c %s", '\U0001F44D', desc) 126 } 127 return nil 128 } 129 130 // GetAddonDdevDescription returns what follows #ddev-description: in any line in action 131 func GetAddonDdevDescription(action string) string { 132 descLines := nodeps.GrepStringInBuffer(action, `[\r\n]+#ddev-description:.*[\r\n]+`) 133 if len(descLines) > 0 { 134 d := strings.Split(descLines[0], ":") 135 if len(d) > 1 { 136 return strings.Trim(d[1], "\r\n\t") 137 } 138 } 139 return "" 140 } 141 142 // ListAvailableAddons lists the add-ons that are listed on github 143 func ListAvailableAddons(officialOnly bool) ([]*github.Repository, error) { 144 client := github2.GetGithubClient(context.Background()) 145 q := "topic:ddev-get fork:true" 146 if officialOnly { 147 q = q + " org:" + globalconfig.DdevGithubOrg 148 } 149 150 opts := &github.SearchOptions{Sort: "updated", Order: "desc", ListOptions: github.ListOptions{PerPage: 200}} 151 var allRepos []*github.Repository 152 for { 153 154 repos, resp, err := client.Search.Repositories(context.Background(), q, opts) 155 if err != nil { 156 msg := fmt.Sprintf("Unable to get list of available services: %v", err) 157 if resp != nil { 158 msg = msg + fmt.Sprintf(" rateinfo=%v", resp.Rate) 159 } 160 return nil, fmt.Errorf(msg) 161 } 162 allRepos = append(allRepos, repos.Repositories...) 163 if resp.NextPage == 0 { 164 break 165 } 166 167 // Set the next page number for the next request 168 opts.ListOptions.Page = resp.NextPage 169 } 170 out := "" 171 for _, r := range allRepos { 172 out = out + fmt.Sprintf("%s: %s\n", r.GetFullName(), r.GetDescription()) 173 } 174 if len(allRepos) == 0 { 175 return nil, fmt.Errorf("No add-ons found") 176 } 177 return allRepos, nil 178 } 179 180 // RemoveAddon removes an addon, taking care to respect #ddev-generated 181 // addonName can be the "Name", or the full "Repository" like ddev/ddev-redis, or 182 // the final par of the repository name like ddev-redis 183 func RemoveAddon(app *DdevApp, addonName string, dict map[string]interface{}, bash string, verbose bool) error { 184 if addonName == "" { 185 return fmt.Errorf("No add-on name specified for removal") 186 } 187 188 manifests, err := GatherAllManifests(app) 189 if err != nil { 190 util.Failed("Unable to gather all manifests: %v", err) 191 } 192 193 var manifestData AddonManifest 194 var ok bool 195 196 if manifestData, ok = manifests[addonName]; !ok { 197 util.Failed("The add-on '%s' does not seem to have a manifest file; please upgrade it.\nUse `ddev get --installed to see installed add-ons.\nIf yours is not there it may have been installed before DDEV v1.22.0.\nUse 'ddev get' to update it.", addonName) 198 } 199 200 // Execute any removal actions 201 for i, action := range manifestData.RemovalActions { 202 err = ProcessAddonAction(action, dict, bash, verbose) 203 desc := GetAddonDdevDescription(action) 204 if err != nil { 205 util.Warning("could not process removal action (%d) '%s': %v", i, desc, err) 206 } 207 } 208 209 // Remove any project files 210 for _, f := range manifestData.ProjectFiles { 211 p := app.GetConfigPath(f) 212 err = fileutil.CheckSignatureOrNoFile(p, nodeps.DdevFileSignature) 213 if err == nil { 214 _ = os.RemoveAll(p) 215 } else { 216 util.Warning("Unwilling to remove '%s' because it does not have #ddev-generated in it: %v; you can manually delete it if it is safe to delete.", p, err) 217 } 218 } 219 220 // Remove any global files 221 globalDotDdev := filepath.Join(globalconfig.GetGlobalDdevDir()) 222 for _, f := range manifestData.GlobalFiles { 223 p := filepath.Join(globalDotDdev, f) 224 err = fileutil.CheckSignatureOrNoFile(p, nodeps.DdevFileSignature) 225 if err == nil { 226 _ = os.RemoveAll(p) 227 } else { 228 util.Warning("Unwilling to remove '%s' because it does not have #ddev-generated in it: %v; you can manually delete it if it is safe to delete.", p, err) 229 } 230 } 231 if len(manifestData.Dependencies) > 0 { 232 for _, dep := range manifestData.Dependencies { 233 if m, ok := manifests[dep]; ok { 234 util.Warning("The add-on you're removing ('%s') declares a dependency on '%s', which is not being removed. You may want to remove it manually if it is no longer needed.", addonName, m.Name) 235 } 236 } 237 } 238 239 err = os.RemoveAll(app.GetConfigPath(filepath.Join(AddonMetadataDir, manifestData.Name))) 240 if err != nil { 241 return fmt.Errorf("Error removing addon metadata directory %s: %v", manifestData.Name, err) 242 } 243 util.Success("Removed add-on %s", addonName) 244 return nil 245 } 246 247 // GatherAllManifests searches for all addon manifests and presents the result 248 // as a map of various names to manifest data 249 func GatherAllManifests(app *DdevApp) (map[string]AddonManifest, error) { 250 metadataDir := app.GetConfigPath(AddonMetadataDir) 251 allManifests := make(map[string]AddonManifest) 252 err := os.MkdirAll(metadataDir, 0755) 253 if err != nil { 254 return nil, err 255 } 256 257 dirs, err := fileutil.ListFilesInDirFullPath(metadataDir) 258 if err != nil { 259 return nil, err 260 } 261 for _, d := range dirs { 262 if !fileutil.IsDirectory(d) { 263 continue 264 } 265 266 mPath := filepath.Join(d, "manifest.yaml") 267 manifestString, err := fileutil.ReadFileIntoString(mPath) 268 if err != nil { 269 return nil, err 270 } 271 var manifestData = &AddonManifest{} 272 err = yaml.Unmarshal([]byte(manifestString), manifestData) 273 if err != nil { 274 return nil, fmt.Errorf("Error unmarshalling manifest data: %v", err) 275 } 276 allManifests[manifestData.Name] = *manifestData 277 allManifests[manifestData.Repository] = *manifestData 278 279 pathParts := strings.Split(manifestData.Repository, "/") 280 if len(pathParts) > 1 { 281 shortRepo := pathParts[len(pathParts)-1] 282 allManifests[shortRepo] = *manifestData 283 } 284 } 285 return allManifests, nil 286 }