detached eae8025

fix: address validated pre-release review findings

commit
eae8025ec18dfb67777f47b523ddbb3111f9f7e9
author
brice · · 2026-04-08 16:17 UTC
parent
0986ec3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +149 -11
M internal/adapters/in/cli/backup.go +2 -1
@@ -7,9 +7,10 @@ import (
77 "text/tabwriter"
88 "time"
99
10+ "github.com/spf13/cobra"
11+
1012 "github.com/bnema/gordon/internal/adapters/dto"
1113 "github.com/bnema/gordon/pkg/bytesize"
12- "github.com/spf13/cobra"
1314 )
1415
1516 // newBackupCmd creates the backup command group.
@@ -7,9 +7,10 @@ import (
7 "text/tabwriter"7 "text/tabwriter"
8 "time"8 "time"
9 9
10+ "github.com/spf13/cobra"
11+
10 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
11 "github.com/bnema/gordon/pkg/bytesize"13 "github.com/bnema/gordon/pkg/bytesize"
12- "github.com/spf13/cobra"
13 )14 )
14 15
15 // newBackupCmd creates the backup command group.16 // newBackupCmd creates the backup command group.
M internal/adapters/in/cli/remote/config.go +3 -1
@@ -110,7 +110,9 @@ func migrateClientConfig(config *ClientConfig) {
110110 config.Active = "default"
111111 }
112112
113- _ = SaveRemotes("", config)
113+ if err := SaveRemotes("", config); err != nil {
114+ fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err)
115+ }
114116 }
115117
116118 func defaultGordonTomlPath() string {
@@ -110,7 +110,9 @@ func migrateClientConfig(config *ClientConfig) {
110 config.Active = "default"110 config.Active = "default"
111 }111 }
112 112
113- _ = SaveRemotes("", config)113+ if err := SaveRemotes("", config); err != nil {
114+ fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err)
115+ }
114 }116 }
115 117
116 func defaultGordonTomlPath() string {118 func defaultGordonTomlPath() string {
M internal/adapters/in/http/middleware/accesslog.go +42 -1
@@ -3,6 +3,7 @@ package middleware
33 import (
44 "net"
55 "net/http"
6+ "net/url"
67 "strings"
78 "time"
89
@@ -60,7 +61,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
6061 Method: r.Method,
6162 Host: r.Host,
6263 Path: r.URL.Path,
63- Query: r.URL.RawQuery,
64+ Query: sanitizeLoggedQuery(r.URL.RawQuery),
6465 Status: rw.StatusCode(),
6566 BytesSent: rw.BytesWritten(),
6667 DurationMS: durationMS,
@@ -85,3 +86,43 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
8586 })
8687 }
8788 }
89+
90+func sanitizeLoggedQuery(rawQuery string) string {
91+ if rawQuery == "" {
92+ return ""
93+ }
94+
95+ parts := strings.Split(rawQuery, "&")
96+ for i, part := range parts {
97+ if part == "" {
98+ continue
99+ }
100+
101+ key, _, found := strings.Cut(part, "=")
102+ decodedKey, err := url.QueryUnescape(key)
103+ if err != nil {
104+ decodedKey = key
105+ }
106+
107+ if !isSensitiveQueryKey(decodedKey) {
108+ continue
109+ }
110+
111+ if found {
112+ parts[i] = key + "=[REDACTED]"
113+ } else {
114+ parts[i] = key
115+ }
116+ }
117+
118+ return strings.Join(parts, "&")
119+}
120+
121+func isSensitiveQueryKey(key string) bool {
122+ switch strings.ToLower(key) {
123+ case "access_token", "auth", "code", "key", "password", "refresh_token", "reset", "secret", "sig", "signature", "token":
124+ return true
125+ default:
126+ return false
127+ }
128+}
@@ -3,6 +3,7 @@ package middleware
3 import (3 import (
4 "net"4 "net"
5 "net/http"5 "net/http"
6+ "net/url"
6 "strings"7 "strings"
7 "time"8 "time"
8 9
@@ -60,7 +61,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
60 Method: r.Method,61 Method: r.Method,
61 Host: r.Host,62 Host: r.Host,
62 Path: r.URL.Path,63 Path: r.URL.Path,
63- Query: r.URL.RawQuery,64+ Query: sanitizeLoggedQuery(r.URL.RawQuery),
64 Status: rw.StatusCode(),65 Status: rw.StatusCode(),
65 BytesSent: rw.BytesWritten(),66 BytesSent: rw.BytesWritten(),
66 DurationMS: durationMS,67 DurationMS: durationMS,
@@ -85,3 +86,43 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
85 })86 })
86 }87 }
87 }88 }
89+
90+func sanitizeLoggedQuery(rawQuery string) string {
91+ if rawQuery == "" {
92+ return ""
93+ }
94+
95+ parts := strings.Split(rawQuery, "&")
96+ for i, part := range parts {
97+ if part == "" {
98+ continue
99+ }
100+
101+ key, _, found := strings.Cut(part, "=")
102+ decodedKey, err := url.QueryUnescape(key)
103+ if err != nil {
104+ decodedKey = key
105+ }
106+
107+ if !isSensitiveQueryKey(decodedKey) {
108+ continue
109+ }
110+
111+ if found {
112+ parts[i] = key + "=[REDACTED]"
113+ } else {
114+ parts[i] = key
115+ }
116+ }
117+
118+ return strings.Join(parts, "&")
119+}
120+
121+func isSensitiveQueryKey(key string) bool {
122+ switch strings.ToLower(key) {
123+ case "access_token", "auth", "code", "key", "password", "refresh_token", "reset", "secret", "sig", "signature", "token":
124+ return true
125+ default:
126+ return false
127+ }
128+}
M internal/adapters/in/http/middleware/accesslog_test.go +17 -0
@@ -58,6 +58,23 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) {
5858 assert.Equal(t, "192.168.1.100", e.ClientIP)
5959 }
6060
61+func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
62+ mock := &mockAccessLogWriter{}
63+ log := testLogger()
64+
65+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ w.WriteHeader(http.StatusOK)
67+ }))
68+
69+ req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=secret-code&state=ok&token=secret-token", nil)
70+ rw := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rw, req)
73+
74+ require.Len(t, mock.entries, 1)
75+ assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]", mock.entries[0].Query)
76+}
77+
6178 func TestAccessLogger_RequestIDReused(t *testing.T) {
6279 mock := &mockAccessLogWriter{}
6380 log := testLogger()
@@ -58,6 +58,23 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) {
58 assert.Equal(t, "192.168.1.100", e.ClientIP)58 assert.Equal(t, "192.168.1.100", e.ClientIP)
59 }59 }
60 60
61+func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
62+ mock := &mockAccessLogWriter{}
63+ log := testLogger()
64+
65+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ w.WriteHeader(http.StatusOK)
67+ }))
68+
69+ req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=secret-code&state=ok&token=secret-token", nil)
70+ rw := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rw, req)
73+
74+ require.Len(t, mock.entries, 1)
75+ assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]", mock.entries[0].Query)
76+}
77+
61 func TestAccessLogger_RequestIDReused(t *testing.T) {78 func TestAccessLogger_RequestIDReused(t *testing.T) {
62 mock := &mockAccessLogWriter{}79 mock := &mockAccessLogWriter{}
63 log := testLogger()80 log := testLogger()
M internal/adapters/in/http/middleware/logging.go +1 -1
@@ -130,7 +130,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
130130 Str("request_id", requestID).
131131 Str(zerowrap.FieldMethod, r.Method).
132132 Str(zerowrap.FieldPath, r.URL.Path).
133- Str("query", r.URL.RawQuery).
133+ Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
134134 Str(zerowrap.FieldHost, r.Host).
135135 Str("user_agent", r.UserAgent()).
136136 Str("referer", r.Referer()).
@@ -130,7 +130,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
130 Str("request_id", requestID).130 Str("request_id", requestID).
131 Str(zerowrap.FieldMethod, r.Method).131 Str(zerowrap.FieldMethod, r.Method).
132 Str(zerowrap.FieldPath, r.URL.Path).132 Str(zerowrap.FieldPath, r.URL.Path).
133- Str("query", r.URL.RawQuery).133+ Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
134 Str(zerowrap.FieldHost, r.Host).134 Str(zerowrap.FieldHost, r.Host).
135 Str("user_agent", r.UserAgent()).135 Str("user_agent", r.UserAgent()).
136 Str("referer", r.Referer()).136 Str("referer", r.Referer()).
M internal/adapters/out/accesslog/writer_test.go +6 -3
@@ -72,7 +72,8 @@ func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
7272 func TestWriter_Stdout_JSON(t *testing.T) {
7373 // Replace stdout to capture output.
7474 orig := os.Stdout
75- r, w, _ := os.Pipe()
75+ r, w, err := os.Pipe()
76+ require.NoError(t, err)
7677 os.Stdout = w
7778
7879 writer, err := New(Config{Format: "json", Output: "stdout"})
@@ -97,7 +98,8 @@ func TestWriter_Stdout_JSON(t *testing.T) {
9798
9899 func TestWriter_Stdout_CLF(t *testing.T) {
99100 orig := os.Stdout
100- r, w, _ := os.Pipe()
101+ r, w, err := os.Pipe()
102+ require.NoError(t, err)
101103 os.Stdout = w
102104
103105 writer, err := New(Config{Format: "clf", Output: "stdout"})
@@ -119,7 +121,8 @@ func TestWriter_Stdout_CLF(t *testing.T) {
119121
120122 func TestWriter_Stdout_Combined(t *testing.T) {
121123 orig := os.Stdout
122- r, w, _ := os.Pipe()
124+ r, w, err := os.Pipe()
125+ require.NoError(t, err)
123126 os.Stdout = w
124127
125128 writer, err := New(Config{Format: "combined", Output: "stdout"})
@@ -72,7 +72,8 @@ func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
72 func TestWriter_Stdout_JSON(t *testing.T) {72 func TestWriter_Stdout_JSON(t *testing.T) {
73 // Replace stdout to capture output.73 // Replace stdout to capture output.
74 orig := os.Stdout74 orig := os.Stdout
75- r, w, _ := os.Pipe()75+ r, w, err := os.Pipe()
76+ require.NoError(t, err)
76 os.Stdout = w77 os.Stdout = w
77 78
78 writer, err := New(Config{Format: "json", Output: "stdout"})79 writer, err := New(Config{Format: "json", Output: "stdout"})
@@ -97,7 +98,8 @@ func TestWriter_Stdout_JSON(t *testing.T) {
97 98
98 func TestWriter_Stdout_CLF(t *testing.T) {99 func TestWriter_Stdout_CLF(t *testing.T) {
99 orig := os.Stdout100 orig := os.Stdout
100- r, w, _ := os.Pipe()101+ r, w, err := os.Pipe()
102+ require.NoError(t, err)
101 os.Stdout = w103 os.Stdout = w
102 104
103 writer, err := New(Config{Format: "clf", Output: "stdout"})105 writer, err := New(Config{Format: "clf", Output: "stdout"})
@@ -119,7 +121,8 @@ func TestWriter_Stdout_CLF(t *testing.T) {
119 121
120 func TestWriter_Stdout_Combined(t *testing.T) {122 func TestWriter_Stdout_Combined(t *testing.T) {
121 orig := os.Stdout123 orig := os.Stdout
122- r, w, _ := os.Pipe()124+ r, w, err := os.Pipe()
125+ require.NoError(t, err)
123 os.Stdout = w126 os.Stdout = w
124 127
125 writer, err := New(Config{Format: "combined", Output: "stdout"})128 writer, err := New(Config{Format: "combined", Output: "stdout"})
M internal/adapters/out/pki/mobileconfig.go +36 -2
@@ -4,6 +4,8 @@ import (
44 "encoding/base64"
55 "fmt"
66 "html"
7+ "strings"
8+ "unicode"
79
810 "github.com/google/uuid"
911 )
@@ -16,6 +18,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
1618 certUUID := uuid.New().String()
1719
1820 safeCN := html.EscapeString(rootCN)
21+ identifierCN := payloadIdentifierComponent(rootCN)
1922 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
2023 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2124 <plist version="1.0">
@@ -53,8 +56,39 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
5356 <string>%s</string>
5457 <key>PayloadVersion</key>
5558 <integer>1</integer>
56-</dict>
57-</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)
59+ </dict>
60+</plist>`, b64Cert, safeCN, identifierCN, certUUID, profileUUID)
5861
5962 return []byte(profile)
6063 }
64+
65+func payloadIdentifierComponent(rootCN string) string {
66+ var b strings.Builder
67+ lastHyphen := false
68+
69+ for _, r := range rootCN {
70+ r = unicode.ToLower(r)
71+ switch {
72+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.':
73+ b.WriteRune(r)
74+ lastHyphen = false
75+ case r == '-':
76+ if !lastHyphen {
77+ b.WriteRune(r)
78+ lastHyphen = true
79+ }
80+ default:
81+ if !lastHyphen {
82+ b.WriteRune('-')
83+ lastHyphen = true
84+ }
85+ }
86+ }
87+
88+ identifier := strings.Trim(b.String(), "-.")
89+ if identifier == "" {
90+ return "gordon-ca"
91+ }
92+
93+ return identifier
94+}
@@ -4,6 +4,8 @@ import (
4 "encoding/base64"4 "encoding/base64"
5 "fmt"5 "fmt"
6 "html"6 "html"
7+ "strings"
8+ "unicode"
7 9
8 "github.com/google/uuid"10 "github.com/google/uuid"
9 )11 )
@@ -16,6 +18,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
16 certUUID := uuid.New().String()18 certUUID := uuid.New().String()
17 19
18 safeCN := html.EscapeString(rootCN)20 safeCN := html.EscapeString(rootCN)
21+ identifierCN := payloadIdentifierComponent(rootCN)
19 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>22 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
20 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">23 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21 <plist version="1.0">24 <plist version="1.0">
@@ -53,8 +56,39 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
53 <string>%s</string>56 <string>%s</string>
54 <key>PayloadVersion</key>57 <key>PayloadVersion</key>
55 <integer>1</integer>58 <integer>1</integer>
56-</dict>59+ </dict>
57-</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)60+</plist>`, b64Cert, safeCN, identifierCN, certUUID, profileUUID)
58 61
59 return []byte(profile)62 return []byte(profile)
60 }63 }
64+
65+func payloadIdentifierComponent(rootCN string) string {
66+ var b strings.Builder
67+ lastHyphen := false
68+
69+ for _, r := range rootCN {
70+ r = unicode.ToLower(r)
71+ switch {
72+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.':
73+ b.WriteRune(r)
74+ lastHyphen = false
75+ case r == '-':
76+ if !lastHyphen {
77+ b.WriteRune(r)
78+ lastHyphen = true
79+ }
80+ default:
81+ if !lastHyphen {
82+ b.WriteRune('-')
83+ lastHyphen = true
84+ }
85+ }
86+ }
87+
88+ identifier := strings.Trim(b.String(), "-.")
89+ if identifier == "" {
90+ return "gordon-ca"
91+ }
92+
93+ return identifier
94+}
M internal/adapters/out/pki/mobileconfig_test.go +9 -0
@@ -21,3 +21,12 @@ func TestGenerateMobileconfig(t *testing.T) {
2121 assert.Contains(t, string(mc), "<key>PayloadType</key>")
2222 assert.Contains(t, string(mc), "com.apple.security.root")
2323 }
24+
25+func TestGenerateMobileconfig_UsesIdentifierSafePayloadIdentifier(t *testing.T) {
26+ mc := pki.GenerateMobileconfig([]byte("cert"), `Gordon Internal CA <prod>`)
27+
28+ assert.Contains(t, string(mc), "<string>Gordon Internal CA &lt;prod&gt;</string>")
29+ assert.Contains(t, string(mc), "<string>dev.gordon.ca.gordon-internal-ca-prod</string>")
30+ assert.NotContains(t, string(mc), "dev.gordon.ca.Gordon Internal CA")
31+ assert.NotContains(t, string(mc), "dev.gordon.ca.gordon-internal-ca-&lt;prod&gt;")
32+}
@@ -21,3 +21,12 @@ func TestGenerateMobileconfig(t *testing.T) {
21 assert.Contains(t, string(mc), "<key>PayloadType</key>")21 assert.Contains(t, string(mc), "<key>PayloadType</key>")
22 assert.Contains(t, string(mc), "com.apple.security.root")22 assert.Contains(t, string(mc), "com.apple.security.root")
23 }23 }
24+
25+func TestGenerateMobileconfig_UsesIdentifierSafePayloadIdentifier(t *testing.T) {
26+ mc := pki.GenerateMobileconfig([]byte("cert"), `Gordon Internal CA <prod>`)
27+
28+ assert.Contains(t, string(mc), "<string>Gordon Internal CA &lt;prod&gt;</string>")
29+ assert.Contains(t, string(mc), "<string>dev.gordon.ca.gordon-internal-ca-prod</string>")
30+ assert.NotContains(t, string(mc), "dev.gordon.ca.Gordon Internal CA")
31+ assert.NotContains(t, string(mc), "dev.gordon.ca.gordon-internal-ca-&lt;prod&gt;")
32+}
M internal/usecase/container/service.go +5 -2
@@ -835,10 +835,13 @@ func isPullFailure(err error) bool {
835835 return false
836836 }
837837
838+ if errors.Is(err, domain.ErrImagePullFailed) {
839+ return true
840+ }
841+
838842 msg := strings.ToLower(err.Error())
839843 return strings.Contains(msg, "failed to pull image") ||
840- strings.Contains(msg, "pull access denied") ||
841- strings.Contains(msg, "unauthorized")
844+ strings.Contains(msg, "pull access denied")
842845 }
843846
844847 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
@@ -835,10 +835,13 @@ func isPullFailure(err error) bool {
835 return false835 return false
836 }836 }
837 837
838+ if errors.Is(err, domain.ErrImagePullFailed) {
839+ return true
840+ }
841+
838 msg := strings.ToLower(err.Error())842 msg := strings.ToLower(err.Error())
839 return strings.Contains(msg, "failed to pull image") ||843 return strings.Contains(msg, "failed to pull image") ||
840- strings.Contains(msg, "pull access denied") ||844+ strings.Contains(msg, "pull access denied")
841- strings.Contains(msg, "unauthorized")
842 }845 }
843 846
844 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {847 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
M internal/usecase/container/service_test.go +28 -0
@@ -502,6 +502,34 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
502502 assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503503 }
504504
505+func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ envLoader := mocks.NewMockEnvLoader(t)
508+ eventBus := mocks.NewMockEventPublisher(t)
509+
510+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
511+ ctx := testContext()
512+
513+ route := domain.Route{
514+ Domain: "test.example.com",
515+ Image: "myapp:latest",
516+ }
517+
518+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
519+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
520+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
521+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
522+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
523+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, errors.New("unauthorized"))
524+
525+ result, err := svc.Deploy(ctx, route)
526+
527+ assert.Nil(t, result)
528+ assert.ErrorContains(t, err, "failed to load environment variables: unauthorized")
529+ var deployErr *domain.DeployFailureError
530+ assert.False(t, errors.As(err, &deployErr))
531+}
532+
505533 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506534 runtime := mocks.NewMockContainerRuntime(t)
507535 svc := NewService(runtime, nil, nil, nil, Config{}, nil)
@@ -502,6 +502,34 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
502 assert.ErrorContains(t, deployErr.Err, "failed to pull image")502 assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503 }503 }
504 504
505+func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
506+ runtime := mocks.NewMockContainerRuntime(t)
507+ envLoader := mocks.NewMockEnvLoader(t)
508+ eventBus := mocks.NewMockEventPublisher(t)
509+
510+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
511+ ctx := testContext()
512+
513+ route := domain.Route{
514+ Domain: "test.example.com",
515+ Image: "myapp:latest",
516+ }
517+
518+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
519+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
520+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
521+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
522+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
523+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, errors.New("unauthorized"))
524+
525+ result, err := svc.Deploy(ctx, route)
526+
527+ assert.Nil(t, result)
528+ assert.ErrorContains(t, err, "failed to load environment variables: unauthorized")
529+ var deployErr *domain.DeployFailureError
530+ assert.False(t, errors.As(err, &deployErr))
531+}
532+
505 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {533 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506 runtime := mocks.NewMockContainerRuntime(t)534 runtime := mocks.NewMockContainerRuntime(t)
507 svc := NewService(runtime, nil, nil, nil, Config{}, nil)535 svc := NewService(runtime, nil, nil, nil, Config{}, nil)

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