Skip to content

Commit fae659e

Browse files
refactor: Refactor ddev get to ddev add-on and into sub-commands, fixes #6146 (#6482)
1 parent 27b1fa0 commit fae659e

File tree

52 files changed

+1024
-844
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1024
-844
lines changed

cmd/ddev/cmd/addon-get.go

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"time"
10+
11+
"github.com/ddev/ddev/pkg/archive"
12+
"github.com/ddev/ddev/pkg/ddevapp"
13+
"github.com/ddev/ddev/pkg/fileutil"
14+
ddevgh "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/output"
18+
"github.com/ddev/ddev/pkg/util"
19+
"github.com/google/go-github/v52/github"
20+
"github.com/otiai10/copy"
21+
"github.com/spf13/cobra"
22+
"gopkg.in/yaml.v3"
23+
)
24+
25+
// AddonGetCmd is the "ddev add-on get" command
26+
var AddonGetCmd = &cobra.Command{
27+
Use: "get <addonOrURL> [project]",
28+
Aliases: []string{"install"},
29+
Args: cobra.MinimumNArgs(1),
30+
Short: "Get/Download a 3rd party add-on (service, provider, etc.)",
31+
Long: `Get/Download a 3rd party add-on (service, provider, etc.). This can be a GitHub repo, in which case the latest release will be used, or it can be a link to a .tar.gz in the correct format (like a particular release's .tar.gz) or it can be a local directory.`,
32+
Example: `ddev add-on get ddev/ddev-redis
33+
ddev add-on get ddev/ddev-redis --version v1.0.4
34+
ddev add-on get https://github.com/ddev/ddev-drupal-solr/archive/refs/tags/v1.2.3.tar.gz
35+
ddev add-on get /path/to/package
36+
ddev add-on get /path/to/tarball.tar.gz
37+
`,
38+
Run: func(cmd *cobra.Command, args []string) {
39+
verbose := false
40+
bash := util.FindBashPath()
41+
requestedVersion := ""
42+
43+
if cmd.Flags().Changed("version") {
44+
requestedVersion = cmd.Flag("version").Value.String()
45+
}
46+
47+
if cmd.Flags().Changed("verbose") {
48+
verbose = true
49+
}
50+
51+
apps, err := getRequestedProjects(args[1:], false)
52+
if err != nil {
53+
util.Failed("Unable to get project(s) %v: %v", args, err)
54+
}
55+
if len(apps) == 0 {
56+
util.Failed("No project(s) found")
57+
}
58+
app := apps[0]
59+
err = os.Chdir(app.AppRoot)
60+
if err != nil {
61+
util.Failed("Unable to change directory to project root %s: %v", app.AppRoot, err)
62+
}
63+
app.DockerEnv()
64+
65+
sourceRepoArg := args[0]
66+
extractedDir := ""
67+
parts := strings.Split(sourceRepoArg, "/")
68+
tarballURL := ""
69+
var cleanup func()
70+
argType := ""
71+
owner := ""
72+
repo := ""
73+
downloadedRelease := ""
74+
switch {
75+
// If the provided sourceRepoArg is a directory, then we will use that as the source
76+
case fileutil.IsDirectory(sourceRepoArg):
77+
// Use the directory as the source
78+
extractedDir = sourceRepoArg
79+
argType = "directory"
80+
81+
// If sourceRepoArg is a tarball on local filesystem, we can use that
82+
case fileutil.FileExists(sourceRepoArg) && (strings.HasSuffix(filepath.Base(sourceRepoArg), "tar.gz") || strings.HasSuffix(filepath.Base(sourceRepoArg), "tar") || strings.HasSuffix(filepath.Base(sourceRepoArg), "tgz")):
83+
// If the provided sourceRepoArg is a file, then we will use that as the source
84+
extractedDir, cleanup, err = archive.ExtractTarballWithCleanup(sourceRepoArg, true)
85+
if err != nil {
86+
util.Failed("Unable to extract %s: %v", sourceRepoArg, err)
87+
}
88+
argType = "tarball"
89+
defer cleanup()
90+
91+
// If the provided sourceRepoArg is a GitHub sourceRepoArg, then we will use that as the source
92+
case len(parts) == 2: // github.com/owner/repo
93+
argType = "github"
94+
owner = parts[0]
95+
repo = parts[1]
96+
ctx := context.Background()
97+
98+
client := ddevgh.GetGithubClient(ctx)
99+
releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, &github.ListOptions{PerPage: 100})
100+
if err != nil {
101+
var rate github.Rate
102+
if resp != nil {
103+
rate = resp.Rate
104+
}
105+
util.Failed("Unable to get releases for %v: %v\nresp.Rate=%v", repo, err, rate)
106+
}
107+
if len(releases) == 0 {
108+
util.Failed("No releases found for %v", repo)
109+
}
110+
releaseItem := 0
111+
releaseFound := false
112+
if requestedVersion != "" {
113+
for i, release := range releases {
114+
if release.GetTagName() == requestedVersion {
115+
releaseItem = i
116+
releaseFound = true
117+
break
118+
}
119+
}
120+
if !releaseFound {
121+
util.Failed("No release found for %v with tag %v", repo, requestedVersion)
122+
}
123+
}
124+
tarballURL = releases[releaseItem].GetTarballURL()
125+
downloadedRelease = releases[releaseItem].GetTagName()
126+
util.Success("Installing %s/%s:%s", owner, repo, downloadedRelease)
127+
fallthrough
128+
129+
// Otherwise, use the provided source as a URL to a tarball
130+
default:
131+
if tarballURL == "" {
132+
tarballURL = sourceRepoArg
133+
argType = "tarball"
134+
}
135+
extractedDir, cleanup, err = archive.DownloadAndExtractTarball(tarballURL, true)
136+
if err != nil {
137+
util.Failed("Unable to download %v: %v", sourceRepoArg, err)
138+
}
139+
defer cleanup()
140+
}
141+
142+
// 20220811: Don't auto-start because it auto-creates the wrong database in some situations, leading to a
143+
// chicken-egg problem in getting database configured. See https://github.com/ddev/ddev-platformsh/issues/24
144+
// Automatically start, as we don't want to be taking actions with Mutagen off, for example.
145+
//if status, _ := app.SiteStatus(); status != ddevapp.SiteRunning {
146+
// err = app.Start()
147+
// if err != nil {
148+
// util.Failed("Failed to start app %s to ddev-get: %v", app.Name, err)
149+
// }
150+
//}
151+
152+
yamlFile := filepath.Join(extractedDir, "install.yaml")
153+
yamlContent, err := fileutil.ReadFileIntoString(yamlFile)
154+
if err != nil {
155+
util.Failed("Unable to read %v: %v", yamlFile, err)
156+
}
157+
var s ddevapp.InstallDesc
158+
err = yaml.Unmarshal([]byte(yamlContent), &s)
159+
if err != nil {
160+
util.Failed("Unable to parse %v: %v", yamlFile, err)
161+
}
162+
163+
yamlMap := make(map[string]interface{})
164+
for name, f := range s.YamlReadFiles {
165+
f := os.ExpandEnv(string(f))
166+
fullpath := filepath.Join(app.GetAppRoot(), f)
167+
168+
yamlMap[name], err = util.YamlFileToMap(fullpath)
169+
if err != nil {
170+
util.Warning("Unable to import yaml file %s: %v", fullpath, err)
171+
}
172+
}
173+
for k, v := range map[string]string{"DdevGlobalConfig": globalconfig.GetGlobalConfigPath(), "DdevProjectConfig": app.GetConfigPath("config.yaml")} {
174+
yamlMap[k], err = util.YamlFileToMap(v)
175+
if err != nil {
176+
util.Warning("Unable to read file %s", v)
177+
}
178+
}
179+
180+
dict, err := util.YamlToDict(yamlMap)
181+
if err != nil {
182+
util.Failed("Unable to YamlToDict: %v", err)
183+
}
184+
// Check to see if any dependencies are missing
185+
if len(s.Dependencies) > 0 {
186+
// Read in full existing registered config
187+
m, err := ddevapp.GatherAllManifests(app)
188+
if err != nil {
189+
util.Failed("Unable to gather manifests: %v", err)
190+
}
191+
for _, dep := range s.Dependencies {
192+
if _, ok := m[dep]; !ok {
193+
util.Failed("The add-on '%s' declares a dependency on '%s'; Please ddev add-on get %s first.", s.Name, dep, dep)
194+
}
195+
}
196+
}
197+
198+
if s.DdevVersionConstraint != "" {
199+
err := ddevapp.CheckDdevVersionConstraint(s.DdevVersionConstraint, fmt.Sprintf("Unable to install the '%s' add-on", s.Name), "")
200+
if err != nil {
201+
util.Failed(err.Error())
202+
}
203+
}
204+
205+
if len(s.PreInstallActions) > 0 {
206+
util.Success("\nExecuting pre-install actions:")
207+
}
208+
for i, action := range s.PreInstallActions {
209+
err = ddevapp.ProcessAddonAction(action, dict, bash, verbose)
210+
if err != nil {
211+
desc := ddevapp.GetAddonDdevDescription(action)
212+
if err != nil {
213+
if !verbose {
214+
util.Failed("Could not process pre-install action (%d) '%s'. For more detail use ddev add-on get --verbose", i, desc)
215+
} else {
216+
util.Failed("Could not process pre-install action (%d) '%s'; error=%v\n action=%s", i, desc, err, action)
217+
}
218+
}
219+
}
220+
}
221+
222+
if len(s.ProjectFiles) > 0 {
223+
util.Success("\nInstalling project-level components:")
224+
}
225+
226+
projectFiles, err := fileutil.ExpandFilesAndDirectories(extractedDir, s.ProjectFiles)
227+
if err != nil {
228+
util.Failed("Unable to expand files and directories: %v", err)
229+
}
230+
for _, file := range projectFiles {
231+
src := filepath.Join(extractedDir, file)
232+
dest := app.GetConfigPath(file)
233+
if err = fileutil.CheckSignatureOrNoFile(dest, nodeps.DdevFileSignature); err == nil {
234+
err = copy.Copy(src, dest)
235+
if err != nil {
236+
util.Failed("Unable to copy %v to %v: %v", src, dest, err)
237+
}
238+
util.Success("%c %s", '\U0001F44D', file)
239+
} else {
240+
util.Warning("NOT overwriting %s. The #ddev-generated signature was not found in the file, so it will not be overwritten. You can remove the file and use ddev add-on get again if you want it to be replaced: %v", dest, err)
241+
}
242+
}
243+
globalDotDdev := filepath.Join(globalconfig.GetGlobalDdevDir())
244+
if len(s.GlobalFiles) > 0 {
245+
util.Success("\nInstalling global components:")
246+
}
247+
248+
globalFiles, err := fileutil.ExpandFilesAndDirectories(extractedDir, s.GlobalFiles)
249+
if err != nil {
250+
util.Failed("Unable to expand global files and directories: %v", err)
251+
}
252+
for _, file := range globalFiles {
253+
src := filepath.Join(extractedDir, file)
254+
dest := filepath.Join(globalDotDdev, file)
255+
256+
// If the file existed and had #ddev-generated OR if it did not exist, copy it in.
257+
if err = fileutil.CheckSignatureOrNoFile(dest, nodeps.DdevFileSignature); err == nil {
258+
err = copy.Copy(src, dest)
259+
if err != nil {
260+
util.Failed("Unable to copy %v to %v: %v", src, dest, err)
261+
}
262+
util.Success("%c %s", '\U0001F44D', file)
263+
} else {
264+
util.Warning("NOT overwriting %s. The #ddev-generated signature was not found in the file, so it will not be overwritten. You can remove the file and use ddev add-on get again if you want it to be replaced: %v", dest, err)
265+
}
266+
}
267+
origDir, _ := os.Getwd()
268+
269+
defer func() {
270+
err = os.Chdir(origDir)
271+
if err != nil {
272+
util.Failed("Unable to chdir to %v: %v", origDir, err)
273+
}
274+
}()
275+
276+
err = os.Chdir(app.GetConfigPath(""))
277+
if err != nil {
278+
util.Failed("Unable to chdir to %v: %v", app.GetConfigPath(""), err)
279+
}
280+
281+
if len(s.PostInstallActions) > 0 {
282+
util.Success("\nExecuting post-install actions:")
283+
}
284+
for i, action := range s.PostInstallActions {
285+
err = ddevapp.ProcessAddonAction(action, dict, bash, verbose)
286+
desc := ddevapp.GetAddonDdevDescription(action)
287+
if err != nil {
288+
if !verbose {
289+
util.Failed("Could not process post-install action (%d) '%s'", i, desc)
290+
} else {
291+
util.Failed("Could not process post-install action (%d) '%s': %v", i, desc, err)
292+
}
293+
}
294+
}
295+
296+
repository := ""
297+
switch argType {
298+
case "github":
299+
repository = fmt.Sprintf("%s/%s", owner, repo)
300+
case "directory":
301+
fallthrough
302+
case "tarball":
303+
repository = sourceRepoArg
304+
}
305+
manifest, err := createManifestFile(app, s.Name, repository, downloadedRelease, s)
306+
if err != nil {
307+
util.Failed("Unable to create manifest file: %v", err)
308+
}
309+
310+
util.Success("\nInstalled DDEV add-on %s, use `ddev restart` to enable.", sourceRepoArg)
311+
if argType == "github" {
312+
util.Success("Please read instructions for this add-on at the source repo at\nhttps://github.com/%v/%v\nPlease file issues and create pull requests there to improve it.", owner, repo)
313+
}
314+
output.UserOut.WithField("raw", manifest).Printf("Installed %s:%s from %s", manifest.Name, manifest.Version, manifest.Repository)
315+
},
316+
}
317+
318+
// createManifestFile creates a manifest file for the addon
319+
func createManifestFile(app *ddevapp.DdevApp, addonName string, repository string, downloadedRelease string, desc ddevapp.InstallDesc) (ddevapp.AddonManifest, error) {
320+
// Create a manifest file
321+
manifest := ddevapp.AddonManifest{
322+
Name: addonName,
323+
Repository: repository,
324+
Version: downloadedRelease,
325+
Dependencies: desc.Dependencies,
326+
InstallDate: time.Now().Format(time.RFC3339),
327+
ProjectFiles: desc.ProjectFiles,
328+
GlobalFiles: desc.GlobalFiles,
329+
RemovalActions: desc.RemovalActions,
330+
}
331+
manifestFile := app.GetConfigPath(fmt.Sprintf("%s/%s/manifest.yaml", ddevapp.AddonMetadataDir, addonName))
332+
if fileutil.FileExists(manifestFile) {
333+
util.Warning("Overwriting existing manifest file %s", manifestFile)
334+
}
335+
manifestData, err := yaml.Marshal(manifest)
336+
if err != nil {
337+
util.Failed("Error marshaling manifest data: %v", err)
338+
}
339+
err = os.MkdirAll(filepath.Dir(manifestFile), 0755)
340+
if err != nil {
341+
util.Failed("Error creating manifest directory: %v", err)
342+
}
343+
if err = fileutil.TemplateStringToFile(string(manifestData), nil, manifestFile); err != nil {
344+
util.Failed("Error writing manifest file: %v", err)
345+
}
346+
return manifest, nil
347+
}
348+
349+
func init() {
350+
AddonGetCmd.Flags().String("version", "", `Specify a particular version of add-on to install`)
351+
AddonGetCmd.Flags().BoolP("verbose", "v", false, "Extended/verbose output")
352+
353+
AddonCmd.AddCommand(AddonGetCmd)
354+
}

0 commit comments

Comments
 (0)