detached 96ba59c

Rename templates package to templating

commit
96ba59cb5cf502144d8ec097312e6a248039ce53
author
bnema · · 2023-10-20 12:55 UTC
parent
920de2d
signature
unsigned
28 files · +414 -12
M internal/app/app.go +3 -3
@@ -9,7 +9,7 @@ import (
99 "os"
1010
1111 "github.com/bnema/gordon/internal/db"
12- "github.com/bnema/gordon/internal/templates"
12+ "github.com/bnema/gordon/internal/templating"
1313 "github.com/bnema/gordon/internal/webui"
1414 "github.com/bnema/gordon/pkg/docker"
1515 _ "github.com/joho/godotenv/autoload"
@@ -108,7 +108,7 @@ func NewApp() *App {
108108 config.General.StorageDir = fmt.Sprintf("%s/%s", config.General.BuildDir, config.General.StorageDir)
109109
110110 // Open the strings.yml file containing the strings for the current language
111- file, err := templates.TemplateFS.Open("locstrings.yml")
111+ file, err := templating.TemplateFS.Open("locstrings.yml")
112112 if err != nil {
113113 log.Fatalf("Failed to open strings.yml: %v", err)
114114 }
@@ -123,7 +123,7 @@ func NewApp() *App {
123123
124124 // Initialize App
125125 a := &App{
126- TemplateFS: templates.TemplateFS,
126+ TemplateFS: templating.TemplateFS,
127127 PublicFS: webui.PublicFS,
128128 LocYML: bytes,
129129 DBDir: DBDir,
@@ -9,7 +9,7 @@ import (
9 "os"9 "os"
10 10
11 "github.com/bnema/gordon/internal/db"11 "github.com/bnema/gordon/internal/db"
12- "github.com/bnema/gordon/internal/templates"12+ "github.com/bnema/gordon/internal/templating"
13 "github.com/bnema/gordon/internal/webui"13 "github.com/bnema/gordon/internal/webui"
14 "github.com/bnema/gordon/pkg/docker"14 "github.com/bnema/gordon/pkg/docker"
15 _ "github.com/joho/godotenv/autoload"15 _ "github.com/joho/godotenv/autoload"
@@ -108,7 +108,7 @@ func NewApp() *App {
108 config.General.StorageDir = fmt.Sprintf("%s/%s", config.General.BuildDir, config.General.StorageDir)108 config.General.StorageDir = fmt.Sprintf("%s/%s", config.General.BuildDir, config.General.StorageDir)
109 109
110 // Open the strings.yml file containing the strings for the current language110 // Open the strings.yml file containing the strings for the current language
111- file, err := templates.TemplateFS.Open("locstrings.yml")111+ file, err := templating.TemplateFS.Open("locstrings.yml")
112 if err != nil {112 if err != nil {
113 log.Fatalf("Failed to open strings.yml: %v", err)113 log.Fatalf("Failed to open strings.yml: %v", err)
114 }114 }
@@ -123,7 +123,7 @@ func NewApp() *App {
123 123
124 // Initialize App124 // Initialize App
125 a := &App{125 a := &App{
126- TemplateFS: templates.TemplateFS,126+ TemplateFS: templating.TemplateFS,
127 PublicFS: webui.PublicFS,127 PublicFS: webui.PublicFS,
128 LocYML: bytes,128 LocYML: bytes,
129 DBDir: DBDir,129 DBDir: DBDir,
A internal/commands/update.go +404 -0
@@ -0,0 +1,404 @@
1+// Heavily inspired by Pocketbase's update command
2+// See: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
3+package commands
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "log"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "runtime"
16+ "strconv"
17+ "strings"
18+
19+ "github.com/AlecAivazis/survey/v2"
20+ "github.com/fatih/color"
21+ "github.com/pocketbase/pocketbase/core"
22+ "github.com/pocketbase/pocketbase/tools/archive"
23+ "github.com/spf13/cobra"
24+)
25+
26+// HttpClient is a base HTTP client interface (usually used for test purposes).
27+type HttpClient interface {
28+ Do(req *http.Request) (*http.Response, error)
29+}
30+
31+// Config defines the config options of the ghupdate plugin.
32+//
33+// NB! This plugin is considered experimental and its config options may change in the future.
34+type Config struct {
35+ // Owner specifies the account owner of the repository (default to "pocketbase").
36+ Owner string
37+
38+ // Repo specifies the name of the repository (default to "pocketbase").
39+ Repo string
40+
41+ // ArchiveExecutable specifies the name of the executable file in the release archive
42+ // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
43+ ArchiveExecutable string
44+
45+ // Optional context to use when fetching and downloading the latest release.
46+ Context context.Context
47+
48+ // The HTTP client to use when fetching and downloading the latest release.
49+ // Defaults to `http.DefaultClient`.
50+ HttpClient HttpClient
51+}
52+
53+// MustRegister registers the ghupdate plugin to the provided app instance
54+// and panic if it fails.
55+func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
56+ if err := Register(app, rootCmd, config); err != nil {
57+ panic(err)
58+ }
59+}
60+
61+// Register registers the ghupdate plugin to the provided app instance.
62+func Register(app core.App, rootCmd *cobra.Command, config Config) error {
63+ p := &plugin{
64+ app: app,
65+ currentVersion: rootCmd.Version,
66+ config: config,
67+ }
68+
69+ if p.config.Owner == "" {
70+ p.config.Owner = "pocketbase"
71+ }
72+
73+ if p.config.Repo == "" {
74+ p.config.Repo = "pocketbase"
75+ }
76+
77+ if p.config.ArchiveExecutable == "" {
78+ p.config.ArchiveExecutable = "pocketbase"
79+ }
80+
81+ if p.config.HttpClient == nil {
82+ p.config.HttpClient = http.DefaultClient
83+ }
84+
85+ if p.config.Context == nil {
86+ p.config.Context = context.Background()
87+ }
88+
89+ rootCmd.AddCommand(p.updateCmd())
90+
91+ return nil
92+}
93+
94+type plugin struct {
95+ app core.App
96+ currentVersion string
97+ config Config
98+}
99+
100+func (p *plugin) updateCmd() *cobra.Command {
101+ var withBackup bool
102+
103+ command := &cobra.Command{
104+ Use: "update",
105+ Short: "Automatically updates the current PocketBase executable with the latest available version",
106+ // @todo remove after logs generalization
107+ // prevents printing the error log twice
108+ SilenceErrors: true,
109+ SilenceUsage: true,
110+ RunE: func(command *cobra.Command, args []string) error {
111+ var needConfirm bool
112+ if isMaybeRunningInDocker() {
113+ needConfirm = true
114+ color.Yellow("NB! It seems that you are in a Docker container.")
115+ color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
116+ } else if isMaybeRunningInNixOS() {
117+ needConfirm = true
118+ color.Yellow("NB! It seems that you are in a NixOS.")
119+ color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
120+ }
121+
122+ if needConfirm {
123+ confirm := false
124+ prompt := &survey.Confirm{
125+ Message: "Do you want to proceed with the update?",
126+ }
127+ survey.AskOne(prompt, &confirm)
128+ if !confirm {
129+ fmt.Println("The command has been cancelled.")
130+ return nil
131+ }
132+ }
133+
134+ return p.update(withBackup)
135+ },
136+ }
137+
138+ command.PersistentFlags().BoolVar(
139+ &withBackup,
140+ "backup",
141+ true,
142+ "Creates a pb_data backup at the end of the update process",
143+ )
144+
145+ return command
146+}
147+
148+func (p *plugin) update(withBackup bool) error {
149+ color.Yellow("Fetching release information...")
150+
151+ latest, err := fetchLatestRelease(
152+ p.config.Context,
153+ p.config.HttpClient,
154+ p.config.Owner,
155+ p.config.Repo,
156+ )
157+ if err != nil {
158+ return err
159+ }
160+
161+ if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
162+ color.Green("You already have the latest PocketBase %s.", p.currentVersion)
163+ return nil
164+ }
165+
166+ suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
167+ if suffix == "" {
168+ return errors.New("unsupported platform")
169+ }
170+
171+ asset, err := latest.findAssetBySuffix(suffix)
172+ if err != nil {
173+ return err
174+ }
175+
176+ releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
177+ defer os.RemoveAll(releaseDir)
178+
179+ color.Yellow("Downloading %s...", asset.Name)
180+
181+ // download the release asset
182+ assetZip := filepath.Join(releaseDir, asset.Name)
183+ if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
184+ return err
185+ }
186+
187+ color.Yellow("Extracting %s...", asset.Name)
188+
189+ extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
190+ defer os.RemoveAll(extractDir)
191+
192+ if err := archive.Extract(assetZip, extractDir); err != nil {
193+ return err
194+ }
195+
196+ color.Yellow("Replacing the executable...")
197+
198+ oldExec, err := os.Executable()
199+ if err != nil {
200+ return err
201+ }
202+ renamedOldExec := oldExec + ".old"
203+ defer os.Remove(renamedOldExec)
204+
205+ newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
206+ if _, err := os.Stat(newExec); err != nil {
207+ // try again with an .exe extension
208+ newExec = newExec + ".exe"
209+ if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
210+ return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
211+ }
212+ }
213+
214+ // rename the current executable
215+ if err := os.Rename(oldExec, renamedOldExec); err != nil {
216+ return fmt.Errorf("Failed to rename the current executable: %w", err)
217+ }
218+
219+ tryToRevertExecChanges := func() {
220+ if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
221+ log.Println(revertErr)
222+ }
223+ }
224+
225+ // replace with the extracted binary
226+ if err := os.Rename(newExec, oldExec); err != nil {
227+ tryToRevertExecChanges()
228+ return fmt.Errorf("Failed replacing the executable: %w", err)
229+ }
230+
231+ if withBackup {
232+ color.Yellow("Creating pb_data backup...")
233+
234+ backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
235+ if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
236+ tryToRevertExecChanges()
237+ return err
238+ }
239+ }
240+
241+ color.HiBlack("---")
242+ color.Green("Update completed successfully! You can start the executable as usual.")
243+
244+ return nil
245+}
246+
247+func fetchLatestRelease(
248+ ctx context.Context,
249+ client HttpClient,
250+ owner string,
251+ repo string,
252+) (*release, error) {
253+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
254+
255+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
256+ if err != nil {
257+ return nil, err
258+ }
259+
260+ res, err := client.Do(req)
261+ if err != nil {
262+ return nil, err
263+ }
264+ defer res.Body.Close()
265+
266+ rawBody, err := io.ReadAll(res.Body)
267+ if err != nil {
268+ return nil, err
269+ }
270+
271+ // http.Client doesn't treat non 2xx responses as error
272+ if res.StatusCode >= 400 {
273+ return nil, fmt.Errorf(
274+ "(%d) failed to fetch latest releases:\n%s",
275+ res.StatusCode,
276+ string(rawBody),
277+ )
278+ }
279+
280+ result := &release{}
281+ if err := json.Unmarshal(rawBody, result); err != nil {
282+ return nil, err
283+ }
284+
285+ return result, nil
286+}
287+
288+func downloadFile(
289+ ctx context.Context,
290+ client HttpClient,
291+ url string,
292+ destPath string,
293+) error {
294+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
295+ if err != nil {
296+ return err
297+ }
298+
299+ res, err := client.Do(req)
300+ if err != nil {
301+ return err
302+ }
303+ defer res.Body.Close()
304+
305+ // http.Client doesn't treat non 2xx responses as error
306+ if res.StatusCode >= 400 {
307+ return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
308+ }
309+
310+ // ensure that the dest parent dir(s) exist
311+ if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
312+ return err
313+ }
314+
315+ dest, err := os.Create(destPath)
316+ if err != nil {
317+ return err
318+ }
319+ defer dest.Close()
320+
321+ if _, err := io.Copy(dest, res.Body); err != nil {
322+ return err
323+ }
324+
325+ return nil
326+}
327+
328+func archiveSuffix(goos, goarch string) string {
329+ switch goos {
330+ case "linux":
331+ switch goarch {
332+ case "amd64":
333+ return "_linux_amd64.zip"
334+ case "arm64":
335+ return "_linux_arm64.zip"
336+ case "arm":
337+ return "_linux_armv7.zip"
338+ }
339+ case "darwin":
340+ switch goarch {
341+ case "amd64":
342+ return "_darwin_amd64.zip"
343+ case "arm64":
344+ return "_darwin_arm64.zip"
345+ }
346+ case "windows":
347+ switch goarch {
348+ case "amd64":
349+ return "_windows_amd64.zip"
350+ case "arm64":
351+ return "_windows_arm64.zip"
352+ }
353+ }
354+
355+ return ""
356+}
357+
358+func compareVersions(a, b string) int {
359+ aSplit := strings.Split(a, ".")
360+ aTotal := len(aSplit)
361+
362+ bSplit := strings.Split(b, ".")
363+ bTotal := len(bSplit)
364+
365+ limit := aTotal
366+ if bTotal > aTotal {
367+ limit = bTotal
368+ }
369+
370+ for i := 0; i < limit; i++ {
371+ var x, y int
372+
373+ if i < aTotal {
374+ x, _ = strconv.Atoi(aSplit[i])
375+ }
376+
377+ if i < bTotal {
378+ y, _ = strconv.Atoi(bSplit[i])
379+ }
380+
381+ if x < y {
382+ return 1 // b is newer
383+ }
384+
385+ if x > y {
386+ return -1 // a is newer
387+ }
388+ }
389+
390+ return 0 // equal
391+}
392+
393+// note: not completely reliable as it may not work on all platforms
394+// but should at least provide a warning for the most common use cases
395+func isMaybeRunningInDocker() bool {
396+ _, err := os.Stat("/.dockerenv")
397+ return err == nil
398+}
399+
400+// note: untested
401+func isMaybeRunningInNixOS() bool {
402+ _, err := os.Stat("/etc/NIXOS")
403+ return err == nil
404+}
@@ -0,0 +1,404 @@
1+// Heavily inspired by Pocketbase's update command
2+// See: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
3+package commands
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "log"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "runtime"
16+ "strconv"
17+ "strings"
18+
19+ "github.com/AlecAivazis/survey/v2"
20+ "github.com/fatih/color"
21+ "github.com/pocketbase/pocketbase/core"
22+ "github.com/pocketbase/pocketbase/tools/archive"
23+ "github.com/spf13/cobra"
24+)
25+
26+// HttpClient is a base HTTP client interface (usually used for test purposes).
27+type HttpClient interface {
28+ Do(req *http.Request) (*http.Response, error)
29+}
30+
31+// Config defines the config options of the ghupdate plugin.
32+//
33+// NB! This plugin is considered experimental and its config options may change in the future.
34+type Config struct {
35+ // Owner specifies the account owner of the repository (default to "pocketbase").
36+ Owner string
37+
38+ // Repo specifies the name of the repository (default to "pocketbase").
39+ Repo string
40+
41+ // ArchiveExecutable specifies the name of the executable file in the release archive
42+ // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
43+ ArchiveExecutable string
44+
45+ // Optional context to use when fetching and downloading the latest release.
46+ Context context.Context
47+
48+ // The HTTP client to use when fetching and downloading the latest release.
49+ // Defaults to `http.DefaultClient`.
50+ HttpClient HttpClient
51+}
52+
53+// MustRegister registers the ghupdate plugin to the provided app instance
54+// and panic if it fails.
55+func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
56+ if err := Register(app, rootCmd, config); err != nil {
57+ panic(err)
58+ }
59+}
60+
61+// Register registers the ghupdate plugin to the provided app instance.
62+func Register(app core.App, rootCmd *cobra.Command, config Config) error {
63+ p := &plugin{
64+ app: app,
65+ currentVersion: rootCmd.Version,
66+ config: config,
67+ }
68+
69+ if p.config.Owner == "" {
70+ p.config.Owner = "pocketbase"
71+ }
72+
73+ if p.config.Repo == "" {
74+ p.config.Repo = "pocketbase"
75+ }
76+
77+ if p.config.ArchiveExecutable == "" {
78+ p.config.ArchiveExecutable = "pocketbase"
79+ }
80+
81+ if p.config.HttpClient == nil {
82+ p.config.HttpClient = http.DefaultClient
83+ }
84+
85+ if p.config.Context == nil {
86+ p.config.Context = context.Background()
87+ }
88+
89+ rootCmd.AddCommand(p.updateCmd())
90+
91+ return nil
92+}
93+
94+type plugin struct {
95+ app core.App
96+ currentVersion string
97+ config Config
98+}
99+
100+func (p *plugin) updateCmd() *cobra.Command {
101+ var withBackup bool
102+
103+ command := &cobra.Command{
104+ Use: "update",
105+ Short: "Automatically updates the current PocketBase executable with the latest available version",
106+ // @todo remove after logs generalization
107+ // prevents printing the error log twice
108+ SilenceErrors: true,
109+ SilenceUsage: true,
110+ RunE: func(command *cobra.Command, args []string) error {
111+ var needConfirm bool
112+ if isMaybeRunningInDocker() {
113+ needConfirm = true
114+ color.Yellow("NB! It seems that you are in a Docker container.")
115+ color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
116+ } else if isMaybeRunningInNixOS() {
117+ needConfirm = true
118+ color.Yellow("NB! It seems that you are in a NixOS.")
119+ color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
120+ }
121+
122+ if needConfirm {
123+ confirm := false
124+ prompt := &survey.Confirm{
125+ Message: "Do you want to proceed with the update?",
126+ }
127+ survey.AskOne(prompt, &confirm)
128+ if !confirm {
129+ fmt.Println("The command has been cancelled.")
130+ return nil
131+ }
132+ }
133+
134+ return p.update(withBackup)
135+ },
136+ }
137+
138+ command.PersistentFlags().BoolVar(
139+ &withBackup,
140+ "backup",
141+ true,
142+ "Creates a pb_data backup at the end of the update process",
143+ )
144+
145+ return command
146+}
147+
148+func (p *plugin) update(withBackup bool) error {
149+ color.Yellow("Fetching release information...")
150+
151+ latest, err := fetchLatestRelease(
152+ p.config.Context,
153+ p.config.HttpClient,
154+ p.config.Owner,
155+ p.config.Repo,
156+ )
157+ if err != nil {
158+ return err
159+ }
160+
161+ if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
162+ color.Green("You already have the latest PocketBase %s.", p.currentVersion)
163+ return nil
164+ }
165+
166+ suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
167+ if suffix == "" {
168+ return errors.New("unsupported platform")
169+ }
170+
171+ asset, err := latest.findAssetBySuffix(suffix)
172+ if err != nil {
173+ return err
174+ }
175+
176+ releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
177+ defer os.RemoveAll(releaseDir)
178+
179+ color.Yellow("Downloading %s...", asset.Name)
180+
181+ // download the release asset
182+ assetZip := filepath.Join(releaseDir, asset.Name)
183+ if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
184+ return err
185+ }
186+
187+ color.Yellow("Extracting %s...", asset.Name)
188+
189+ extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
190+ defer os.RemoveAll(extractDir)
191+
192+ if err := archive.Extract(assetZip, extractDir); err != nil {
193+ return err
194+ }
195+
196+ color.Yellow("Replacing the executable...")
197+
198+ oldExec, err := os.Executable()
199+ if err != nil {
200+ return err
201+ }
202+ renamedOldExec := oldExec + ".old"
203+ defer os.Remove(renamedOldExec)
204+
205+ newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
206+ if _, err := os.Stat(newExec); err != nil {
207+ // try again with an .exe extension
208+ newExec = newExec + ".exe"
209+ if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
210+ return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
211+ }
212+ }
213+
214+ // rename the current executable
215+ if err := os.Rename(oldExec, renamedOldExec); err != nil {
216+ return fmt.Errorf("Failed to rename the current executable: %w", err)
217+ }
218+
219+ tryToRevertExecChanges := func() {
220+ if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
221+ log.Println(revertErr)
222+ }
223+ }
224+
225+ // replace with the extracted binary
226+ if err := os.Rename(newExec, oldExec); err != nil {
227+ tryToRevertExecChanges()
228+ return fmt.Errorf("Failed replacing the executable: %w", err)
229+ }
230+
231+ if withBackup {
232+ color.Yellow("Creating pb_data backup...")
233+
234+ backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
235+ if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
236+ tryToRevertExecChanges()
237+ return err
238+ }
239+ }
240+
241+ color.HiBlack("---")
242+ color.Green("Update completed successfully! You can start the executable as usual.")
243+
244+ return nil
245+}
246+
247+func fetchLatestRelease(
248+ ctx context.Context,
249+ client HttpClient,
250+ owner string,
251+ repo string,
252+) (*release, error) {
253+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
254+
255+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
256+ if err != nil {
257+ return nil, err
258+ }
259+
260+ res, err := client.Do(req)
261+ if err != nil {
262+ return nil, err
263+ }
264+ defer res.Body.Close()
265+
266+ rawBody, err := io.ReadAll(res.Body)
267+ if err != nil {
268+ return nil, err
269+ }
270+
271+ // http.Client doesn't treat non 2xx responses as error
272+ if res.StatusCode >= 400 {
273+ return nil, fmt.Errorf(
274+ "(%d) failed to fetch latest releases:\n%s",
275+ res.StatusCode,
276+ string(rawBody),
277+ )
278+ }
279+
280+ result := &release{}
281+ if err := json.Unmarshal(rawBody, result); err != nil {
282+ return nil, err
283+ }
284+
285+ return result, nil
286+}
287+
288+func downloadFile(
289+ ctx context.Context,
290+ client HttpClient,
291+ url string,
292+ destPath string,
293+) error {
294+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
295+ if err != nil {
296+ return err
297+ }
298+
299+ res, err := client.Do(req)
300+ if err != nil {
301+ return err
302+ }
303+ defer res.Body.Close()
304+
305+ // http.Client doesn't treat non 2xx responses as error
306+ if res.StatusCode >= 400 {
307+ return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
308+ }
309+
310+ // ensure that the dest parent dir(s) exist
311+ if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
312+ return err
313+ }
314+
315+ dest, err := os.Create(destPath)
316+ if err != nil {
317+ return err
318+ }
319+ defer dest.Close()
320+
321+ if _, err := io.Copy(dest, res.Body); err != nil {
322+ return err
323+ }
324+
325+ return nil
326+}
327+
328+func archiveSuffix(goos, goarch string) string {
329+ switch goos {
330+ case "linux":
331+ switch goarch {
332+ case "amd64":
333+ return "_linux_amd64.zip"
334+ case "arm64":
335+ return "_linux_arm64.zip"
336+ case "arm":
337+ return "_linux_armv7.zip"
338+ }
339+ case "darwin":
340+ switch goarch {
341+ case "amd64":
342+ return "_darwin_amd64.zip"
343+ case "arm64":
344+ return "_darwin_arm64.zip"
345+ }
346+ case "windows":
347+ switch goarch {
348+ case "amd64":
349+ return "_windows_amd64.zip"
350+ case "arm64":
351+ return "_windows_arm64.zip"
352+ }
353+ }
354+
355+ return ""
356+}
357+
358+func compareVersions(a, b string) int {
359+ aSplit := strings.Split(a, ".")
360+ aTotal := len(aSplit)
361+
362+ bSplit := strings.Split(b, ".")
363+ bTotal := len(bSplit)
364+
365+ limit := aTotal
366+ if bTotal > aTotal {
367+ limit = bTotal
368+ }
369+
370+ for i := 0; i < limit; i++ {
371+ var x, y int
372+
373+ if i < aTotal {
374+ x, _ = strconv.Atoi(aSplit[i])
375+ }
376+
377+ if i < bTotal {
378+ y, _ = strconv.Atoi(bSplit[i])
379+ }
380+
381+ if x < y {
382+ return 1 // b is newer
383+ }
384+
385+ if x > y {
386+ return -1 // a is newer
387+ }
388+ }
389+
390+ return 0 // equal
391+}
392+
393+// note: not completely reliable as it may not work on all platforms
394+// but should at least provide a warning for the most common use cases
395+func isMaybeRunningInDocker() bool {
396+ _, err := os.Stat("/.dockerenv")
397+ return err == nil
398+}
399+
400+// note: untested
401+func isMaybeRunningInNixOS() bool {
402+ _, err := os.Stat("/etc/NIXOS")
403+ return err == nil
404+}
M internal/httpserve/handler/adminroutes.go +1 -1
@@ -5,7 +5,7 @@ import (
55
66 "github.com/bnema/gordon/internal/app"
77 "github.com/bnema/gordon/internal/httpserve/middleware"
8- "github.com/bnema/gordon/internal/templates/render"
8+ "github.com/bnema/gordon/internal/templating/render"
99 "github.com/labstack/echo/v4"
1010 )
1111
@@ -5,7 +5,7 @@ import (
5 5
6 "github.com/bnema/gordon/internal/app"6 "github.com/bnema/gordon/internal/app"
7 "github.com/bnema/gordon/internal/httpserve/middleware"7 "github.com/bnema/gordon/internal/httpserve/middleware"
8- "github.com/bnema/gordon/internal/templates/render"8+ "github.com/bnema/gordon/internal/templating/render"
9 "github.com/labstack/echo/v4"9 "github.com/labstack/echo/v4"
10 )10 )
11 11
M internal/httpserve/handler/containers.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "time"
88
99 "github.com/bnema/gordon/internal/app"
10- "github.com/bnema/gordon/internal/templates/render"
10+ "github.com/bnema/gordon/internal/templating/render"
1111 "github.com/bnema/gordon/pkg/docker"
1212 "github.com/bnema/gordon/pkg/humanize"
1313 "github.com/docker/docker/api/types"
@@ -7,7 +7,7 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/gordon/internal/app"9 "github.com/bnema/gordon/internal/app"
10- "github.com/bnema/gordon/internal/templates/render"10+ "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"11 "github.com/bnema/gordon/pkg/docker"
12 "github.com/bnema/gordon/pkg/humanize"12 "github.com/bnema/gordon/pkg/humanize"
13 "github.com/docker/docker/api/types"13 "github.com/docker/docker/api/types"
M internal/httpserve/handler/createcontainer.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "fmt"
55
66 "github.com/bnema/gordon/internal/app"
7- "github.com/bnema/gordon/internal/templates/render"
7+ "github.com/bnema/gordon/internal/templating/render"
88 "github.com/bnema/gordon/pkg/docker"
99 "github.com/bnema/gordon/pkg/sanitize"
1010 "github.com/labstack/echo/v4"
@@ -4,7 +4,7 @@ import (
4 "fmt"4 "fmt"
5 5
6 "github.com/bnema/gordon/internal/app"6 "github.com/bnema/gordon/internal/app"
7- "github.com/bnema/gordon/internal/templates/render"7+ "github.com/bnema/gordon/internal/templating/render"
8 "github.com/bnema/gordon/pkg/docker"8 "github.com/bnema/gordon/pkg/docker"
9 "github.com/bnema/gordon/pkg/sanitize"9 "github.com/bnema/gordon/pkg/sanitize"
10 "github.com/labstack/echo/v4"10 "github.com/labstack/echo/v4"
M internal/httpserve/handler/editcontainer.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "strings"
77
88 "github.com/bnema/gordon/internal/app"
9- "github.com/bnema/gordon/internal/templates/render"
9+ "github.com/bnema/gordon/internal/templating/render"
1010 "github.com/bnema/gordon/pkg/docker"
1111 "github.com/labstack/echo/v4"
1212 "gopkg.in/yaml.v3"
@@ -6,7 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/gordon/internal/app"8 "github.com/bnema/gordon/internal/app"
9- "github.com/bnema/gordon/internal/templates/render"9+ "github.com/bnema/gordon/internal/templating/render"
10 "github.com/bnema/gordon/pkg/docker"10 "github.com/bnema/gordon/pkg/docker"
11 "github.com/labstack/echo/v4"11 "github.com/labstack/echo/v4"
12 "gopkg.in/yaml.v3"12 "gopkg.in/yaml.v3"
M internal/httpserve/handler/oauth.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 "github.com/bnema/gordon/internal/app"
1212 "github.com/bnema/gordon/internal/db"
1313 "github.com/bnema/gordon/internal/db/queries"
14- "github.com/bnema/gordon/internal/templates/render"
14+ "github.com/bnema/gordon/internal/templating/render"
1515 "github.com/gorilla/sessions"
1616 _ "github.com/joho/godotenv/autoload"
1717 "github.com/labstack/echo-contrib/session"
@@ -11,7 +11,7 @@ import (
11 "github.com/bnema/gordon/internal/app"11 "github.com/bnema/gordon/internal/app"
12 "github.com/bnema/gordon/internal/db"12 "github.com/bnema/gordon/internal/db"
13 "github.com/bnema/gordon/internal/db/queries"13 "github.com/bnema/gordon/internal/db/queries"
14- "github.com/bnema/gordon/internal/templates/render"14+ "github.com/bnema/gordon/internal/templating/render"
15 "github.com/gorilla/sessions"15 "github.com/gorilla/sessions"
16 _ "github.com/joho/godotenv/autoload"16 _ "github.com/joho/godotenv/autoload"
17 "github.com/labstack/echo-contrib/session"17 "github.com/labstack/echo-contrib/session"
M internal/httpserve/handler/uploadcontainerimage.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "net/http"
77
88 "github.com/bnema/gordon/internal/app"
9- "github.com/bnema/gordon/internal/templates/render"
9+ "github.com/bnema/gordon/internal/templating/render"
1010 "github.com/bnema/gordon/pkg/store"
1111 "github.com/labstack/echo/v4"
1212 )
@@ -6,7 +6,7 @@ import (
6 "net/http"6 "net/http"
7 7
8 "github.com/bnema/gordon/internal/app"8 "github.com/bnema/gordon/internal/app"
9- "github.com/bnema/gordon/internal/templates/render"9+ "github.com/bnema/gordon/internal/templating/render"
10 "github.com/bnema/gordon/pkg/store"10 "github.com/bnema/gordon/pkg/store"
11 "github.com/labstack/echo/v4"11 "github.com/labstack/echo/v4"
12 )12 )
R internal/templates/embed.go → internal/templating/embed.go +1 -1 renamed, 90% similar
@@ -1,4 +1,4 @@
1-package templates
1+package templating
22
33 import (
44 "embed"
@@ -1,4 +1,4 @@
1-package templates1+package templating
2 2
3 import (3 import (
4 "embed"4 "embed"
R internal/templates/models/html/admin/dashboard.gohtml → internal/templating/models/html/admin/dashboard.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/admin/index.gohtml → internal/templating/models/html/admin/index.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/admin/install.gohtml → internal/templating/models/html/admin/install.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/admin/manager.gohtml → internal/templating/models/html/admin/manager.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/admin/user.gohtml → internal/templating/models/html/admin/user.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/components.gohtml → internal/templating/models/html/fragments/components.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/containerlist.gohtml → internal/templating/models/html/fragments/containerlist.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/createcontainer.gohtml → internal/templating/models/html/fragments/createcontainer.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/editcontainer.gohtml → internal/templating/models/html/fragments/editcontainer.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/footer.gohtml → internal/templating/models/html/fragments/footer.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/header.gohtml → internal/templating/models/html/fragments/header.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/imagelist.gohtml → internal/templating/models/html/fragments/imagelist.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/menu.gohtml → internal/templating/models/html/fragments/menu.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/fragments/uploadimage.gohtml → internal/templating/models/html/fragments/uploadimage.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/html/login/index.gohtml → internal/templating/models/html/login/index.gohtml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/models/locstrings.yml → internal/templating/models/locstrings.yml +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/render/cmdparams.go → internal/templating/render/cmdparams.go +0 -2 renamed, 99% similar
@@ -10,7 +10,6 @@ import (
1010
1111 // FromInputsToCmdParams transforms the inputs map into a ContainerCommandParams struct
1212 func FromInputsToCmdParams(inputs map[string]string, a *app.App) (docker.ContainerCommandParams, error) {
13-
1413 volumeStr := inputs["volumes"]
1514 volumeSlice := strings.Split(volumeStr, ",")
1615
@@ -86,7 +85,6 @@ type YAMLContainerParams struct {
8685
8786 // FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct
8887 func FromYAMLStructToCmdParams(yamlParams YAMLContainerParams) (docker.ContainerCommandParams, error) {
89-
9088 // with the image name find the image id
9189 imageID, err := docker.GetImageID(yamlParams.Image)
9290 if err != nil {
@@ -10,7 +10,6 @@ import (
10 10
11 // FromInputsToCmdParams transforms the inputs map into a ContainerCommandParams struct11 // FromInputsToCmdParams transforms the inputs map into a ContainerCommandParams struct
12 func FromInputsToCmdParams(inputs map[string]string, a *app.App) (docker.ContainerCommandParams, error) {12 func FromInputsToCmdParams(inputs map[string]string, a *app.App) (docker.ContainerCommandParams, error) {
13-
14 volumeStr := inputs["volumes"]13 volumeStr := inputs["volumes"]
15 volumeSlice := strings.Split(volumeStr, ",")14 volumeSlice := strings.Split(volumeStr, ",")
16 15
@@ -86,7 +85,6 @@ type YAMLContainerParams struct {
86 85
87 // FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct86 // FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct
88 func FromYAMLStructToCmdParams(yamlParams YAMLContainerParams) (docker.ContainerCommandParams, error) {87 func FromYAMLStructToCmdParams(yamlParams YAMLContainerParams) (docker.ContainerCommandParams, error) {
89-
90 // with the image name find the image id88 // with the image name find the image id
91 imageID, err := docker.GetImageID(yamlParams.Image)89 imageID, err := docker.GetImageID(yamlParams.Image)
92 if err != nil {90 if err != nil {
R internal/templates/render/html.go → internal/templating/render/html.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/templates/render/localization.go → internal/templating/render/localization.go +0 -0 renamed, 100% similar

renamed, 100% similar

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close