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
4 changes: 4 additions & 0 deletions .github/coverage-floors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ internal/controlplane/sim 0 # measured 0.0 2026-10-05
internal/cost 38 # measured 40.8 2026-10-05
internal/credmask 72 # measured 72.1 2026-10-26
internal/database 21 # measured 23.1 2026-10-05
internal/database/metadatavalue 79 # measured 81.8 2026-10-29
internal/database/vectorsearch 94 # measured 96.9 2026-10-10
internal/deploy 53 # measured 55.2 2026-10-05
internal/deploy/archguard 76 # measured 78.6 2026-10-09
internal/deploy/bluegreen 44 # measured 46.3 2026-10-05
internal/deploy/imagetags 98 # measured 100.0 2026-10-29
internal/deploy/pintest 57 # measured 59.5 2026-10-13 (P7-HYG-49)
internal/deploy/progress 86 # measured 88.8 2026-10-12
internal/deprecation 75 # measured 77.3 2026-10-05
Expand All @@ -73,6 +75,7 @@ internal/errs 93 # measured 95.8 2026-10-05
internal/eval 77 # measured 79.1 2026-10-05
internal/exporter 58 # measured 60.2 2026-10-12 (P7-ADOPT-17; snapshot, pg_dump and auth reads need a live Postgres, covered by scripts/e2e/export.sh)
internal/featureflags 69 # measured 71.7 2026-10-05
internal/frontcheck 98 # measured 100.0 2026-10-29
internal/generate 65 # measured 67.5 2026-10-05
internal/hasura 87 # measured 89.1 2026-10-05
internal/health 26 # measured 28.1 2026-10-05
Expand Down Expand Up @@ -110,6 +113,7 @@ internal/migration 14 # measured 16.8 2026-10-05
internal/migrationai 69 # measured 71.3 2026-10-05
internal/model 65 # measured 67.3 2026-10-05
internal/nginx 75 # measured 77.4 2026-10-05
internal/nginx/plugincheck 98 # measured 100.0 2026-10-29
internal/nginx/routemodel 66 # measured 68.2 2026-10-08
internal/nginxtopo 98 # measured 100.0 2026-10-05
internal/oauth 82 # measured 84.4 2026-10-05
Expand Down
24 changes: 24 additions & 0 deletions .github/docs/maintenance-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Maintenance safety

Deploy rollback treats an absent `.nself/state/deploy.json` as a fresh install.
An existing record must identify its schema version or legacy strategy. Empty
objects and JSON `null` are errors, so they cannot select legacy rollback.
Before remote image pulls, an inspect failure stops the apply unless Docker
explicitly reports that the image is absent. Invalid image IDs also stop the apply.

Operation locks invalidate their re-entry token before releasing the kernel
lock. A token from a different host cannot authorize re-entry. Unsupported
platform notices appear only when stderr is a terminal.

Plugin nginx validation ignores template examples in comments and permits
native uppercase nginx variables. Known unresolved template names still refuse
the snippet. Init container classification uses the effective Compose label
after applying overrides in file order.

Hasura metadata exports preserve strings with multiple trailing newlines through
the include layout. Hasura CLI projects may omit `metadata_directory` to use
`metadata`; directory aliases are compared by filesystem identity.

The affected-package helper scans owned data paths for readers outside their
owning package and includes importers when embedded data is deleted. The vendor
check preserves spaces in local replacement paths when scanning for symlinks.
25 changes: 1 addition & 24 deletions cmd/commands/deploy_promote_proof.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ package commands

import (
"context"
"fmt"
"sort"

cpromote "github.com/nself-org/cli/internal/controlplane/promote"
Expand Down Expand Up @@ -52,28 +51,6 @@ func provePromoteApplied(ctx context.Context, w *promoteWiring, st *cpromote.Sta
return nil
}

// matchPromoteRunning requires one running identity for each compared service.
func matchPromoteRunning(rows []cpromote.DigestRow, images []cpromote.SourceImage) error {
if len(rows) == 0 {
return fmt.Errorf("no compared image identities")
}
running := make(map[string]string, len(images))
for _, image := range images {
running[image.Service] = image.Digest
}
for _, row := range rows {
if row.Status == "unverifiable" {
delete(running, row.Service)
continue
}
digest, found := running[row.Service]
if !found || row.Target == "" || digest != row.Target || row.Source != row.Target {
return fmt.Errorf("service %s running digest %s differs from compared digest %s", row.Service, digest, row.Target)
}
delete(running, row.Service)
}
if len(running) > 0 {
return fmt.Errorf("running release has uncompared services")
}
return nil
return cpromote.MatchRunning(rows, images)
}
3 changes: 3 additions & 0 deletions cmd/commands/deploy_promote_signal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func (r *promoteSignalRemote) Run(ctx context.Context, command string) ([]byte,
<-ctx.Done()
return nil, ctx.Err()
}
if strings.Contains(command, "{{.Id}}") {
return []byte("sha256:" + strings.Repeat("a", 64) + " web:1\n"), nil
}
raw, err := r.v15Remote.Run(ctx, command)
if err == nil && (strings.Contains(command, "images --format json") || strings.Contains(command, " ps --all --format json") || strings.Contains(command, "image inspect") || strings.Contains(command, "config --format json --no-env-resolution") || strings.Contains(command, "__NSELF_ABSENT__")) {
return r.proof.Run(ctx, command)
Expand Down
3 changes: 3 additions & 0 deletions internal/build/plugin_configs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ func TestPluginSnippetSkip(t *testing.T) {
hasSSL bool
want string // substring of the reason; "" = injected
}{
{"comment placeholder", "# ${NSELF_DOMAIN}\nserver { listen 80; }\n", false, ""},
{"native uppercase variable", "map $host $UPSTREAM { default localhost; }\nserver { location / { proxy_pass http://${UPSTREAM}; } }\n", false, ""},
{"native route variable", "map $host $CUSTOM_ROUTE { default localhost; }\nserver { location / { proxy_pass http://${CUSTOM_ROUTE}; } }\n", false, ""},
{"valid server block", "server {\n listen 80;\n server_name a.example.com;\n location / { return 204; }\n}\n", false, ""},
{"nginx variable in braces", "server {\n listen 80;\n location / { return 301 https://${host}$request_uri; }\n}\n", false, ""},
{"upstream and map at top level", "upstream u { server a:1; }\nmap $http_upgrade $c { default upgrade; }\nserver { listen 80; }\n", false, ""},
Expand Down
43 changes: 2 additions & 41 deletions internal/build/plugin_nginx.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ import (
"log/slog"
"os"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/nself-org/cli/internal/config"
"github.com/nself-org/cli/internal/nginx/routemodel"
"github.com/nself-org/cli/internal/nginxtopo"
"github.com/nself-org/cli/internal/nginx/plugincheck"
)

// claimsOf returns the set of "<port>|<server_name>" claims a rendered
Expand Down Expand Up @@ -109,45 +107,8 @@ func InjectPluginNginxRoutes(workdir, pluginDir string, cfg *config.Config) (int
return injectPluginNginxRoutesVia(newDiskSink(workdir), workdir, pluginDir, cfg, true)
}

// placeholderRE matches a ${NAME} template variable buildTemplateVars does
// not provide. nginx's own variables are lower case, so ${host} is left alone.
var placeholderRE = regexp.MustCompile(`\$\{[A-Z][A-Z0-9_]*\}`)

// serverOnly lists directives nginx accepts only inside server (or deeper):
// at the http{} level nginx/sites is included in, each one stops nginx with
// "directive is not allowed here".
var serverOnly = map[string]bool{"location": true, "if": true, "listen": true, "server_name": true,
"proxy_pass": true, "fastcgi_pass": true, "uwsgi_pass": true, "scgi_pass": true, "grpc_pass": true,
"return": true, "rewrite": true, "try_files": true, "set": true, "alias": true, "internal": true, "limit_except": true}

// pluginSnippetSkip returns why a rendered plugin snippet must stay out of
// nginx/sites, or "" to inject it. One bad file in nginx/sites stops nginx,
// taking every route down, and installed plugin copies can be old, so the
// build checks what it can before nginx does (D-0478 review):
// - a ${NAME} the CLI does not render (nginx reads it as an unknown variable);
// - text that does not parse, or a top-level directive that is valid only
// inside a server block (a bare location block);
// - a load of the base domain's certificate pair (the ${SSL_DIR} path) while
// the build serves no TLS (hasSSL false: SSL_MODE none, or letsencrypt/
// custom before the pair exists), which would also block the ACME
// challenge that issues it.
func pluginSnippetSkip(rendered string, vars map[string]string, hasSSL bool) string {
if m := placeholderRE.FindString(rendered); m != "" {
return "it uses " + m + ", which nself does not provide"
}
tree, err := routemodel.Parse(rendered)
if err != nil {
return "it does not parse: " + err.Error()
}
for _, b := range tree {
if serverOnly[b.Name] {
return "it has a top-level " + b.Name + " directive, which nginx accepts only inside a server block"
}
}
if !hasSSL && strings.Contains(rendered, nginxtopo.NginxSSLContainerPath+"/"+vars["SSL_DIR"]+"/") {
return "it needs the base domain's TLS certificate, which this build does not serve (issue it with nself trust ssl setup, or set SSL_MODE=local)"
}
return ""
return plugincheck.Skip(rendered, vars, hasSSL)
}

// resolvePluginNginxDir returns the plugin directory the nginx snippets are
Expand Down
29 changes: 29 additions & 0 deletions internal/controlplane/promote/running.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package promote

import "fmt"

// MatchRunning requires one running identity for each compared service.
func MatchRunning(rows []DigestRow, images []SourceImage) error {
if len(rows) == 0 {
return fmt.Errorf("no compared image identities")
}
running := make(map[string]string, len(images))
for _, image := range images {
running[image.Service] = image.Digest
}
for _, row := range rows {
if row.Status == "unverifiable" {
delete(running, row.Service)
continue
}
digest, found := running[row.Service]
if !found || row.Target == "" || digest != row.Target || row.Source != row.Target {
return fmt.Errorf("service %s running digest %s differs from compared digest %s", row.Service, digest, row.Target)
}
delete(running, row.Service)
}
if len(running) > 0 {
return fmt.Errorf("running release has uncompared services")
}
return nil
}
27 changes: 27 additions & 0 deletions internal/controlplane/promote/running_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package promote

import "testing"

func TestMatchRunningIdentities(t *testing.T) {
row := DigestRow{Service: "web", Source: "sha256:a", Target: "sha256:a"}
image := SourceImage{Service: "web", Digest: "sha256:a"}
for _, tc := range []struct {
name string
rows []DigestRow
images []SourceImage
wantErr bool
}{
{"equal", []DigestRow{row}, []SourceImage{image}, false},
{"no comparison", nil, []SourceImage{image}, true},
{"missing running service", []DigestRow{row}, nil, true},
{"changed running digest", []DigestRow{row}, []SourceImage{{Service: "web", Digest: "sha256:b"}}, true},
{"uncompared service", []DigestRow{row}, []SourceImage{image, {Service: "other", Digest: "sha256:a"}}, true},
{"unverifiable service", []DigestRow{{Service: "web", Status: "unverifiable"}}, []SourceImage{image}, false},
} {
t.Run(tc.name, func(t *testing.T) {
if err := MatchRunning(tc.rows, tc.images); (err != nil) != tc.wantErr {
t.Fatalf("MatchRunning = %v, wantErr %t", err, tc.wantErr)
}
})
}
}
2 changes: 2 additions & 0 deletions internal/controlplane/sim/promote_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ func TestSimPromote(t *testing.T) {
else printf '[]'; fi ;;
*" ps --all --format json"*)
printf '{"Name":"proj-web-1","Service":"web","State":"running"}\n' ;;
"image inspect --format {{.Id}} "*)
printf 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n' ;;
*"image inspect --format"*)
for last; do :; done
digest=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Expand Down
2 changes: 2 additions & 0 deletions internal/controlplane/sim/remote_apply_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ case "$*" in
fi ;;
*"up -d --no-deps"*) for last; do :; done; n=$(cat "/tmp/sim-up-$last" 2>/dev/null || echo 0); echo $((n+1)) > "/tmp/sim-up-$last" ;;
"inspect -f"*) shift 3; for i in "$@"; do n=$(cat "/tmp/sim-up-${i#id-}" 2>/dev/null || echo 0); printf '2026-10-10T00:00:%02dZ\n' "$n"; done ;;
"image inspect --format {{.Id}} "*)
printf 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' ;;
"network inspect"*) exit 1 ;;
esac
exit 0
Expand Down
13 changes: 9 additions & 4 deletions internal/database/hasura_apply_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ package database
// D-0564). nself writes its own layout (a config.yaml index of !include lines
// at the metadata root, one <key>.yaml per top-level key; hasura_export_layout.go),
// which only the in-process include resolver reads. The Hasura CLI expects its
// own project (hasura/config.yaml naming metadata_directory, then version.yaml
// own project (hasura/config.yaml, optional metadata_directory, then version.yaml
// and databases/), so it is used only for a real Hasura CLI project.
// Inputs: the hasura/ project directory and its metadata/ directory.
// Outputs: isHasuraCLIProject.
Expand All @@ -27,7 +27,7 @@ type hasuraCLIProjectConfig struct {

// isHasuraCLIProject reports whether hasuraDir is a Hasura CLI project whose
// metadata the hasura binary should apply: hasuraDir/config.yaml names a
// metadata_directory that resolves to metadataDir, and metadataDir carries no
// metadata_directory (default metadata) identifying metadataDir, which carries no
// nself config.yaml index (that index is nself's layout, which the Hasura CLI
// cannot read).
func isHasuraCLIProject(hasuraDir, metadataDir string) bool {
Expand All @@ -44,10 +44,15 @@ func isHasuraCLIProject(hasuraDir, metadataDir string) bool {
}
dir := strings.TrimSpace(c.MetadataDirectory)
if dir == "" {
return false
dir = "metadata"
}
if !filepath.IsAbs(dir) {
dir = filepath.Join(hasuraDir, dir)
}
return filepath.Clean(dir) == filepath.Clean(metadataDir)
configured, err := os.Stat(dir)
if err != nil || !configured.IsDir() {
return false
}
metadata, err := os.Stat(metadataDir)
return err == nil && metadata.IsDir() && os.SameFile(configured, metadata)
}
18 changes: 17 additions & 1 deletion internal/database/hasura_apply_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestIsHasuraCLIProject(t *testing.T) {
{"cli project", map[string]string{"hasura/config.yaml": "metadata_directory: metadata\n"}, true},
{"cli project ./metadata", map[string]string{"hasura/config.yaml": "metadata_directory: ./metadata\n"}, true},
{"no project config", map[string]string{"hasura/metadata/tables.yaml": "[]\n"}, false},
{"no metadata_directory", map[string]string{"hasura/config.yaml": "version: 3\n"}, false},
{"default metadata_directory", map[string]string{"hasura/config.yaml": "version: 3\n"}, true},
{"other metadata_directory", map[string]string{"hasura/config.yaml": "metadata_directory: md\n"}, false},
{"unparsable config", map[string]string{"hasura/config.yaml": "metadata_directory: [\n"}, false},
{"nself index wins", map[string]string{
Expand All @@ -150,3 +150,19 @@ func TestIsHasuraCLIProject(t *testing.T) {
}
}
}

func TestIsHasuraCLIProjectSymlinkDirectory(t *testing.T) {
project := t.TempDir()
metadata := filepath.Join(project, "metadata")
if err := os.Mkdir(metadata, 0o755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(project, "linked-metadata")
if err := os.Symlink(metadata, alias); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
writeFixtureFile(t, project, "config.yaml", "metadata_directory: linked-metadata\n")
if !isHasuraCLIProject(project, metadata) {
t.Fatal("metadata directory aliases must identify the same CLI project")
}
}
18 changes: 18 additions & 0 deletions internal/database/hasura_archive_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ func TestHasuraLayoutExportEqualsArchive(t *testing.T) {
}
}

// Keep-chomp YAML strings must preserve every trailing newline through includes.
func TestHasuraLayoutPreservesTrailingNewlines(t *testing.T) {
data := []byte(`{"actions":[{"definition":{"handler":"https://example.test\n\n\n"}}]}`)
dir := t.TempDir()
if err := writeMetadataTree(dir, data); err != nil {
t.Fatal(err)
}
resolved, err := resolveIncludes(dir, filepath.Join(dir, metadataIndexFile))
if err != nil {
t.Fatal(err)
}
var doc interface{}
if err := yaml.Unmarshal(resolved, &doc); err != nil {
t.Fatal(err)
}
sameJSON(t, convertYAMLToJSON(doc), data)
}

// An older monolithic archive (one config.yaml, everything inline) still resolves.
func TestHasuraLayoutLegacyMonolithicStillResolves(t *testing.T) {
data := layoutInput(t)
Expand Down
27 changes: 4 additions & 23 deletions internal/database/hasura_export_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ package database
// style) instead. The same JSON form is used when the body contains the text
// "!include", with each "!include" written as "\u0021include", so a string value
// is never mistaken for an include directive; YAML decodes it back to "!".
// Multi-line bodies without "!include" stay block YAML.
// Keep-chomp scalars also use JSON because the resolver trims trailing newlines.
// Other multi-line bodies without "!include" stay block YAML.
// Pure and deterministic (sorted keys, no clock, no disk) except
// writeMetadataTree, which only writes the files it is given. A key that is not
// a safe file name is rejected. The layout is what applyViaIncludeResolver reads.
Expand All @@ -25,7 +26,7 @@ import (
"sort"
"strings"

"gopkg.in/yaml.v3"
"github.com/nself-org/cli/internal/database/metadatavalue"
)

const metadataIndexFile = "config.yaml"
Expand Down Expand Up @@ -55,7 +56,7 @@ func metadataLayout(data []byte) ([]metadataFile, error) {
files := make([]metadataFile, 0, len(keys)+1)
var index strings.Builder
for _, k := range keys {
body, err := marshalMetadataValue(raw[k])
body, err := metadatavalue.Marshal(raw[k])
if err != nil {
return nil, fmt.Errorf("marshal %s to YAML: %w", k, err)
}
Expand All @@ -67,26 +68,6 @@ func metadataLayout(data []byte) ([]metadataFile, error) {
return files, nil
}

// marshalMetadataValue encodes one top-level value for its <key>.yaml file:
// block YAML, except single-line bodies and bodies containing "!include", which
// become one line of JSON with "!include" escaped (see the header for why).
func marshalMetadataValue(v interface{}) ([]byte, error) {
body, err := yaml.Marshal(v)
if err != nil {
return nil, err
}
oneLine := strings.Count(strings.TrimRight(string(body), "\n"), "\n") == 0
if !oneLine && !strings.Contains(string(body), "!include") {
return body, nil
}
js, err := json.Marshal(v)
if err != nil {
return nil, err
}
esc := strings.ReplaceAll(string(js), "!include", `\u0021include`)
return []byte(esc + "\n"), nil
}

// checkMetadataKey rejects a top-level key that would escape the directory,
// collide with the index file, or break an `!include` line.
func checkMetadataKey(k string) error {
Expand Down
Loading
Loading