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  }