Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions cmd/commands/init_templates_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ package commands

import (
"context"
"slices"
"strings"

"github.com/nself-org/cli/internal/license"
"github.com/nself-org/cli/internal/plugin/catalog"
Expand All @@ -27,7 +25,7 @@ var templateSource clonetemplate.Source = func(ctx context.Context) (clonetempla
e, ok := res.Catalog.Lookup(slug)
return clonetemplate.PluginInfo{Slug: e.Slug, Tier: e.Tier, Bundles: e.Bundles}, ok
}
keys := templateLicenseKeys()
keys := license.ConfiguredKeys()
return find, func(ctx context.Context, bundle string) (bool, error) {
var firstErr error
for _, key := range keys {
Expand All @@ -42,20 +40,3 @@ var templateSource clonetemplate.Source = func(ctx context.Context) (clonetempla
return false, firstErr
}, nil
}

// templateLicenseKeys is every configured licence key, the owner key first, so
// coverage under any of them counts (multi-key support, as plugin install).
func templateLicenseKeys() []string {
keys := license.CollectLicenseKeys()
// CollectLicenseKeys reads only the primary key file; key.2 to key.10 are
// stored keys too, and a covered second key must not read as E110.
for _, k := range license.GetAllStoredKeys() {
if k = strings.TrimSpace(k); k != "" && !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
if k := strings.TrimSpace(license.CollectLicenseKey()); k != "" {
keys = append([]string{k}, slices.DeleteFunc(keys, func(x string) bool { return x == k })...)
}
return keys
}
7 changes: 4 additions & 3 deletions cmd/commands/init_templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/nself-org/cli/internal/compat"
"github.com/nself-org/cli/internal/compat/compattest"
"github.com/nself-org/cli/internal/errs"
"github.com/nself-org/cli/internal/license"
clonetemplate "github.com/nself-org/cli/internal/templates/clone"
)

Expand Down Expand Up @@ -249,7 +250,7 @@ func TestTemplateLicenseKeysOwnerFirst(t *testing.T) {
t.Setenv("NSELF_PLUGIN_LICENSE_KEY", "fake-key-alpha")
t.Setenv("NSELF_LICENSE_KEY_1", "fake-key-bravo")
t.Setenv("NSELF_LICENSE_KEY_2", "fake-key-charlie")
if got := strings.Join(templateLicenseKeys(), ","); got != "fake-key-bravo,fake-key-alpha,fake-key-charlie" {
if got := strings.Join(license.ConfiguredKeys(), ","); got != "fake-key-bravo,fake-key-alpha,fake-key-charlie" {
t.Errorf("keys = %s, want the owner key first, each once", got)
}
}
Expand All @@ -268,7 +269,7 @@ func TestTemplateLicenseKeysStoredNumbered(t *testing.T) {
t.Fatal(err)
}
}
if got := strings.Join(templateLicenseKeys(), ","); got != "fake-key-one,fake-key-two,fake-key-ten" {
if got := strings.Join(license.ConfiguredKeys(), ","); got != "fake-key-one,fake-key-two,fake-key-ten" {
t.Errorf("keys = %q, want the primary then key.2 and key.10, each once", got)
}
}
Expand All @@ -279,7 +280,7 @@ func TestTemplateLicenseKeysOwnerKeyTrimmed(t *testing.T) {
compattest.Set(t, true)
isolateSource(t, func(http.ResponseWriter, *http.Request) {})
t.Setenv("NSELF_PLUGIN_LICENSE_KEY_OWNER", "fake-key-bravo\n")
if got := strings.Join(templateLicenseKeys(), ","); got != "fake-key-bravo" {
if got := strings.Join(license.ConfiguredKeys(), ","); got != "fake-key-bravo" {
t.Errorf("keys = %q, want the trimmed owner key", got)
}
}
12 changes: 7 additions & 5 deletions cmd/commands/start_containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ func postgresAlreadyRunning(healthStatus string) bool {
return healthStatus == "healthy"
}

var dbInitCredentialHints = []string{ // .env.secrets overrides .env; build persists credentials there (D-0708)
"Check PostgreSQL logs: nself logs postgres",
"Verify POSTGRES_USER and POSTGRES_PASSWORD in the file that defines them; .env.secrets overrides .env, so edit it there once build has persisted a value",
"Try restarting: nself restart",
}

// initializeDatabasePhase runs database initialization (Step 5). Must run
// BEFORE other services start: the auth service requires the auth schema to
// exist in PostgreSQL, so schemas and extensions must be created while only
Expand All @@ -244,11 +250,7 @@ func initializeDatabasePhase(ctx context.Context, cfg *config.Config, opts start
// hyphenated project directory name) is a config problem, not a
// connectivity/credentials one — the generic hints below actively
// misdirect for this case, so name the real problem instead.
hints := []string{
"Check PostgreSQL logs: nself logs postgres",
"Verify POSTGRES_USER and POSTGRES_PASSWORD in .env",
"Try restarting: nself restart",
}
hints := dbInitCredentialHints
if errors.Is(err, database.ErrInvalidIdentifier) {
hints = []string{
"The name is not a valid SQL identifier: it must start with a letter or underscore and contain only letters, digits, and underscores",
Expand Down
13 changes: 12 additions & 1 deletion cmd/commands/start_containers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ package commands
// value ("healthy", "unhealthy", "starting", "none",
// "not_found") plus an empty string for the zero value.

import "testing"
import (
"strings"
"testing"
)

func TestPostgresAlreadyRunning(t *testing.T) {
tests := []struct {
Expand All @@ -46,3 +49,11 @@ func TestPostgresAlreadyRunning(t *testing.T) {
})
}
}

// D-0708: .env.secrets wins over .env, so the credential hint must name it.
func TestDBInitCredentialHints_NameEnvSecrets(t *testing.T) {
joined := strings.Join(dbInitCredentialHints, "\n")
if !strings.Contains(joined, ".env.secrets") {
t.Errorf("hints must point at .env.secrets, got:\n%s", joined)
}
}
19 changes: 19 additions & 0 deletions internal/backup/destinations/inline_option.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package destinations

import (
"regexp"
"strings"
)

// inlineOptionRe is the `,key=` shape of an inline rclone option ("myr,pass=x").
var inlineOptionRe = regexp.MustCompile(`,[A-Za-z0-9_]+=`)

// HasInlineOption reports whether a relative uri carries an inline rclone
// option with no ":" after it ("myr,pass=ab/cd"): a malformed remote, not a
// directory. "./" and "../" always mean a path, so they never match.
func HasInlineOption(uri string) bool {
if strings.HasPrefix(uri, "./") || strings.HasPrefix(uri, "../") {
return false
}
return inlineOptionRe.MatchString(uri)
}
17 changes: 17 additions & 0 deletions internal/backup/destinations/inline_option_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package destinations

import "testing"

func TestHasInlineOption(t *testing.T) {
for uri, want := range map[string]bool{
"myr,pass=ab/cd": true,
"./myr,pass=ab/cd": false,
"../myr,pass=ab/cd": false,
"backups": false,
"back,ups": false,
} {
if got := HasInlineOption(uri); got != want {
t.Errorf("HasInlineOption(%q) = %v, want %v", uri, got, want)
}
}
}
17 changes: 3 additions & 14 deletions internal/backup/stream_dest_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"

Expand All @@ -30,20 +29,14 @@ var (
bareNoticed = map[string]bool{}
)

// inlineOptionRe is the `,key=` shape of an inline rclone option
// ("myr,pass=x"). A relative value carrying it is a malformed remote, not a
// directory, even when no ":" follows.
var inlineOptionRe = regexp.MustCompile(`,[A-Za-z0-9_]+=`)

// isBarePath reports whether rclone would have read uri as a local path: not
// empty, not option-shaped, and no ":" before the first "/" (so no scheme, no
// name:path remote and no :backend connection string). A value starting with
// "/" is always a path (no rclone remote starts with "/"), commas included.
// A value with padding whitespace or a control character, and a relative
// value with an inline-credential shape (a "," before a ":", e.g.
// "myr,pass=ab/cd:p", or a ",key=" option with no ":" at all, "myr,pass=ab/cd"; "./" and "../" always mean a path),
// are left to the parser, which refuses them without
// echoing them (D-0467).
// "myr,pass=ab/cd:p", or a ",key=" option with no ":", see HasInlineOption), are
// left to the parser, which refuses them without echoing them (D-0467).
func isBarePath(uri string) bool {
if uri == "" || strings.HasPrefix(uri, "-") || uri != strings.TrimSpace(uri) {
return false
Expand All @@ -54,11 +47,7 @@ func isBarePath(uri string) bool {
if strings.HasPrefix(uri, "/") {
return true
}
if strings.Contains(uri, ":") && destinations.HasInlineCredentials(uri) {
return false
}
explicitPath := strings.HasPrefix(uri, "./") || strings.HasPrefix(uri, "../")
if !explicitPath && inlineOptionRe.MatchString(uri) {
if (strings.Contains(uri, ":") && destinations.HasInlineCredentials(uri)) || destinations.HasInlineOption(uri) {
return false
}
head, _, _ := strings.Cut(uri, "/")
Expand Down
3 changes: 2 additions & 1 deletion internal/build/orchestrator_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"path/filepath"
"strings"

"github.com/nself-org/cli/internal/composeenv"
"github.com/nself-org/cli/internal/config"
)

Expand Down Expand Up @@ -84,7 +85,7 @@ func persistGeneratedSecretsFx(workdir string, cfg *config.Config, fx Effects, s
if *c.dst == "" || onDisk[c.key] || os.Getenv(c.key) != "" {
continue
}
if live, ok := adoptLiveCredential(sink, workdir, c.key); ok {
if live, ok := adoptLiveCredential(sink, workdir, composeenv.EnvName(cfg.Env), c.key); ok {
*c.dst = live
if !fx.Planning() {
slog.Warn("key is in no env file; reusing the value the running stack was built with. Pin it by adding the key to .env.secrets (or your env file)", "key", c.key)
Expand Down
28 changes: 7 additions & 21 deletions internal/build/orchestrator_secrets_adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,44 +10,30 @@ package build
// ${VAR} reference or an empty value is not a credential. Values
// are never logged. An adopted value is used for this build's
// render only and is never written to .env.secrets. .nself/
// compose.env holds whichever ENV built last; adoption only
// compose.env holds whichever ENV built last (composeenv
// records that ENV and refuses another's); adoption only
// happens when no env file and no process export supplies the
// key, so it can only pick up a value an earlier build got from a
// process export or generated itself. .env.computed is not read:
// it never carries these keys literally.

import (
"bufio"
"bytes"
"path/filepath"

"strings"

"github.com/nself-org/cli/internal/composeenv"
"github.com/nself-org/cli/internal/config"
)

// adoptLiveCredential returns key's literal value from the previous build's
// .nself/compose.env.
func adoptLiveCredential(sink Sink, workdir, key string) (string, bool) {
// .nself/compose.env, unless a different ENV (env) wrote it (D-1149).
func adoptLiveCredential(sink Sink, workdir, env, key string) (string, bool) {
data, err := sink.ReadFile(filepath.Join(workdir, composeEnvFile))
if err != nil {
return "", false
}
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || line[0] == '#' {
continue
}
k, raw, ok := strings.Cut(line, "=")
if !ok || k != key {
continue
}
if v := parseEnvValue(raw); v != "" && !strings.HasPrefix(v, "${") {
return v, true
}
}
return "", false
return composeenv.Literal(data, env, key, parseEnvValue)
}

// parseEnvValue is the inverse of config.QuoteComposeEnvValue and
Expand Down
52 changes: 52 additions & 0 deletions internal/build/orchestrator_secrets_adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"testing"

"github.com/nself-org/cli/internal/composeenv"
"github.com/nself-org/cli/internal/config"
)

Expand Down Expand Up @@ -243,3 +244,54 @@ func TestSecretScanList_FollowsCascadeRule(t *testing.T) {
}
}
}

// D-1149: compose.env written by a dev build must not hand its password to a
// prod build; the same env still adopts, and a file without the marker (built
// before it existed) keeps adopting.
func TestPersistGeneratedSecrets_AdoptHonoursBuildEnvMarker(t *testing.T) {
const body = "POSTGRES_PASSWORD=fake-dev-pg-password\nHASURA_GRAPHQL_ADMIN_SECRET=fake-dev-admin-secret\n"
for _, tc := range []struct {
name, header, env string
adopt bool
}{
{"dev file, prod build", composeenv.Marker + "dev\n", "prod", false},
{"prod file, dev build", composeenv.Marker + "prod\n", "dev", false},
{"prod file, prod build", composeenv.Marker + "prod\n", "prod", true},
{"dev file, unset env is dev", composeenv.Marker + "dev\n", "", true},
{"no marker", "", "prod", true},
} {
t.Run(tc.name, func(t *testing.T) {
dir := projectWithComposeEnv(t, tc.header+body)
cfg := genCfg()
cfg.Env = tc.env
if err := persistGeneratedSecrets(dir, cfg); err != nil {
t.Fatal(err)
}
adopted := cfg.Postgres.Password == "fake-dev-pg-password" && cfg.Hasura.AdminSecret == "fake-dev-admin-secret"
if adopted != tc.adopt {
t.Fatalf("adopted = %v, want %v", adopted, tc.adopt)
}
if !tc.adopt && !strings.Contains(readSecrets(t, dir), "POSTGRES_PASSWORD=fake-generated-pg-password\n") {
t.Error("a refused adoption must fall back to persisting the generated value")
}
})
}
}

// The writer records the ENV that the reader checks.
func TestWriteComposeEnv_RecordsBuildEnv(t *testing.T) {
dir := t.TempDir()
cfg := genCfg()
cfg.Env = "prod"
cfg.ProjectName = "test"
sink := newDiskSink(dir)
if err := writeComposeEnvVia(sink, dir, cfg, map[string]string{"POSTGRES_PASSWORD": "fake-pg"}, nil); err != nil {
t.Fatal(err)
}
if got, ok := adoptLiveCredential(sink, dir, "prod", "POSTGRES_PASSWORD"); !ok || got != "fake-pg" {
t.Errorf("same env: got %q, %v", got, ok)
}
if _, ok := adoptLiveCredential(sink, dir, "dev", "POSTGRES_PASSWORD"); ok {
t.Error("dev build adopted a prod compose.env")
}
}
5 changes: 5 additions & 0 deletions internal/build/plugins_entitlement_mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ func TestMountIgnoresOverride(t *testing.T) {

func TestMountEmptyFileE125(t *testing.T) {
compattest.Set(t, true)
// No licence key from the host: a stored or env key would renew a real JWT online.
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("NSELF_PLUGIN_LICENSE_KEY", "")
t.Setenv("NSELF_PLUGIN_LICENSE_KEY_OWNER", "")
t.Setenv("LICENSE_CACHE_PATH", filepath.Join(t.TempDir(), "missing"))
t.Setenv("LICENSE_PUBLIC_KEY_OVERRIDE", "")
dir, in := entitlementFixture(t, "licensed")
Expand Down
2 changes: 2 additions & 0 deletions internal/build/secrets_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"sort"
"strings"

"github.com/nself-org/cli/internal/composeenv"
"github.com/nself-org/cli/internal/config"
)

Expand Down Expand Up @@ -232,6 +233,7 @@ func writeComposeEnvVia(sink Sink, workdir string, cfg *config.Config, secrets,
sb.WriteString("# GENERATED BY nself build - DO NOT HAND EDIT\n")
sb.WriteString("# Interpolation values for the ${VAR} references in docker-compose.yml.\n")
sb.WriteString("# Passed to docker compose via --env-file by nself start/stop/restart.\n")
sb.WriteString(composeenv.Marker + composeenv.EnvName(cfg.Env) + "\n") // D-1149
for _, k := range keys {
sb.WriteString(k)
sb.WriteString("=")
Expand Down
2 changes: 1 addition & 1 deletion internal/build/testdata/plan-mode/golden-dev-minimal.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ d 0755 project/ssl
d 0755 project/ssl/certificates
f 0600 project/.env 3f9f5e92353c2eafb5d2fbf56c4693cf8feb9bf82bb07d1b1e3bc230339f2db5
f 0600 project/.env.computed 11d69b678463f3b0ecff1c6a2036bd3ac6ac39c6f0ab75b775da0196f209ed55
f 0600 project/.nself/compose.env fd68648754cf9a6c5f72298ef87d050aee49495249735d97bea75fbb09622caa
f 0600 project/.nself/compose.env 2850ac3f38865ec99ae420df326ba1bc4cf199e09f705731d6f56caad5a55023
f 0600 project/docker-compose.yml 916a3b8f73f849cdf1ac401dca17e3991695f54bc0d9fad3779f00ebf73647de
f 0644 project/.nself/build-profile a172cedcae47474b615c54d510a5d84a8dea3032e958587430b413538be3f333
f 0644 project/.nself/build-version 8ceaa7b1b997a4743b4d5e74b2a46af8c5ebe7765abb07eca372441aa3cbf51e
Expand Down
2 changes: 1 addition & 1 deletion internal/build/testdata/plan-mode/golden-fronted.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ d 0755 home
d 0755 plugins
f 0600 fronted-stack/backend/.env 779469afb28aab57a312a30294588741be9f1bae8794eaca9a5f2fc4630e45b5
f 0600 fronted-stack/backend/.env.computed 11d69b678463f3b0ecff1c6a2036bd3ac6ac39c6f0ab75b775da0196f209ed55
f 0600 fronted-stack/backend/.nself/compose.env fd68648754cf9a6c5f72298ef87d050aee49495249735d97bea75fbb09622caa
f 0600 fronted-stack/backend/.nself/compose.env 2850ac3f38865ec99ae420df326ba1bc4cf199e09f705731d6f56caad5a55023
f 0600 fronted-stack/backend/docker-compose.yml 680e12ec51abc9b8ab6443494c6f4d1e4bd79faee3e2be3a0b9795fd3132c3a3
f 0644 fronted-stack/backend/.nself/build-profile a172cedcae47474b615c54d510a5d84a8dea3032e958587430b413538be3f333
f 0644 fronted-stack/backend/.nself/build-version 8ceaa7b1b997a4743b4d5e74b2a46af8c5ebe7765abb07eca372441aa3cbf51e
Expand Down
2 changes: 1 addition & 1 deletion internal/build/testdata/plan-mode/golden-prod-plugins.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ d 0755 project/ssl/certificates
d 0755 project/ssl/certificates/example-org
f 0600 project/.env a453ab255f1ff28c49aa67169dea59e6ed810549318c1bf13d96b484bc299829
f 0600 project/.env.computed 2f35a3b9b8f489945a149d23e41588cca4a7a21686ab5e8b365d09b991ed6756
f 0600 project/.nself/compose.env 2694d3f78d0e965fc7cef1e35bd08629860d0141633e4be6676ce11e036356e6
f 0600 project/.nself/compose.env 5e447d1c6fda2d1d9921cd2418eacda7c07066bf981db2c60ee7842dab9794d2
f 0600 project/docker-compose.yml 3b0c42186c3ef4fd16bd5228d3e92a11ef4c631c000faffa30036ce2ffea1e59
f 0600 project/ssl/certificates/example-org/privkey.pem 4a3ec3730504983f960fb2df35a1d68d640ff55d151fa3128ca0fc707f86882e
f 0644 plugins/nself-alpha/Dockerfile bb57c7da220a8753d7bdabac0d3afdb6efa742e4c736c5bc93ab40dfd5e23b9b
Expand Down
Loading
Loading