Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6300a48451 | ||
|
|
1d1c8182be |
@@ -17,7 +17,10 @@
|
|||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules.
|
||||||
.git/config
|
.git/config
|
||||||
|
.git/modules/**/config
|
||||||
|
|
||||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
# Anchored because it occurs once where agents run at the repo root.
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
with:
|
||||||
|
# All history and tags, which `git describe --tags` needs.
|
||||||
|
fetch-depth: 0
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ linters:
|
|||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
|
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
|||||||
+27
-29
@@ -1,11 +1,11 @@
|
|||||||
# The lint phase, the test phase and the build. script/lint and
|
# The lint phase, the test phase and a development environment.
|
||||||
# script/test each build one phase alone; a plain `docker build .` builds
|
# script/lint and script/test each build one phase alone; a plain
|
||||||
# both, because the build stage copies a file from each. Formatting is
|
# `docker build .` builds both, because the last stage copies a file from
|
||||||
# checked on the host by script/fmt-check, not here.
|
# each. Formatting is checked on the host by script/fmt-check, not here.
|
||||||
|
|
||||||
# Lint phase
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.12.2, 2026-09-07
|
# golangci/golangci-lint:v2.14.0, 2026-10-04
|
||||||
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -16,13 +16,12 @@ COPY . .
|
|||||||
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version
|
# image ships.
|
||||||
# script/bootstrap installs on the host; change both together.
|
# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version
|
||||||
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test
|
# script/bootstrap installs on the host; change both together, and the
|
||||||
|
# same image in the last stage.
|
||||||
# -race needs cgo, and cgo needs a C toolchain.
|
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test
|
||||||
RUN apk add --no-cache gcc musl-dev
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -35,21 +34,27 @@ RUN go test -timeout 90s -race -cover ./... || \
|
|||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies
|
# Development environment, and the last stage: a plain `docker build .`
|
||||||
# are what make BuildKit build them first, so this stage cannot run
|
# builds this one. It holds the source tree in /src, what
|
||||||
# unless lint and test passed.
|
# script/bootstrap installs, and keyfunc built from that tree on the
|
||||||
# golang:1.26-alpine, 2026-09-07
|
# PATH. Nothing is wanted from either phase above; the copies are what
|
||||||
FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder
|
# make BuildKit build them first, so this stage cannot run unless lint
|
||||||
|
# and test passed.
|
||||||
|
# golang:1.26.8-trixie, 2026-10-04
|
||||||
|
FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379
|
||||||
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=test /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
|
||||||
RUN apk add --no-cache make git
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
# script/bootstrap needs only script/ and the dependency manifests.
|
||||||
RUN go mod download
|
COPY script/ script/
|
||||||
|
COPY go.mod go.sum package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -64,11 +69,4 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
|||||||
echo "no version could be derived although the build context carries .git" >&2; \
|
echo "no version could be derived although the build context carries .git" >&2; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
make build VERSION="$version"
|
make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc
|
||||||
|
|
||||||
# alpine:3.23, 2026-09-07
|
|
||||||
FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
|
|
||||||
|
|
||||||
COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc
|
|
||||||
|
|
||||||
ENTRYPOINT ["keyfunc"]
|
|
||||||
|
|||||||
@@ -290,15 +290,15 @@ passes through `ssh`'s own exit status.
|
|||||||
|
|
||||||
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
|
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
|
||||||
with the status a shell gives a program killed by that signal (130 for SIGINT).
|
with the status a shell gives a program killed by that signal (130 for SIGINT).
|
||||||
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file: it removes
|
An interrupted `age encrypt -o` or `age decrypt -o` leaves no file. While it is
|
||||||
the unfinished file it was writing, leaves a file already at the named path as
|
writing the file, the signal makes it remove the unfinished file, leave a file
|
||||||
it was, and exits with status 1. It puts the file in place a tenth of a second
|
already at the named path as it was, and exit with status 1. It puts the file in
|
||||||
after its input ends, and a signal in that time still counts: Ctrl-C on a
|
place a tenth of a second after its input ends, and a signal in that time still
|
||||||
pipeline also ends the program feeding it, so the input can end just before the
|
counts: Ctrl-C on a pipeline also ends the program feeding it, so the input can
|
||||||
signal arrives. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the
|
end just before the signal arrives. While `ssh to` or `ssh install` has `ssh` or
|
||||||
signal ends that program instead, the tool removes its agent socket or working
|
`sftp` running, the signal ends that program instead, the tool removes its agent
|
||||||
files, and it exits with status 1, or for `ssh to` with `ssh`'s own status if
|
socket or working files, and it exits with status 1, or for `ssh to` with
|
||||||
`ssh` reported one.
|
`ssh`'s own status if `ssh` reported one.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
@@ -343,7 +343,10 @@ standard: most Makefile targets are thin shims over an executable in `script/`
|
|||||||
- `script/docker` builds the Docker image, uncached, tagged with the project
|
- `script/docker` builds the Docker image, uncached, tagged with the project
|
||||||
name and stamped with the version `git describe` gives on the host. The image
|
name and stamped with the version `git describe` gives on the host. The image
|
||||||
cannot be built unless the `lint` and `test` phases pass, so a plain
|
cannot be built unless the `lint` and `test` phases pass, so a plain
|
||||||
`docker build .` runs them too.
|
`docker build .` runs them too. The image is a development environment, not a
|
||||||
|
runtime image: the Debian Go image with what `script/bootstrap` installs, the
|
||||||
|
source tree in `/src`, and `keyfunc` built from it on the `PATH`.
|
||||||
|
`docker run --rm -it keyfunc` opens a shell in it.
|
||||||
- `script/cibuild` is the CI build the Gitea workflow calls: it runs
|
- `script/cibuild` is the CI build the Gitea workflow calls: it runs
|
||||||
`bootstrap`, then `check`, then builds the image as `script/docker` does.
|
`bootstrap`, then `check`, then builds the image as `script/docker` does.
|
||||||
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
|
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
|
||||||
|
|||||||
+55
-36
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-02
|
last_modified: 2026-10-04
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -160,7 +160,7 @@ style conventions are in separate documents:
|
|||||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
hash), so lint failures surface in seconds rather than after a full compile,
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
and the test phase is based on the Go image. The canonical Go repo
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
`Dockerfile`:
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
@@ -173,8 +173,9 @@ style conventions are in separate documents:
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not.
|
||||||
|
# golang:1.x, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS test
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -192,6 +193,8 @@ style conventions are in separate documents:
|
|||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=test /src/go.sum /dev/null
|
COPY --from=test /src/go.sum /dev/null
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -236,19 +239,23 @@ style conventions are in separate documents:
|
|||||||
- If the project requires CGO or system libraries for linting (e.g.
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
`vips-dev`), install them in the lint phase with `apk add`.
|
`vips-dev`), install them in the lint phase with `apk add`.
|
||||||
- `.dockerignore` lets `.git` into the build context. It keeps out
|
- `.dockerignore` lets `.git` into the build context. It keeps out
|
||||||
`.git/config`, which `git describe` does not need and which can hold a
|
`.git/config` and each submodule's `config` under `.git/modules/` at any
|
||||||
credential: a password in a remote URL, or the token the CI checkout step
|
depth (`.git/modules/**/config`), which `git describe` does not need and
|
||||||
stores there. The stage that compiles has `git` (the Debian Go image has
|
which can hold a credential: a password in a remote URL, or the token the
|
||||||
it; an alpine one needs `apk add --no-cache git`) and takes the version
|
CI checkout step stores there. The stage that compiles has `git` (the
|
||||||
from the `VERSION` build argument when one is given, otherwise from
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
`git describe --tags --always`. That gives the tag on a tagged commit; on
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
a later commit, the tag, the number of commits since it and the short
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
reachable. `ARG VERSION` has no default, and the build fails if the
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
context carries `.git` and the version still comes out empty, `dev` or
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
`unknown`. A plain `docker build .` with no build arguments must succeed;
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
a Dockerfile that refuses an empty build argument drops that refusal and
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
keeps the argument.
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
@@ -310,17 +317,19 @@ style conventions are in separate documents:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
cache, so the target cannot report a pass it did not earn, and the rerun
|
cache, so neither run can report a stored pass in place of running the
|
||||||
reproduces a failure instead of replaying it. It leaves the build cache
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
alone, so it costs the runtime of the suite and no recompilation.
|
and no recompilation.
|
||||||
|
|
||||||
Note that this is a second, independent cache, stacked below the Docker
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
it does not guarantee `go test` inside that step does any work, because the
|
without running them. That matters on a developer's machine, where this
|
||||||
`GOCACHE` baked into earlier image layers survives into the re-executed
|
target runs and the directory lasts from one run to the next. The `test`
|
||||||
step. They are two separate defects requiring two separate fixes, and a fix
|
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||||
for one must not be recorded as covering the other.
|
result for this repo's tests and nothing before its `go test` step runs a
|
||||||
|
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||||
|
step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
@@ -451,12 +460,18 @@ style conventions are in separate documents:
|
|||||||
`test-support` depguard rule, where a repo names its own test-support packages
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
by full import path. A repo adds entries there and changes nothing else, and a
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
re-vendor carries its entries forward. The canonical golangci-lint version is
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
image
|
image
|
||||||
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
only pin, since no repo installs golangci-lint on the host: bumping the
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
version means changing it and nothing else.
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
@@ -592,10 +607,10 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
@@ -626,3 +641,7 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
|
|||||||
+11
-12
@@ -3,18 +3,18 @@
|
|||||||
package age
|
package age
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||||
"sneak.berlin/go/keyfunc/internal/cli/options"
|
"sneak.berlin/go/keyfunc/internal/cli/options"
|
||||||
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||||
"sneak.berlin/go/keyfunc/internal/derive"
|
"sneak.berlin/go/keyfunc/internal/derive"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,17 +190,16 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
|
|||||||
// the new file in the named file's place only when the work succeeded,
|
// the new file in the named file's place only when the work succeeded,
|
||||||
// so a file that is already there survives a run that failed.
|
// so a file that is already there survives a run that failed.
|
||||||
//
|
//
|
||||||
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught. One that comes while
|
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context
|
||||||
// the work runs, or within signalWait after it has ended, wins: the new
|
// does. One that comes while the work runs, or within signalWait after
|
||||||
// file is removed and ErrInterrupted returned at once, without waiting
|
// it has ended, wins: the new file is removed and ErrInterrupted
|
||||||
// for the work, which may be blocked reading its input.
|
// returned at once, without waiting for the work, which may be blocked
|
||||||
|
// reading its input.
|
||||||
func output(
|
func output(
|
||||||
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
||||||
) error {
|
) error {
|
||||||
signals := make(chan os.Signal, 1)
|
interrupted, stop := signals.Context(context.Background())
|
||||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
defer stop()
|
||||||
|
|
||||||
defer signal.Stop(signals)
|
|
||||||
|
|
||||||
// The file is made in the same directory so that putting it in
|
// The file is made in the same directory so that putting it in
|
||||||
// place is a rename and never a copy, and it is readable only by
|
// place is a rename and never a copy, and it is readable only by
|
||||||
@@ -217,11 +216,11 @@ func output(
|
|||||||
select {
|
select {
|
||||||
case failed := <-worked:
|
case failed := <-worked:
|
||||||
select {
|
select {
|
||||||
case <-signals:
|
case <-interrupted.Done():
|
||||||
case <-time.After(signalWait):
|
case <-time.After(signalWait):
|
||||||
return finish(file, name, failed)
|
return finish(file, name, failed)
|
||||||
}
|
}
|
||||||
case <-signals:
|
case <-interrupted.Done():
|
||||||
}
|
}
|
||||||
|
|
||||||
return finish(file, name, ErrInterrupted)
|
return finish(file, name, ErrInterrupted)
|
||||||
|
|||||||
+64
-26
@@ -134,16 +134,40 @@ func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
named := filepath.Join(directory, "notes")
|
||||||
|
|
||||||
|
// nohup starts the tool with SIGHUP ignored. A tool that caught it
|
||||||
|
// anyway would turn it back on and be ended by it.
|
||||||
|
command, producer := writing(
|
||||||
|
t, directory, "the secret\n",
|
||||||
|
"nohup", os.Args[0], "age", "encrypt", "-o", named,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
|
||||||
|
require.NoError(t, producer.Close())
|
||||||
|
waitForTool(t, "SIGHUP under nohup", command)
|
||||||
|
|
||||||
|
require.Equal(t, 0, command.ProcessState.ExitCode())
|
||||||
|
|
||||||
|
left, err := os.ReadDir(directory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, left, 1)
|
||||||
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
|
||||||
|
}
|
||||||
|
|
||||||
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
||||||
// operation says, as a subprocess writing into a directory of its own
|
// operation says, writing into a directory of its own, and once it has
|
||||||
// and reading the input from a pipe. It waits until the tool has begun
|
// begun writing sends it the signal, then ends the input if endInput
|
||||||
// writing the file beside the one it was named, and sends it the
|
// says so and otherwise leaves it open. The tool has to end with status
|
||||||
// signal, then ends the input if endInput says so and otherwise leaves
|
// 1 and leave the directory empty. A tool that went on reading would
|
||||||
// it open. The tool has to end with status 1 and leave the directory
|
// not end until the input did; one that did not remove the file it was
|
||||||
// empty. A tool that went on reading would not end until the input
|
// writing would leave it there, with what it had written so far; one
|
||||||
// did; one that did not remove the file it was writing would leave it
|
// that put that file in place because the input ended would leave the
|
||||||
// there, with what it had written so far; one that put that file in
|
// named file.
|
||||||
// place because the input ended would leave the named file.
|
|
||||||
func interrupted(
|
func interrupted(
|
||||||
t *testing.T, signal os.Signal, operation, input string, endInput bool,
|
t *testing.T, signal os.Signal, operation, input string, endInput bool,
|
||||||
) {
|
) {
|
||||||
@@ -152,12 +176,38 @@ func interrupted(
|
|||||||
name := operation + " " + signal.String()
|
name := operation + " " + signal.String()
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
|
|
||||||
//nolint:gosec // the binary is this test's own, re-run as the tool
|
command, producer := writing(
|
||||||
command := exec.CommandContext(
|
t, directory, input,
|
||||||
t.Context(), os.Args[0], "age", operation,
|
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
|
||||||
"-o", filepath.Join(directory, "notes"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(signal))
|
||||||
|
|
||||||
|
if endInput {
|
||||||
|
require.NoError(t, producer.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForTool(t, name, command)
|
||||||
|
|
||||||
|
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
||||||
|
|
||||||
|
left, err := os.ReadDir(directory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, left, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writing starts argv, the tool told to write into directory, as a
|
||||||
|
// subprocess reading the input from a pipe, and returns once the tool
|
||||||
|
// has begun writing the file beside the one it was named. The pipe is
|
||||||
|
// left open for the caller to end.
|
||||||
|
func writing(
|
||||||
|
t *testing.T, directory, input string, argv ...string,
|
||||||
|
) (*exec.Cmd, io.WriteCloser) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // this test's own binary as the tool, or nohup running it
|
||||||
|
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
|
||||||
|
|
||||||
command.Env = append(os.Environ(), runAsTool+"=1")
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||||
|
|
||||||
producer, err := command.StdinPipe()
|
producer, err := command.StdinPipe()
|
||||||
@@ -175,19 +225,7 @@ func interrupted(
|
|||||||
return err == nil && len(entries) > 0
|
return err == nil && len(entries) > 0
|
||||||
}, 5*time.Second, 5*time.Millisecond)
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
require.NoError(t, command.Process.Signal(signal))
|
return command, producer
|
||||||
|
|
||||||
if endInput {
|
|
||||||
require.NoError(t, producer.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForTool(t, name, command)
|
|
||||||
|
|
||||||
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
|
||||||
|
|
||||||
left, err := os.ReadDir(directory)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, left, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// written puts the contents in a file of that name in a directory of
|
// written puts the contents in a file of that name in a directory of
|
||||||
|
|||||||
+1
-2
@@ -80,8 +80,7 @@ func Main() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var passed ssh.StatusError
|
if passed, ok := errors.AsType[ssh.StatusError](err); ok {
|
||||||
if errors.As(err, &passed) {
|
|
||||||
return passed.Status
|
return passed.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Package signals catches the signals that end the tool, for the
|
||||||
|
// commands that clean up before they end.
|
||||||
|
package signals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the
|
||||||
|
// context it returns is cancelled when one of them arrives, and stop
|
||||||
|
// stops catching them. It leaves out any of the three the tool was
|
||||||
|
// started with set to be ignored, as nohup does with SIGHUP, because
|
||||||
|
// catching a signal turns an ignored one back on and would end a run
|
||||||
|
// that was meant to survive it.
|
||||||
|
func Context(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
|
||||||
|
caught := make([]os.Signal, 0, len(endings))
|
||||||
|
|
||||||
|
for _, ending := range endings {
|
||||||
|
if !signal.Ignored(ending) {
|
||||||
|
caught = append(caught, ending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given no signals at all, NotifyContext would catch every one.
|
||||||
|
if len(caught) == 0 {
|
||||||
|
return context.WithCancel(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signal.NotifyContext(parent, caught...)
|
||||||
|
}
|
||||||
@@ -8,13 +8,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where the key goes on the host and what the file it arrives in is
|
// Where the key goes on the host and what the file it arrives in is
|
||||||
@@ -67,10 +66,7 @@ func install() *cobra.Command {
|
|||||||
// From here on a signal cancels the context, which
|
// From here on a signal cancels the context, which
|
||||||
// sftp runs under, instead of ending the tool, so sftp
|
// sftp runs under, instead of ending the tool, so sftp
|
||||||
// ends and the working directory is still removed.
|
// ends and the working directory is still removed.
|
||||||
ctx, stop := signal.NotifyContext(
|
ctx, stop := signals.Context(cmd.Context())
|
||||||
cmd.Context(),
|
|
||||||
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
|
||||||
)
|
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
cmd.SetContext(ctx)
|
cmd.SetContext(ctx)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
|
||||||
"slices"
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusError says the tool should end with the status ssh ended with.
|
// StatusError says the tool should end with the status ssh ended with.
|
||||||
@@ -47,10 +47,7 @@ func to() *cobra.Command {
|
|||||||
// cancels the context instead of ending the tool, so
|
// cancels the context instead of ending the tool, so
|
||||||
// ssh ends and the socket and its directory are still
|
// ssh ends and the socket and its directory are still
|
||||||
// removed.
|
// removed.
|
||||||
ctx, stop := signal.NotifyContext(
|
ctx, stop := signals.Context(cmd.Context())
|
||||||
cmd.Context(),
|
|
||||||
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
|
||||||
)
|
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
served, err := key.Serve(ctx, comment)
|
served, err := key.Serve(ctx, comment)
|
||||||
@@ -99,8 +96,7 @@ func connect(ctx context.Context, argv []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var ended *exec.ExitError
|
if ended, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
if errors.As(err, &ended) {
|
|
||||||
status := ended.ExitCode()
|
status := ended.ExitCode()
|
||||||
if status < 0 {
|
if status < 0 {
|
||||||
// A signal ended ssh, and a signal has no status of its
|
// A signal ended ssh, and a signal has no status of its
|
||||||
|
|||||||
Reference in New Issue
Block a user