• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gatewayd-io / gatewayd / 12498383953

26 Dec 2024 01:45AM UTC coverage: 62.994% (+0.4%) from 62.62%
12498383953

Pull #644

github

mostafa
Fix missing log message and span
Pull Request #644: Refactor commands

870 of 1120 new or added lines in 11 files covered. (77.68%)

33 existing lines in 3 files now uncovered.

5231 of 8304 relevant lines covered (62.99%)

17.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.44
/cmd/plugin_install.go
1
package cmd
2

3
import (
4
        "archive/tar"
5
        "archive/zip"
6
        "compress/gzip"
7
        "context"
8
        "errors"
9
        "fmt"
10
        "io"
11
        "net/http"
12
        "os"
13
        "path"
14
        "path/filepath"
15
        "regexp"
16
        "runtime"
17
        "slices"
18
        "strings"
19

20
        "github.com/codingsince1985/checksum"
21
        "github.com/gatewayd-io/gatewayd/config"
22
        gerr "github.com/gatewayd-io/gatewayd/errors"
23
        "github.com/getsentry/sentry-go"
24
        "github.com/google/go-github/v53/github"
25
        "github.com/spf13/cast"
26
        "github.com/spf13/cobra"
27
        "golang.org/x/exp/maps"
28
        yamlv3 "gopkg.in/yaml.v3"
29
)
30

31
type (
32
        Location       string
33
        Source         string
34
        Extension      string
35
        configFileType string
36
)
37

38
const (
39
        NumParts                    int            = 2
40
        LatestVersion               string         = "latest"
41
        FolderPermissions           os.FileMode    = 0o755
42
        FilePermissions             os.FileMode    = 0o644
43
        ExecFilePermissions         os.FileMode    = 0o755
44
        ExecFileMask                os.FileMode    = 0o111
45
        MaxFileSize                 int64          = 1024 * 1024 * 100 // 100 MB
46
        BackupFileExt               string         = ".bak"
47
        DefaultPluginConfigFilename string         = "./gatewayd_plugin.yaml"
48
        GitHubURLPrefix             string         = "github.com/"
49
        GitHubURLRegex              string         = `^github.com\/[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-]+@(?:latest|v(=|>=|<=|=>|=<|>|<|!=|~|~>|\^)?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$` //nolint:lll
50
        LocationArgs                Location       = "args"
51
        LocationConfig              Location       = "config"
52
        SourceUnknown               Source         = "unknown"
53
        SourceFile                  Source         = "file"
54
        SourceGitHub                Source         = "github"
55
        ExtensionZip                Extension      = ".zip"
56
        ExtensionTarGz              Extension      = ".tar.gz"
57
        Global                      configFileType = "global"
58
        Plugins                     configFileType = "plugins"
59
)
60

61
// pluginInstallCmd represents the plugin install command.
62
var pluginInstallCmd = &cobra.Command{
63
        Use:     "install",
64
        Short:   "Install a plugin from a local archive or a GitHub repository",
65
        Example: "  gatewayd plugin install <github.com/gatewayd-io/gatewayd-plugin-cache@latest|/path/to/plugin[.zip|.tar.gz]>", //nolint:lll
66
        Run: func(cmd *cobra.Command, args []string) {
6✔
67
                enableSentry, _ := cmd.Flags().GetBool("sentry")
6✔
68
                pluginConfigFile, _ := cmd.Flags().GetString("plugin-config")
6✔
69
                pluginOutputDir, _ := cmd.Flags().GetString("output-dir")
6✔
70
                pullOnly, _ := cmd.Flags().GetBool("pull-only")
6✔
71
                cleanup, _ := cmd.Flags().GetBool("cleanup")
6✔
72
                noPrompt, _ := cmd.Flags().GetBool("no-prompt")
6✔
73
                update, _ := cmd.Flags().GetBool("update")
6✔
74
                backupConfig, _ := cmd.Flags().GetBool("backup")
6✔
75
                overwriteConfig, _ := cmd.Flags().GetBool("overwrite-config")
6✔
76
                pluginName, _ := cmd.Flags().GetString("name")
6✔
77
                skipPathSlipVerification, _ := cmd.Flags().GetBool("skip-path-slip-verification")
6✔
78

6✔
79
                // Enable Sentry.
6✔
80
                if enableSentry {
12✔
81
                        // Initialize Sentry.
6✔
82
                        err := sentry.Init(sentry.ClientOptions{
6✔
83
                                Dsn:              DSN,
6✔
84
                                TracesSampleRate: config.DefaultTraceSampleRate,
6✔
85
                                AttachStacktrace: config.DefaultAttachStacktrace,
6✔
86
                        })
6✔
87
                        if err != nil {
6✔
88
                                cmd.Println("Sentry initialization failed: ", err)
×
89
                                return
×
90
                        }
×
91

92
                        // Flush buffered events before the program terminates.
93
                        defer sentry.Flush(config.DefaultFlushTimeout)
6✔
94
                        // Recover from panics and report the error to Sentry.
6✔
95
                        defer sentry.Recover()
6✔
96
                }
97

98
                switch detectInstallLocation(args) {
6✔
99
                case LocationArgs:
4✔
100
                        // Install the plugin from the CLI argument.
4✔
101
                        cmd.Println("Installing plugin from CLI argument")
4✔
102
                        installPlugin(
4✔
103
                                cmd,
4✔
104
                                args[0],
4✔
105
                                pluginOutputDir,
4✔
106
                                pullOnly,
4✔
107
                                cleanup,
4✔
108
                                noPrompt,
4✔
109
                                update,
4✔
110
                                backupConfig,
4✔
111
                                overwriteConfig,
4✔
112
                                skipPathSlipVerification,
4✔
113
                                pluginConfigFile,
4✔
114
                                pluginName,
4✔
115
                        )
4✔
116
                case LocationConfig:
2✔
117
                        // Read the gatewayd_plugins.yaml file.
2✔
118
                        pluginsConfig, err := os.ReadFile(pluginConfigFile)
2✔
119
                        if err != nil {
2✔
120
                                cmd.Println(err)
×
121
                                return
×
122
                        }
×
123

124
                        // Get the registered plugins from the plugins configuration file.
125
                        var localPluginsConfig map[string]any
2✔
126
                        if err := yamlv3.Unmarshal(pluginsConfig, &localPluginsConfig); err != nil {
2✔
127
                                cmd.Println("Failed to unmarshal the plugins configuration file: ", err)
×
128
                                return
×
129
                        }
×
130
                        pluginsList := cast.ToSlice(localPluginsConfig["plugins"])
2✔
131

2✔
132
                        // Get the list of plugin download URLs.
2✔
133
                        pluginURLs := map[string]string{}
2✔
134
                        existingPluginURLs := map[string]string{}
2✔
135
                        for _, plugin := range pluginsList {
4✔
136
                                // Get the plugin instance.
2✔
137
                                pluginInstance := cast.ToStringMapString(plugin)
2✔
138

2✔
139
                                // Append the plugin URL to the list of plugin URLs.
2✔
140
                                name := cast.ToString(pluginInstance["name"])
2✔
141
                                url := cast.ToString(pluginInstance["url"])
2✔
142
                                if url == "" {
2✔
143
                                        cmd.Println("Plugin URL or file path not found in the plugins configuration file for", name)
×
144
                                        return
×
145
                                }
×
146

147
                                // Check if duplicate plugin names exist in the plugins configuration file.
148
                                if _, ok := pluginURLs[name]; ok {
2✔
149
                                        cmd.Println("Duplicate plugin name found in the plugins configuration file:", name)
×
150
                                        return
×
151
                                }
×
152

153
                                // Update list of plugin URLs based on
154
                                // whether the plugin is already installed or not.
155
                                localPath := cast.ToString(pluginInstance["localPath"])
2✔
156
                                if _, err := os.Stat(localPath); err == nil {
2✔
157
                                        existingPluginURLs[name] = url
×
158
                                } else {
2✔
159
                                        pluginURLs[name] = url
2✔
160
                                }
2✔
161
                        }
162

163
                        // Check if the plugin is already installed and prompt the user to confirm the update.
164
                        if len(existingPluginURLs) > 0 {
2✔
165
                                pluginNames := strings.Join(maps.Keys[map[string]string](existingPluginURLs), ", ")
×
166
                                cmd.Printf("The following plugins are already installed: %s\n", pluginNames)
×
167

×
168
                                if noPrompt {
×
169
                                        if !update {
×
170
                                                cmd.Println("Use the --update flag to update the plugins")
×
171
                                                cmd.Println("Aborting...")
×
172
                                                return
×
173
                                        }
×
174

175
                                        // Merge the existing plugin URLs with the plugin URLs.
176
                                        for name, url := range existingPluginURLs {
×
177
                                                pluginURLs[name] = url
×
178
                                        }
×
179
                                } else {
×
180
                                        cmd.Print("Do you want to update the existing plugins? [y/N] ")
×
181
                                        var response string
×
182
                                        _, err := fmt.Scanln(&response)
×
183
                                        if err == nil && strings.ToLower(response) == "y" {
×
184
                                                // Set the update flag to true, so that the installPlugin function
×
185
                                                // can update the existing plugins and doesn't ask for user input again.
×
186
                                                update = true
×
187

×
188
                                                // Merge the existing plugin URLs with the plugin URLs.
×
189
                                                for name, url := range existingPluginURLs {
×
190
                                                        pluginURLs[name] = url
×
191
                                                }
×
192
                                        } else {
×
193
                                                cmd.Println("Existing plugins will not be updated")
×
194
                                        }
×
195
                                }
196
                        }
197

198
                        // Validate the plugin URLs.
199
                        if len(args) == 0 && len(pluginURLs) == 0 {
2✔
200
                                if len(existingPluginURLs) > 0 && !update {
×
201
                                        cmd.Println("Use the --update flag to update the plugins")
×
202
                                } else {
×
203
                                        cmd.Println(
×
204
                                                "No plugin URLs or file path found in the plugins configuration file or CLI argument")
×
205
                                        cmd.Println("Aborting...")
×
206
                                }
×
207
                                return
×
208
                        }
209

210
                        // Install all the plugins from the plugins configuration file.
211
                        cmd.Println("Installing plugins from plugins configuration file")
2✔
212
                        for _, pluginURL := range pluginURLs {
4✔
213
                                installPlugin(
2✔
214
                                        cmd,
2✔
215
                                        pluginURL,
2✔
216
                                        pluginOutputDir,
2✔
217
                                        pullOnly,
2✔
218
                                        cleanup,
2✔
219
                                        noPrompt,
2✔
220
                                        update,
2✔
221
                                        backupConfig,
2✔
222
                                        overwriteConfig,
2✔
223
                                        skipPathSlipVerification,
2✔
224
                                        pluginConfigFile,
2✔
225
                                        pluginName,
2✔
226
                                )
2✔
227
                        }
2✔
228
                default:
×
229
                        cmd.Println("Invalid plugin URL or file path")
×
230
                }
231
        },
232
}
233

234
func init() {
1✔
235
        pluginCmd.AddCommand(pluginInstallCmd)
1✔
236

1✔
237
        pluginInstallCmd.Flags().StringP(
1✔
238
                "plugin-config", "p", config.GetDefaultConfigFilePath(config.PluginsConfigFilename),
1✔
239
                "Plugin config file")
1✔
240
        pluginInstallCmd.Flags().StringP(
1✔
241
                "output-dir", "o", "./plugins", "Output directory for the plugin")
1✔
242
        pluginInstallCmd.Flags().Bool(
1✔
243
                "pull-only", false, "Only pull the plugin, don't install it")
1✔
244
        pluginInstallCmd.Flags().Bool(
1✔
245
                "cleanup", true,
1✔
246
                "Delete downloaded and extracted files after installing the plugin (except the plugin binary)")
1✔
247
        pluginInstallCmd.Flags().Bool(
1✔
248
                "no-prompt", true, "Do not prompt for user input")
1✔
249
        pluginInstallCmd.Flags().Bool(
1✔
250
                "update", false, "Update the plugin if it already exists")
1✔
251
        pluginInstallCmd.Flags().Bool(
1✔
252
                "backup", false, "Backup the plugins configuration file before installing the plugin")
1✔
253
        pluginInstallCmd.Flags().StringP(
1✔
254
                "name", "n", "", "Name of the plugin (only for installing from archive files)")
1✔
255
        pluginInstallCmd.Flags().Bool(
1✔
256
                "overwrite-config", true, "Overwrite the existing plugins configuration file (overrides --update, only used for installing from the plugins configuration file)") //nolint:lll
1✔
257
        pluginInstallCmd.Flags().Bool(
1✔
258
                "skip-path-slip-verification", false, "Skip path slip verification when extracting the plugin archive from a TRUSTED source") //nolint:lll
1✔
259
        pluginInstallCmd.Flags().Bool(
1✔
260
                "sentry", true, "Enable Sentry")
1✔
261
}
1✔
262

263
// extractZip extracts the files from a zip archive.
264
func extractZip(
265
        filename, dest string, skipPathSlipVerification bool,
NEW
266
) ([]string, *gerr.GatewayDError) {
×
267
        // Open and extract the zip file.
×
268
        zipRc, err := zip.OpenReader(filename)
×
269
        if err != nil {
×
270
                return nil, gerr.ErrExtractFailed.Wrap(err)
×
271
        }
×
272
        defer zipRc.Close()
×
273

×
274
        // Create the output directory if it doesn't exist.
×
275
        if err := os.MkdirAll(dest, FolderPermissions); err != nil {
×
276
                return nil, gerr.ErrExtractFailed.Wrap(err)
×
277
        }
×
278

279
        // Extract the files.
280
        var filenames []string
×
281
        for _, fileOrDir := range zipRc.File {
×
282
                switch fileInfo := fileOrDir.FileInfo(); {
×
283
                case fileInfo.IsDir():
×
284
                        // Sanitize the path.
×
285
                        dirName := filepath.Clean(fileOrDir.Name)
×
286
                        if !path.IsAbs(dirName) {
×
287
                                // Create the directory.
×
288
                                destPath := path.Join(dest, dirName)
×
289
                                if err := os.MkdirAll(destPath, FolderPermissions); err != nil {
×
290
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
291
                                }
×
292
                        }
293
                case fileInfo.Mode().IsRegular():
×
294
                        // Sanitize the path.
×
295
                        outFilename := filepath.Join(filepath.Clean(dest), filepath.Clean(fileOrDir.Name))
×
296

×
297
                        // Check for ZipSlip.
×
298
                        if !skipPathSlipVerification &&
×
299
                                strings.HasPrefix(outFilename, string(os.PathSeparator)) {
×
300
                                return nil, gerr.ErrExtractFailed.Wrap(
×
301
                                        fmt.Errorf("illegal file path: %s", outFilename))
×
302
                        }
×
303

304
                        // Create the file.
305
                        outFile, err := os.Create(outFilename)
×
306
                        if err != nil {
×
307
                                return nil, gerr.ErrExtractFailed.Wrap(err)
×
308
                        }
×
309
                        defer outFile.Close()
×
310

×
311
                        // Open the file in the zip archive.
×
312
                        fileRc, err := fileOrDir.Open()
×
313
                        if err != nil {
×
314
                                os.Remove(outFilename)
×
315
                                return nil, gerr.ErrExtractFailed.Wrap(err)
×
316
                        }
×
317

318
                        // Copy the file contents.
319
                        if _, err := io.Copy(outFile, io.LimitReader(fileRc, MaxFileSize)); err != nil {
×
320
                                os.Remove(outFilename)
×
321
                                return nil, gerr.ErrExtractFailed.Wrap(err)
×
322
                        }
×
323

324
                        fileMode := fileOrDir.FileInfo().Mode()
×
325
                        // Set the file permissions.
×
326
                        if fileMode.IsRegular() && fileMode&ExecFileMask != 0 {
×
327
                                if err := os.Chmod(outFilename, ExecFilePermissions); err != nil {
×
328
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
329
                                }
×
330
                        } else {
×
331
                                if err := os.Chmod(outFilename, FilePermissions); err != nil {
×
332
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
333
                                }
×
334
                        }
335

336
                        filenames = append(filenames, outFile.Name())
×
337
                default:
×
338
                        return nil, gerr.ErrExtractFailed.Wrap(
×
339
                                fmt.Errorf("unknown file type: %s", fileOrDir.Name))
×
340
                }
341
        }
342

343
        return filenames, nil
×
344
}
345

346
// extractTarGz extracts the files from a tar.gz archive.
347
func extractTarGz(
348
        filename, dest string, skipPathSlipVerification bool,
349
) ([]string, *gerr.GatewayDError) {
3✔
350
        // Open and extract the tar.gz file.
3✔
351
        gzipStream, err := os.Open(filename)
3✔
352
        if err != nil {
3✔
353
                return nil, gerr.ErrExtractFailed.Wrap(err)
×
354
        }
×
355
        defer gzipStream.Close()
3✔
356

3✔
357
        uncompressedStream, err := gzip.NewReader(gzipStream)
3✔
358
        if err != nil {
3✔
359
                return nil, gerr.ErrExtractFailed.Wrap(err)
×
360
        }
×
361
        defer uncompressedStream.Close()
3✔
362

3✔
363
        // Create the output directory if it doesn't exist.
3✔
364
        if err := os.MkdirAll(dest, FolderPermissions); err != nil {
3✔
365
                return nil, gerr.ErrExtractFailed.Wrap(err)
×
366
        }
×
367

368
        tarReader := tar.NewReader(uncompressedStream)
3✔
369
        var filenames []string
3✔
370

3✔
371
        for {
21✔
372
                header, err := tarReader.Next()
18✔
373

18✔
374
                if errors.Is(err, io.EOF) {
21✔
375
                        break
3✔
376
                }
377

378
                if err != nil {
15✔
379
                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
380
                }
×
381

382
                switch header.Typeflag {
15✔
383
                case tar.TypeDir:
×
384
                        // Sanitize the path
×
385
                        cleanPath := filepath.Clean(header.Name)
×
386
                        // Ensure it is not an absolute path
×
387
                        if !path.IsAbs(cleanPath) {
×
388
                                destPath := path.Join(dest, cleanPath)
×
389
                                if err := os.MkdirAll(destPath, FolderPermissions); err != nil {
×
390
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
391
                                }
×
392
                        }
393
                case tar.TypeReg:
15✔
394
                        // Sanitize the path
15✔
395
                        outFilename := path.Join(filepath.Clean(dest), filepath.Clean(header.Name))
15✔
396

15✔
397
                        // Check for TarSlip.
15✔
398
                        if !skipPathSlipVerification &&
15✔
399
                                strings.HasPrefix(outFilename, string(os.PathSeparator)) {
15✔
400
                                return nil, gerr.ErrExtractFailed.Wrap(
×
401
                                        fmt.Errorf("illegal file path: %s", outFilename))
×
402
                        }
×
403

404
                        // Create the file.
405
                        outFile, err := os.Create(outFilename)
15✔
406
                        if err != nil {
15✔
407
                                return nil, gerr.ErrExtractFailed.Wrap(err)
×
408
                        }
×
409
                        defer outFile.Close()
15✔
410

15✔
411
                        if _, err := io.Copy(outFile, io.LimitReader(tarReader, MaxFileSize)); err != nil {
15✔
412
                                os.Remove(outFilename)
×
413
                                return nil, gerr.ErrExtractFailed.Wrap(err)
×
414
                        }
×
415

416
                        fileMode := header.FileInfo().Mode()
15✔
417
                        // Set the file permissions
15✔
418
                        if fileMode.IsRegular() && fileMode&ExecFileMask != 0 {
18✔
419
                                if err := os.Chmod(outFilename, ExecFilePermissions); err != nil {
3✔
420
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
421
                                }
×
422
                        } else {
12✔
423
                                if err := os.Chmod(outFilename, FilePermissions); err != nil {
12✔
424
                                        return nil, gerr.ErrExtractFailed.Wrap(err)
×
425
                                }
×
426
                        }
427

428
                        filenames = append(filenames, outFile.Name())
15✔
429
                default:
×
430
                        return nil, gerr.ErrExtractFailed.Wrap(
×
431
                                fmt.Errorf("unknown file type: %s", header.Name))
×
432
                }
433
        }
434

435
        return filenames, nil
3✔
436
}
437

438
// findAsset finds the release asset that matches the given criteria in the release.
439
func findAsset(release *github.RepositoryRelease, match func(string) bool) (string, string, int64) {
8✔
440
        if release == nil {
8✔
441
                return "", "", 0
×
442
        }
×
443

444
        // Find the matching release.
445
        for _, asset := range release.Assets {
28✔
446
                if match(asset.GetName()) {
28✔
447
                        return asset.GetName(), asset.GetBrowserDownloadURL(), asset.GetID()
8✔
448
                }
8✔
449
        }
450
        return "", "", 0
×
451
}
452

453
// downloadFile downloads the plugin from the given GitHub URL from the release assets.
454
func downloadFile(
455
        client *github.Client,
456
        account, pluginName string,
457
        releaseID int64,
458
        filename, outputDir string,
459
) (string, *gerr.GatewayDError) {
8✔
460
        // Download the plugin.
8✔
461
        readCloser, redirectURL, err := client.Repositories.DownloadReleaseAsset(
8✔
462
                context.Background(), account, pluginName, releaseID, http.DefaultClient)
8✔
463
        if err != nil {
8✔
464
                return "", gerr.ErrDownloadFailed.Wrap(err)
×
465
        }
×
466
        defer readCloser.Close()
8✔
467

8✔
468
        if redirectURL != "" {
8✔
469
                // Download the plugin from the redirect URL.
×
470
                ctx, cancel := context.WithCancel(context.Background())
×
471
                defer cancel()
×
472

×
473
                req, err := http.NewRequestWithContext(ctx, http.MethodGet, redirectURL, nil)
×
474
                if err != nil {
×
475
                        return "", gerr.ErrDownloadFailed.Wrap(err)
×
476
                }
×
477

478
                resp, err := http.DefaultClient.Do(req)
×
479
                if err != nil {
×
480
                        return "", gerr.ErrDownloadFailed.Wrap(err)
×
481
                }
×
482
                defer resp.Body.Close()
×
483

×
484
                readCloser = resp.Body
×
485
        }
486

487
        if readCloser == nil {
8✔
488
                return "", gerr.ErrDownloadFailed.Wrap(
×
489
                        fmt.Errorf("unable to download file: %s", filename))
×
490
        }
×
491

492
        // Create the output file in the current directory and write the downloaded content.
493
        var filePath string
8✔
494
        if outputDir == "" || !filepath.IsAbs(outputDir) {
16✔
495
                cwd, err := os.Getwd()
8✔
496
                if err != nil {
8✔
497
                        return "", gerr.ErrDownloadFailed.Wrap(err)
×
498
                }
×
499
                filePath = path.Join([]string{cwd, filename}...)
8✔
500
        } else {
×
501
                filePath = path.Join([]string{outputDir, filename}...)
×
502
        }
×
503

504
        output, err := os.Create(filePath)
8✔
505
        if err != nil {
8✔
506
                return "", gerr.ErrDownloadFailed.Wrap(err)
×
507
        }
×
508
        defer output.Close()
8✔
509

8✔
510
        // Write the bytes to the file.
8✔
511
        _, err = io.Copy(output, readCloser)
8✔
512
        if err != nil {
8✔
513
                return "", gerr.ErrDownloadFailed.Wrap(err)
×
514
        }
×
515

516
        return filePath, nil
8✔
517
}
518

519
// deleteFiles deletes the files in the toBeDeleted list.
520
func deleteFiles(toBeDeleted []string) {
3✔
521
        for _, filename := range toBeDeleted {
17✔
522
                if err := os.Remove(filename); err != nil {
14✔
523
                        fmt.Println("There was an error deleting the file: ", err) //nolint:forbidigo
×
524
                        return
×
525
                }
×
526
        }
527
}
528

529
// detectInstallLocation detects the installation location based on the number of arguments.
530
func detectInstallLocation(args []string) Location {
6✔
531
        if len(args) == 0 {
8✔
532
                return LocationConfig
2✔
533
        }
2✔
534

535
        return LocationArgs
4✔
536
}
537

538
// detectSource detects the source of the path.
539
func detectSource(path string) Source {
6✔
540
        if _, err := os.Stat(path); err == nil {
8✔
541
                return SourceFile
2✔
542
        }
2✔
543

544
        // Check if the path is a URL.
545
        if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, GitHubURLPrefix) { //nolint:lll
8✔
546
                return SourceGitHub
4✔
547
        }
4✔
548

549
        return SourceUnknown
×
550
}
551

552
// getFileExtension returns the extension of the archive based on the OS.
553
func getFileExtension() Extension {
8✔
554
        if runtime.GOOS == "windows" {
8✔
555
                return ExtensionZip
×
556
        }
×
557

558
        return ExtensionTarGz
8✔
559
}
560

561
// installPlugin installs a plugin from a given URL.
562
func installPlugin(
563
        cmd *cobra.Command,
564
        pluginURL string,
565
        pluginOutputDir string,
566
        pullOnly bool,
567
        cleanup bool,
568
        noPrompt bool,
569
        update bool,
570
        backupConfig bool,
571
        overwriteConfig bool,
572
        skipPathSlipVerification bool,
573
        pluginConfigFile string,
574
        pluginName string,
575
) {
6✔
576
        var (
6✔
577
                // This is a list of files that will be deleted after the plugin is installed.
6✔
578
                toBeDeleted []string
6✔
579

6✔
580
                // Source of the plugin: file or GitHub.
6✔
581
                source = detectSource(pluginURL)
6✔
582

6✔
583
                // The extension of the archive based on the OS: .zip or .tar.gz.
6✔
584
                archiveExt = getFileExtension()
6✔
585

6✔
586
                releaseID         int64
6✔
587
                downloadURL       string
6✔
588
                pluginFilename    string
6✔
589
                checksumsFilename string
6✔
590
                account           string
6✔
591
                err               error
6✔
592
                client            *github.Client
6✔
593
        )
6✔
594

6✔
595
        switch source {
6✔
596
        case SourceFile:
2✔
597
                // Pull the plugin from a local archive.
2✔
598
                pluginFilename = filepath.Clean(pluginURL)
2✔
599
                if _, err := os.Stat(pluginFilename); os.IsNotExist(err) {
2✔
600
                        cmd.Println("The plugin file could not be found")
×
601
                        return
×
602
                }
×
603

604
                if pluginName == "" {
2✔
605
                        cmd.Println("Plugin name not specified")
×
606
                        return
×
607
                }
×
608
        case SourceGitHub:
4✔
609
                // Strip scheme from the plugin URL.
4✔
610
                pluginURL = strings.TrimPrefix(strings.TrimPrefix(pluginURL, "http://"), "https://")
4✔
611

4✔
612
                // Validate the URL.
4✔
613
                splittedURL := strings.Split(pluginURL, "@")
4✔
614
                if len(splittedURL) < NumParts {
4✔
615
                        if pluginFilename == "" {
×
616
                                // If the version is not specified, use the latest version.
×
617
                                pluginURL = fmt.Sprintf("%s@%s", pluginURL, LatestVersion)
×
618
                        }
×
619
                }
620

621
                validGitHubURL := regexp.MustCompile(GitHubURLRegex)
4✔
622
                if !validGitHubURL.MatchString(pluginURL) {
4✔
623
                        cmd.Println(
×
624
                                "Invalid URL. Use the following format: github.com/account/repository@version")
×
625
                        return
×
626
                }
×
627

628
                // Get the plugin version.
629
                pluginVersion := LatestVersion
4✔
630
                splittedURL = strings.Split(pluginURL, "@")
4✔
631
                // If the version is not specified, use the latest version.
4✔
632
                if len(splittedURL) < NumParts {
4✔
633
                        cmd.Println("Version not specified. Using latest version")
×
634
                }
×
635
                if len(splittedURL) >= NumParts {
8✔
636
                        pluginVersion = splittedURL[1]
4✔
637
                }
4✔
638

639
                // Get the plugin account and repository.
640
                accountRepo := strings.Split(strings.TrimPrefix(splittedURL[0], GitHubURLPrefix), "/")
4✔
641
                if len(accountRepo) != NumParts {
4✔
642
                        cmd.Println(
×
643
                                "Invalid URL. Use the following format: github.com/account/repository@version")
×
644
                        return
×
645
                }
×
646
                account = accountRepo[0]
4✔
647
                pluginName = accountRepo[1]
4✔
648
                if account == "" || pluginName == "" {
4✔
649
                        cmd.Println(
×
650
                                "Invalid URL. Use the following format: github.com/account/repository@version")
×
651
                        return
×
652
                }
×
653

654
                // Get the release artifact from GitHub.
655
                client = github.NewClient(nil)
4✔
656
                var release *github.RepositoryRelease
4✔
657

4✔
658
                if pluginVersion == LatestVersion || pluginVersion == "" {
4✔
659
                        // Get the latest release.
×
660
                        release, _, err = client.Repositories.GetLatestRelease(
×
661
                                context.Background(), account, pluginName)
×
662
                } else if strings.HasPrefix(pluginVersion, "v") {
8✔
663
                        // Get a specific release.
4✔
664
                        release, _, err = client.Repositories.GetReleaseByTag(
4✔
665
                                context.Background(), account, pluginName, pluginVersion)
4✔
666
                }
4✔
667

668
                if err != nil {
4✔
669
                        cmd.Println("The plugin could not be found: ", err.Error())
×
670
                        return
×
671
                }
×
672

673
                if release == nil {
4✔
674
                        cmd.Println("The plugin could not be found in the release assets")
×
675
                        return
×
676
                }
×
677

678
                // Create the output directory if it doesn't exist.
679
                if err := os.MkdirAll(pluginOutputDir, FolderPermissions); err != nil {
4✔
680
                        cmd.Println("There was an error creating the output directory: ", err)
×
681
                        return
×
682
                }
×
683

684
                // Find and download the plugin binary from the release assets.
685
                pluginFilename, downloadURL, releaseID = findAsset(release, func(name string) bool {
20✔
686
                        return strings.Contains(name, runtime.GOOS) &&
16✔
687
                                strings.Contains(name, runtime.GOARCH) &&
16✔
688
                                strings.Contains(name, string(archiveExt))
16✔
689
                })
16✔
690
                if downloadURL != "" && releaseID != 0 {
8✔
691
                        cmd.Println("Downloading", downloadURL)
4✔
692
                        filePath, gErr := downloadFile(
4✔
693
                                client, account, pluginName, releaseID, pluginFilename, pluginOutputDir)
4✔
694
                        toBeDeleted = append(toBeDeleted, filePath)
4✔
695
                        if gErr != nil {
4✔
696
                                cmd.Println("Download failed: ", gErr)
×
697
                                if cleanup {
×
698
                                        deleteFiles(toBeDeleted)
×
699
                                }
×
700
                                return
×
701
                        }
702
                        cmd.Println("File downloaded to", filePath)
4✔
703
                        cmd.Println("Download completed successfully")
4✔
704
                } else {
×
705
                        cmd.Println("The plugin file could not be found in the release assets")
×
706
                        return
×
707
                }
×
708

709
                // Find and download the checksums.txt from the release assets.
710
                checksumsFilename, downloadURL, releaseID = findAsset(release, func(name string) bool {
8✔
711
                        return strings.Contains(name, "checksums.txt")
4✔
712
                })
4✔
713
                if checksumsFilename != "" && downloadURL != "" && releaseID != 0 {
8✔
714
                        cmd.Println("Downloading", downloadURL)
4✔
715
                        filePath, gErr := downloadFile(
4✔
716
                                client, account, pluginName, releaseID, checksumsFilename, pluginOutputDir)
4✔
717
                        toBeDeleted = append(toBeDeleted, filePath)
4✔
718
                        if gErr != nil {
4✔
719
                                cmd.Println("Download failed: ", gErr)
×
720
                                if cleanup {
×
721
                                        deleteFiles(toBeDeleted)
×
722
                                }
×
723
                                return
×
724
                        }
725
                        cmd.Println("File downloaded to", filePath)
4✔
726
                        cmd.Println("Download completed successfully")
4✔
727
                } else {
×
728
                        cmd.Println("The checksum file could not be found in the release assets")
×
729
                        return
×
730
                }
×
731

732
                // Read the checksums text file.
733
                checksums, err := os.ReadFile(checksumsFilename)
4✔
734
                if err != nil {
4✔
735
                        cmd.Println("There was an error reading the checksums file: ", err)
×
736
                        return
×
737
                }
×
738

739
                // Get the checksum for the plugin binary.
740
                sum, err := checksum.SHA256sum(pluginFilename)
4✔
741
                if err != nil {
4✔
742
                        cmd.Println("There was an error calculating the checksum: ", err)
×
743
                        return
×
744
                }
×
745

746
                // Verify the checksums.
747
                checksumLines := strings.Split(string(checksums), "\n")
4✔
748
                for _, line := range checksumLines {
8✔
749
                        if strings.Contains(line, pluginFilename) {
8✔
750
                                checksumPart := strings.Split(line, " ")[0]
4✔
751
                                if checksumPart != sum {
4✔
752
                                        cmd.Println("Checksum verification failed")
×
753
                                        return
×
754
                                }
×
755

756
                                cmd.Println("Checksum verification passed")
4✔
757
                                break
4✔
758
                        }
759
                }
760

761
                if pullOnly {
7✔
762
                        cmd.Println("Plugin binary downloaded to", pluginFilename)
3✔
763
                        // Only the checksums file will be deleted if the --pull-only flag is set.
3✔
764
                        if err := os.Remove(checksumsFilename); err != nil {
3✔
765
                                cmd.Println("There was an error deleting the file: ", err)
×
766
                        }
×
767
                        return
3✔
768
                }
769
        case SourceUnknown:
×
NEW
770
                fallthrough
×
771
        default:
×
772
                cmd.Println("Invalid URL or file path")
×
NEW
773
                return
×
774
        }
775

776
        // NOTE: The rest of the code is executed regardless of the source,
777
        // since the plugin binary is already available (or downloaded) at this point.
778

779
        // Create a new "gatewayd_plugins.yaml" file if it doesn't exist.
780
        if _, err := os.Stat(pluginConfigFile); os.IsNotExist(err) {
3✔
781
                generateConfig(cmd, Plugins, pluginConfigFile, false)
×
782
        } else if !backupConfig && !noPrompt {
3✔
783
                // If the config file exists, we should prompt the user to back up
×
784
                // the plugins configuration file.
×
785
                cmd.Print("Do you want to backup the plugins configuration file? [Y/n] ")
×
786
                var backupOption string
×
787
                _, err := fmt.Scanln(&backupOption)
×
788
                if err == nil && strings.ToLower(backupOption) == "n" {
×
789
                        backupConfig = false
×
790
                } else {
×
791
                        backupConfig = true
×
792
                }
×
793
        }
794

795
        // Read the "gatewayd_plugins.yaml" file.
796
        pluginsConfig, err := os.ReadFile(pluginConfigFile)
3✔
797
        if err != nil {
3✔
798
                cmd.Println(err)
×
799
                return
×
800
        }
×
801

802
        // Get the registered plugins from the plugins configuration file.
803
        var localPluginsConfig map[string]any
3✔
804
        if err := yamlv3.Unmarshal(pluginsConfig, &localPluginsConfig); err != nil {
3✔
805
                cmd.Println("Failed to unmarshal the plugins configuration file: ", err)
×
806
                return
×
807
        }
×
808
        pluginsList := cast.ToSlice(localPluginsConfig["plugins"])
3✔
809

3✔
810
        // Check if the plugin is already installed.
3✔
811
        for _, plugin := range pluginsList {
4✔
812
                // User already chosen to update the plugin using the --update CLI flag.
1✔
813
                if update {
2✔
814
                        break
1✔
815
                }
816

817
                pluginInstance := cast.ToStringMap(plugin)
×
818
                if pluginInstance["name"] == pluginName {
×
819
                        // Show a list of options to the user.
×
820
                        cmd.Println("Plugin is already installed.")
×
821
                        if !noPrompt {
×
822
                                cmd.Print("Do you want to update the plugin? [y/N] ")
×
823

×
824
                                var updateOption string
×
825
                                _, err := fmt.Scanln(&updateOption)
×
826
                                if err != nil && strings.ToLower(updateOption) == "y" {
×
827
                                        break
×
828
                                }
829
                        }
830

831
                        cmd.Println("Aborting...")
×
832
                        if cleanup {
×
833
                                deleteFiles(toBeDeleted)
×
834
                        }
×
835
                        return
×
836
                }
837
        }
838

839
        // Check if the user wants to take a backup of the plugins configuration file.
840
        if backupConfig {
6✔
841
                backupFilename := pluginConfigFile + BackupFileExt
3✔
842
                if err := os.WriteFile(backupFilename, pluginsConfig, FilePermissions); err != nil {
3✔
843
                        cmd.Println("There was an error backing up the plugins configuration file: ", err)
×
844
                }
×
845
                cmd.Println("Backup completed successfully")
3✔
846
        }
847

848
        // Extract the archive.
849
        var filenames []string
3✔
850
        var gErr *gerr.GatewayDError
3✔
851
        switch archiveExt {
3✔
852
        case ExtensionZip:
×
NEW
853
                filenames, gErr = extractZip(pluginFilename, pluginOutputDir, skipPathSlipVerification)
×
854
        case ExtensionTarGz:
3✔
855
                filenames, gErr = extractTarGz(pluginFilename, pluginOutputDir, skipPathSlipVerification)
3✔
856
        default:
×
857
                cmd.Println("Invalid archive extension")
×
858
                return
×
859
        }
860

861
        if gErr != nil {
3✔
862
                cmd.Println("There was an error extracting the plugin archive:", gErr)
×
863
                if cleanup {
×
864
                        deleteFiles(toBeDeleted)
×
865
                }
×
866
                return
×
867
        }
868

869
        // Delete all the files except the extracted plugin binary,
870
        // which will be deleted from the list further down.
871
        toBeDeleted = append(toBeDeleted, filenames...)
3✔
872

3✔
873
        // Find the extracted plugin binary.
3✔
874
        localPath := ""
3✔
875
        pluginFileSum := ""
3✔
876
        for _, filename := range filenames {
6✔
877
                if strings.Contains(filename, pluginName) {
6✔
878
                        cmd.Println("Plugin binary extracted to", filename)
3✔
879

3✔
880
                        // Remove the plugin binary from the list of files to be deleted.
3✔
881
                        toBeDeleted = slices.DeleteFunc[[]string, string](toBeDeleted, func(s string) bool {
20✔
882
                                return s == filename
17✔
883
                        })
17✔
884

885
                        localPath = filename
3✔
886
                        // Get the checksum for the extracted plugin binary.
3✔
887
                        // TODO: Should we verify the checksum using the checksum.txt file instead?
3✔
888
                        pluginFileSum, err = checksum.SHA256sum(filename)
3✔
889
                        if err != nil {
3✔
890
                                cmd.Println("There was an error calculating the checksum: ", err)
×
891
                                return
×
892
                        }
×
893
                        break
3✔
894
                }
895
        }
896

897
        var contents string
3✔
898
        if source == SourceGitHub {
4✔
899
                // Get the list of files in the repository.
1✔
900
                var repoContents *github.RepositoryContent
1✔
901
                repoContents, _, _, err = client.Repositories.GetContents(
1✔
902
                        context.Background(), account, pluginName, DefaultPluginConfigFilename, nil)
1✔
903
                if err != nil {
1✔
904
                        cmd.Println(
×
905
                                "There was an error getting the default plugins configuration file: ", err)
×
906
                        return
×
907
                }
×
908
                // Get the contents of the file.
909
                contents, err = repoContents.GetContent()
1✔
910
                if err != nil {
1✔
911
                        cmd.Println(
×
912
                                "There was an error getting the default plugins configuration file: ", err)
×
913
                        return
×
914
                }
×
915
        } else {
2✔
916
                // Get the contents of the file.
2✔
917
                contentsBytes, err := os.ReadFile(
2✔
918
                        filepath.Join(pluginOutputDir, DefaultPluginConfigFilename))
2✔
919
                if err != nil {
2✔
920
                        cmd.Println(
×
921
                                "There was an error getting the default plugins configuration file: ", err)
×
922
                        return
×
923
                }
×
924
                contents = string(contentsBytes)
2✔
925
        }
926

927
        // Get the plugin configuration from the downloaded plugins configuration file.
928
        var downloadedPluginConfig map[string]any
3✔
929
        if err := yamlv3.Unmarshal([]byte(contents), &downloadedPluginConfig); err != nil {
3✔
930
                cmd.Println("Failed to unmarshal the downloaded plugins configuration file: ", err)
×
931
                return
×
932
        }
×
933
        defaultPluginConfig := cast.ToSlice(downloadedPluginConfig["plugins"])
3✔
934

3✔
935
        // Get the plugin configuration.
3✔
936
        pluginConfig := cast.ToStringMap(defaultPluginConfig[0])
3✔
937

3✔
938
        // Update the plugin's local path and checksum.
3✔
939
        pluginConfig["localPath"] = localPath
3✔
940
        pluginConfig["checksum"] = pluginFileSum
3✔
941

3✔
942
        // Add the plugin config to the list of plugin configs.
3✔
943
        added := false
3✔
944
        for idx, plugin := range pluginsList {
4✔
945
                pluginInstance := cast.ToStringMap(plugin)
1✔
946
                if pluginInstance["name"] == pluginName {
2✔
947
                        pluginsList[idx] = pluginConfig
1✔
948
                        added = true
1✔
949
                        break
1✔
950
                }
951
        }
952
        if !added {
5✔
953
                pluginsList = append(pluginsList, pluginConfig)
2✔
954
        }
2✔
955

956
        // Merge the result back into the config map.
957
        localPluginsConfig["plugins"] = pluginsList
3✔
958

3✔
959
        // Marshal the map into YAML.
3✔
960
        updatedPlugins, err := yamlv3.Marshal(localPluginsConfig)
3✔
961
        if err != nil {
3✔
962
                cmd.Println("There was an error marshalling the plugins configuration: ", err)
×
963
                return
×
964
        }
×
965

966
        // Write the YAML to the plugins config file if the --overwrite-config flag is set.
967
        if overwriteConfig {
5✔
968
                if err = os.WriteFile(pluginConfigFile, updatedPlugins, FilePermissions); err != nil {
2✔
969
                        cmd.Println("There was an error writing the plugins configuration file: ", err)
×
970
                        return
×
971
                }
×
972
        }
973

974
        // Delete the downloaded and extracted files, except the plugin binary,
975
        // if the --cleanup flag is set.
976
        if cleanup {
6✔
977
                deleteFiles(toBeDeleted)
3✔
978
        }
3✔
979

980
        // TODO: Add a rollback mechanism.
981
        cmd.Println("Plugin installed successfully")
3✔
982
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc