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
6 changes: 6 additions & 0 deletions .github/wiki/cmd-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ The steps are resolve, confirm, verify, preflight, source-health, capture, build
compare, snapshot, save, apply, migrate, health, and record. Builds use the target
cascade in a scratch directory. Production values never enter the operator's tree.

Local-server promotion on a Windows operator machine is unproven end to end. The
local runner starts every command through `sh -c`, so it needs a POSIX `sh` on
`PATH`, and the model resolver gives that process a minimal environment (`PATH`,
`HOME`, `DOCKER_CONFIG`, `XDG_CONFIG_HOME`) without `SYSTEMROOT`, which Windows
programs often need. Promote from Linux or macOS, or from WSL.

Compare proves per-platform image digest equality before taking a snapshot or
changing the target. It resolves every staged compose fragment with the target's
environment files and captures the source using its compose manifest. Each service's
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/deploy_promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func runPromoteEnvironments(cmd *cobra.Command, source, target string) error {

// resolveModel isolates Compose interpolation from the operator's shell values.
func (r localPromoteRemote) resolveModel(ctx context.Context, command string) ([]byte, error) {
c := exec.CommandContext(ctx, "sh", "-c", command) //nolint:gosec // fixed command and validated, quoted paths
c := exec.CommandContext(ctx, "sh", "-c", command) //nolint:gosec // fixed command fragments; remote paths are validated and every path is shell-quoted (local paths are quoted only)
c.Dir = r.root
c.Env = []string{"PATH=" + os.Getenv("PATH")}
for _, key := range []string{"HOME", "DOCKER_CONFIG", "XDG_CONFIG_HOME"} {
Expand Down
7 changes: 7 additions & 0 deletions cmd/commands/deploy_scratch_fronted.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ func finishScratchStage(w io.Writer, set deploy.ArtifactSet, stale []string) (de
// clearScratch removes the scratch root of env (both layouts).
func clearScratch(workdir, env string) error {
root := scratchDirFor(workdir, env)
// RemoveAll follows a symlinked ancestor, so a planted .nself or
// .nself/deploy-build link would aim the delete at another tree.
for _, anc := range []string{filepath.Join(workdir, ".nself"), filepath.Dir(root)} {
if fi, err := os.Lstat(anc); err == nil && fi.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("deploy: %s is a symlink; refusing to clear the scratch directory through it", anc)
}
}
if err := os.RemoveAll(root); err != nil {
return fmt.Errorf("deploy: clearing %s: %w", root, err)
}
Expand Down
42 changes: 42 additions & 0 deletions cmd/commands/deploy_scratch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,45 @@ func isolateDeployConfigEnv(t *testing.T) {
}
}
}

// D-0824: clearScratch never deletes through a symlinked .nself ancestor.
func TestClearScratchRefusesSymlinkedAncestor(t *testing.T) {
for _, tc := range []struct {
name string
link func(work, other string) string // returns the planted link's path
}{
{".nself", func(work, other string) string { return filepath.Join(work, ".nself") }},
{".nself/deploy-build", func(work, other string) string {
_ = os.MkdirAll(filepath.Join(work, ".nself"), 0o755)
return filepath.Join(work, ".nself", "deploy-build")
}},
} {
t.Run(tc.name, func(t *testing.T) {
work, other := t.TempDir(), t.TempDir()
victim := filepath.Join(other, "prod", "keep.txt") // what scratchDirFor(work, "prod") would hit through the link
wf(t, victim, "keep\n")
if tc.name == ".nself" {
wf(t, filepath.Join(other, "deploy-build", "prod", "keep.txt"), "keep\n")
victim = filepath.Join(other, "deploy-build", "prod", "keep.txt")
}
if err := os.Symlink(other, tc.link(work, other)); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := clearScratch(work, "prod"); err == nil {
t.Error("clearScratch went through a symlinked ancestor without an error")
}
if _, err := os.Stat(victim); err != nil {
t.Errorf("the file behind the link was deleted: %v", err)
}
})
}
// A plain tree is still cleared.
work := t.TempDir()
wf(t, filepath.Join(work, ".nself", "deploy-build", "prod", "stale.txt"), "x")
if err := clearScratch(work, "prod"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(work, ".nself", "deploy-build", "prod")); err == nil {
t.Error("the scratch root was not cleared")
}
}
13 changes: 7 additions & 6 deletions internal/controlplane/promote/digest_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func parseComposeRows[T any](raw []byte, what string) ([]T, error) {
return rows, nil
}

// composePSCommand lists every container of the source project with its service.
const composePSCommand = "docker compose ps --all --format json"

// composeContainer is one row of `docker compose ps --format json`.
type composeContainer struct {
Name string `json:"Name"`
Expand All @@ -56,15 +59,13 @@ type composeContainer struct {
// Compose's images JSON carries ContainerName and no Service (checked against
// Compose 5.6), so rows without a service are mapped through `docker compose ps`.
// A container that ps does not name keeps an empty service and fails closed.
// CaptureSourceManifest snapshots the ps listing next to the images listing, so
// the mapping here sees the same containers the images listing named.
func resolveImageServices(ctx context.Context, run Runner, images []composeImage) ([]composeImage, error) {
missing := false
for _, image := range images {
missing = missing || image.Service == ""
}
if !missing {
if !missingService(images) {
return images, nil
}
raw, err := run(ctx, "docker compose ps --all --format json")
raw, err := run(ctx, composePSCommand)
if err != nil {
return nil, fmt.Errorf("list source compose containers: %w", err)
}
Expand Down
30 changes: 29 additions & 1 deletion internal/controlplane/promote/source_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,35 @@ func CaptureSourceManifest(ctx context.Context, run Runner, root string) (Runner
return run(c, command)
}
raw, err := captured(ctx, "docker compose images --format json")
return captured, raw, err
if err != nil {
return captured, raw, err
}
// Images rows without a service are mapped through ps. Capture that listing
// now and replay it, so a container recreated or renamed before the compare
// cannot be mapped to another service.
if rows, perr := parseComposeImages(raw); perr == nil && missingService(rows) {
ps, err := captured(ctx, composePSCommand)
if err != nil {
return captured, raw, fmt.Errorf("list source compose containers: %w", err)
}
replay := func(c context.Context, command string) ([]byte, error) {
if command == composePSCommand {
return ps, nil
}
return captured(c, command)
}
return replay, raw, nil
}
return captured, raw, nil
}

func missingService(images []composeImage) bool {
for _, image := range images {
if image.Service == "" {
return true
}
}
return false
}

// sourceManifest reads optional host manifests; every entry is validated before use.
Expand Down
47 changes: 47 additions & 0 deletions internal/controlplane/promote/source_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,50 @@ func TestResolvedComposeImagesLocalPaths(t *testing.T) {
}
}
}

// D-0701: the ps listing that maps containers to services is read at capture,
// next to the images listing, and replayed at compare. A container that moves
// to another service in between cannot change the mapping.
func TestCaptureSourceManifestReplaysPS(t *testing.T) {
ctx := context.Background()
psReads := 0
ps := []string{`{"Name":"proj-api-1","Service":"api"}` + "\n" + `{"Name":"proj-db-1","Service":"db"}`, `{"Name":"proj-api-1","Service":"db"}` + "\n" + `{"Name":"proj-db-1","Service":"api"}`}
run := func(_ context.Context, command string) ([]byte, error) {
switch {
case strings.Contains(command, ".nself/compose"):
return []byte("__NSELF_ABSENT__"), nil
case strings.HasSuffix(command, " images --format json"):
return []byte(`[{"ID":"sha256:a","ContainerName":"proj-api-1","Repository":"example.invalid/api","Tag":"v1"},{"ID":"sha256:d","ContainerName":"proj-db-1","Repository":"example.invalid/db","Tag":"v1"}]`), nil
case strings.HasSuffix(command, " ps --all --format json"):
psReads++
return []byte(ps[min(psReads-1, len(ps)-1)]), nil // the second read is the swapped one
case strings.Contains(command, " config --format json"):
return []byte(`{"services":{"api":{"image":"example.invalid/api:v1"},"db":{"image":"example.invalid/db:v1"}}}`), nil
case strings.HasPrefix(command, "docker image inspect"):
repo := "example.invalid/api"
if strings.Contains(command, "sha256:d") {
repo = "example.invalid/db"
}
return []byte(`["` + repo + `@sha256:` + strings.Repeat("c", 64) + `"]|linux/amd64`), nil
}
return nil, fmt.Errorf("unexpected command %s", command)
}
lookup := func(_ context.Context, ref string) (map[string]string, error) {
return map[string]string{"linux/amd64": "sha256:" + strings.Repeat(map[bool]string{true: "a", false: "b"}[strings.Contains(ref, "/api")], 64)}, nil
}
captured, raw, err := CaptureSourceManifest(ctx, run, "/srv/fixture")
if err != nil || psReads != 1 {
t.Fatalf("capture: %v, ps reads %d", err, psReads)
}
images, err := CaptureManifestSource(ctx, captured, raw, lookup)
if err != nil || psReads != 1 {
t.Fatalf("compare: %v, ps reads %d (the compare must not list containers again)", err, psReads)
}
got := map[string]string{}
for _, image := range images {
got[image.Service] = image.Ref
}
if got["api"] != "example.invalid/api:v1" || got["db"] != "example.invalid/db:v1" {
t.Fatalf("mapping followed the later ps listing: %#v", got)
}
}
56 changes: 56 additions & 0 deletions internal/controlplane/promote/verify/evidence_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package verify

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

// goldenRunner replays a golden captured from the real ci plugin
// (testdata/README.md) as the plugin's stdout with the given exit status.
func goldenRunner(t *testing.T, name string, exit int) EvidenceRunner {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return func(context.Context, string, string) ([]byte, int, error) { return raw, exit, nil }
}

// D-1217: the report keys read here (data.class, data.result, data.quarantined,
// error.message) are the ones the real plugin prints.
func TestEvidenceReaderMatchesPluginGoldens(t *testing.T) {
ctx := context.Background()
t.Run("keys decode from the real report", func(t *testing.T) {
for _, name := range []string{"evidence-verify-pass.golden.json", "evidence-verify-release-class.golden.json"} {
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
var rep evidenceReport
if err := json.Unmarshal(raw, &rep); err != nil || rep.Data.Class == "" || rep.Data.Result == "" || rep.Data.Quarantined == nil {
t.Errorf("%s: class %q result %q quarantined %v: a key the reader needs is missing", name, rep.Data.Class, rep.Data.Result, rep.Data.Quarantined)
}
}
})
t.Run("a passing default-class report is refused for its class", func(t *testing.T) {
err := checkEvidence(ctx, goldenRunner(t, "evidence-verify-pass.golden.json", 0), "evidence.json", "rev")
if err == nil || !strings.Contains(err.Error(), `class "default" result "pass"`) {
t.Fatalf("err = %v, want E497 naming class default result pass", err)
}
})
t.Run("a release-class passing report is accepted when the plugin exits 0", func(t *testing.T) {
if err := checkEvidence(ctx, goldenRunner(t, "evidence-verify-release-class.golden.json", 0), "evidence.json", "rev"); err != nil {
t.Fatalf("err = %v, want nil: class/result/quarantined were not read from the real report", err)
}
})
t.Run("a refusal carries the plugin's error message", func(t *testing.T) {
err := checkEvidence(ctx, goldenRunner(t, "evidence-verify-rejected.golden.json", 11), "evidence.json", "rev")
if codeOf(err) != "E497" || !strings.Contains(err.Error(), "evidence rejected") {
t.Fatalf("err = %v, want E497 with the plugin's message", err)
}
})
}
21 changes: 21 additions & 0 deletions internal/controlplane/promote/verify/testdata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# evidence verify goldens

Real stdout of `ci evidence verify --file <evidence.json> --at-revision <sha> --json`,
captured from the ci plugin (nself-org/plugins, free/ci, plugin version 1.2.13,
plugins commit 076f5063f245cbe0fcd2d05120eef2a477679e6c), built with
`go build -mod=vendor ./cmd` and run against a scratch git repository
(`NSELF_V15=1`, `NSELF_CI_REPO`, `NSELF_CI_HOME`, one `kind: test` job).

- `evidence-verify-pass.golden.json`: a passing default-class document (exit 0).
- `evidence-verify-release-class.golden.json`: the same document with its check
kind edited to `release`, which the plugin classes as `release` (exit 11: the
edit breaks the document's digests, so the plugin rejects it, but the report
still carries the class). A passing release-class run needs a container
executor, which the capture host did not have.
- `evidence-verify-rejected.golden.json`: a rejected document (exit 11), the
error envelope.

`evidence_contract_test.go` feeds these through the same reader promote uses, so
a plugin rename of `data.class`, `data.result`, `data.quarantined` or
`error.message` fails here instead of turning every promote into E497.
To refresh after a plugin report change, rerun the command above and replace the files.
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{
"schema_version": "1",
"command": "ci evidence verify",
"data": {
"revision": "4613469d35e5185e840146bb8029963dd98e117a",
"result": "pass",
"class": "default",
"binding": {
"state": "bound",
"reason": null
},
"freshness": {
"state": "current",
"reason": null
},
"quarantined": false,
"evidence": {
"schema": "ci.evidence/v1",
"revision": "4613469d35e5185e840146bb8029963dd98e117a",
"repo_id": "local:4710ee8d7d0fd5ea52e5912f29b59b25dde280901f343827487fce28e0a9fb5c",
"run_id": "r20261028T124124-6c6f249b",
"pipeline": {
"id": "default",
"name": "default",
"kind": "default"
},
"trigger": "local",
"source_trust": "owner",
"policy_digest": "9a1bfbc95f4d2fd4600559bea48a6e2e9a7112852fea1bd7898fb347ebd4f31f",
"input_digests": {
"config": "59deaad650ed51e4a92143e1ed3d51da2b7e795f388efca079d826ef17075830",
"lockfiles": [
{
"path": "go.sum",
"sha256": "363b7201a7fdf356fae5489cc44ec14f1526cb7fa905728bbec28a155967e755"
}
],
"generated": []
},
"worktree_state": "clean",
"tracked_changes_digest": null,
"binding": {
"state": "bound",
"reason": null
},
"runner": {
"id": "local",
"provider": "local",
"platform": "darwin/arm64",
"isolation": "process"
},
"producer": {
"kind": "coordinator-recorded",
"version": "1"
},
"timings": {
"queued_at": "2026-10-28T12:41:24Z",
"started_at": "2026-10-28T12:41:24Z",
"finished_at": "2026-10-28T12:41:25Z",
"duration_ms": 240
},
"tool_versions": {
"git": "2.56.0"
},
"checks": [
{
"id": "unit",
"job_id": "unit",
"kind": "test",
"tool": "",
"tool_version": null,
"required": true,
"substantive": true,
"result": "pass",
"findings": 0,
"excerpt": "",
"log_ref": null
}
],
"artifacts": [],
"retries": [],
"selection": {
"mode": "full",
"reason": "selection.no_base",
"digest": "247589de6d9f5793deeafb412aad0162a418d4caf0c5d0d38881e1af2e3cb82b"
},
"placement_ref": "sched:decision/r20261028T124124-6c6f249b.unit-385cfdbc.a1",
"result": "pass",
"counts": {
"pass": 1,
"fail": 0,
"skip": 0,
"error": 0,
"total": 1
},
"freshness": {
"state": "current",
"reason": null
},
"signature": {
"kind": "coordinator-recorded"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schema_version": "1",
"command": "ci evidence verify",
"error": {
"code": "E629",
"message": "E685: evidence rejected: evidence.invalid (schema validation at validating https://nself.org/schemas/ci-evidence.v1.schema.json: unexpected additional properties [\"not\"])",
"exit_code": 11,
"class": "other"
}
}
Loading
Loading