From 90531c148d202b6064a4e8472d600f622b1dc183 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Feb 2026 14:13:32 +0000 Subject: [PATCH 1/2] feat(ci): auto-merge pipeline, org gate, and QA fix hints (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(core): decompose Core into serviceManager + messageBus (#215) Extract two focused, unexported components from the Core "god object": - serviceManager: owns service registry, lifecycle tracking (startables/ stoppables), and service lock - messageBus: owns IPC action dispatch, query handling, and task handling All public API methods on Core become one-line delegation wrappers. Zero consumer changes — no files outside pkg/framework/core/ modified. Co-Authored-By: Claude Opus 4.5 * fix(core): remove unused fields from test struct Co-Authored-By: Claude Opus 4.5 * fix(core): address review feedback from Gemini and Copilot - Move locked check inside mutex in registerService to fix TOCTOU race - Add mutex guards to enableLock and applyLock methods - Replace fmt.Errorf with errors.Join in action() for correct error aggregation (consistent with queryAll and lifecycle methods) - Add TestMessageBus_Action_Bad for error aggregation coverage Co-Authored-By: Claude Opus 4.5 * ci(workflows): bump host-uk/build from v3 to v4 Co-Authored-By: Claude Opus 4.5 * ci(workflows): replace Wails build with Go CLI build The build action doesn't yet support Wails v3. Comment out the GUI build step and use host-uk/build/actions/setup/go for Go toolchain setup with a plain `go build` for the CLI binary. Co-Authored-By: Claude Opus 4.5 * fix(container): check context before select in Stop to fix flaky test Stop() now checks ctx.Err() before entering the select block. When a pre-cancelled context is passed, the select could non-deterministically choose <-done over <-ctx.Done() if the process had already exited, causing TestLinuxKitManager_Stop_Good_ContextCancelled to fail on CI. Co-Authored-By: Claude Opus 4.5 * fix(ci): trim CodeQL matrix to valid languages Remove javascript-typescript and actions from CodeQL matrix — this repo contains only Go and Python. Invalid languages blocked SARIF upload and prevented merge. Co-Authored-By: Claude Opus 4.5 * feat(go): add `core go fuzz` command and wire into QA - New `core go fuzz` command discovers Fuzz* targets and runs them with configurable --duration (default 10s per target) - Fuzz added to default QA checks with 5s burst duration - Seed fuzz targets for core package: FuzzE (error constructor), FuzzServiceRegistration, FuzzMessageDispatch Co-Authored-By: Claude Opus 4.5 * ci(codeql): add workflow_dispatch trigger for manual runs Allows manual triggering of CodeQL when the automatic pull_request trigger doesn't fire. Co-Authored-By: Claude Opus 4.5 * ci(codeql): remove workflow in favour of default setup CodeQL default setup is now enabled via repo settings for go and python. The workflow-based approach uploaded results as "code quality" rather than "code scanning", which didn't satisfy the code_scanning ruleset requirement. Default setup handles this natively. Co-Authored-By: Claude Opus 4.5 * ci(workflows): add explicit permissions to all workflows - agent-verify: add issues: write (was missing, writes comments/labels) - ci: add contents: read (explicit least-privilege) - coverage: add contents: read (explicit least-privilege) All workflows now declare permissions explicitly. Repo default is read-only, so workflows without a block silently lacked write access. Co-Authored-By: Claude Opus 4.5 * ci(workflows): replace inline logic with org reusable workflow callers agent-verify.yml and auto-project.yml now delegate to centralised reusable workflows in host-uk/.github, reducing per-repo duplication. Co-Authored-By: Claude Opus 4.5 * feat(ci): auto-merge pipeline, org gate, and QA fix hints Add auto-merge workflow for org member PRs, external PR gate with label-based approval, and actionable fix instructions for QA failures. - auto-merge.yml: enable squash auto-merge for org member PRs - pr-gate.yml: org-gate check blocks external PRs without label - cmd_qa.go: add FixHint field, fixHintFor(), extractFailingTest() - Ruleset: thread resolution, stale review dismissal, 1min merge wait Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/auto-merge.yml | 40 ++++++++++++++++++++++++++ .github/workflows/pr-gate.yml | 42 ++++++++++++++++++++++++++++ internal/cmd/go/cmd_qa.go | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 00000000..ec3cf86b --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,40 @@ +name: Auto Merge + +on: + pull_request: + types: [opened, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: "!github.event.pull_request.draft" + runs-on: ubuntu-latest + steps: + - name: Check org membership and enable auto-merge + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + } catch { + core.info(`${author} is not an org member — skipping auto-merge`); + return; + } + + await exec.exec('gh', [ + 'pr', 'merge', process.env.PR_NUMBER, + '--auto', '--squash', + ]); + core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..299f186b --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,42 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened, synchronize, reopened, labeled] + +permissions: + contents: read + +jobs: + org-gate: + runs-on: ubuntu-latest + steps: + - name: Check org membership or approval label + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + // Check if author is an org member + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + core.info(`${author} is an org member — gate passed`); + return; + } catch { + core.info(`${author} is not an org member — checking for label`); + } + + // Check for external-approved label + const labels = context.payload.pull_request.labels.map(l => l.name); + if (labels.includes('external-approved')) { + core.info('external-approved label present — gate passed'); + return; + } + + core.setFailed( + `External PR from ${author} requires an org member to add the "external-approved" label before merge.` + ); diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 2ac1dfc5..527b6003 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "regexp" "strings" "time" @@ -147,6 +148,7 @@ type CheckResult struct { Duration string `json:"duration"` Error string `json:"error,omitempty"` Output string `json:"output,omitempty"` + FixHint string `json:"fix_hint,omitempty"` } func runGoQA(cmd *cli.Command, args []string) error { @@ -218,6 +220,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose { result.Output = output } + result.FixHint = fixHintFor(check.Name, output) failed++ if !qaJSON && !qaQuiet { @@ -225,6 +228,9 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose && output != "" { cli.Text(output) } + if result.FixHint != "" { + cli.Hint("fix", result.FixHint) + } } if qaFailFast { @@ -260,6 +266,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if !qaJSON && !qaQuiet { cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } } @@ -436,6 +443,47 @@ func buildCheck(name string) QACheck { } } +// fixHintFor returns an actionable fix instruction for a given check failure. +func fixHintFor(checkName, output string) string { + switch checkName { + case "format", "fmt": + return "Run 'core go qa fmt --fix' to auto-format." + case "vet": + return "Fix the issues reported by go vet — typically genuine bugs." + case "lint": + return "Run 'core go qa lint --fix' for auto-fixable issues." + case "test": + if name := extractFailingTest(output); name != "" { + return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name) + } + return "Run 'go test -run -v ./path/' to debug." + case "race": + return "Data race detected. Add mutex, channel, or atomic to synchronise shared state." + case "bench": + return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce." + case "vuln": + return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'." + case "sec": + return "Review gosec findings. Common fixes: validate inputs, parameterised queries." + case "fuzz": + return "Add a regression test for the crashing input in testdata/fuzz//." + case "docblock": + return "Add doc comments to exported symbols: '// Name does X.' before each declaration." + default: + return "" + } +} + +var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`) + +// extractFailingTest parses the first failing test name from go test output. +func extractFailingTest(output string) string { + if m := failTestRe.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + return "" +} + func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { // Handle internal checks if check.Command == "_internal_" { From c54b28249c5db68344b67b38c1470e9c2ea1c40e Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Feb 2026 14:33:33 +0000 Subject: [PATCH 2/2] chore(io): Migrate pkg/cli to Medium abstraction (#285) * chore(io): Migrate pkg/cli to Medium abstraction - Update `PIDFile` struct to include `io.Medium` field. - Update `NewPIDFile` signature to accept `io.Medium`. - Update `PIDFile` methods to use injected medium instead of `io.Local`. - Add `Medium` field to `DaemonOptions`. - Update `NewDaemon` to default to `io.Local` if no medium is provided. - Update `pkg/cli/daemon_test.go` to reflect changes and add mock medium tests. * chore(io): Migrate pkg/cli to Medium abstraction - Update `PIDFile` struct to include `io.Medium` field. - Update `NewPIDFile` signature to accept `io.Medium`. - Update `PIDFile` methods to use injected medium instead of `io.Local`. - Add `Medium` field to `DaemonOptions`. - Update `NewDaemon` to default to `io.Local` if no medium is provided. - Update `pkg/cli/daemon_test.go` to reflect changes and add mock medium tests. - Fix flaky test `TestLinuxKitManager_Stop_Good_ContextCancelled` by checking context at the start of `Stop`. - Add fail-fast context checks to all `LinuxKitManager` methods taking a context. --- .github/workflows/auto-merge.yml | 40 -------------------------- .github/workflows/pr-gate.yml | 42 ---------------------------- internal/cmd/go/cmd_qa.go | 48 -------------------------------- pkg/cli/daemon.go | 28 ++++++++++++------- pkg/cli/daemon_test.go | 46 +++++++++++++++++++++++++++--- pkg/container/linuxkit.go | 20 +++++++++++++ 6 files changed, 80 insertions(+), 144 deletions(-) delete mode 100644 .github/workflows/auto-merge.yml delete mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml deleted file mode 100644 index ec3cf86b..00000000 --- a/.github/workflows/auto-merge.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Auto Merge - -on: - pull_request: - types: [opened, reopened, ready_for_review] - -permissions: - contents: write - pull-requests: write - -jobs: - auto-merge: - if: "!github.event.pull_request.draft" - runs-on: ubuntu-latest - steps: - - name: Check org membership and enable auto-merge - uses: actions/github-script@v7 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - with: - script: | - const { owner, repo } = context.repo; - const author = context.payload.pull_request.user.login; - - try { - await github.rest.orgs.checkMembershipForUser({ - org: owner, - username: author, - }); - } catch { - core.info(`${author} is not an org member — skipping auto-merge`); - return; - } - - await exec.exec('gh', [ - 'pr', 'merge', process.env.PR_NUMBER, - '--auto', '--squash', - ]); - core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml deleted file mode 100644 index 299f186b..00000000 --- a/.github/workflows/pr-gate.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: PR Gate - -on: - pull_request_target: - types: [opened, synchronize, reopened, labeled] - -permissions: - contents: read - -jobs: - org-gate: - runs-on: ubuntu-latest - steps: - - name: Check org membership or approval label - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const author = context.payload.pull_request.user.login; - - // Check if author is an org member - try { - await github.rest.orgs.checkMembershipForUser({ - org: owner, - username: author, - }); - core.info(`${author} is an org member — gate passed`); - return; - } catch { - core.info(`${author} is not an org member — checking for label`); - } - - // Check for external-approved label - const labels = context.payload.pull_request.labels.map(l => l.name); - if (labels.includes('external-approved')) { - core.info('external-approved label present — gate passed'); - return; - } - - core.setFailed( - `External PR from ${author} requires an org member to add the "external-approved" label before merge.` - ); diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 527b6003..2ac1dfc5 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "os/exec" - "regexp" "strings" "time" @@ -148,7 +147,6 @@ type CheckResult struct { Duration string `json:"duration"` Error string `json:"error,omitempty"` Output string `json:"output,omitempty"` - FixHint string `json:"fix_hint,omitempty"` } func runGoQA(cmd *cli.Command, args []string) error { @@ -220,7 +218,6 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose { result.Output = output } - result.FixHint = fixHintFor(check.Name, output) failed++ if !qaJSON && !qaQuiet { @@ -228,9 +225,6 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose && output != "" { cli.Text(output) } - if result.FixHint != "" { - cli.Hint("fix", result.FixHint) - } } if qaFailFast { @@ -266,7 +260,6 @@ func runGoQA(cmd *cli.Command, args []string) error { if !qaJSON && !qaQuiet { cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) - cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } } @@ -443,47 +436,6 @@ func buildCheck(name string) QACheck { } } -// fixHintFor returns an actionable fix instruction for a given check failure. -func fixHintFor(checkName, output string) string { - switch checkName { - case "format", "fmt": - return "Run 'core go qa fmt --fix' to auto-format." - case "vet": - return "Fix the issues reported by go vet — typically genuine bugs." - case "lint": - return "Run 'core go qa lint --fix' for auto-fixable issues." - case "test": - if name := extractFailingTest(output); name != "" { - return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name) - } - return "Run 'go test -run -v ./path/' to debug." - case "race": - return "Data race detected. Add mutex, channel, or atomic to synchronise shared state." - case "bench": - return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce." - case "vuln": - return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'." - case "sec": - return "Review gosec findings. Common fixes: validate inputs, parameterised queries." - case "fuzz": - return "Add a regression test for the crashing input in testdata/fuzz//." - case "docblock": - return "Add doc comments to exported symbols: '// Name does X.' before each declaration." - default: - return "" - } -} - -var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`) - -// extractFailingTest parses the first failing test name from go test output. -func extractFailingTest(output string) string { - if m := failTestRe.FindStringSubmatch(output); len(m) > 1 { - return m[1] - } - return "" -} - func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { // Handle internal checks if check.Command == "_internal_" { diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index e43df9f1..90b2fd28 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -74,13 +74,14 @@ func IsStderrTTY() bool { // PIDFile manages a process ID file for single-instance enforcement. type PIDFile struct { - path string - mu sync.Mutex + medium io.Medium + path string + mu sync.Mutex } // NewPIDFile creates a PID file manager. -func NewPIDFile(path string) *PIDFile { - return &PIDFile{path: path} +func NewPIDFile(m io.Medium, path string) *PIDFile { + return &PIDFile{medium: m, path: path} } // Acquire writes the current PID to the file. @@ -90,7 +91,7 @@ func (p *PIDFile) Acquire() error { defer p.mu.Unlock() // Check if PID file exists - if data, err := io.Local.Read(p.path); err == nil { + if data, err := p.medium.Read(p.path); err == nil { pid, err := strconv.Atoi(data) if err == nil && pid > 0 { // Check if process is still running @@ -101,19 +102,19 @@ func (p *PIDFile) Acquire() error { } } // Stale PID file, remove it - _ = io.Local.Delete(p.path) + _ = p.medium.Delete(p.path) } // Ensure directory exists if dir := filepath.Dir(p.path); dir != "." { - if err := io.Local.EnsureDir(dir); err != nil { + if err := p.medium.EnsureDir(dir); err != nil { return fmt.Errorf("failed to create PID directory: %w", err) } } // Write current PID pid := os.Getpid() - if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil { + if err := p.medium.Write(p.path, strconv.Itoa(pid)); err != nil { return fmt.Errorf("failed to write PID file: %w", err) } @@ -124,7 +125,7 @@ func (p *PIDFile) Acquire() error { func (p *PIDFile) Release() error { p.mu.Lock() defer p.mu.Unlock() - return io.Local.Delete(p.path) + return p.medium.Delete(p.path) } // Path returns the PID file path. @@ -246,6 +247,9 @@ func (h *HealthServer) Addr() string { // DaemonOptions configures daemon mode execution. type DaemonOptions struct { + // Medium is the filesystem abstraction. + Medium io.Medium + // PIDFile path for single-instance enforcement. // Leave empty to skip PID file management. PIDFile string @@ -283,13 +287,17 @@ func NewDaemon(opts DaemonOptions) *Daemon { opts.ShutdownTimeout = 30 * time.Second } + if opts.Medium == nil { + opts.Medium = io.Local + } + d := &Daemon{ opts: opts, reload: make(chan struct{}, 1), } if opts.PIDFile != "" { - d.pid = NewPIDFile(opts.PIDFile) + d.pid = NewPIDFile(opts.Medium, opts.PIDFile) } if opts.HealthAddr != "" { diff --git a/pkg/cli/daemon_test.go b/pkg/cli/daemon_test.go index 5eb51329..d128b5e2 100644 --- a/pkg/cli/daemon_test.go +++ b/pkg/cli/daemon_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/host-uk/core/pkg/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +32,7 @@ func TestPIDFile(t *testing.T) { tmpDir := t.TempDir() pidPath := filepath.Join(tmpDir, "test.pid") - pid := NewPIDFile(pidPath) + pid := NewPIDFile(io.Local, pidPath) // Acquire should succeed err := pid.Acquire() @@ -58,7 +59,7 @@ func TestPIDFile(t *testing.T) { err := os.WriteFile(pidPath, []byte("999999999"), 0644) require.NoError(t, err) - pid := NewPIDFile(pidPath) + pid := NewPIDFile(io.Local, pidPath) // Should acquire successfully (stale PID removed) err = pid.Acquire() @@ -72,7 +73,7 @@ func TestPIDFile(t *testing.T) { tmpDir := t.TempDir() pidPath := filepath.Join(tmpDir, "subdir", "nested", "test.pid") - pid := NewPIDFile(pidPath) + pid := NewPIDFile(io.Local, pidPath) err := pid.Acquire() require.NoError(t, err) @@ -85,9 +86,26 @@ func TestPIDFile(t *testing.T) { }) t.Run("path getter", func(t *testing.T) { - pid := NewPIDFile("/tmp/test.pid") + pid := NewPIDFile(io.Local, "/tmp/test.pid") assert.Equal(t, "/tmp/test.pid", pid.Path()) }) + + t.Run("with mock medium", func(t *testing.T) { + mock := io.NewMockMedium() + pidPath := "/tmp/mock.pid" + pid := NewPIDFile(mock, pidPath) + + err := pid.Acquire() + require.NoError(t, err) + + assert.True(t, mock.Exists(pidPath)) + data, _ := mock.Read(pidPath) + assert.NotEmpty(t, data) + + err = pid.Release() + require.NoError(t, err) + assert.False(t, mock.Exists(pidPath)) + }) } func TestHealthServer(t *testing.T) { @@ -244,6 +262,26 @@ func TestDaemon(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) }) + + t.Run("with mock medium", func(t *testing.T) { + mock := io.NewMockMedium() + pidPath := "/tmp/daemon.pid" + + d := NewDaemon(DaemonOptions{ + Medium: mock, + PIDFile: pidPath, + HealthAddr: "127.0.0.1:0", + }) + + err := d.Start() + require.NoError(t, err) + + assert.True(t, mock.Exists(pidPath)) + + err = d.Stop() + require.NoError(t, err) + assert.False(t, mock.Exists(pidPath)) + }) } func TestRunWithTimeout(t *testing.T) { diff --git a/pkg/container/linuxkit.go b/pkg/container/linuxkit.go index 2f2780af..252b864a 100644 --- a/pkg/container/linuxkit.go +++ b/pkg/container/linuxkit.go @@ -52,6 +52,10 @@ func NewLinuxKitManagerWithHypervisor(state *State, hypervisor Hypervisor) *Linu // Run starts a new LinuxKit VM from the given image. func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions) (*Container, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Validate image exists if !io.Local.IsFile(image) { return nil, fmt.Errorf("image not found: %s", image) @@ -232,6 +236,10 @@ func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) { // Stop stops a running container by sending SIGTERM. func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + container, ok := m.state.Get(id) if !ok { return fmt.Errorf("container not found: %s", id) @@ -290,6 +298,10 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { // List returns all known containers, verifying process state. func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + containers := m.state.All() // Verify each running container's process is still alive @@ -319,6 +331,10 @@ func isProcessRunning(pid int) bool { // Logs returns a reader for the container's log output. func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (goio.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + _, ok := m.state.Get(id) if !ok { return nil, fmt.Errorf("container not found: %s", id) @@ -403,6 +419,10 @@ func (f *followReader) Close() error { // Exec executes a command inside the container via SSH. func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) error { + if err := ctx.Err(); err != nil { + return err + } + container, ok := m.state.Get(id) if !ok { return fmt.Errorf("container not found: %s", id)