diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f2285ffa..d421493e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,6 +4,7 @@ set -e [ -f go.mod ] || exit 0 # no Go code yet # git ls-files only lists tracked files, so this is already just our own code. # The repo does not vendor: it builds from the module cache, same as CI. +# shellcheck disable=SC2046 # git ls-files output is a list of filenames, splitting is the point unformatted=$(gofmt -l $(git ls-files '*.go') 2>/dev/null) [ -z "$unformatted" ] || { echo "gofmt needed:"; echo "$unformatted"; exit 1; } go vet ./... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..0eb5b00b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Every path requests review from the maintainer. The recipe and docs +# lines exist so a recipe PR shows who reviews it, even while that is +# the same person. +* @NovusEdge +/internal/recipes/bundled/ @NovusEdge +/docs/recipes/ @NovusEdge diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..cac89597 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,44 @@ +name: Bug +description: stoat did something wrong +labels: [bug] +body: + - type: input + id: version + attributes: + label: stoat version + description: Output of `stoat --version` + validations: + required: true + - type: input + id: host + attributes: + label: Host distro and kernel + description: Output of `uname -sr` and the distro name + validations: + required: true + - type: input + id: guest + attributes: + label: Guest OS and mode + description: For example `alpine live`, `ubuntu cloud`, `debian disk` + - type: textarea + id: doctor + attributes: + label: stoat doctor + description: Paste the output of `stoat doctor` + render: text + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened + description: The command or key you pressed, what you expected, what you saw + validations: + required: true + - type: textarea + id: log + attributes: + label: Log tail + description: Paste the last 50 lines of `~/.stoat/logs/stoat.log` and, for a provisioning problem, `~/.stoat//last-provision.log` + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..202d55e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security issue + url: https://github.com/NovusEdge/stoat/security/advisories/new + about: Report a vulnerability privately diff --git a/.github/ISSUE_TEMPLATE/guest.yml b/.github/ISSUE_TEMPLATE/guest.yml new file mode 100644 index 00000000..4eedfb9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/guest.yml @@ -0,0 +1,34 @@ +name: Guest OS +description: Request support for a guest OS +labels: [guest] +body: + - type: input + id: name + attributes: + label: OS name and version + validations: + required: true + - type: input + id: image + attributes: + label: Cloud image URL + description: A qcow2 or raw image with cloud-init, if one exists + - type: dropdown + id: init + attributes: + label: Init system + options: [systemd, openrc, rc, other] + validations: + required: true + - type: input + id: pkg + attributes: + label: Package manager + description: For example `apt`, `dnf`, `pkg` + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Default ssh user, whether sudo or doas ships, anything the installer needs diff --git a/.github/ISSUE_TEMPLATE/recipe.yml b/.github/ISSUE_TEMPLATE/recipe.yml new file mode 100644 index 00000000..5f6f2820 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/recipe.yml @@ -0,0 +1,34 @@ +name: Recipe +description: A bundled recipe is broken, or you want a new one +labels: [recipes] +body: + - type: dropdown + id: kind + attributes: + label: Kind + options: + - A bundled recipe fails + - Request for a new recipe + validations: + required: true + - type: input + id: recipe + attributes: + label: Recipe name + validations: + required: true + - type: input + id: guest + attributes: + label: Guest OS and mode + description: For example `fedora cloud` + validations: + required: true + - type: textarea + id: detail + attributes: + label: Detail + description: For a failure, paste `~/.stoat//last-provision.log`. For a request, say what the recipe installs and which guests it should support. + render: text + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9189cefe --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## What changed + +## Why + +## Tests run + +- [ ] `just check` and `just test` +- [ ] `just lint` +- [ ] live boot or `just e2e` (required for a change under `internal/core`, `internal/sshx`, `internal/cloudinit`, `internal/apkovl`, or a bundled recipe; paste the output below) + +## OS matrix + +Only for a recipe or guest change. One line per guest OS tested: `alpine: ok`, `debian: not tested`. + +## Docs + +- [ ] `docs/` updated, or no user-visible change diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..51a93671 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39bdd623..a76482b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: name: check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.26' @@ -35,6 +35,30 @@ jobs: - name: test run: go test ./... + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.13.2 + + shellcheck: + name: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: install + run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck + # Warning level: an info-level note about a quoted variable in a + # bundled installer is not worth a red PR. + - name: shellcheck + run: shellcheck -S warning $(git ls-files 'internal/recipes/bundled/*.sh' 'internal/recipes/bundled/*/*.sh' 'scripts/*.sh' '.githooks/*') + # A separate job because it needs Python rather than Go, and because a # failure here should read as "the MCP server broke", not "the Go suite # broke". Until this existed, mcp/'s tests ran only on a contributor's @@ -48,7 +72,7 @@ jobs: run: working-directory: mcp steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..950ce04f --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,24 @@ +name: e2e + +# Manual only. scripts/e2e.sh boots a real KVM VM and takes ~15 minutes; +# no hosted runner has /dev/kvm, so this runs on a self-hosted runner with +# the `kvm` label. It is the gate for the needs-live-boot label. +on: + workflow_dispatch: + +jobs: + e2e: + name: e2e + runs-on: [self-hosted, linux, kvm] + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: kvm + run: test -r /dev/kvm && test -w /dev/kvm + - name: e2e + env: + STOAT_HOME: ${{ runner.temp }}/stoat-e2e + run: ./scripts/e2e.sh diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..744bdcb8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,20 @@ +version: "2" +linters: + enable: + - errcheck + - staticcheck + - govet + - unused + - misspell + settings: + errcheck: + # A CLI cannot act on a failed write to its own stdout or stderr. The + # exclusion keeps every print site free of a two-token discard. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + staticcheck: + # ST and QF rewrite working code for taste. QF1001 turned an allowlist + # test into its De Morgan form and broke the comment above it. + checks: ["all", "-ST*", "-QF*"] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..65159c99 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at a private security advisory at https://github.com/NovusEdge/stoat/security/advisories/new, or an email to the maintainer listed on the GitHub profile. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a117a09..d4d65e73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,21 +4,22 @@ ```sh just setup # builds, installs to ~/.local/bin, reports missing host deps -just hooks # installs the pre-commit and commit-msg hooks just dev # runs the TUI against a scratch STOAT_HOME ``` +`just setup` also installs the git hooks. + Go 1.26 (pinned in `go.mod`), `just`, and for anything that boots a VM: KVM, `qemu-system-x86_64`, `qemu-img`, `ssh`. `stoat doctor` lists what is -missing. +missing. `just lint` also needs `golangci-lint` v2 and `shellcheck`. ## Branches and pull requests - Branch off `main`. One change per branch. - Every PR is squash merged. The PR title becomes the commit subject, so write it in the commit grammar below. -- Sign off every commit (`git commit -s`). The DCO check reads the - `Signed-off-by` trailer. +- Sign off every commit (`git commit -s`). The DCO check on the PR reads + the `Signed-off-by` trailer and blocks a merge without it. - No `Co-Authored-By` or tool-attribution trailers. The `commit-msg` hook strips them. - A PR that changes a bundled recipe, a guest, or the boot path gets the @@ -38,7 +39,9 @@ was found. ## Gates `just check` runs gofmt, `go vet`, and `go build`. The pre-commit hook -runs the same. CI runs those plus `go test ./...` on every PR. +runs the same. `just lint` runs golangci-lint and shellcheck. CI runs +all of those plus `go test ./...` on every PR. The DCO app checks the +sign-off trailer on every commit in a PR. ## Tests @@ -74,6 +77,11 @@ A change to a subsystem or an interface another package depends on starts as an issue that states the design, discussed before the implementation PR. Settled decisions go in `docs/design/`. +`docs/design/core-api.md` §12 holds the conventions a new CLI command +follows: the `wire` struct behind `--json`, `fail` against `failMsg`, the +shared `confirm` helper, kong aliases, and `tomlx` for a TOML file. A +reviewer rejects a PR that ignores them. + ## Recipes Bundled recipes are `xfce`, `docker`, `devtools`, `tailscale`, and the set diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..e7cc3d32 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security + +## Reporting + +Open a private security advisory at +https://github.com/NovusEdge/stoat/security/advisories/new. Do not open a +public issue. + +You get an acknowledgement within 7 days and a fix or a decision within +90 days. Public disclosure waits for the fix or the 90 days, whichever +comes first. + +## What counts + +- A guest reaching host files outside the 9p share it was given. +- An MCP tool that runs host code, or accepts a host path outside + `~/.stoat/shared//`. +- A bundled recipe that pipes a download into a root shell without a + checksum or a signature check. +- A secret written to a log, a `--json` result, or a world-readable file. + +## What does not count + +- A guest escaping QEMU. That is QEMU's boundary. +- An agent destroying a VM it was given access to. Snapshots are the + mitigation. +- Prompt injection of an agent that drives stoat. See + `docs/design/core-api.md` §10.4. diff --git a/cmd/installer/main_linux.go b/cmd/installer/main_linux.go index e12bcbf8..7a80afdd 100644 --- a/cmd/installer/main_linux.go +++ b/cmd/installer/main_linux.go @@ -63,7 +63,7 @@ func runHeadless(repoDir, home string) int { fmt.Fprintln(os.Stderr, "error:", err) return 1 } - defer os.RemoveAll(tmp) + defer func() { _ = os.RemoveAll(tmp) }() outPath := filepath.Join(tmp, "stoat") if err := installer.Build(repoDir, version, outPath); err != nil { diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 34d98da7..5af93960 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -327,3 +327,22 @@ Once more than one caller exists, two current mechanisms are unsafe: - **VM directory creation and `vm.toml` writes** are unlocked, so two `Create` calls with the same name race. The API needs a data-root lock held across allocate-and-write in `Create`, and per-VM locks for state transitions. Invisible until it corrupts something, so it is designed in rather than added after. + +## 12. CLI conventions + +Every new command follows these. A reviewer rejects a PR that does not. + +- A command's `--json` data is a struct in `internal/cli/wire`, never an + inline `map[string]any`. The struct is the schema; `json.md` documents + it; the MCP server reuses it. +- `a.fail(stdout, stderr, err)` for an error that came from `core`. + `a.failMsg(stdout, stderr, sentinel, msg)` for a condition the CLI + detected itself, such as a missing flag or a refused prompt. +- A destructive command calls the shared `confirm` helper: `-y` skips + the prompt, `--json` and `--quiet` refuse without `-y`, a TTY prompts. + No command hand-rolls that branch. +- A command that aliases another declares `aliases:"..."` on the kong + struct and shares one `toArgs` case. No duplicate command struct. +- Every TOML file type decodes through `internal/tomlx`, which wraps the + path into errors and reports unknown keys. +- Lists in a `wire` struct are never `null`; use `nonNil`. diff --git a/internal/apkovl/apkovl.go b/internal/apkovl/apkovl.go index cc508956..15ade204 100644 --- a/internal/apkovl/apkovl.go +++ b/internal/apkovl/apkovl.go @@ -157,14 +157,14 @@ func Build(v *config.VM) error { tmp := filepath.Join(v.OvlDir(), tmpName) // Remove any temp file orphaned by a crash mid-Build before tmpName // existed: unlike tmpName, it matches the initramfs's glob. - os.Remove(filepath.Join(v.OvlDir(), legacyTmpName)) + _ = os.Remove(filepath.Join(v.OvlDir(), legacyTmpName)) f, err := os.Create(tmp) if err != nil { return err } defer func() { - f.Close() - os.Remove(tmp) + _ = f.Close() + _ = os.Remove(tmp) }() gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) diff --git a/internal/apkovl/apkovl_test.go b/internal/apkovl/apkovl_test.go index 322e7db4..64cbd564 100644 --- a/internal/apkovl/apkovl_test.go +++ b/internal/apkovl/apkovl_test.go @@ -19,7 +19,7 @@ func entries(t *testing.T, path string) (map[string]string, map[string]*tar.Head if err != nil { t.Fatal(err) } - defer f.Close() + defer func() { _ = f.Close() }() gz, err := gzip.NewReader(f) if err != nil { t.Fatal(err) @@ -219,7 +219,7 @@ func TestBuildFailureLeavesGoodOverlayIntact(t *testing.T) { if err := os.Chmod(v.OvlDir(), 0o555); err != nil { t.Fatalf("chmod ovl dir: %v", err) } - defer os.Chmod(v.OvlDir(), 0o755) + defer func() { _ = os.Chmod(v.OvlDir(), 0o755) }() if err := Build(v); err == nil { t.Fatal("expected Build to fail when the ovl dir is read-only") diff --git a/internal/backend/cloudinit.go b/internal/backend/cloudinit.go index cb5b18d8..129ff54a 100644 --- a/internal/backend/cloudinit.go +++ b/internal/backend/cloudinit.go @@ -57,7 +57,7 @@ func (cloudinitBackend) Prepare(v *config.VM) error { if v.Disk != "" { out, err := exec.Command("qemu-img", "resize", v.DiskPath(), v.Disk).CombinedOutput() if err != nil { - os.Remove(v.DiskPath()) + _ = os.Remove(v.DiskPath()) return fmt.Errorf("qemu-img resize to %s: %s", v.Disk, strings.TrimSpace(string(out))) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cf9f342c..3ca19ae3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -355,7 +355,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ // rather than failing the command the user actually asked for. `logs` // re-Inits and reports its own error, since there the log IS the command. _ = logx.Init() - defer logx.Close() + defer func() { _ = logx.Close() }() logx.L().Debug("cli", "cmd", a.Cmd, "vm", a.VM) switch a.Cmd { diff --git a/internal/cli/run_access.go b/internal/cli/run_access.go index b5639766..3d587d06 100644 --- a/internal/cli/run_access.go +++ b/internal/cli/run_access.go @@ -168,7 +168,7 @@ func streamFile(path string, out io.Writer, done <-chan error) error { for { select { case err := <-done: - offset = copyNew(path, out, offset) + copyNew(path, out, offset) return err case <-ticker.C: offset = copyNew(path, out, offset) @@ -181,7 +181,7 @@ func copyNew(path string, out io.Writer, offset int64) int64 { if err != nil { return offset } - defer f.Close() + defer func() { _ = f.Close() }() fi, err := f.Stat() if err != nil || fi.Size() <= offset { return offset @@ -189,6 +189,6 @@ func copyNew(path string, out io.Writer, offset int64) int64 { if _, err := f.Seek(offset, io.SeekStart); err != nil { return offset } - io.Copy(out, f) + _, _ = io.Copy(out, f) return fi.Size() } diff --git a/internal/cli/run_image.go b/internal/cli/run_image.go index dc2d09b2..1ad4c44a 100644 --- a/internal/cli/run_image.go +++ b/internal/cli/run_image.go @@ -50,7 +50,7 @@ func runPull(a *Args, stdout, stderr io.Writer) int { if a.JSON { em = wire.NewEmitter(stdout) } - var lastPct int = -1 + var lastPct = -1 // Both renderers fire only on a percentage CHANGE: a per-read event is // thousands of lines for one image, and a consumer gains nothing from them. progress := func(done, total int64) { diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 5a0bd36f..f3d91157 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -136,9 +136,9 @@ func mountsDoc(v *config.VM) string { const opts = "trans=virtio,version=9p2000.L,%s,_netdev,nofail" var b strings.Builder b.WriteString("#cloud-config\nmounts:\n") - b.WriteString(fmt.Sprintf(" - [ work, /mnt/work, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "rw"))) + fmt.Fprintf(&b, " - [ work, /mnt/work, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "rw")) if v.Share != "" { - b.WriteString(fmt.Sprintf(" - [ host, /mnt/host, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "ro"))) + fmt.Fprintf(&b, " - [ host, /mnt/host, 9p, %q, \"0\", \"0\" ]\n", fmt.Sprintf(opts, "ro")) } return b.String() } @@ -197,10 +197,10 @@ func ValidateFragment(body string) (annotated string, err error) { if err != nil { return "", fmt.Errorf("creating schema-check temp file: %w", err) } - defer os.Remove(f.Name()) + defer func() { _ = os.Remove(f.Name()) }() if _, err := f.WriteString(body); err != nil { - f.Close() + _ = f.Close() return "", fmt.Errorf("writing schema-check temp file: %w", err) } if err := f.Close(); err != nil { diff --git a/internal/cloudinit/cloudinit_test.go b/internal/cloudinit/cloudinit_test.go index 11503855..aa479c3a 100644 --- a/internal/cloudinit/cloudinit_test.go +++ b/internal/cloudinit/cloudinit_test.go @@ -132,7 +132,7 @@ func TestSeedWritesUserDataAndMetaData(t *testing.T) { if err != nil { t.Fatalf("seed.iso not written: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() label := make([]byte, 32) if _, err := f.ReadAt(label, 0x8028); err != nil { diff --git a/internal/cloudinit/scripts.go b/internal/cloudinit/scripts.go index 7615e725..aa01f224 100644 --- a/internal/cloudinit/scripts.go +++ b/internal/cloudinit/scripts.go @@ -43,7 +43,7 @@ func WrapScripts(scripts []Script) string { rc.WriteString("runcmd:\n") for _, s := range scripts { path := fmt.Sprintf("%s/%s.sh", scriptDir, s.Name) - wf.WriteString(fmt.Sprintf(" - path: %s\n", path)) + fmt.Fprintf(&wf, " - path: %s\n", path) wf.WriteString(" permissions: '0755'\n") wf.WriteString(" content: |\n") wf.WriteString(indentBlock(s.Content)) @@ -52,7 +52,7 @@ func WrapScripts(scripts []Script) string { // leaves no marker for a script that failed, so a failed recipe stays // pending instead of being recorded as applied. marker := fmt.Sprintf("%s/%s", MarkerDir, s.Name) - rc.WriteString(fmt.Sprintf(" - %s && mkdir -p %s && touch %s\n", path, MarkerDir, marker)) + fmt.Fprintf(&rc, " - %s && mkdir -p %s && touch %s\n", path, MarkerDir, marker) } return "#cloud-config\n" + wf.String() + rc.String() diff --git a/internal/config/config.go b/internal/config/config.go index 53272870..9b9cf8fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -194,7 +194,7 @@ func (v *VM) Save() error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() return toml.NewEncoder(f).Encode(v) } @@ -341,7 +341,7 @@ func FreePort() (int, error) { if err != nil { continue } - l.Close() + _ = l.Close() return p, nil } return 0, fmt.Errorf("no free port in 2200-2299") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20f134fc..260bdc07 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -394,7 +394,7 @@ func TestFreePortFreshInstallNoVMs(t *testing.T) { if err != nil { t.Errorf("FreePort returned %d, which is not actually free: %v", p, err) } else { - l.Close() + _ = l.Close() } } diff --git a/internal/config/lock.go b/internal/config/lock.go index a17c7fb7..ed23ba3c 100644 --- a/internal/config/lock.go +++ b/internal/config/lock.go @@ -71,14 +71,14 @@ func lockFile(name string) (func(), error) { return nil, err } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - f.Close() + _ = f.Close() return nil, err } return func() { // Unlock explicitly rather than relying on Close: the ordering is // then visible to a reader, and Close alone would still be correct // only because this fd is not shared. - syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - f.Close() + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() }, nil } diff --git a/internal/core/access_test.go b/internal/core/access_test.go index 9b6aeb8d..5386e3b2 100644 --- a/internal/core/access_test.go +++ b/internal/core/access_test.go @@ -95,7 +95,7 @@ func TestLogsReturnsWrittenBytes(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -108,7 +108,7 @@ func TestLogsReturnsWrittenBytes(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err = io.ReadAll(r) if err != nil { t.Fatal(err) @@ -128,7 +128,7 @@ func TestLogsMissingFileIsEmptyNotError(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -169,7 +169,7 @@ func TestLogsBrokenVMStillServesConsoleLog(t *testing.T) { if err != nil { t.Fatalf("Logs on a broken VM should still serve the console log, got err = %v", err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) @@ -190,7 +190,7 @@ func TestLogsBrokenVMMissingFileIsEmptyNotError(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { t.Fatal(err) diff --git a/internal/core/apply.go b/internal/core/apply.go index 2ec6b2b6..2a0b465a 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -291,8 +291,8 @@ func appendProvisionLog(v *config.VM, s string) { if err != nil { return } - defer f.Close() - f.WriteString(s) + defer func() { _ = f.Close() }() + _, _ = f.WriteString(s) } // discoverCloudInitApplied rebuilds v.Applied for a cloudinit VM from the diff --git a/internal/core/apply_test.go b/internal/core/apply_test.go index 89b7249a..e40811a7 100644 --- a/internal/core/apply_test.go +++ b/internal/core/apply_test.go @@ -47,7 +47,7 @@ func TestApplyRefusesWhileLockIsHeld(t *testing.T) { release := make(chan struct{}) held := make(chan struct{}) go func() { - WithProvisionLock(v.Dir, func() error { + _ = WithProvisionLock(v.Dir, func() error { close(held) <-release return nil @@ -141,15 +141,15 @@ func TestApplyOnlyAcceptsAValidSubset(t *testing.T) { if err != nil { t.Fatal(err) } - defer ln.Close() + defer func() { _ = ln.Close() }() go func() { for { c, err := ln.Accept() if err != nil { return } - c.Write([]byte("SSH-2.0-fake\r\n")) - c.Close() + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) + _ = c.Close() } }() port := ln.Addr().(*net.TCPAddr).Port @@ -600,15 +600,15 @@ func TestApplyRecordsHashAndSkipsOnRerun(t *testing.T) { if err != nil { t.Fatal(err) } - defer ln.Close() + defer func() { _ = ln.Close() }() go func() { for { c, err := ln.Accept() if err != nil { return } - c.Write([]byte("SSH-2.0-fake\r\n")) - c.Close() + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) + _ = c.Close() } }() port := ln.Addr().(*net.TCPAddr).Port diff --git a/internal/core/clone.go b/internal/core/clone.go index 0114d12b..ac9b0be8 100644 --- a/internal/core/clone.go +++ b/internal/core/clone.go @@ -102,7 +102,7 @@ func Clone(name, newName string) (VM, error) { // Leave no trace of a failed clone, matching Create's own rule for a // failed qemu-img call: otherwise List shows a VM with no disk that // can never boot. - os.RemoveAll(clone.Dir) + _ = os.RemoveAll(clone.Dir) return VM{}, err } diff --git a/internal/core/core.go b/internal/core/core.go index f2aa64bf..ef344fb3 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -126,7 +126,7 @@ func Create(s Spec) (VM, error) { if err != nil { // Leave no trace of a failed creation: otherwise the list shows a // VM with no disk.qcow2 that can never boot. - os.RemoveAll(v.Dir) + _ = os.RemoveAll(v.Dir) return VM{}, fmt.Errorf("qemu-img: %s", strings.TrimSpace(string(out))) } } diff --git a/internal/core/lock.go b/internal/core/lock.go index df626769..5f581fb4 100644 --- a/internal/core/lock.go +++ b/internal/core/lock.go @@ -31,7 +31,7 @@ func WithProvisionLock(dir string, fn func() error) error { if err != nil { return fmt.Errorf("open provision lock: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { if errors.Is(err, syscall.EWOULDBLOCK) { @@ -39,7 +39,7 @@ func WithProvisionLock(dir string, fn func() error) error { } return fmt.Errorf("lock %s: %w", path, err) } - defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() return fn() } diff --git a/internal/core/vm_test.go b/internal/core/vm_test.go index 82571b5a..85b26061 100644 --- a/internal/core/vm_test.go +++ b/internal/core/vm_test.go @@ -174,7 +174,7 @@ func TestDestroyRefusesWhileRunning(t *testing.T) { t.Fatalf("VM directory should still exist after a refused destroy: %v", err) } stop() - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) if err := Destroy("work"); err != nil { t.Fatalf("Destroy after stopping: %v", err) diff --git a/internal/core/wait.go b/internal/core/wait.go index 8f1ad041..b16b2205 100644 --- a/internal/core/wait.go +++ b/internal/core/wait.go @@ -106,8 +106,8 @@ func sshBannerUp(ctx context.Context, v *config.VM) bool { if err != nil { return false } - defer c.Close() - c.SetReadDeadline(time.Now().Add(2 * time.Second)) + defer func() { _ = c.Close() }() + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 4) _, err = io.ReadFull(c, buf) return err == nil && string(buf) == "SSH-" diff --git a/internal/core/wait_test.go b/internal/core/wait_test.go index b75c199e..1ab71022 100644 --- a/internal/core/wait_test.go +++ b/internal/core/wait_test.go @@ -30,15 +30,15 @@ func fakeSSHD(t *testing.T, port int) (int, func()) { return } go func() { - c.Write([]byte("SSH-2.0-fake\r\n")) + _, _ = c.Write([]byte("SSH-2.0-fake\r\n")) <-done - c.Close() + _ = c.Close() }() } }() return l.Addr().(*net.TCPAddr).Port, func() { close(done) - l.Close() + _ = l.Close() } } @@ -94,7 +94,7 @@ func TestWaitReachablePollsUntilUp(t *testing.T) { t.Fatal(err) } port := l.Addr().(*net.TCPAddr).Port - l.Close() + _ = l.Close() v := &config.VM{Name: "work", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: port} if err := v.Save(); err != nil { diff --git a/internal/hostcheck/kvm_linux.go b/internal/hostcheck/kvm_linux.go index ee621b0f..8991db18 100644 --- a/internal/hostcheck/kvm_linux.go +++ b/internal/hostcheck/kvm_linux.go @@ -18,7 +18,7 @@ func KVMCheck() Check { return kvmCheckAt("/dev/kvm") } func kvmCheckAt(path string) Check { f, err := os.OpenFile(path, os.O_RDWR, 0) if err == nil { - f.Close() + _ = f.Close() return Check{Name: "/dev/kvm", OK: true, Detail: "read/write"} } diff --git a/internal/installer/build.go b/internal/installer/build.go index 69c9c1f3..561da83f 100644 --- a/internal/installer/build.go +++ b/internal/installer/build.go @@ -102,7 +102,7 @@ func copyFile(src, dst string) error { if err != nil { return err } - defer in.Close() + defer func() { _ = in.Close() }() out, err := os.Create(dst) if err != nil { return err @@ -126,14 +126,14 @@ func Install(srcPath, destDir string) (string, error) { if err != nil { return "", err } - defer src.Close() + defer func() { _ = src.Close() }() tmp, err := os.CreateTemp(destDir, ".stoat-*") if err != nil { return "", err } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds if _, err := io.Copy(tmp, src); err != nil { _ = tmp.Close() diff --git a/internal/installer/tui.go b/internal/installer/tui.go index 63d01d01..1f481b61 100644 --- a/internal/installer/tui.go +++ b/internal/installer/tui.go @@ -161,7 +161,7 @@ func buildCmd(repoDir, version string) tea.Cmd { // Nothing will call installCmd to clean this up. The build // produced no binary to hand it, so this path // removes the temp dir itself. - os.RemoveAll(tmp) + _ = os.RemoveAll(tmp) return errMsg{err: err} } return builtMsg{tmpPath: out} @@ -173,7 +173,7 @@ func installCmd(src, destDir, repoDir, home string) tea.Cmd { // buildCmd's temp dir has done its job once the binary is copied out, // whether the copy succeeds or fails. It is removed here // unconditionally, so a run never leaks ~10MB. - defer os.RemoveAll(filepath.Dir(src)) + defer func() { _ = os.RemoveAll(filepath.Dir(src)) }() path, err := Install(src, destDir) if err != nil { return errMsg{err: err} diff --git a/internal/iso/iso.go b/internal/iso/iso.go index f778aedd..70216915 100644 --- a/internal/iso/iso.go +++ b/internal/iso/iso.go @@ -346,7 +346,7 @@ func Latest(flavor string) (*Release, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("index: %s", resp.Status) } @@ -410,7 +410,7 @@ func fetchChecksum(checksumURL, filename string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("checksum: %s", resp.Status) } @@ -469,7 +469,7 @@ func fileDigest(path string, h hash.Hash) (string, error) { if err != nil { return "", err } - defer f.Close() + defer func() { _ = f.Close() }() if _, err := io.Copy(h, f); err != nil { return "", err } @@ -528,7 +528,7 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download: %s", resp.Status) } @@ -575,8 +575,8 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) if n > 0 { stall.Reset(stallTimeout) if _, werr := f.Write(buf[:n]); werr != nil { - f.Close() - os.Remove(part) + _ = f.Close() + _ = os.Remove(part) return "", werr } h.Write(buf[:n]) @@ -589,8 +589,8 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) break } if rerr != nil { - f.Close() - os.Remove(part) + _ = f.Close() + _ = os.Remove(part) if stalled.Load() { return "", fmt.Errorf("download stalled: no data for %s", stallTimeout) } @@ -602,19 +602,19 @@ func Download(ctx context.Context, r *Release, progress func(done, total int64)) // file would still pass verification and get renamed into place as // "verified". if err := f.Close(); err != nil { - os.Remove(part) + _ = os.Remove(part) return "", err } if r.SHA256 != "" { if got := hex.EncodeToString(h.Sum(nil)); got != r.SHA256 { - os.Remove(part) + _ = os.Remove(part) return "", fmt.Errorf("checksum mismatch: got %s, want %s", got, r.SHA256) } r.Verified = true } if err := os.Rename(part, final); err != nil { - os.Remove(part) + _ = os.Remove(part) return "", err } return rel, nil diff --git a/internal/iso/iso_test.go b/internal/iso/iso_test.go index bf013191..bfbb46d3 100644 --- a/internal/iso/iso_test.go +++ b/internal/iso/iso_test.go @@ -36,7 +36,7 @@ func TestDownload_RenameFailureCleansUpPart(t *testing.T) { sum := sha256.Sum256(body) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -248,12 +248,12 @@ func TestResolveAndDownload_Entry(t *testing.T) { const filename = "example-cloudimg-amd64.qcow2" fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer fileSrv.Close() sumsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - fmt.Fprintf(w, "%s %s\nfeedface some-other-file.qcow2\n", hex.EncodeToString(sum[:]), filename) + _, _ = fmt.Fprintf(w, "%s %s\nfeedface some-other-file.qcow2\n", hex.EncodeToString(sum[:]), filename) })) defer sumsSrv.Close() @@ -323,7 +323,7 @@ func TestDownload_SHA512(t *testing.T) { const filename = "example-genericcloud-amd64.qcow2" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -355,7 +355,7 @@ func TestDownload_UnverifiedWhenNoChecksum(t *testing.T) { const filename = "unverified-cloudimg-amd64.qcow2" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -456,7 +456,7 @@ func TestCatalog_FedoraURLNotArchived(t *testing.T) { if err != nil { t.Skipf("network unreachable, skipping live fedora-cloud check: %v", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK, http.StatusPartialContent: @@ -497,7 +497,7 @@ func TestDownloadOutlastsMetadataTimeout(t *testing.T) { w.Header().Set("Content-Length", fmt.Sprint(len(body))) flusher, _ := w.(http.Flusher) for i := 0; i < len(body); i += 64 { - w.Write(body[i:min(i+64, len(body))]) + _, _ = w.Write(body[i:min(i+64, len(body))]) if flusher != nil { flusher.Flush() } @@ -544,7 +544,7 @@ func TestResolve_AlpineDirectURLEntry(t *testing.T) { const filename = "alpine-cloud.qcow2" fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Write(body) + _, _ = w.Write(body) })) defer fileSrv.Close() diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 1e616946..b806c1f7 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -70,8 +70,8 @@ func generate(path, comment string) error { } // ssh-keygen refuses to overwrite. A stale half of the pair, either the // private key or its .pub, would wedge this, so clear both first. - os.Remove(path) - os.Remove(path + ".pub") + _ = os.Remove(path) + _ = os.Remove(path + ".pub") cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", path) // Extra safety alongside the lock above. ssh-keygen prompts on stdin // when it finds a file it did not expect, and a background or diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go index bfd69248..f23ebbc2 100644 --- a/internal/logx/logx_test.go +++ b/internal/logx/logx_test.go @@ -35,7 +35,7 @@ func TestInitCreatesLogAndAppends(t *testing.T) { t.Fatal(err) } L().Info("second message") - Close() + _ = Close() b, _ = os.ReadFile(Path()) if !strings.Contains(string(b), "first message") { t.Error("Init truncated an existing log") diff --git a/internal/qemu/qmp.go b/internal/qemu/qmp.go index f8dcb184..fff54f73 100644 --- a/internal/qemu/qmp.go +++ b/internal/qemu/qmp.go @@ -38,7 +38,7 @@ func dialQMP(v *config.VM) (*qmp, error) { return nil, fmt.Errorf("qmp: %w", err) } if err := c.SetDeadline(time.Now().Add(qmpTimeout)); err != nil { - c.Close() + _ = c.Close() return nil, err } q := &qmp{c: c, dec: json.NewDecoder(c)} @@ -49,15 +49,15 @@ func dialQMP(v *config.VM) (*qmp, error) { QMP json.RawMessage `json:"QMP"` } if err := q.dec.Decode(&greeting); err != nil { - c.Close() + _ = c.Close() return nil, fmt.Errorf("qmp greeting: %w", err) } if greeting.QMP == nil { - c.Close() + _ = c.Close() return nil, fmt.Errorf("qmp: not a QMP socket (no greeting)") } if _, err := q.command("qmp_capabilities", nil); err != nil { - c.Close() + _ = c.Close() return nil, err } return q, nil @@ -152,7 +152,7 @@ func snapshotCmd(v *config.VM, hmp string) error { if err != nil { return err } - defer q.Close() + defer func() { _ = q.Close() }() return q.hmpChecked(hmp) } @@ -183,6 +183,6 @@ func SnapshotInfo(v *config.VM) (string, error) { if err != nil { return "", err } - defer q.Close() + defer func() { _ = q.Close() }() return q.hmp("info snapshots") } diff --git a/internal/qemu/run.go b/internal/qemu/run.go index 2f8543f9..7424d353 100644 --- a/internal/qemu/run.go +++ b/internal/qemu/run.go @@ -24,7 +24,7 @@ func Preflight() error { if err != nil { return fmt.Errorf("/dev/kvm not usable: %w (are you in the kvm group?)", err) } - f.Close() + _ = f.Close() return nil } @@ -59,11 +59,11 @@ func Running(v *config.VM) bool { } cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", p)) if err != nil { - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) return false } if !cmdlineMatches(cmdline, v.Dir) { - os.Remove(v.PidPath()) + _ = os.Remove(v.PidPath()) return false } return true @@ -106,7 +106,7 @@ func Start(v *config.VM) error { if Running(v) { return fmt.Errorf("%s is already running", v.Name) } - os.Remove(v.MonitorPath()) + _ = os.Remove(v.MonitorPath()) // The interactive install happens inside the guest, where stoat can't // watch it finish. Checked here, at the next start, since that's the // only moment the boot order matters. @@ -203,7 +203,7 @@ func Stop(v *config.VM) error { } if c, err := dialMonitor(v); err == nil { fmt.Fprintln(c, "system_powerdown") - c.Close() + _ = c.Close() for i := 0; i < 100; i++ { if !Running(v) { logx.L().Info("stopped", "vm", v.Name) diff --git a/internal/qemu/sendkey.go b/internal/qemu/sendkey.go index d4bdb054..876a9a2c 100644 --- a/internal/qemu/sendkey.go +++ b/internal/qemu/sendkey.go @@ -64,7 +64,7 @@ func TypeConsolePassword(v *config.VM) error { if err != nil { return fmt.Errorf("qemu monitor: %w", err) } - defer c.Close() + defer func() { _ = c.Close() }() for _, k := range keys { if _, err := fmt.Fprintln(c, "sendkey "+k); err != nil { return fmt.Errorf("qemu monitor: %w", err) diff --git a/internal/recipes/bundled/docker/install-debian.sh b/internal/recipes/bundled/docker/install-debian.sh index 71a80955..6cd4290c 100755 --- a/internal/recipes/bundled/docker/install-debian.sh +++ b/internal/recipes/bundled/docker/install-debian.sh @@ -9,7 +9,8 @@ apt-get update apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \ +id=$(. /etc/os-release && echo "$ID") +curl -fsSL "https://download.docker.com/linux/$id/gpg" | \ gpg --batch --yes --no-tty --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg diff --git a/internal/recipes/bundled/docker/install.sh b/internal/recipes/bundled/docker/install.sh index e291bbe0..3ad91bed 100755 --- a/internal/recipes/bundled/docker/install.sh +++ b/internal/recipes/bundled/docker/install.sh @@ -9,7 +9,8 @@ apt-get update apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \ +id=$(. /etc/os-release && echo "$ID") +curl -fsSL "https://download.docker.com/linux/$id/gpg" | \ gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index d890cf9c..55d2235f 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -140,7 +140,7 @@ func Wait(ctx context.Context, v *config.VM, timeout time.Duration) error { if bannerReady(c, time.Until(deadline)) { return nil } - c.Close() + _ = c.Close() } else if ctx.Err() != nil { return ctx.Err() } @@ -196,7 +196,7 @@ func bannerReady(c net.Conn, budget time.Duration) bool { if budget < d { d = budget } - c.SetReadDeadline(time.Now().Add(d)) + _ = c.SetReadDeadline(time.Now().Add(d)) buf := make([]byte, 4) _, err := io.ReadFull(c, buf) return err == nil && string(buf) == "SSH-" @@ -231,7 +231,7 @@ func Provision(ctx context.Context, v *config.VM) (err error) { if err != nil { return err } - defer log.Close() + defer func() { _ = log.Close() }() fmt.Fprintf(log, "waiting for ssh on port %d…\n", v.SSHPort) if err := Wait(ctx, v, WaitTimeout); err != nil { diff --git a/internal/sshx/sshx_test.go b/internal/sshx/sshx_test.go index 390317af..e9d04be2 100644 --- a/internal/sshx/sshx_test.go +++ b/internal/sshx/sshx_test.go @@ -214,7 +214,7 @@ func acceptOnly(t *testing.T, body string) int { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { for { c, err := l.Accept() @@ -222,7 +222,7 @@ func acceptOnly(t *testing.T, body string) int { return } if body != "" { - c.Write([]byte(body)) + _, _ = c.Write([]byte(body)) } } }() @@ -274,14 +274,14 @@ func TestWaitSucceedsOnSlowBanner(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { c, err := l.Accept() if err != nil { return } time.Sleep(500 * time.Millisecond) - c.Write([]byte("SSH-2.0-OpenSSH_9.6\r\n")) + _, _ = c.Write([]byte("SSH-2.0-OpenSSH_9.6\r\n")) }() port := l.Addr().(*net.TCPAddr).Port @@ -323,14 +323,14 @@ func acceptAndClose(t *testing.T) int { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { _ = l.Close() }) go func() { for { c, err := l.Accept() if err != nil { return } - c.Close() + _ = c.Close() } }() return l.Addr().(*net.TCPAddr).Port diff --git a/internal/testutil/fakevm.go b/internal/testutil/fakevm.go index bd31caf5..f47c2077 100644 --- a/internal/testutil/fakevm.go +++ b/internal/testutil/fakevm.go @@ -67,8 +67,8 @@ func FakeRunning(t *testing.T, dir string) func() { break } if time.Now().After(deadline) { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() t.Fatalf("fake VM process never showed %q in its cmdline; qemu.Running would not match it", dir+"/") } time.Sleep(2 * time.Millisecond) @@ -76,12 +76,12 @@ func FakeRunning(t *testing.T, dir string) func() { pidFile := filepath.Join(dir, "qemu.pid") if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() t.Fatal(err) } return func() { - cmd.Process.Kill() - cmd.Wait() + _ = cmd.Process.Kill() + _ = cmd.Wait() } } diff --git a/internal/tui/app.go b/internal/tui/app.go index a29bcec6..c296f91d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -137,7 +137,7 @@ func Run() error { // ponytail: a log we can't open is not worth refusing to start over. // logx.L() falls back to io.Discard, so the TUI just runs without a log. _ = logx.Init() - defer logx.Close() + defer func() { _ = logx.Close() }() m := model{ provisioning: map[string]provState{}, cloudInit: map[string]string{}, diff --git a/internal/tui/detail.go b/internal/tui/detail.go index a8275776..7ef95600 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -71,7 +71,7 @@ func tailLog(name string, n int) string { if err != nil { return "" } - defer r.Close() + defer func() { _ = r.Close() }() b, err := io.ReadAll(r) if err != nil { return "" @@ -478,9 +478,7 @@ func (m model) viewDetail() string { } parts = append(parts, "") - for _, l := range provLinesExcept(m, v.Name) { - parts = append(parts, l) - } + parts = append(parts, provLinesExcept(m, v.Name)...) // An always-present slot: an appearing status line replaces blank // space instead of pushing the footer down. parts = append(parts, warnStyle.Render(m.status)) diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index 3c28324d..b64afb15 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -115,7 +115,7 @@ func TestToggleInstalledFailedSaveLeavesMemoryUnchanged(t *testing.T) { if err := os.Chmod(tomlPath, 0o444); err != nil { t.Fatalf("chmod vm.toml: %v", err) } - t.Cleanup(func() { os.Chmod(tomlPath, 0o644) }) // let TempDir cleanup remove it + t.Cleanup(func() { _ = os.Chmod(tomlPath, 0o644) }) // let TempDir cleanup remove it v, err := core.Get(cv.Name) if err != nil { diff --git a/internal/tui/edit.go b/internal/tui/edit.go index 0e586d81..4bcbc40a 100644 --- a/internal/tui/edit.go +++ b/internal/tui/edit.go @@ -107,12 +107,6 @@ func backendOf(v *config.VM) string { return "ssh" } -// parseSize is core.ParseSize under a local name. core.Update now owns disk -// size validation, so edit.go no longer calls this. form.go's create-VM -// disk field still needs it for its own presentational parse, the same -// role buildPatch's RAM/CPUs/SSHPort checks play here. -var parseSize = core.ParseSize - // name is this VM's identity for core.Update and config.Load: the // directory, never the vm.toml `name` field, which can diverge from it (see // core.VM.Name's own comment on why the directory is authoritative). diff --git a/internal/tui/imagemodal_test.go b/internal/tui/imagemodal_test.go index 7e3bab50..f9ede2bd 100644 --- a/internal/tui/imagemodal_test.go +++ b/internal/tui/imagemodal_test.go @@ -957,7 +957,7 @@ func TestByoScreenFitsAndCentersAtAnIntermediateTerminal(t *testing.T) { // flush to a corner the way a too-narrow terminal forces it to. stripped := ansi.Strip(out) lines := strings.Split(stripped, "\n") - var leftmostBorder int = -1 + var leftmostBorder = -1 for _, line := range lines { if i := strings.Index(line, "╭"); i >= 0 { leftmostBorder = i diff --git a/internal/tui/list.go b/internal/tui/list.go index b8ef47e9..b68f6cd7 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -288,9 +288,7 @@ func (m model) viewList() string { // In-flight provision runs sit above the status line: they are ongoing // state, not a one-off message, and a run started from here keeps going // while the user moves around the list. - for _, l := range provLines(m) { - parts = append(parts, l) - } + parts = append(parts, provLines(m)...) parts = append(parts, warnStyle.Render(m.status)) parts = append(parts, renderFooter(listHelp{sshAvailable: sshAvailable}, m.width, m.showHelp)) return column(appContentWidth, parts...) diff --git a/internal/tui/logpager.go b/internal/tui/logpager.go index 1c1bcf68..b9d2c0b6 100644 --- a/internal/tui/logpager.go +++ b/internal/tui/logpager.go @@ -83,7 +83,7 @@ func openLogPager(name string) tea.Cmd { // console log yet. That one is empty, so reading it whole is not a // problem. func tailReadCloser(rc io.ReadCloser, max int64) (content string, truncated bool, err error) { - defer rc.Close() + defer func() { _ = rc.Close() }() if seeker, ok := rc.(io.Seeker); ok { if size, serr := seeker.Seek(0, io.SeekEnd); serr == nil { start := int64(0) diff --git a/internal/tui/provstep.go b/internal/tui/provstep.go index 19bff29d..05f0d1cd 100644 --- a/internal/tui/provstep.go +++ b/internal/tui/provstep.go @@ -40,7 +40,7 @@ func tailBytes(path string, n int64) []byte { if err != nil { return nil } - defer f.Close() + defer func() { _ = f.Close() }() fi, err := f.Stat() if err != nil { return nil diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 05e7f24b..acfac053 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -110,11 +110,6 @@ func radio(label string, on bool) string { return glyphRadioOff + " " + label } -// rowGap separates rows inside a pane. A terminal has no fractional leading, -// so "a bit more line spacing" can only mean one blank line; this names it in -// one place. -const rowGap = "\n\n" - // appContentWidth is the left edge every screen's stacked panes share. Before // this, each pane centered on its own width, so switching screens, or a pane // changing size as content appeared, shifted the whole block sideways. diff --git a/justfile b/justfile index e76b777b..c892d88a 100644 --- a/justfile +++ b/justfile @@ -21,12 +21,12 @@ install: build # build and install stoat, interactively: checks the host too [group('build')] -setup: +setup: hooks go run ./cmd/installer # build and install stoat without a TTY: for CI and scripts [group('build')] -setup-headless: +setup-headless: hooks go run ./cmd/installer --no-tty # remove the installed binary: never touches ~/.stoat @@ -86,6 +86,12 @@ check: go vet ./... go build ./... +# exactly what the lint and shellcheck CI jobs run +[group('dev')] +lint: + golangci-lint run ./... + shellcheck -S warning $(git ls-files "internal/recipes/bundled/*.sh" "internal/recipes/bundled/*/*.sh" "scripts/*.sh" ".githooks/*") + # format in place [group('dev')] fmt: diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 1aa3a622..d5a19710 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -19,7 +19,7 @@ UP_TIMEOUT=1200 # install + xfce pull + reboot-once X_TIMEOUT=90 # Xorg restart after the reboot-once # Prefer the just-built binary over whatever is on PATH. -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) STOAT="$root/stoat" [ -x "$STOAT" ] || STOAT=stoat