Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/image-bump-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ services:
meilisearch-init: {regex: '^(\d+)\.(\d+)$', track: patch}
postgres: {regex: '^(\d+)-alpine$', track: patch}
python: {regex: '^(\d+)\.(\d+)-slim$', track: patch}
valkey: {regex: '^(\d+)\.(\d+)-alpine$', track: patch}
redis: {regex: '^(\d+)-alpine$', track: patch}
# --- exact versions: patch releases within the current minor ---
alertmanager: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
auth: {regex: '^(\d+)\.(\d+)\.(\d+)$', track: patch}
auth-v14: {regex: '^(\d+)\.(\d+)\.(\d+)$', track: patch}
cadvisor: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
elasticsearch: {regex: '^(\d+)\.(\d+)\.(\d+)$', track: patch}
grafana: {regex: '^(\d+)\.(\d+)\.(\d+)$', track: patch}
hasura: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
hasura-v14: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
lego: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
loki: {regex: '^(\d+)\.(\d+)\.(\d+)$', track: patch}
mlflow: {regex: '^v(\d+)\.(\d+)\.(\d+)$', track: patch}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
# tooling but GitHub rejects the whole file, reporting only "this run
# likely failed because of a workflow file issue". Catch it here instead
# of burning a push cycle.
- name: Image bump policy coverage
run: go test -mod=vendor ./tools/imagebump -run TestRepositoryPolicyCoverage

- name: workflow YAML lint
run: bash scripts/ci/workflow-lint.sh

Expand Down
3 changes: 3 additions & 0 deletions cmd/commands/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const fixtureBundlesJSON = `{
}`

func TestMain(m *testing.M) {
// Fixtures model local projects; CI-specific tests opt in explicitly.
_ = os.Unsetenv("CI")
_ = os.Unsetenv("NSELF_CI")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fixtureBundlesJSON))
}))
Expand Down
1 change: 1 addition & 0 deletions cmd/commands/canon_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ func TestCanonEngineSafety(t *testing.T) {
// TestCanonEnginePrepare drives the whole pre-dispatch path on the fixture tree:
// argv rewrite, tree relocation, destination check, warning, hub preflight.
func TestCanonEnginePrepare(t *testing.T) {
t.Setenv("NSELF_PLUGIN_DIR", t.TempDir())
tb := fixtureTable(t)
run := func(root *cobra.Command, v15, mount bool, argv ...string) ([]string, string, error) {
t.Helper()
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/ci_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func retireLegacyCICache() {
_ = os.Remove(legacy)
}
}
fmt.Fprintln(ciNoticeOut, "notice: ~/.nself/bin/nself-ci is no longer used (unverified); nself now installs the signed ci plugin")
_, _ = fmt.Fprintln(ciNoticeOut, "notice: ~/.nself/bin/nself-ci is no longer used (unverified); nself now installs the signed ci plugin")
})
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/start_containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func initializeDatabasePhase(ctx context.Context, cfg *config.Config, opts start
// 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",
"Verify POSTGRES_USER in .env and POSTGRES_PASSWORD in .env.secrets (which overrides .env)",
"Try restarting: nself restart",
}
if errors.Is(err, database.ErrInvalidIdentifier) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/surface_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ func (e *surfaceEnv) resetProject(t *testing.T) {
if envName == "" {
envName = "dev"
}
if err := os.WriteFile(filepath.Join(e.proj, ".env"), []byte("PROJECT_NAME=fixture\nENV="+envName+"\nMINIO_ENABLED=false\n"), 0o600); err != nil {
if err := os.WriteFile(filepath.Join(e.proj, ".env"), []byte("PROJECT_NAME=fixture\nENV="+envName+"\nMINIO_ENABLED=false\nPOSTGRES_PASSWORD=actuallyuniquevalueforthisenvironmentnumberone\nHASURA_GRAPHQL_ADMIN_SECRET=totallydifferentvaluealtogetherforthisservice\n"), 0o600); err != nil {
t.Fatal(err)
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/build/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const fixtureBundlesJSON = `{
}`

func TestMain(m *testing.M) {
// Fixtures model local projects; CI-specific tests opt in explicitly.
_ = os.Unsetenv("CI")
_ = os.Unsetenv("NSELF_CI")
if err := bundle.LoadBytes([]byte(fixtureBundlesJSON)); err != nil {
panic("seeding bundle fixture: " + err.Error())
}
Expand Down
10 changes: 9 additions & 1 deletion internal/build/nginx_sites_prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ package build
import (
"bytes"
"fmt"
"github.com/nself-org/cli/internal/compat"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -201,13 +202,20 @@ func pruneGeneratedNginxSitesVia(sink Sink, sitesDir string) (removed int, forei
return 0, nil, regErr
}

var owned map[string]string
if compat.V15() {
owned, err = recordedSiteFiles(sink)
if err != nil {
return 0, nil, err
}
}
const headBytes = 128
for _, e := range entries {
if e.IsDir() || registered["nginx/sites/"+e.Name()] {
continue
}
path := filepath.Join(sitesDir, e.Name())
if isGeneratedNginxSiteFile(sink, path, headBytes) {
if isGeneratedNginxSiteFile(sink, path, headBytes) && (!compat.V15() || owned["nginx/sites/"+e.Name()] != "") {
if rmErr := sink.Remove(path); rmErr != nil {
return removed, foreign, fmt.Errorf("removing generated file %s: %w", path, rmErr)
}
Expand Down
30 changes: 30 additions & 0 deletions internal/build/nginx_sites_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package build

import (
"encoding/json"
"fmt"
"os"
)

// A marker proves provenance, not ownership: copied marked files must survive
// orphan pruning unless this project's generated-state record owns their path.
func recordedSiteFiles(sink Sink) (map[string]string, error) {
data, err := sink.ReadFile(".nself/state/generated.json")
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading generated-state ownership: %w", err)
}
var state struct {
SchemaVersion string `json:"schema_version"`
Files map[string]string `json:"files"`
}
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("reading generated-state ownership: %w", err)
}
if state.SchemaVersion != "1" {
return nil, fmt.Errorf("unsupported generated-state ownership schema %q", state.SchemaVersion)
}
return state.Files, nil
}
3 changes: 3 additions & 0 deletions internal/build/orchestrator_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ func persistGeneratedSecretsFx(workdir string, cfg *config.Config, fx Effects, s
}
continue
}
if os.Getenv("NSELF_CI") != "" || os.Getenv("CI") != "" {
return fmt.Errorf("%s is unset in CI: export it for every build or define it in .env.secrets; refusing to persist an auto-generated value that would shadow later exports", c.key)
}
toWrite = append(toWrite, secretEntry{c.key, *c.dst})
}

Expand Down
8 changes: 8 additions & 0 deletions internal/build/plugins_entitlement_mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -89,6 +90,13 @@ func TestMountIgnoresOverride(t *testing.T) {
}

func TestMountEmptyFileE125(t *testing.T) {
// An installed host licence must not turn this empty-cache fixture into
// an online renewal test.
t.Setenv("HOME", t.TempDir())
t.Setenv("NSELF_PLUGIN_LICENSE_KEY", "")
for i := 1; i <= 10; i++ {
t.Setenv(fmt.Sprintf("NSELF_LICENSE_KEY_%d", i), "")
}
compattest.Set(t, true)
t.Setenv("LICENSE_CACHE_PATH", filepath.Join(t.TempDir(), "missing"))
t.Setenv("LICENSE_PUBLIC_KEY_OVERRIDE", "")
Expand Down
37 changes: 37 additions & 0 deletions internal/build/secrets_ci_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package build

import (
"os"
"path/filepath"
"strings"
"testing"

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

func TestCIMissingCoreSecretRefusesPersistence(t *testing.T) {
for _, flag := range []string{"CI", "NSELF_CI"} {
for _, key := range []string{"POSTGRES_PASSWORD", "HASURA_GRAPHQL_ADMIN_SECRET"} {
t.Run(flag+"/"+key, func(t *testing.T) {
clearSecretEnv(t)
t.Setenv("CI", "")
t.Setenv("NSELF_CI", "")
t.Setenv(flag, "1")
cfg := &config.Config{Env: "dev"}
if key == "POSTGRES_PASSWORD" {
cfg.Postgres.Password = "fixture-generated"
} else {
cfg.Hasura.AdminSecret = "fixture-generated"
}
dir := t.TempDir()
err := persistGeneratedSecrets(dir, cfg)
if err == nil || !strings.Contains(err.Error(), key) {
t.Fatalf("want missing-key refusal, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".env.secrets")); !os.IsNotExist(err) {
t.Fatal("CI refusal wrote secrets")
}
})
}
}
}
19 changes: 19 additions & 0 deletions internal/config/secrets_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package config

import (
"strings"
"testing"
)

func TestPlaceholderHintNamesSecretsFile(t *testing.T) {
for _, value := range []string{"", "changeme"} {
t.Run(value, func(t *testing.T) {
cfg := validPlaceholderTestConfig("prod")
cfg.Postgres.Password = value
err := validateNoPlaceholderSecrets(cfg)
if err == nil || !strings.Contains(err.Error(), ".env.secrets") {
t.Fatalf("hint must identify winning file: %v", err)
}
})
}
}
6 changes: 3 additions & 3 deletions internal/config/validator_placeholder_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ func validateNoPlaceholderSecrets(cfg *Config) error {
}
for _, f := range required {
if f.value == "" {
return fmt.Errorf("%s is empty in %s environment — set a unique value in .env: %w",
return fmt.Errorf("%s is empty in %s environment — set a unique value in .env.secrets (which overrides .env): %w",
f.name, cfg.Env, errs.ErrPlaceholderSecret)
}
if isPlaceholderSecret(f.value) {
return fmt.Errorf("%s is a known placeholder value in %s environment — set a unique value in .env: %w",
return fmt.Errorf("%s is a known placeholder value in %s environment — set a unique value in .env.secrets (which overrides .env): %w",
f.name, cfg.Env, errs.ErrPlaceholderSecret)
}
}
Expand All @@ -103,7 +103,7 @@ func validateNoPlaceholderSecrets(cfg *Config) error {
}
for _, f := range optional {
if isPlaceholderSecret(f.value) {
return fmt.Errorf("%s is a known placeholder value in %s environment — set a unique value in .env: %w",
return fmt.Errorf("%s is a known placeholder value in %s environment — set a unique value in .env.secrets (which overrides .env): %w",
f.name, cfg.Env, errs.ErrPlaceholderSecret)
}
}
Expand Down
44 changes: 44 additions & 0 deletions internal/oplock/oplockcmd/preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//go:build !windows

package oplockcmd

import (
"context"
"testing"

"github.com/nself-org/cli/internal/cmdregistry"
"github.com/nself-org/cli/internal/compat/compattest"
"github.com/nself-org/cli/internal/oplock"
"github.com/spf13/cobra"
)

func TestPreviewUnderHeldLock(t *testing.T) {
for _, row := range []struct{ path, flag string }{{"build", "plan"}, {"config set", "preview"}, {"update", "plan"}} {
t.Run(row.path, func(t *testing.T) {
compattest.Set(t, true)
dir := projectDir(t)
t.Setenv(oplock.EnvToken, "")
held, err := oplock.Acquire(context.Background(), dir, oplock.Opts{Command: "holder"})
if err != nil {
t.Fatal(err)
}
defer held.Release()
ran := false
_, cmd := newTree(write(), func(*cobra.Command) error { ran = true; return nil })
cmd.Flags().Bool(row.flag, false, "preview")
if err := cmd.Flags().Set(row.flag, "true"); err != nil {
t.Fatal(err)
}
// The registry's path identifies the real command even in this fixture.
entry := write()
entry.Path = "nself " + row.path
cmd.RunE = Wrap(func(*cobra.Command) (*cmdregistry.Command, error) { return entry, nil }, func(*cobra.Command, []string) error { ran = true; return nil })
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !ran {
t.Fatal("preview body did not run")
}
})
}
}
14 changes: 14 additions & 0 deletions internal/oplock/oplockcmd/wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,19 @@ func Effective(cmd *cobra.Command, e *cmdregistry.Command) (side, out string) {
out = *f.Output
}
}
// These flags render a plan and never apply it, even when the command's
// default registry class is write or destructive.
preview := ""
switch e.Path {
case "nself build", "nself update":
preview = "plan"
case "nself config set":
preview = "preview"
}
if preview != "" {
if f := cmd.Flags().Lookup(preview); f != nil && f.Value.Type() == "bool" && f.Value.String() == "true" {
side = canon.SideEffectRead
}
}
return side, out
}
4 changes: 2 additions & 2 deletions internal/plugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func checkTablePrefixConflict(pluginDir, newPluginName string, newTables []strin
}

for _, entry := range entries {
if !entry.IsDir() {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
if isOwnPluginDir(entry.Name(), newPluginName) {
Expand Down Expand Up @@ -169,7 +169,7 @@ func checkTableConflicts(pluginDir string, newPlugin *PluginManifest) error {
}

for _, entry := range entries {
if !entry.IsDir() || isOwnPluginDir(entry.Name(), newPlugin.Name) {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || isOwnPluginDir(entry.Name(), newPlugin.Name) {
continue
}
existing, err := parseManifest(filepath.Join(pluginDir, entry.Name(), "plugin.json"))
Expand Down
2 changes: 1 addition & 1 deletion internal/plugin/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func collectInstalledPluginRoutes(pluginDir, skipPlugin string) []nginx.NginxRou
}

for _, entry := range entries {
if !entry.IsDir() {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
// isOwnPluginDir also skips "<skipPlugin>.prev", the backup Update()
Expand Down
33 changes: 33 additions & 0 deletions internal/plugin/staging_conflict_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package plugin

import (
"os"
"path/filepath"
"testing"
)

func TestConflictScansIgnoreStaging(t *testing.T) {
for _, name := range []string{".staging-thirdparty-123", "installed"} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, name)
if err := os.Mkdir(p, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(p, "plugin.json"), []byte(`{"name":"other","version":"1.0.0","description":"fixture","category":"utilities","license":"MIT","tables":["np_chat_messages"],"apiEndpoints":["chat.example.test/messages"]}`), 0644); err != nil {
t.Fatal(err)
}
wantConflict := name == "installed"
if err := checkTablePrefixConflict(dir, "chat", []string{"np_chat_messages"}); (err != nil) != wantConflict {
t.Fatalf("prefix conflict: %v", err)
}
if err := checkTableConflicts(dir, &PluginManifest{Name: "chat", Tables: []string{"np_chat_messages"}}); (err != nil) != wantConflict {
t.Fatalf("table conflict: %v", err)
}
routes := collectInstalledPluginRoutes(dir, "chat")
if got := len(routes) > len(baseServiceRoutes); got != wantConflict {
t.Fatalf("route conflict: %v", got)
}
})
}
}
4 changes: 2 additions & 2 deletions internal/reconcile/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ func ApplyBuild(ctx context.Context, req Request, opt ApplyOptions) (applied *Pl

// P7-LIVE-04 (EPIC D5): the generated-state record tells a generated change
// from a human one. A missing record is the first run on an existing
// project: nothing is detected (never block), the apply records what it
// wrote and warns once below. A record a caller already supplied (tests,
// project: compute compares marked files to the fresh render, and the
// apply records what it wrote and warns once below. A record a caller already supplied (tests,
// later callers) is used as given.
state, err := LoadGeneratedState(req.ProjectDir)
if err != nil {
Expand Down
Loading
Loading