Skip to content

Commit e48945e

Browse files
authored
fix: deploy hygiene — validate before persist, filter env, honest badge, real hostname (CashPilot-Desktop-ada) (#99)
Four pre-existing latents from the PR #96 review: 1. DeployService persisted credentials BEFORE validation, so a rejected deploy left invalid/blank creds lingering (and lighting the 'Configured' badge for a service that could never start). Validate via the new Manager.ValidateCredentials first; persist only if it passes. 2. buildEnv copied every stored-cred key into the container env unfiltered. Restrict to catalog-declared keys so an orphaned pre-migration key can't leak into a container. (The native twin keeps its documented passthrough.) 3. The 'Configured' badge was len(creds)>0, so an orphaned old-key blob read as configured though a deploy would fail. Key it off the current required fields via Manager.RequiredCredentialsMet. 4. An unedited deploy form resubmitted the raw 'cashpilot-{hostname}' default as an override, which buildEnv only substituted on defaults (and to the literal 'desktop') — producing a device literally named cashpilot-{hostname}. Expand {hostname} to the real host on defaults AND overrides (both runtimes), and surface AppState.hostname so the form renders the real value. Tests: buildEnv filter+hostname, ValidateCredentials/RequiredCredentialsMet, DeployService validate-before-persist. go test -race + vet + gofmt clean, tsc clean.
1 parent 854084b commit e48945e

9 files changed

Lines changed: 227 additions & 39 deletions

File tree

app.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ type AppState struct {
223223
// keyed by slug (e.g. the MystNodes per-node earnings breakdown). The frontend
224224
// parses the raw JSON per service; the backend stores and forwards it opaquely.
225225
ServiceDetails map[string]string `json:"serviceDetails"`
226+
// Hostname is this machine's name, so a deploy form can render a {hostname}-defaulted
227+
// field with the real value the deploy path will substitute (instead of the literal
228+
// "cashpilot-{hostname}" the raw catalog default would otherwise show and submit).
229+
Hostname string `json:"hostname"`
226230
}
227231

228232
type Notification struct {
@@ -374,6 +378,7 @@ func (a *App) GetAppState() (AppState, error) {
374378
Summary: a.computeEarningsSummary(earnings),
375379
Health: a.store.HealthScores(7),
376380
ServiceDetails: a.store.ListServiceDetails(),
381+
Hostname: runtime.DeviceHostname(),
377382
}, nil
378383
}
379384

@@ -652,7 +657,7 @@ func (a *App) GetSettingsState() (SettingsState, error) {
652657
collectors = append(collectors, CollectorSetting{
653658
Slug: svc.Slug,
654659
Name: svc.Name,
655-
Configured: len(creds) > 0,
660+
Configured: a.credsConfigured(svc.Slug, creds),
656661
Collector: svc.Collector.Type,
657662
})
658663
}
@@ -880,15 +885,28 @@ func (a *App) DeployService(slug string, values map[string]string) (store.Deploy
880885
if err := a.ready(); err != nil {
881886
return store.Deployment{}, err
882887
}
883-
if len(values) > 0 {
884-
if err := a.store.SaveCredentials(slug, values); err != nil {
888+
// The credentials this deploy will use: the submitted values (which replace the
889+
// stored blob) when provided, else whatever is already stored.
890+
creds := values
891+
if len(creds) == 0 {
892+
stored, err := a.store.GetCredentials(slug)
893+
if err != nil {
885894
return store.Deployment{}, err
886895
}
896+
creds = stored
887897
}
888-
creds, err := a.store.GetCredentials(slug)
889-
if err != nil {
898+
// Validate BEFORE persisting, so a rejected deploy never leaves invalid/blank creds
899+
// lingering — they could never deploy, yet they would linger and light the
900+
// "Configured" badge for a service that cannot actually start.
901+
if err := a.services.ValidateCredentials(slug, creds); err != nil {
902+
a.emitError("deploy", err)
890903
return store.Deployment{}, err
891904
}
905+
if len(values) > 0 {
906+
if err := a.store.SaveCredentials(slug, values); err != nil {
907+
return store.Deployment{}, err
908+
}
909+
}
892910
deployment, err := a.services.Deploy(a.ctx, slug, creds)
893911
if err != nil {
894912
a.emitError("deploy", err)
@@ -1344,6 +1362,21 @@ func hostnameOrDefault(value string) string {
13441362
return value
13451363
}
13461364

1365+
// credsConfigured reports whether a service should show as "Configured" in Settings:
1366+
// it must have stored credentials AND those credentials must satisfy the catalog's
1367+
// current required fields. Keying off required fields (not merely len(creds) > 0) stops
1368+
// an orphaned pre-migration blob — non-empty but missing the current keys — from
1369+
// falsely reading as configured when a deploy would in fact fail until re-entry.
1370+
func (a *App) credsConfigured(slug string, creds map[string]string) bool {
1371+
if len(creds) == 0 {
1372+
return false
1373+
}
1374+
if a.services == nil {
1375+
return true
1376+
}
1377+
return a.services.RequiredCredentialsMet(slug, creds)
1378+
}
1379+
13471380
// ensureFleetAPIKey loads the fleet bearer token into memory at startup, keeping it
13481381
// out of config.json. It prefers the OS keychain (with a 0600 file fallback) via
13491382
// config.FleetKey/SetFleetKey. A legacy plaintext token still in config.json is

app_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/GeiserX/CashPilot-Desktop/internal/config"
1919
"github.com/GeiserX/CashPilot-Desktop/internal/exchange"
2020
"github.com/GeiserX/CashPilot-Desktop/internal/runtime"
21+
"github.com/GeiserX/CashPilot-Desktop/internal/services"
2122
"github.com/GeiserX/CashPilot-Desktop/internal/store"
2223
)
2324

@@ -662,3 +663,50 @@ func TestNotificationsFlagsOutdated(t *testing.T) {
662663
t.Fatalf("notifications() did not include an 'update available' warning: %+v", items)
663664
}
664665
}
666+
667+
// TestDeployServiceValidatesBeforePersist covers CashPilot-Desktop-ada fix 1: a deploy
668+
// whose credentials fail validation must be rejected WITHOUT persisting the invalid blob
669+
// (the pre-fix flow saved first, leaving lingering creds that lit the "Configured" badge
670+
// for a service that could never actually deploy).
671+
func TestDeployServiceValidatesBeforePersist(t *testing.T) {
672+
t.Setenv("CASHPILOT_DESKTOP_DATA_DIR", t.TempDir())
673+
cfg, err := config.NewManager()
674+
if err != nil {
675+
t.Fatalf("config.NewManager error: %v", err)
676+
}
677+
st, err := store.Open(cfg.DataDir())
678+
if err != nil {
679+
t.Fatalf("store.Open error: %v", err)
680+
}
681+
t.Cleanup(func() { _ = st.Close() })
682+
683+
cat, err := catalog.LoadEmbedded(fstest.MapFS{
684+
"services/bandwidth/req.yml": {Data: []byte(
685+
"name: Req\nslug: req\ncategory: bandwidth\nstatus: active\ndocker:\n image: req/image:1.0.0\n env:\n - key: TOKEN\n label: Token\n required: true\n")},
686+
})
687+
if err != nil {
688+
t.Fatalf("catalog.LoadEmbedded error: %v", err)
689+
}
690+
691+
provider := runtime.NewDockerProvider()
692+
app := &App{
693+
cfg: cfg,
694+
store: st,
695+
catalog: cat,
696+
runtime: provider,
697+
services: services.NewManager(provider, cat, st),
698+
ctx: context.Background(),
699+
}
700+
701+
// Non-empty but invalid (missing the required TOKEN): the runtime is never reached.
702+
if _, err := app.DeployService("req", map[string]string{"WRONG": "x"}); err == nil {
703+
t.Fatal("expected a validation error for the missing required field")
704+
}
705+
got, err := st.GetCredentials("req")
706+
if err != nil {
707+
t.Fatalf("GetCredentials error: %v", err)
708+
}
709+
if len(got) != 0 {
710+
t.Fatalf("a rejected deploy must not persist creds, got %v", got)
711+
}
712+
}

frontend/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1260,7 +1260,7 @@ function renderWizardServiceSetup(service: Service) {
12601260
${fields.map((item) => `
12611261
<label>
12621262
<span>${escapeHtml(item.label)}${item.required ? " *" : ""}</span>
1263-
<input data-wizard-env="${item.key}" type="${item.secret ? "password" : "text"}" placeholder="${escapeHtml(item.description)}" value="${escapeHtml(item.default || "")}" />
1263+
<input data-wizard-env="${item.key}" type="${item.secret ? "password" : "text"}" placeholder="${escapeHtml(item.description)}" value="${escapeHtml((item.default || "").replaceAll("{hostname}", state?.hostname || "desktop"))}" />
12641264
</label>
12651265
`).join("") || `<p class="muted">No credentials are required by the catalog for this service.</p>`}
12661266
</div>

frontend/src/wails.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export interface AppState {
4242
currencies: string[];
4343
summary: EarningsSummary;
4444
serviceDetails: Record<string, string> | null;
45+
// This machine's hostname, so a {hostname}-defaulted deploy field renders the real
46+
// value the deploy path will substitute rather than the literal "{hostname}".
47+
hostname: string;
4548
}
4649

4750
// MystNode mirrors the Go mystNode struct: one Mysterium node's per-node

internal/runtime/native.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,14 +690,17 @@ func buildNativeEnv(svc catalog.Service, overrides map[string]string) map[string
690690
env := make(map[string]string)
691691
for _, item := range svc.Native.Env {
692692
if item.Default != "" {
693-
env[item.Key] = strings.ReplaceAll(item.Default, "{hostname}", "desktop")
693+
env[item.Key] = item.Default
694694
}
695695
}
696696
for key, value := range overrides {
697697
env[key] = value
698698
}
699+
// Expand {hostname} to the real hostname on both defaults and overrides (an unedited
700+
// form resubmits the raw default), matching the Docker buildEnv path.
701+
hostname := DeviceHostname()
699702
for key, value := range env {
700-
env[key] = substitute(value, env)
703+
env[key] = substitute(strings.ReplaceAll(value, "{hostname}", hostname), env)
701704
}
702705
return env
703706
}

internal/runtime/runtime.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"math"
11+
"os"
1112
"os/exec"
1213
"regexp"
1314
goruntime "runtime"
@@ -543,18 +544,39 @@ func containerName(slug string) string {
543544
return "cashpilot-" + slug
544545
}
545546

547+
// DeviceHostname returns this machine's hostname for {hostname} expansion, falling
548+
// back to "desktop" when the OS can't report one. Exported so the app layer can offer
549+
// the same value as the form default the deploy path will substitute.
550+
func DeviceHostname() string {
551+
if h, err := os.Hostname(); err == nil && h != "" {
552+
return h
553+
}
554+
return "desktop"
555+
}
556+
546557
func buildEnv(svc catalog.Service, overrides map[string]string) map[string]string {
547558
env := make(map[string]string)
559+
declared := make(map[string]bool, len(svc.Docker.Env))
548560
for _, item := range svc.Docker.Env {
561+
declared[item.Key] = true
549562
if item.Default != "" {
550-
env[item.Key] = strings.ReplaceAll(item.Default, "{hostname}", "desktop")
563+
env[item.Key] = item.Default
551564
}
552565
}
566+
// Only catalog-declared keys reach the container: an orphaned stored credential
567+
// (e.g. a pre-migration USER_ID/DEVICE_NAME left in the blob) must not leak into
568+
// the environment of a service whose schema no longer includes it.
553569
for key, value := range overrides {
554-
env[key] = value
570+
if declared[key] {
571+
env[key] = value
572+
}
555573
}
574+
// Expand {hostname} on BOTH defaults and overrides to the real hostname: an unedited
575+
// form submits the raw "cashpilot-{hostname}" default back as an override, which the
576+
// old default-only substitution left as a literal container name.
577+
hostname := DeviceHostname()
556578
for key, value := range env {
557-
env[key] = substitute(value, env)
579+
env[key] = substitute(strings.ReplaceAll(value, "{hostname}", hostname), env)
558580
}
559581
return env
560582
}

internal/runtime/runtime_test.go

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -705,34 +705,34 @@ func TestParseMemoryBytes(t *testing.T) {
705705
want int64
706706
wantErr bool
707707
}{
708-
{in: "768m", want: 768 * 1024 * 1024}, // mysterium
709-
{in: "2g", want: 2 * 1024 * 1024 * 1024}, // storj
710-
{in: "1536m", want: 1536 * 1024 * 1024}, // anyone-protocol
711-
{in: "256m", want: 256 * 1024 * 1024}, // honeygain/earnapp/proxyrack
712-
{in: "128m", want: 128 * 1024 * 1024}, // expendable earners
713-
{in: "512k", want: 512 * 1024}, // kibibytes
714-
{in: "1024", want: 1024}, // bare byte count, no suffix
715-
{in: "1024b", want: 1024}, // bare byte count, explicit trailing "b"
716-
{in: "2gb", want: 2 * 1024 * 1024 * 1024}, // explicit trailing "b"
717-
{in: "1gb", want: 1024 * 1024 * 1024}, // explicit trailing "b"
718-
{in: "512mb", want: 512 * 1024 * 1024}, // explicit trailing "b"
719-
{in: "4kb", want: 4 * 1024}, // explicit trailing "b"
720-
{in: "1t", want: 1024 * 1024 * 1024 * 1024}, // tebibytes
721-
{in: "1tb", want: 1024 * 1024 * 1024 * 1024}, // tebibytes, explicit trailing "b"
722-
{in: "768M", want: 768 * 1024 * 1024}, // case-insensitive suffix
723-
{in: " 256m ", want: 256 * 1024 * 1024}, // surrounding whitespace
724-
{in: "1.5g", want: 1536 * 1024 * 1024}, // fractional mantissa
725-
{in: "", wantErr: true}, // empty
726-
{in: " ", wantErr: true}, // whitespace only
727-
{in: "b", wantErr: true}, // unit only, no number
728-
{in: "m", wantErr: true}, // unit only, no number
729-
{in: "abc", wantErr: true}, // not a number
730-
{in: "12x", wantErr: true}, // trailing character isn't a known unit
731-
{in: "g2", wantErr: true}, // unit-like character isn't trailing
732-
{in: "0", wantErr: true}, // non-positive
733-
{in: "0m", wantErr: true}, // non-positive
734-
{in: "0g", wantErr: true}, // non-positive
735-
{in: "-5m", wantErr: true}, // negative
708+
{in: "768m", want: 768 * 1024 * 1024}, // mysterium
709+
{in: "2g", want: 2 * 1024 * 1024 * 1024}, // storj
710+
{in: "1536m", want: 1536 * 1024 * 1024}, // anyone-protocol
711+
{in: "256m", want: 256 * 1024 * 1024}, // honeygain/earnapp/proxyrack
712+
{in: "128m", want: 128 * 1024 * 1024}, // expendable earners
713+
{in: "512k", want: 512 * 1024}, // kibibytes
714+
{in: "1024", want: 1024}, // bare byte count, no suffix
715+
{in: "1024b", want: 1024}, // bare byte count, explicit trailing "b"
716+
{in: "2gb", want: 2 * 1024 * 1024 * 1024}, // explicit trailing "b"
717+
{in: "1gb", want: 1024 * 1024 * 1024}, // explicit trailing "b"
718+
{in: "512mb", want: 512 * 1024 * 1024}, // explicit trailing "b"
719+
{in: "4kb", want: 4 * 1024}, // explicit trailing "b"
720+
{in: "1t", want: 1024 * 1024 * 1024 * 1024}, // tebibytes
721+
{in: "1tb", want: 1024 * 1024 * 1024 * 1024}, // tebibytes, explicit trailing "b"
722+
{in: "768M", want: 768 * 1024 * 1024}, // case-insensitive suffix
723+
{in: " 256m ", want: 256 * 1024 * 1024}, // surrounding whitespace
724+
{in: "1.5g", want: 1536 * 1024 * 1024}, // fractional mantissa
725+
{in: "", wantErr: true}, // empty
726+
{in: " ", wantErr: true}, // whitespace only
727+
{in: "b", wantErr: true}, // unit only, no number
728+
{in: "m", wantErr: true}, // unit only, no number
729+
{in: "abc", wantErr: true}, // not a number
730+
{in: "12x", wantErr: true}, // trailing character isn't a known unit
731+
{in: "g2", wantErr: true}, // unit-like character isn't trailing
732+
{in: "0", wantErr: true}, // non-positive
733+
{in: "0m", wantErr: true}, // non-positive
734+
{in: "0g", wantErr: true}, // non-positive
735+
{in: "-5m", wantErr: true}, // negative
736736
{in: "100000000000000000000000t", wantErr: true}, // overflows a 64-bit byte count
737737
}
738738
for _, tc := range cases {
@@ -829,3 +829,22 @@ func TestApplyResourceLimitsRejectsBadValue(t *testing.T) {
829829
}
830830
}
831831
}
832+
833+
// TestBuildEnvFiltersUndeclaredAndSubstitutesHostname covers CashPilot-Desktop-ada:
834+
// an orphaned (non-catalog) override key is dropped, and {hostname} is expanded to the
835+
// real host on BOTH defaults and overrides (an unedited form resubmits the raw default).
836+
func TestBuildEnvFiltersUndeclaredAndSubstitutesHostname(t *testing.T) {
837+
svc := catalog.Service{Docker: catalog.DockerConfig{Env: []catalog.EnvVar{
838+
{Key: "DEVICE_ID", Default: "cashpilot-{hostname}"},
839+
}}}
840+
env := buildEnv(svc, map[string]string{"ORPHAN": "leak", "DEVICE_ID": "cashpilot-{hostname}"})
841+
if _, ok := env["ORPHAN"]; ok {
842+
t.Fatalf("undeclared override leaked into env: %q", env["ORPHAN"])
843+
}
844+
if strings.Contains(env["DEVICE_ID"], "{hostname}") {
845+
t.Fatalf("{hostname} left unexpanded in an override: %q", env["DEVICE_ID"])
846+
}
847+
if want := "cashpilot-" + DeviceHostname(); env["DEVICE_ID"] != want {
848+
t.Fatalf("DEVICE_ID = %q, want %q", env["DEVICE_ID"], want)
849+
}
850+
}

internal/services/manager.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,30 @@ func validateRequired(svc catalog.Service, credentials map[string]string) error
334334
}
335335
return nil
336336
}
337+
338+
// ValidateCredentials reports whether the given credentials would let the service
339+
// deploy — mirroring Deploy's pre-checks (known, deployable, required fields present) —
340+
// so a caller can validate BEFORE persisting anything.
341+
func (m *Manager) ValidateCredentials(slug string, credentials map[string]string) error {
342+
svc, ok := m.catalog.Get(slug)
343+
if !ok {
344+
return fmt.Errorf("unknown service: %s", slug)
345+
}
346+
if svc.ManualOnly {
347+
return fmt.Errorf("%s is tracked manually and has no Docker image", svc.Name)
348+
}
349+
return validateRequired(svc, credentials)
350+
}
351+
352+
// RequiredCredentialsMet reports whether every required field for the service is present
353+
// in the given credentials. Unlike ValidateCredentials it does not reject a manually
354+
// tracked service, so the settings "Configured" badge can reflect whether the stored
355+
// blob actually satisfies the current schema — not merely that it is non-empty, which an
356+
// orphaned pre-migration blob (wrong keys) would also be.
357+
func (m *Manager) RequiredCredentialsMet(slug string, credentials map[string]string) bool {
358+
svc, ok := m.catalog.Get(slug)
359+
if !ok {
360+
return false
361+
}
362+
return validateRequired(svc, credentials) == nil
363+
}

internal/services/manager_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,36 @@ func TestRefreshRecordsResolvedRuntimeKindPerProvider(t *testing.T) {
558558
t.Fatalf("expected earnapp recorded under native-process, got ok=%v runtime=%q", ok, dep.Runtime)
559559
}
560560
}
561+
562+
// TestValidateCredentials covers the deploy pre-validation used to validate BEFORE
563+
// persisting (CashPilot-Desktop-ada): it mirrors Deploy's known/deployable/required gates.
564+
func TestValidateCredentials(t *testing.T) {
565+
m := NewManager(&fakeProvider{}, newTestCatalog(t), newTestStore(t))
566+
if err := m.ValidateCredentials("does-not-exist", nil); err == nil || !strings.Contains(err.Error(), "unknown service") {
567+
t.Fatalf("unknown service: got %v", err)
568+
}
569+
if err := m.ValidateCredentials("manual-svc", nil); err == nil || !strings.Contains(err.Error(), "tracked manually") {
570+
t.Fatalf("manual only: got %v", err)
571+
}
572+
if err := m.ValidateCredentials("example", nil); err == nil || !strings.Contains(err.Error(), "missing required field") {
573+
t.Fatalf("missing required: got %v", err)
574+
}
575+
if err := m.ValidateCredentials("example", map[string]string{"TOKEN": "x"}); err != nil {
576+
t.Fatalf("valid creds should pass, got %v", err)
577+
}
578+
}
579+
580+
// TestRequiredCredentialsMet covers the "Configured" badge check: true only when the
581+
// current required fields are present, so an orphaned blob (wrong keys) reads false.
582+
func TestRequiredCredentialsMet(t *testing.T) {
583+
m := NewManager(&fakeProvider{}, newTestCatalog(t), newTestStore(t))
584+
if m.RequiredCredentialsMet("does-not-exist", nil) {
585+
t.Fatal("unknown service must not be met")
586+
}
587+
if m.RequiredCredentialsMet("example", map[string]string{"ORPHAN": "old"}) {
588+
t.Fatal("an orphaned blob missing the required TOKEN must not read as met")
589+
}
590+
if !m.RequiredCredentialsMet("example", map[string]string{"TOKEN": "x"}) {
591+
t.Fatal("the required TOKEN present must read as met")
592+
}
593+
}

0 commit comments

Comments
 (0)