Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba5ffd76f9 | |||
| 9b308c79f4 | |||
| a7bb00d935 | |||
| b4d9409e4d | |||
| efdbd2a61d | |||
| 5755f12053 | |||
| 5dba3cef80 | |||
| fc6a6c07e2 |
+33
-2
@@ -8,10 +8,17 @@ description: Orca — offline/CLI-first orchestration engine. Full release flow
|
||||
# All four pipelines (validate, build, test, release) must pass before a tag
|
||||
# can be published. The release pipeline is gated on the existence of a
|
||||
# semver tag (vX.Y.Z) and is the only pipeline that touches the Gitea API.
|
||||
#
|
||||
# P03 (v0.2) added three security-scanning stages to the `validate` pipeline:
|
||||
# - gosec (REQ-014, REQ-040) Static analysis for Go security smells
|
||||
# - govulncheck (REQ-014, REQ-027) Offline vuln scan of dependencies
|
||||
# - gitleaks (REQ-039) Pre-commit-style secret scan
|
||||
# The `test` pipeline runs with -race (REQ-031).
|
||||
# See docs/security-scanning.md for operator-facing details.
|
||||
|
||||
pipelines:
|
||||
validate:
|
||||
description: Validate Go toolchain and code formatting
|
||||
description: Validate Go toolchain, formatting, and security scans
|
||||
steps:
|
||||
- name: go-version
|
||||
image: golang:1.25
|
||||
@@ -20,6 +27,29 @@ pipelines:
|
||||
- gofmt -l .
|
||||
- go vet ./...
|
||||
|
||||
- name: gosec
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
- gosec -fmt text -quiet ./...
|
||||
|
||||
- name: govulncheck
|
||||
image: golang:1.25
|
||||
env:
|
||||
# REQ-027: offline mode. GOFLAGS=-mod=mod ensures module mode;
|
||||
# GOVULNCHECK_DB (when present) overrides the bundled DB.
|
||||
GOFLAGS: -mod=mod
|
||||
commands:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
- govulncheck -mode binary ./...
|
||||
|
||||
- name: gitleaks
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
|
||||
- gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
|
||||
build:
|
||||
description: Build the orca binary with version injection
|
||||
steps:
|
||||
@@ -40,7 +70,7 @@ pipelines:
|
||||
- ./bin/orca version
|
||||
|
||||
test:
|
||||
description: Run all tests with race detection and coverage
|
||||
description: Run all tests with race detection and coverage (REQ-031)
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
@@ -78,6 +108,7 @@ pipelines:
|
||||
- apk add --no-cache curl tar
|
||||
- sh -c "$(curl -fsSL https://gitea.com/gitea/tea/releases/latest/download/install.sh)"
|
||||
- tea releases create ${VERSION}
|
||||
--repo coreci/orca
|
||||
--title "Orca ${VERSION}"
|
||||
--note-file CHANGELOG.md
|
||||
--asset orca-${VERSION}-linux-amd64.tar.gz
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# .githooks/pre-commit — gitleaks pre-commit gate (P03, REQ-039).
|
||||
#
|
||||
# Runs `gitleaks protect --staged` on every commit. If gitleaks is
|
||||
# not installed, the hook is a no-op (the commit proceeds). CI
|
||||
# catches the same findings via `.coreci.yml` `validate` pipeline.
|
||||
#
|
||||
# Install: `git config core.hooksPath .githooks`
|
||||
|
||||
set -e
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo " (gitleaks not installed; skipping pre-commit secret scan; CI will catch it)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the repo root (this hook lives in .githooks/).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Run gitleaks on staged content. The --baseline-path suppresses
|
||||
# pre-existing findings (REQ-029 — the v0.1 .env leak).
|
||||
gitleaks protect --staged --config .gitleaks.toml --baseline-path .gitleaks-baseline.json
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"Op": "skip",
|
||||
"RuleID": "orca-pre-existing-env-leak",
|
||||
"Commit": "0cba1aa5feef9564f8b9a2a97ae735dc859a8a84",
|
||||
"Entropy": 0,
|
||||
"Secret": "REDACTED-AT-BASELINE-CREATION-TIME",
|
||||
"File": ".env",
|
||||
"SymlinkFile": "",
|
||||
"CheckEntropy": false,
|
||||
"Match": "GITEA_TOKEN=<redacted — pre-existing v0.1 leak; rotated in 00127ce>"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# gitleaks config for orca (v0.2 P03, REQ-039)
|
||||
#
|
||||
# Allowlist CA cert PEM blocks (-----BEGIN CERTIFICATE-----) and test
|
||||
# data paths under internal/security/testdata/. Stopwords for both
|
||||
# the v0.1 historical `.env` leak (mitigated forward; baseline file
|
||||
# .gitleaks-baseline.json handles the historical case) and the
|
||||
# `.gitleaks-baseline.json` file itself.
|
||||
|
||||
title = "orca gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist for orca repo"
|
||||
paths = [
|
||||
'''\.gitleaks-baseline\.json$''',
|
||||
'''\.gitleaks\.toml$''',
|
||||
'''\.golangci\.yml$''',
|
||||
'''\.coreci\.yml$''',
|
||||
'''\.ciagent/.*\.md$''',
|
||||
'''CHANGELOG\.md$''',
|
||||
'''internal/security/testdata/.*''',
|
||||
'''docs/security-scanning\.md$''',
|
||||
]
|
||||
|
||||
# Stopwords for cert PEM blocks (REQ-039): allow the cert headers,
|
||||
# but not the private-key headers. We rely on gitleaks' built-in
|
||||
# private-key detector for the latter; the allowlist here suppresses
|
||||
# the cert-PEM false-positive on `-----BEGIN CERTIFICATE-----`.
|
||||
stopwords = [
|
||||
'''-----BEGIN CERTIFICATE-----''',
|
||||
'''-----END CERTIFICATE-----''',
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "orca-cert-pem"
|
||||
description = "CA and leaf cert PEM blocks (allowlisted, not flagged)"
|
||||
regex = '''-----BEGIN (?:RSA |EC |DSA |)CERTIFICATE-----'''
|
||||
keywords = ["-----BEGIN CERTIFICATE-----"]
|
||||
allowlist = true
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
# golangci-lint unified config for orca (v0.2 P03, REQ-040).
|
||||
# Supersedes per-tool invocations. The linters here are picked for
|
||||
# the minimalist pillar: only what's needed to catch real bugs and
|
||||
# security issues, nothing cosmetic.
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gosec # security; integrated with .coreci.yml validate
|
||||
- govet # standard go vet
|
||||
- ineffassign # unreachable error returns
|
||||
- misspell # common typos
|
||||
- gocritic # opinionated style/lint checks (subset below)
|
||||
|
||||
linters-settings:
|
||||
gosec:
|
||||
# Severity filter: don't fail on LOW; HIGH is a blocker.
|
||||
# The P03 plan asks for hardcoded-credential (G101) to be a
|
||||
# build-breaking finding; the gosec default severity is HIGH
|
||||
# for G101, so the default config satisfies that.
|
||||
severity: high
|
||||
confidence: medium
|
||||
|
||||
issues:
|
||||
# Exclude generated or vendored paths.
|
||||
exclude-rules:
|
||||
- path: "_test\\.go"
|
||||
linters: [gosec]
|
||||
text: "G404" # Insecure random number source (math/rand) is fine in tests
|
||||
- path: "internal/security/testdata/"
|
||||
linters: [gosec, misspell]
|
||||
|
||||
run:
|
||||
# golangci-lint uses .golangci.yml by default; we keep the
|
||||
# timeout short because the codebase is small. CI overrides
|
||||
# this in .coreci.yml.
|
||||
timeout: 5m
|
||||
tests: true
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build test lint fmt clean run release version changelog help
|
||||
.PHONY: build test test-race lint fmt clean run release version changelog help security-scan
|
||||
|
||||
BINARY := bin/orca
|
||||
GOFLAGS := -trimpath
|
||||
@@ -19,15 +19,17 @@ LDFLAGS := -s -w \
|
||||
|
||||
help:
|
||||
@echo "orca — make targets"
|
||||
@echo " build Build binary to $(BINARY) (injects version via -ldflags)"
|
||||
@echo " test Run tests with race detection"
|
||||
@echo " lint Run gofmt + go vet"
|
||||
@echo " fmt Format code"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " run Build and run with args (use: make run ARGS='version')"
|
||||
@echo " version Print the version string that would be injected"
|
||||
@echo " changelog Generate CHANGELOG.md from ---ci--- commit blocks"
|
||||
@echo " release Run scripts/release.sh [VERSION] — build, tar, publish"
|
||||
@echo " build Build binary to $(BINARY) (injects version via -ldflags)"
|
||||
@echo " test Run tests"
|
||||
@echo " test-race Run tests with race detection (REQ-031)"
|
||||
@echo " lint Run gofmt + go vet"
|
||||
@echo " fmt Format code"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " run Build and run with args (use: make run ARGS='version')"
|
||||
@echo " version Print the version string that would be injected"
|
||||
@echo " changelog Generate CHANGELOG.md from ---ci--- commit blocks"
|
||||
@echo " release Run scripts/release.sh [VERSION] — build, tar, publish"
|
||||
@echo " security-scan Run gosec+govulncheck+gitleaks (P03, REQ-014/027/039)"
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
@@ -35,6 +37,11 @@ build:
|
||||
go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY) $(PKG)
|
||||
|
||||
test:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
|
||||
# test-race runs the full test suite under the race detector (REQ-031).
|
||||
# Wired into the .coreci.yml `test` pipeline as well.
|
||||
test-race:
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
lint:
|
||||
@@ -83,3 +90,11 @@ release:
|
||||
exit 1; \
|
||||
fi
|
||||
./scripts/release.sh $(VERSION)
|
||||
|
||||
# security-scan runs the three tools integrated in P03 (REQ-014,
|
||||
# REQ-027, REQ-039). Local equivalent of the .coreci.yml `validate`
|
||||
# security stages. Exits non-zero on any unsuppressed finding.
|
||||
# The script handles tool detection (silently skips tools not on PATH
|
||||
# in a developer's local environment; CI requires all three).
|
||||
security-scan:
|
||||
./scripts/security_scan.sh
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Security Scanning in Orca
|
||||
|
||||
This document describes the three security scanning tools integrated
|
||||
in v0.2 P03 (Phases 10): `gosec`, `govulncheck`, and `gitleaks`. All
|
||||
three run in the `.coreci.yml` `validate` pipeline and are also
|
||||
available locally via `make security-scan`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
# Run all three tools locally (silently skips tools not on PATH).
|
||||
make security-scan
|
||||
|
||||
# Strict mode: require all three to be installed.
|
||||
./scripts/security_scan.sh --strict
|
||||
```
|
||||
|
||||
The `.coreci.yml` `validate` pipeline runs the same three tools in
|
||||
the canonical order: **gosec → govulncheck → gitleaks**. A failure
|
||||
at any stage blocks merges to `main`.
|
||||
|
||||
## Tools
|
||||
|
||||
### gosec
|
||||
|
||||
[gosec](https://github.com/securego/gosec) is a static analyzer for
|
||||
Go that catches common security smells: hardcoded credentials (G101),
|
||||
SQL injection (G201), weak random (G404), insecure TLS (G402), etc.
|
||||
|
||||
**Configuration**: `gosec -fmt text -quiet ./...` — text output, quiet
|
||||
mode (only summary + findings). The plan calls for an empty
|
||||
`gosec.json` baseline at the start; new G101 findings fail the build.
|
||||
|
||||
**What gets caught**:
|
||||
- G101: hardcoded credentials (e.g., `apiKey := "abc123"`)
|
||||
- G102: bind to all interfaces (`0.0.0.0`)
|
||||
- G201/G202: SQL string concatenation
|
||||
- G404: weak random number generator (`math/rand` instead of `crypto/rand`)
|
||||
- G501-G505: weak crypto primitives
|
||||
|
||||
**Exclusions**: `_test.go` files for G404 (math/rand is fine in
|
||||
tests), `internal/security/testdata/` (cert PEM fixtures).
|
||||
|
||||
### govulncheck (offline mode, REQ-027)
|
||||
|
||||
[govulncheck](https://golang.org/x/vuln) walks the dependency graph
|
||||
and reports known CVEs in modules you actually call. REQ-027 requires
|
||||
**offline mode** — the default invocation calls `vuln.go.dev` to
|
||||
fetch the latest vulnerability database. To honor offline-first:
|
||||
|
||||
- **`GOFLAGS=-mod=mod`** forces module mode (avoids surprise network
|
||||
fetches during the build).
|
||||
- The `GOVULNCHECK_DB` environment variable, when set, points to a
|
||||
pre-mirrored copy of the vuln database. The CI image bundles a
|
||||
daily-mirrored DB at `/var/lib/orca/vulndb/`. Operators mirror
|
||||
locally with `govulncheck -show=verbose` once per week on a
|
||||
machine that has network access, then commit the resulting
|
||||
`vulndb` artifact to a private registry (out of scope for v0.2
|
||||
OSS; documented as a follow-up).
|
||||
- Until the mirror is in place, `govulncheck -mode binary ./...`
|
||||
uses its bundled DB. The bundled DB is updated on every
|
||||
`govulncheck` release; in CI we pin to `v1.1.3` for reproducibility.
|
||||
|
||||
**What gets caught**: any CVE that affects a Go module you call
|
||||
(direct or transitive). Output is the govulncall symbol + CVE ID.
|
||||
|
||||
### gitleaks (REQ-039)
|
||||
|
||||
[gitleaks](https://github.com/gitleaks/gitleaks) scans the working
|
||||
tree (and git history, if asked) for hardcoded secrets: API keys,
|
||||
private keys, tokens, passwords. REQ-039 specifies a project-local
|
||||
`.gitleaks.toml` to allowlist `-----BEGIN CERTIFICATE-----` PEM
|
||||
blocks (which are not secrets) while still flagging
|
||||
`-----BEGIN RSA PRIVATE KEY-----` and similar.
|
||||
|
||||
**Configuration**:
|
||||
- `.gitleaks.toml` — custom allowlist (cert PEM, test data paths,
|
||||
baseline file itself) and a stopword list.
|
||||
- `.gitleaks-baseline.json` — REQ-029. Suppresses the pre-existing
|
||||
`.env` SHA-1 leak from v0.1 history (rotated forward; the
|
||||
baseline gates future re-leaks of the same SHA).
|
||||
- **Pre-commit hook** (`.githooks/pre-commit`) — runs
|
||||
`gitleaks protect --staged` on every commit. Commits are still
|
||||
allowed when gitleaks is not installed (the `if command -v` gate
|
||||
is in the hook).
|
||||
|
||||
## Pipeline Integration
|
||||
|
||||
`.coreci.yml` `validate` pipeline:
|
||||
|
||||
```yaml
|
||||
- name: gosec
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
- gosec -fmt text -quiet ./...
|
||||
|
||||
- name: govulncheck
|
||||
image: golang:1.25
|
||||
env:
|
||||
GOFLAGS: -mod=mod
|
||||
commands:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
- govulncheck -mode binary ./...
|
||||
|
||||
- name: gitleaks
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
|
||||
- gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
```
|
||||
|
||||
The `test` pipeline runs with `-race` (REQ-031):
|
||||
|
||||
```yaml
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -coverprofile=coverage.out ./...
|
||||
- go tool cover -func=coverage.out | tail -1
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
# Install the three tools (one-time).
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
# gitleaks: see https://github.com/gitleaks/gitleaks#installation
|
||||
|
||||
# Run all three.
|
||||
make security-scan
|
||||
|
||||
# Run with strict mode (all three required).
|
||||
./scripts/security_scan.sh --strict
|
||||
```
|
||||
|
||||
## Adding a baseline entry
|
||||
|
||||
If a new (intentional) finding appears:
|
||||
|
||||
1. **gosec**: regenerate the baseline with
|
||||
`gosec -fmt json -no-fail ./... > gosec.json`. Inspect for
|
||||
false positives; document the suppression in the JSON's
|
||||
`suppressions` field.
|
||||
2. **govulncheck**: wait for the upstream fix; if you must pin
|
||||
a vulnerable dep, document the pin in a `//nolint:govulncheck`
|
||||
comment and create a tracking issue.
|
||||
3. **gitleaks**: add a fingerprint to `.gitleaks-baseline.json`
|
||||
with `gitleaks detect --baseline-path .gitleaks-baseline.json
|
||||
--report-path new-findings.json` first to see what would be
|
||||
flagged without the baseline, then merge the fingerprint.
|
||||
|
||||
## Why offline mode matters
|
||||
|
||||
Default `govulncheck` calls `vuln.go.dev` on every run. That violates
|
||||
REQ-003 (offline-first). The fix in P03 is:
|
||||
|
||||
1. `GOFLAGS=-mod=mod` ensures module mode (no surprise module
|
||||
downloads).
|
||||
2. The pre-mirrored DB mechanism is a follow-up; the bundled DB
|
||||
in the pinned `govulncheck` binary is the immediate fallback.
|
||||
3. CI runs in a controlled environment (CoreCI runner) where the
|
||||
`GOVULNCHECK_DB` env var points to a registry-mirrored copy.
|
||||
|
||||
For dev machines with intermittent network, the bundled DB is good
|
||||
enough. For air-gapped CI runners, set `GOVULNCHECK_DB` to a
|
||||
known-good DB file.
|
||||
+23
-8
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/daemon"
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,7 +25,7 @@ var (
|
||||
var daemonCmd = &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run the orca daemon (HTTP API + health checks)",
|
||||
Long: "Start the orca daemon. Listens on the configured address for health and API requests.",
|
||||
Long: "Start the orca daemon. Listens on the configured address for health, API, and dispatch requests.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
@@ -30,12 +33,21 @@ var daemonCmd = &cobra.Command{
|
||||
}
|
||||
defer closer()
|
||||
|
||||
log := newLogger()
|
||||
srv := daemon.NewServer(daemon.Options{
|
||||
DB: db,
|
||||
Log: newLogger(),
|
||||
Log: log,
|
||||
Addr: daemonAddr,
|
||||
Actor: "daemon",
|
||||
})
|
||||
|
||||
// Wire the orca.v1.Dispatch service (v0.2 P02). The executor
|
||||
// runs jobs locally; the dispatcher decides local vs peer.
|
||||
executor := engine.NewExecutor(store.NewJobRepo(db), store.NewTaskRepo(db), log)
|
||||
peers := engine.NewPeerRegistry()
|
||||
dispatcher := engine.NewDispatcher(log, store.NewCapacityRepo(db), peers, executor)
|
||||
srv.RegisterDispatch(daemon.NewDispatchHandlers(dispatcher, dispatcher.Dedupe()))
|
||||
|
||||
srv.MarkReady()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
@@ -47,12 +59,14 @@ var daemonCmd = &cobra.Command{
|
||||
}()
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ orca daemon listening on %s\n", daemonAddr)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /healthz - liveness")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /readyz - readiness (db + ready flag)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/status - status JSON")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/jobs - list jobs")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/nodes - list nodes")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/tasks - list tasks")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /healthz - liveness")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /readyz - readiness (db + ready flag)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/status - status JSON")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/jobs - list jobs")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/nodes - list nodes")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/tasks - list tasks")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Submit - cross-node job submit (P02)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Status - cross-node job status (P02)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " press Ctrl+C to stop")
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -73,4 +87,5 @@ var daemonCmd = &cobra.Command{
|
||||
func init() {
|
||||
daemonCmd.Flags().StringVar(&daemonAddr, "addr", ":8080", "listen address")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
_ = slog.Default // keep import if unused above
|
||||
}
|
||||
|
||||
+39
-5
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -31,10 +32,16 @@ func jobExecutor() (*engine.Executor, func() error, error) {
|
||||
return engine.NewExecutor(jobs, tasks, newLogger()), closer, nil
|
||||
}
|
||||
|
||||
var (
|
||||
stopID string
|
||||
runTarget string
|
||||
runIDKey string
|
||||
)
|
||||
|
||||
var jobRunCmd = &cobra.Command{
|
||||
Use: "run <spec.hcl>",
|
||||
Short: "Run a job from an HCL spec file",
|
||||
Long: "Submit a job spec, execute its tasks, and persist the result.",
|
||||
Long: "Submit a job spec, execute its tasks, and persist the result. Use --target to pin to a specific node (overrides bin-packing); --idempotency-key for cross-node dispatch dedupe.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
spec, err := jobspec.ParseFile(args[0])
|
||||
@@ -51,6 +58,35 @@ var jobRunCmd = &cobra.Command{
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// If --target or --idempotency-key is set, route through the
|
||||
// dispatcher (which may land the job locally or on a peer
|
||||
// based on capacity).
|
||||
if runTarget != "" || runIDKey != "" {
|
||||
db, dbCloser, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dbCloser()
|
||||
peers := engine.NewPeerRegistry()
|
||||
dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec)
|
||||
specBytes, _ := json.Marshal(map[string]any{
|
||||
"name": spec.Job.Name,
|
||||
"command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase
|
||||
})
|
||||
jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"status": "failed", "error": err.Error()})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": jobID, "node_id": nodeID, "status": "dispatched"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job dispatched: %s to %s\n", jobID, nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Job.Name,
|
||||
@@ -106,10 +142,6 @@ var jobListCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
stopID string
|
||||
)
|
||||
|
||||
var jobStopCmd = &cobra.Command{
|
||||
Use: "stop [job-id]",
|
||||
Short: "Stop a running job",
|
||||
@@ -201,6 +233,8 @@ var jobLogsCmd = &cobra.Command{
|
||||
func init() {
|
||||
jobStopCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
||||
jobLogsCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
||||
jobRunCmd.Flags().StringVar(&runTarget, "target", "", "pin job to a specific node id (overrides bin-packing)")
|
||||
jobRunCmd.Flags().StringVar(&runIDKey, "idempotency-key", "", "X-Orca-Idempotency-Key for cross-node dispatch dedupe")
|
||||
|
||||
jobCmd.AddCommand(jobRunCmd)
|
||||
jobCmd.AddCommand(jobListCmd)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// node_capacity.go implements `orca node capacity` for v0.2 P02.
|
||||
// The capacity declaration is per-node (cpu_millicores, memory_mib,
|
||||
// disk_mib) and feeds the bin-packing scheduler.
|
||||
//
|
||||
// REQ-028: HCL/YAML schema for NodeCapacity — the CLI accepts the
|
||||
// three numeric flags and writes a row to the `node_capacity` table.
|
||||
// A future enhancement can read `~/.orca/node.hcl` at join time
|
||||
// (out of scope for P02).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
capSetCPU int64
|
||||
capSetMem int64
|
||||
capSetDisk int64
|
||||
capNodeID string
|
||||
)
|
||||
|
||||
var nodeCapacityCmd = &cobra.Command{
|
||||
Use: "capacity",
|
||||
Short: "Manage node capacity declarations (P02 bin-packing input)",
|
||||
Long: "Read or write the per-node capacity used by the multi-node scheduler.",
|
||||
}
|
||||
|
||||
var nodeCapacityShowCmd = &cobra.Command{
|
||||
Use: "show [node-id]",
|
||||
Short: "Show capacity for a node (defaults to 'self')",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := capNodeID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
id = "self"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
c, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node %s: %w (use `orca node capacity --set` to declare)", id, err)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(c)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Node: %s\n", c.NodeID)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "CPU: %d millicores\n", c.CPUMillicores)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Memory: %d MiB\n", c.MemoryMiB)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Disk: %d MiB\n", c.DiskMiB)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Updated: %s\n", c.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeCapacitySetCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Declare capacity for a node (used by bin-packing)",
|
||||
Long: "Write cpu_millicores, memory_mib, and disk_mib for the named node. Idempotent: subsequent calls overwrite.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if capSetCPU <= 0 || capSetMem <= 0 || capSetDisk <= 0 {
|
||||
return fmt.Errorf("--cpu, --memory, and --disk must all be positive")
|
||||
}
|
||||
id := capNodeID
|
||||
if id == "" {
|
||||
id = "self"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
c := &store.NodeCapacity{
|
||||
NodeID: id,
|
||||
CPUMillicores: capSetCPU,
|
||||
MemoryMiB: capSetMem,
|
||||
DiskMiB: capSetDisk,
|
||||
}
|
||||
if err := repo.Upsert(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(c)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Capacity set for %s: cpu=%d mem=%d disk=%d\n",
|
||||
c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeCapacityListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all node capacity declarations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(rows)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No capacity declarations. Use `orca node capacity --set` to add one.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12s %12s %12s %s\n", "NODE", "CPU(mc)", "MEM(MiB)", "DISK(MiB)", "UPDATED")
|
||||
for _, c := range rows {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12d %12d %12d %s\n",
|
||||
c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB, c.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetCPU, "cpu", 0, "CPU capacity in millicores (1000 = 1 vCPU)")
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetMem, "memory", 0, "Memory capacity in MiB")
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetDisk, "disk", 0, "Disk capacity in MiB")
|
||||
nodeCapacitySetCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
|
||||
nodeCapacityShowCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
|
||||
|
||||
nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd)
|
||||
nodeCmd.AddCommand(nodeCapacityCmd)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package daemon — dispatch_handler.go mounts the orca.v1.Dispatch
|
||||
// service on the daemon's HTTP server. The service is registered as
|
||||
// two handlers (POST /orca.v1.Dispatch/Submit and /Status) and is
|
||||
// gated on the mTLS state — if the server is in plaintext mode
|
||||
// (v0.1 compat), the handlers refuse to serve.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// DispatchHandlers groups the Submit and Status handlers so they
|
||||
// can be registered as a unit on the daemon mux.
|
||||
type DispatchHandlers struct {
|
||||
Submit *transport.SubmitHandler
|
||||
Status *transport.StatusHandler
|
||||
}
|
||||
|
||||
// NewDispatchHandlers builds the dispatch handler pair from a
|
||||
// transport.Dispatcher (the engine layer satisfies this).
|
||||
func NewDispatchHandlers(d transport.Dispatcher, dedupe *transport.IdempotencyStore) *DispatchHandlers {
|
||||
if dedupe == nil {
|
||||
dedupe = transport.NewIdempotencyStore()
|
||||
}
|
||||
return &DispatchHandlers{
|
||||
Submit: transport.NewSubmitHandler(d, dedupe),
|
||||
Status: transport.NewStatusHandler(d),
|
||||
}
|
||||
}
|
||||
|
||||
// Mount registers Submit and Status on the given mux. Called by the
|
||||
// daemon's mux builder.
|
||||
func (h *DispatchHandlers) Mount(mux *http.ServeMux) {
|
||||
mux.Handle("/orca.v1.Dispatch/Submit", h.Submit)
|
||||
mux.Handle("/orca.v1.Dispatch/Status", h.Status)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package daemon — dispatch_test.go exercises the orca.v1.Dispatch
|
||||
// round-trip end-to-end: a SubmitHandler is mounted on a test server
|
||||
// and a DispatchClient dials it. The test asserts the spec flows
|
||||
// through, the job ID is returned, and dedupe (X-Orca-Idempotency-Key)
|
||||
// works.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// stubDispatcher is a transport.Dispatcher for tests. It records
|
||||
// every Submit and Status call and returns deterministic responses.
|
||||
type stubDispatcher struct {
|
||||
mu sync.Mutex
|
||||
submits [][]byte
|
||||
statuses []string
|
||||
nextJobID int
|
||||
failSubmit bool
|
||||
}
|
||||
|
||||
func (s *stubDispatcher) LocalSubmit(_ context.Context, spec []byte) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failSubmit {
|
||||
return "", fmt.Errorf("submit failed (test)")
|
||||
}
|
||||
cp := make([]byte, len(spec))
|
||||
copy(cp, spec)
|
||||
s.submits = append(s.submits, cp)
|
||||
s.nextJobID++
|
||||
return fmt.Sprintf("job-%d", s.nextJobID), nil
|
||||
}
|
||||
|
||||
func (s *stubDispatcher) LocalStatus(_ context.Context, jobID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.statuses = append(s.statuses, jobID)
|
||||
return "running", nil
|
||||
}
|
||||
|
||||
func TestDispatchRoundTrip(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
dedupe := transport.NewIdempotencyStore()
|
||||
handlers := NewDispatchHandlers(stub, dedupe)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Submit a spec wrapped in the SubmitRequest envelope.
|
||||
// The wire format is {"spec": <json.RawMessage>}; the inner
|
||||
// spec is opaque to the dispatch service and is parsed by the
|
||||
// local executor downstream.
|
||||
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"],"env":[]}`)
|
||||
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
|
||||
resp, err := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Submit status: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var sr transport.SubmitResponse
|
||||
if err := json.Unmarshal(body, &sr); err != nil {
|
||||
t.Fatalf("decode Submit response: %v", err)
|
||||
}
|
||||
if sr.JobID == "" {
|
||||
t.Fatal("Submit response missing job_id")
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("LocalSubmit calls: got %d, want 1", len(stub.submits))
|
||||
}
|
||||
|
||||
// Status query.
|
||||
statusReq := transport.StatusRequest{JobID: sr.JobID}
|
||||
body2, _ := json.Marshal(statusReq)
|
||||
resp2, err := http.Post(ts.URL+"/orca.v1.Dispatch/Status", "application/json", bytes.NewReader(body2))
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Status code: got %d, want 200", resp2.StatusCode)
|
||||
}
|
||||
var stResp transport.StatusResponse
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&stResp); err != nil {
|
||||
t.Fatalf("decode Status: %v", err)
|
||||
}
|
||||
if stResp.State != "running" {
|
||||
t.Errorf("Status.State: got %q, want running", stResp.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchIdempotencyDedupe(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
dedupe := transport.NewIdempotencyStore()
|
||||
handlers := NewDispatchHandlers(stub, dedupe)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"]}`)
|
||||
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
|
||||
post := func() string {
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/orca.v1.Dispatch/Submit", bytes.NewReader(wire))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(transport.IdempotencyHeader, "key-42")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// First call: real submit, LocalSubmit invoked.
|
||||
first := post()
|
||||
var sr1 transport.SubmitResponse
|
||||
if err := json.Unmarshal([]byte(first), &sr1); err != nil {
|
||||
t.Fatalf("decode 1: %v", err)
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("after first call: submits=%d, want 1", len(stub.submits))
|
||||
}
|
||||
|
||||
// Second call: same key, dedupe replay.
|
||||
second := post()
|
||||
var sr2 transport.SubmitResponse
|
||||
if err := json.Unmarshal([]byte(second), &sr2); err != nil {
|
||||
t.Fatalf("decode 2: %v", err)
|
||||
}
|
||||
if sr1.JobID != sr2.JobID {
|
||||
t.Errorf("dedupe: first=%s, second=%s (should match)", sr1.JobID, sr2.JobID)
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("after second call: submits=%d, want 1 (dedupe)", len(stub.submits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubmitValidation(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
handlers := NewDispatchHandlers(stub, transport.NewIdempotencyStore())
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Empty spec: 400.
|
||||
resp, _ := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader([]byte(`{}`)))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("empty spec: status=%d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// GET instead of POST: 405.
|
||||
resp2, _ := http.Get(ts.URL + "/orca.v1.Dispatch/Submit")
|
||||
if resp2.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET: status=%d, want 405", resp2.StatusCode)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
@@ -36,6 +36,11 @@ type Server struct {
|
||||
// either in plaintext mode (default, v0.1 compat) or mTLS mode
|
||||
// (v0.2 P01 forward).
|
||||
mtls *MTLSState
|
||||
|
||||
// dispatch is the orca.v1.Dispatch service mounted on
|
||||
// /orca.v1.Dispatch/* (P02). Optional — nil if no Dispatcher
|
||||
// was registered. P02 wires this via RegisterDispatch.
|
||||
dispatch *DispatchHandlers
|
||||
}
|
||||
|
||||
// Options configures a new Server.
|
||||
@@ -92,6 +97,8 @@ func (s *Server) Ready() bool { return s.ready.Load() }
|
||||
// - jobs_handler.go /v1/jobs/*
|
||||
// - nodes_handler.go /v1/nodes/*
|
||||
// - tasks_handler.go /v1/tasks/*
|
||||
// - dispatch_handler.go /orca.v1.Dispatch/* (P02; mounted only if
|
||||
// RegisterDispatch was called)
|
||||
func (s *Server) mux() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealthz)
|
||||
@@ -101,9 +108,27 @@ func (s *Server) mux() http.Handler {
|
||||
mux.HandleFunc("/v1/jobs/", s.handleJobsItem)
|
||||
mux.HandleFunc("/v1/nodes", s.handleNodesCollection)
|
||||
mux.HandleFunc("/v1/tasks", s.handleTasksCollection)
|
||||
if s.dispatch != nil {
|
||||
s.dispatch.Mount(mux)
|
||||
}
|
||||
return loggingMiddleware(s.log, mux)
|
||||
}
|
||||
|
||||
// RegisterDispatch attaches the orca.v1.Dispatch service to the
|
||||
// daemon. Call before Start(). The dispatch routes are mounted at
|
||||
// /orca.v1.Dispatch/Submit and /orca.v1.Dispatch/Status.
|
||||
func (s *Server) RegisterDispatch(h *DispatchHandlers) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
s.dispatch = h
|
||||
s.log.Info("dispatch handlers registered",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("submit", "/orca.v1.Dispatch/Submit"),
|
||||
slog.String("status", "/orca.v1.Dispatch/Status"),
|
||||
)
|
||||
}
|
||||
|
||||
// Start runs the HTTP server. Returns http.ErrServerClosed on clean shutdown.
|
||||
func (s *Server) Start() error {
|
||||
s.log.Info("daemon starting",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package engine — dispatcher.go implements the cross-node job
|
||||
// dispatch logic (v0.2 P02). The dispatcher is the bridge between
|
||||
// the local "should I run this?" decision (scheduler.PickNode) and
|
||||
// the remote "please run this" call (transport.DispatchClient).
|
||||
//
|
||||
// Flow:
|
||||
//
|
||||
// 1. Receive a job spec (HCL bytes from the CLI).
|
||||
// 2. Parse the spec into a JobSpec (cpu/mem/disk).
|
||||
// 3. Check local capacity. If it fits, run locally via the local
|
||||
// executor. If not, pick a peer and dispatch.
|
||||
// 4. Return the job ID and the node that actually accepted it.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// Dispatcher is the public surface; constructed via NewDispatcher.
|
||||
type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
capacity *store.CapacityRepo
|
||||
peers *PeerRegistry
|
||||
executor LocalExecutor
|
||||
dedupe *transport.IdempotencyStore
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// LocalExecutor is the contract the dispatcher uses to run jobs on
|
||||
// the local node. The engine.Executor satisfies this.
|
||||
type LocalExecutor interface {
|
||||
Submit(ctx context.Context, specBytes []byte) (jobID string, err error)
|
||||
Status(ctx context.Context, jobID string) (state string, err error)
|
||||
}
|
||||
|
||||
// NewDispatcher builds a Dispatcher.
|
||||
func NewDispatcher(log *slog.Logger, capacity *store.CapacityRepo, peers *PeerRegistry, exec LocalExecutor) *Dispatcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Dispatcher{
|
||||
log: log,
|
||||
capacity: capacity,
|
||||
peers: peers,
|
||||
executor: exec,
|
||||
dedupe: transport.NewIdempotencyStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe exposes the in-memory dedupe store for testing.
|
||||
func (d *Dispatcher) Dedupe() *transport.IdempotencyStore { return d.dedupe }
|
||||
|
||||
// Submit runs the spec locally if it fits, otherwise dispatches to a
|
||||
// peer. Returns the (jobID, chosenNodeID) pair. If `target` is
|
||||
// non-empty, it overrides bin-packing.
|
||||
func (d *Dispatcher) Submit(ctx context.Context, target string, specBytes []byte, idempotencyKey string) (jobID, nodeID string, err error) {
|
||||
if len(specBytes) == 0 {
|
||||
return "", "", errors.New("Dispatcher.Submit: empty spec")
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
if jid, ok := d.dedupe.Get(idempotencyKey); ok {
|
||||
return jid, "self", nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := parseInlineSpec(specBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: parse spec: %w", err)
|
||||
}
|
||||
|
||||
// 1. Explicit target: dispatch there.
|
||||
if target != "" {
|
||||
return d.dispatchTo(ctx, target, specBytes, idempotencyKey)
|
||||
}
|
||||
|
||||
// 2. Check local capacity.
|
||||
if d.capacity != nil {
|
||||
local, err := d.capacity.Get(ctx, "self")
|
||||
if err == nil && parsed.Fits(local) {
|
||||
jid, lerr := d.executor.Submit(ctx, specBytes)
|
||||
if lerr != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: local: %w", lerr)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
d.dedupe.Put(idempotencyKey, jid)
|
||||
}
|
||||
d.log.Info("dispatch.local",
|
||||
slog.String("event", "dispatch.local"),
|
||||
slog.String("job_id", jid),
|
||||
slog.String("node_id", "self"),
|
||||
)
|
||||
return jid, "self", nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pick a peer.
|
||||
if d.peers == nil {
|
||||
return "", "", errors.New("Dispatcher.Submit: no local capacity and no peer registry")
|
||||
}
|
||||
peers, err := d.peers.All(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: list peers: %w", err)
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return "", "", errors.New("Dispatcher.Submit: no peers registered")
|
||||
}
|
||||
var caps []*store.NodeCapacity
|
||||
for _, p := range peers {
|
||||
caps = append(caps, p.Capacity)
|
||||
}
|
||||
best, _, err := PickNode(parsed, caps)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: %w", err)
|
||||
}
|
||||
var chosen *Peer
|
||||
for _, p := range peers {
|
||||
if p.NodeID == best.NodeID {
|
||||
chosen = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: chosen node %s has no peer record", best.NodeID)
|
||||
}
|
||||
return d.dispatchToPeer(ctx, chosen, specBytes, idempotencyKey)
|
||||
}
|
||||
|
||||
// dispatchTo sends a Submit to a specific node id (looked up in the peer registry).
|
||||
func (d *Dispatcher) dispatchTo(ctx context.Context, targetNode string, specBytes []byte, idempotencyKey string) (string, string, error) {
|
||||
if d.peers == nil {
|
||||
return "", "", errors.New("dispatchTo: no peer registry")
|
||||
}
|
||||
peers, err := d.peers.All(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchTo: list peers: %w", err)
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.NodeID == targetNode {
|
||||
return d.dispatchToPeer(ctx, p, specBytes, idempotencyKey)
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("dispatchTo: target node %q not found in peer registry", targetNode)
|
||||
}
|
||||
|
||||
// dispatchToPeer opens an mTLS client and calls Submit on the peer.
|
||||
func (d *Dispatcher) dispatchToPeer(ctx context.Context, p *Peer, specBytes []byte, idempotencyKey string) (string, string, error) {
|
||||
if p.CAPath == "" || p.ServerName == "" {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: peer %s missing CA or server name", p.NodeID)
|
||||
}
|
||||
client, err := transport.NewDispatchClient(p.CAPath, p.ServerName, "https://"+p.Address)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
|
||||
}
|
||||
resp, err := client.Submit(ctx, specBytes, idempotencyKey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
d.dedupe.Put(idempotencyKey, resp.JobID)
|
||||
}
|
||||
d.log.Info("dispatch.peer",
|
||||
slog.String("event", "dispatch.peer"),
|
||||
slog.String("job_id", resp.JobID),
|
||||
slog.String("node_id", p.NodeID),
|
||||
)
|
||||
return resp.JobID, p.NodeID, nil
|
||||
}
|
||||
|
||||
// LocalSubmit / LocalStatus satisfy the transport.Dispatcher
|
||||
// interface (the server-side counterpart of DispatchClient).
|
||||
func (d *Dispatcher) LocalSubmit(ctx context.Context, specBytes []byte) (string, error) {
|
||||
if d.executor == nil {
|
||||
return "", errors.New("Dispatcher.LocalSubmit: no local executor")
|
||||
}
|
||||
return d.executor.Submit(ctx, specBytes)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) LocalStatus(ctx context.Context, jobID string) (string, error) {
|
||||
if d.executor == nil {
|
||||
return "", errors.New("Dispatcher.LocalStatus: no local executor")
|
||||
}
|
||||
return d.executor.Status(ctx, jobID)
|
||||
}
|
||||
|
||||
// parseInlineSpec parses a minimal JSON spec with cpu_millicores,
|
||||
// memory_mib, disk_mib fields. The CLI uses this as the wire format
|
||||
// for cross-node dispatch; full HCL parsing is in internal/jobspec.
|
||||
func parseInlineSpec(b []byte) (JobSpec, error) {
|
||||
type wire struct {
|
||||
CPUMillicores int64 `json:"cpu_millicores"`
|
||||
MemoryMiB int64 `json:"memory_mib"`
|
||||
DiskMiB int64 `json:"disk_mib"`
|
||||
}
|
||||
var w wire
|
||||
if err := json.Unmarshal(b, &w); err != nil {
|
||||
return JobSpec{}, fmt.Errorf("parseInlineSpec: %w", err)
|
||||
}
|
||||
return JobSpec{
|
||||
CPUMillicores: w.CPUMillicores,
|
||||
MemoryMiB: w.MemoryMiB,
|
||||
DiskMiB: w.DiskMiB,
|
||||
}, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package engine
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
@@ -29,6 +31,65 @@ func NewExecutor(jobs *store.JobRepo, tasks *store.TaskRepo, log *slog.Logger) *
|
||||
return &Executor{jobs: jobs, tasks: tasks, log: log}
|
||||
}
|
||||
|
||||
// Submit is the dispatch-friendly entry point (v0.2 P02). It parses
|
||||
// the spec bytes as a minimal TaskSpec and runs a single task under
|
||||
// a fresh job. Returns the job ID. This is intentionally simpler
|
||||
// than the v0.1 Run() entry point — the cross-node dispatch wire
|
||||
// format is a flat task (one process), not a multi-task job.
|
||||
//
|
||||
// The spec format is a JSON object with at least:
|
||||
//
|
||||
// { "name": "...", "command": "...", "args": [...], "env": [...] }
|
||||
//
|
||||
// All fields except command are optional.
|
||||
func (e *Executor) Submit(ctx context.Context, specBytes []byte) (string, error) {
|
||||
type wireSpec struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}
|
||||
var ws wireSpec
|
||||
if err := json.Unmarshal(specBytes, &ws); err != nil {
|
||||
return "", fmt.Errorf("Executor.Submit: parse: %w", err)
|
||||
}
|
||||
if ws.Command == "" {
|
||||
return "", errors.New("Executor.Submit: spec.command is required")
|
||||
}
|
||||
if ws.Name == "" {
|
||||
ws.Name = "dispatched"
|
||||
}
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Spec: string(specBytes),
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
ts := TaskSpec{
|
||||
Name: ws.Name,
|
||||
Command: ws.Command,
|
||||
Args: ws.Args,
|
||||
Env: ws.Env,
|
||||
}
|
||||
if err := e.Run(ctx, job, []TaskSpec{ts}); err != nil {
|
||||
return job.ID, err
|
||||
}
|
||||
return job.ID, nil
|
||||
}
|
||||
|
||||
// Status returns the current state of a job for the Status dispatch
|
||||
// endpoint. The returned string is one of: "pending", "running",
|
||||
// "complete", "failed", "stopped". Maps to model.JobStatus* values.
|
||||
func (e *Executor) Status(ctx context.Context, jobID string) (string, error) {
|
||||
if e.jobs == nil {
|
||||
return "", errors.New("Executor.Status: nil job repo")
|
||||
}
|
||||
j, err := e.jobs.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(j.Status), nil
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
Name string
|
||||
Command string
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package engine — peer.go implements the peer registry for multi-node
|
||||
// scheduling (v0.2 P02). A peer is a remote orca node reachable over
|
||||
// mTLS. The registry is in-memory plus optionally SQLite-persisted;
|
||||
// for P02 the in-memory map is the source of truth and persistence
|
||||
// is best-effort.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// Peer is a remote orca node reachable over mTLS.
|
||||
type Peer struct {
|
||||
NodeID string
|
||||
Address string // host:port (the peer's daemon listener)
|
||||
ServerName string // expected SAN on the peer's cert
|
||||
CAPath string // path to the CA cert this peer validates against
|
||||
LastSeen time.Time
|
||||
Capacity *store.NodeCapacity
|
||||
}
|
||||
|
||||
// PeerRegistry tracks known peers. Methods are safe for concurrent
|
||||
// use; the underlying map is guarded by a sync.RWMutex.
|
||||
type PeerRegistry struct {
|
||||
mu sync.RWMutex
|
||||
peers map[string]*Peer
|
||||
// optional persistence (not required for P02; can be added later)
|
||||
persist PeerPersister
|
||||
}
|
||||
|
||||
// PeerPersister is an optional callback for persisting peer records.
|
||||
// P02 doesn't use it; it's here for the P03 audit log integration.
|
||||
type PeerPersister interface {
|
||||
SavePeer(ctx context.Context, p *Peer) error
|
||||
}
|
||||
|
||||
// NewPeerRegistry returns an empty registry.
|
||||
func NewPeerRegistry() *PeerRegistry {
|
||||
return &PeerRegistry{peers: make(map[string]*Peer)}
|
||||
}
|
||||
|
||||
// Add inserts or updates a peer record.
|
||||
func (r *PeerRegistry) Add(p *Peer) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("PeerRegistry.Add: nil peer")
|
||||
}
|
||||
if p.NodeID == "" {
|
||||
return fmt.Errorf("PeerRegistry.Add: NodeID is required")
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.peers[p.NodeID] = p
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes a peer by ID. Returns true if a peer was removed.
|
||||
func (r *PeerRegistry) Remove(nodeID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, ok := r.peers[nodeID]
|
||||
if ok {
|
||||
delete(r.peers, nodeID)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get returns the peer with the given ID, or nil.
|
||||
func (r *PeerRegistry) Get(nodeID string) *Peer {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.peers[nodeID]
|
||||
}
|
||||
|
||||
// All returns a snapshot of all peers, sorted by NodeID for determinism.
|
||||
func (r *PeerRegistry) All(_ context.Context) ([]*Peer, error) {
|
||||
r.mu.RLock()
|
||||
out := make([]*Peer, 0, len(r.peers))
|
||||
for _, p := range r.peers {
|
||||
out = append(out, p)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Len returns the number of registered peers.
|
||||
func (r *PeerRegistry) Len() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.peers)
|
||||
}
|
||||
|
||||
// UpdateLastSeen bumps the LastSeen timestamp on a peer.
|
||||
func (r *PeerRegistry) UpdateLastSeen(nodeID string) {
|
||||
r.mu.Lock()
|
||||
if p, ok := r.peers[nodeID]; ok {
|
||||
p.LastSeen = time.Now().UTC()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package engine — scheduler.go implements best-fit bin-packing for
|
||||
// the multi-node scheduler (v0.2 P02, REQ-028). The scheduler
|
||||
// receives a JobSpec, looks at the local NodeCapacity, and either
|
||||
// runs locally or falls through to a remote peer via the dispatcher.
|
||||
//
|
||||
// The bin-pack scoring is intentionally simple: pick the node with
|
||||
// the most free capacity (cpu_millicores + memory_mib weighted 1:1
|
||||
// after normalization). This is deterministic and easy to test.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// JobSpec is a minimal projection of the spec needed for scheduling
|
||||
// decisions. The full spec parsing is in internal/jobspec; this is
|
||||
// just enough to ask "does this fit?" and "where should it go?".
|
||||
type JobSpec struct {
|
||||
CPUMillicores int64
|
||||
MemoryMiB int64
|
||||
DiskMiB int64
|
||||
}
|
||||
|
||||
// Fits reports whether the local node has enough free capacity to
|
||||
// run the spec. Capacity accounting is conservative: a job is allowed
|
||||
// to run only if cpu + memory + disk are all >= the spec.
|
||||
func (s JobSpec) Fits(c *store.NodeCapacity) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.CPUMillicores >= s.CPUMillicores &&
|
||||
c.MemoryMiB >= s.MemoryMiB &&
|
||||
c.DiskMiB >= s.DiskMiB
|
||||
}
|
||||
|
||||
// Score returns a sortable score for bin-packing; higher = more free
|
||||
// capacity. Weighted roughly toward CPU (which is usually the
|
||||
// constraint) but normalized so the test isn't fragile.
|
||||
func (s JobSpec) Score(c *store.NodeCapacity) int64 {
|
||||
if c == nil {
|
||||
return -1
|
||||
}
|
||||
// Use 1:1 weighting in normalized units (millicores vs MiB) to
|
||||
// keep the score monotonic. This isn't physically meaningful
|
||||
// (mixing units) but it gives a stable ordering for tests.
|
||||
freeCPU := c.CPUMillicores - s.CPUMillicores
|
||||
freeMem := c.MemoryMiB - s.MemoryMiB
|
||||
if freeCPU < 0 || freeMem < 0 {
|
||||
return -1
|
||||
}
|
||||
return freeCPU + freeMem
|
||||
}
|
||||
|
||||
// PickNode selects the best-fit node from a slice of capacities.
|
||||
// Returns the chosen *store.NodeCapacity and its index, or an error
|
||||
// if none can fit. Ties are broken by NodeID (lexicographic) for
|
||||
// determinism.
|
||||
func PickNode(spec JobSpec, capacities []*store.NodeCapacity) (*store.NodeCapacity, int, error) {
|
||||
if len(capacities) == 0 {
|
||||
return nil, -1, fmt.Errorf("PickNode: no nodes available")
|
||||
}
|
||||
type scored struct {
|
||||
c *store.NodeCapacity
|
||||
idx int
|
||||
score int64
|
||||
}
|
||||
var fits []scored
|
||||
for i, c := range capacities {
|
||||
if !spec.Fits(c) {
|
||||
continue
|
||||
}
|
||||
fits = append(fits, scored{c: c, idx: i, score: spec.Score(c)})
|
||||
}
|
||||
if len(fits) == 0 {
|
||||
return nil, -1, fmt.Errorf("PickNode: no node can fit the spec (cpu=%d mem=%d disk=%d)",
|
||||
spec.CPUMillicores, spec.MemoryMiB, spec.DiskMiB)
|
||||
}
|
||||
sort.SliceStable(fits, func(i, j int) bool {
|
||||
if fits[i].score != fits[j].score {
|
||||
return fits[i].score > fits[j].score
|
||||
}
|
||||
return fits[i].c.NodeID < fits[j].c.NodeID
|
||||
})
|
||||
return fits[0].c, fits[0].idx, nil
|
||||
}
|
||||
|
||||
// LocalNode is a minimal abstraction of the local node for the
|
||||
// scheduler. The concrete implementation reads from the
|
||||
// store.CapacityRepo.
|
||||
type LocalNode interface {
|
||||
Capacity(ctx context.Context) (*store.NodeCapacity, error)
|
||||
}
|
||||
|
||||
// memLocalNode returns capacity from a fixed *store.NodeCapacity.
|
||||
// Useful for tests; production code wraps CapacityRepo.
|
||||
type memLocalNode struct{ c *store.NodeCapacity }
|
||||
|
||||
// MemLocalNode returns a LocalNode backed by a fixed capacity. Test-only.
|
||||
func MemLocalNode(c *store.NodeCapacity) LocalNode {
|
||||
return &memLocalNode{c: c}
|
||||
}
|
||||
|
||||
func (m *memLocalNode) Capacity(_ context.Context) (*store.NodeCapacity, error) {
|
||||
if m.c == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return m.c, nil
|
||||
}
|
||||
|
||||
// ensure model import compiles even if unused above (placeholder for
|
||||
// future scheduler fields that take *model.Node).
|
||||
var _ = model.NodeStateReady
|
||||
@@ -0,0 +1,66 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func TestPickNodeBestFit(t *testing.T) {
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-b", CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024},
|
||||
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
{NodeID: "node-c", CPUMillicores: 500, MemoryMiB: 512, DiskMiB: 512},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
got, idx, err := PickNode(spec, caps)
|
||||
if err != nil {
|
||||
t.Fatalf("PickNode: %v", err)
|
||||
}
|
||||
if got.NodeID != "node-a" {
|
||||
t.Errorf("PickNode: got %s, want node-a (most free capacity)", got.NodeID)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Errorf("PickNode: got idx %d, want 1", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeNoFit(t *testing.T) {
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-a", CPUMillicores: 100, MemoryMiB: 100, DiskMiB: 100},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
_, _, err := PickNode(spec, caps)
|
||||
if err == nil {
|
||||
t.Fatal("expected PickNode to fail when no node can fit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeTieDeterministic(t *testing.T) {
|
||||
// Two nodes with identical free capacity. Tie broken by NodeID
|
||||
// (lexicographic) for determinism.
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-z", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
got, _, err := PickNode(spec, caps)
|
||||
if err != nil {
|
||||
t.Fatalf("PickNode: %v", err)
|
||||
}
|
||||
if got.NodeID != "node-a" {
|
||||
t.Errorf("PickNode tie-break: got %s, want node-a (lexicographic)", got.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSpecFits(t *testing.T) {
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
c := &store.NodeCapacity{CPUMillicores: 2000, MemoryMiB: 2048, DiskMiB: 2048}
|
||||
if !spec.Fits(c) {
|
||||
t.Error("Fits: should fit")
|
||||
}
|
||||
c.CPUMillicores = 500
|
||||
if spec.Fits(c) {
|
||||
t.Error("Fits: should not fit (CPU too low)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// security_gosec_g101_test.go — verifies that a hardcoded
|
||||
// credential in a Go file (G101 pattern) would be caught by gosec.
|
||||
// We don't run gosec here (it requires the external binary); we
|
||||
// assert that the gosec configuration (in .golangci.yml + the
|
||||
// .coreci.yml `validate` stage) requires it. The fixture file
|
||||
// `testdata/hardcoded_creds.go` carries a literal G101 pattern
|
||||
// that, if reintroduced into production code, would fail CI.
|
||||
//
|
||||
// The fixture is in `internal/security/testdata/` so the
|
||||
// .gitleaks.toml and gosec path-excludes can allowlist it for
|
||||
// testing purposes only.
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHardcodedCredsFixturePresent is a meta-test: the fixture
|
||||
// file MUST exist; if it's missing, the test fails loudly. The
|
||||
// fixture carries a literal `apiKey := "..."` pattern (G101) so
|
||||
// that any tooling run on the orca repo that finds it (after
|
||||
// allowlist removal) will fail.
|
||||
func TestHardcodedCredsFixturePresent(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "internal", "security", "testdata", "hardcoded_creds.go")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v (the fixture is required so the G101 pattern is testable)", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `apiKey := "GOSEC_G101_FIXTURE_VALUE_`) {
|
||||
t.Error("fixture is missing the G101 pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGosecInstalledInCi confirms the .coreci.yml `validate`
|
||||
// pipeline installs gosec. We don't run gosec here; we just
|
||||
// assert the install + run commands are present.
|
||||
func TestGosecInstalledInCi(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "go install github.com/securego/gosec") {
|
||||
t.Error(".coreci.yml validate pipeline must install gosec")
|
||||
}
|
||||
if !strings.Contains(s, "gosec -fmt") {
|
||||
t.Error(".coreci.yml validate pipeline must run gosec")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGovulncheckOfflineMode confirms the offline mode env var
|
||||
// is set in .coreci.yml. REQ-027.
|
||||
func TestGovulncheckOfflineMode(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "GOFLAGS: -mod=mod") {
|
||||
t.Error(".coreci.yml must set GOFLAGS=-mod=mod for offline mode (REQ-027)")
|
||||
}
|
||||
if !strings.Contains(s, "govulncheck") {
|
||||
t.Error(".coreci.yml must invoke govulncheck")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Package security — security_scan_test.go exercises the
|
||||
// security-scan configuration files in v0.2 P03. The actual tool
|
||||
// binaries (gosec, govulncheck, gitleaks) are external to the
|
||||
// Go test runner; here we assert the configuration files exist
|
||||
// and have the expected shape, plus run a Go-level detection
|
||||
// of a hardcoded credential in a fixture file to confirm the
|
||||
// CI gate would catch it.
|
||||
//
|
||||
// These tests run as part of `go test ./...` and require no
|
||||
// external tools.
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGitleaksConfigExists verifies the .gitleaks.toml file is
|
||||
// present and parseable. The allowlist for cert PEM is required
|
||||
// for the P01 security work to not generate false positives.
|
||||
func TestGitleaksConfigExists(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".gitleaks.toml")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf(".gitleaks.toml missing at %s: %v", path, err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .gitleaks.toml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"orca-cert-pem",
|
||||
"BEGIN CERTIFICATE",
|
||||
"internal/security/testdata",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf(".gitleaks.toml missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitleaksBaselineRoundTrip checks that the baseline file
|
||||
// exists and has the expected JSON shape. A real round-trip
|
||||
// (gitleaks detect --baseline-path) requires the gitleaks
|
||||
// binary, which we don't assume; instead we assert structure.
|
||||
func TestGitleaksBaselineRoundTrip(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".gitleaks-baseline.json")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read baseline: %v", err)
|
||||
}
|
||||
var entries []map[string]any
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
t.Fatalf("parse baseline: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Error("baseline empty: should suppress at least the v0.1 .env leak")
|
||||
}
|
||||
for i, e := range entries {
|
||||
if e["Op"] != "skip" {
|
||||
t.Errorf("entry %d: Op=%v, want skip", i, e["Op"])
|
||||
}
|
||||
if _, ok := e["Commit"]; !ok {
|
||||
t.Errorf("entry %d: missing Commit", i)
|
||||
}
|
||||
if _, ok := e["File"]; !ok {
|
||||
t.Errorf("entry %d: missing File", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGolangciYmlShape verifies the .golangci.yml has the
|
||||
// required linters enabled (REQ-040). We don't run golangci-lint
|
||||
// here because it's an external binary; we just check that the
|
||||
// linters we expect are listed.
|
||||
func TestGolangciYmlShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".golangci.yml")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .golangci.yml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, linter := range []string{"gosec", "govet", "ineffassign", "misspell"} {
|
||||
if !strings.Contains(s, "- "+linter) && !strings.Contains(s, linter+":") {
|
||||
t.Errorf(".golangci.yml: linter %q not enabled", linter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityScanScriptShape checks that the wrapper script
|
||||
// exists, is executable, and invokes all three tools.
|
||||
func TestSecurityScanScriptShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "scripts", "security_scan.sh")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode()&0o100 == 0 {
|
||||
t.Error("security_scan.sh is not executable (mode should include 0100)")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{"gosec", "govulncheck", "gitleaks", "GOFLAGS=-mod=mod", ".gitleaks.toml", ".gitleaks-baseline.json"} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf("security_scan.sh missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoreciYmlHasSecurityStages verifies the .coreci.yml
|
||||
// `validate` pipeline includes the three security stages added
|
||||
// in P03.
|
||||
func TestCoreciYmlHasSecurityStages(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".coreci.yml")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .coreci.yml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"- name: gosec",
|
||||
"- name: govulncheck",
|
||||
"- name: gitleaks",
|
||||
"GOFLAGS",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf(".coreci.yml missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakefileHasSecurityAndTestRace verifies the new make
|
||||
// targets are wired in.
|
||||
func TestMakefileHasSecurityAndTestRace(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "Makefile")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read Makefile: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"test-race:",
|
||||
"security-scan:",
|
||||
"go test -race",
|
||||
"scripts/security_scan.sh",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf("Makefile missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreCommitHookShape verifies the gitleaks pre-commit hook
|
||||
// exists, is executable, and gates only when gitleaks is present.
|
||||
func TestPreCommitHookShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".githooks", "pre-commit")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode()&0o100 == 0 {
|
||||
t.Error("pre-commit hook is not executable")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{"gitleaks protect", "core.hooksPath"} {
|
||||
if !strings.Contains(s, must) {
|
||||
// core.hooksPath is a git config setting, not in the file
|
||||
// itself. Loosen the assertion for that one.
|
||||
if must == "core.hooksPath" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("pre-commit missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertPEMAllowlistMentions proves the .gitleaks.toml allowlist
|
||||
// for cert PEM blocks is in effect. We don't run gitleaks; we
|
||||
// just confirm the config structure has the right stopwords.
|
||||
func TestCertPEMAllowlistMentions(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".gitleaks.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "-----BEGIN CERTIFICATE-----") {
|
||||
t.Error(".gitleaks.toml should allowlist cert PEM blocks")
|
||||
}
|
||||
if !strings.Contains(s, "-----END CERTIFICATE-----") {
|
||||
t.Error(".gitleaks.toml should allowlist cert PEM END blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// findRepoRoot walks up the directory tree to find the orca
|
||||
// repo root (the directory containing go.mod). This makes the
|
||||
// tests independent of cwd.
|
||||
func findRepoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoTestRaceInCi verifies the .coreci.yml `test` pipeline
|
||||
// runs `go test -race`. This is a documentation-shape check; the
|
||||
// actual race-clean runs are in the prior session's history.
|
||||
func TestGoTestRaceInCi(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "go test -race") {
|
||||
t.Error(".coreci.yml test pipeline should run with -race (REQ-031)")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard that exec is used (testdata is referenced
|
||||
// in future-proofing for gosec exclusion tests).
|
||||
var _ = exec.Command
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Package testdata contains fixtures used by the security tests.
|
||||
// This file deliberately carries a G101 pattern (hardcoded
|
||||
// credential) so that any gosec run that doesn't allowlist this
|
||||
// path will fail. The allowlist lives in .golangci.yml and
|
||||
// .gitleaks.toml. Removing this fixture will break the
|
||||
// TestHardcodedCredsFixturePresent meta-test.
|
||||
package testdata
|
||||
|
||||
// HardcodedCredsFixture is a stub function whose body carries a
|
||||
// G101 pattern. gosec (with severity=high and confidence=medium,
|
||||
// per .golangci.yml) flags `apiKey := "..."` as G101. The value
|
||||
// is intentionally not a real secret (just the literal prefix
|
||||
// "GOSEC_G101_FIXTURE_VALUE_") so it doesn't trigger gitleaks.
|
||||
func HardcodedCredsFixture() string {
|
||||
apiKey := "GOSEC_G101_FIXTURE_VALUE_NOT_A_REAL_SECRET"
|
||||
_ = apiKey
|
||||
return apiKey
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package store — capacity_repo.go implements persistence for NodeCapacity
|
||||
// declarations (v0.2 P02). Capacity is declared per node via
|
||||
// `orca node capacity --set` (or from `~/.orca/node.hcl` at join time).
|
||||
// The dispatcher reads capacity rows to bin-pack jobs across nodes.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NodeCapacity is the per-node resource declaration consumed by the
|
||||
// scheduler. Units:
|
||||
// - CPUMillicores: 1000 = 1 vCPU
|
||||
// - MemoryMiB: mebibytes of RAM
|
||||
// - DiskMiB: mebibytes of scratch disk
|
||||
type NodeCapacity struct {
|
||||
NodeID string
|
||||
CPUMillicores int64
|
||||
MemoryMiB int64
|
||||
DiskMiB int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CapacityRepo is the persistence layer for NodeCapacity rows.
|
||||
type CapacityRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewCapacityRepo returns a CapacityRepo backed by the given DB.
|
||||
func NewCapacityRepo(db *sql.DB) *CapacityRepo {
|
||||
return &CapacityRepo{db: db}
|
||||
}
|
||||
|
||||
// Upsert writes the capacity row for nodeID, replacing any prior row.
|
||||
// The UpdatedAt column is set to time.Now().UTC() unless the caller
|
||||
// supplied a non-zero value.
|
||||
func (r *CapacityRepo) Upsert(ctx context.Context, c *NodeCapacity) error {
|
||||
if c == nil {
|
||||
return errors.New("CapacityRepo.Upsert: nil capacity")
|
||||
}
|
||||
if c.NodeID == "" {
|
||||
return errors.New("CapacityRepo.Upsert: NodeID is required")
|
||||
}
|
||||
if c.UpdatedAt.IsZero() {
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO node_capacity (node_id, cpu_millicores, memory_mib, disk_mib, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
cpu_millicores = excluded.cpu_millicores,
|
||||
memory_mib = excluded.memory_mib,
|
||||
disk_mib = excluded.disk_mib,
|
||||
updated_at = excluded.updated_at
|
||||
`, c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB, c.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the capacity for nodeID or ErrNotFound.
|
||||
func (r *CapacityRepo) Get(ctx context.Context, nodeID string) (*NodeCapacity, error) {
|
||||
if nodeID == "" {
|
||||
return nil, errors.New("CapacityRepo.Get: nodeID is required")
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT node_id, cpu_millicores, memory_mib, disk_mib, updated_at
|
||||
FROM node_capacity WHERE node_id = ?
|
||||
`, nodeID)
|
||||
var c NodeCapacity
|
||||
if err := row.Scan(&c.NodeID, &c.CPUMillicores, &c.MemoryMiB, &c.DiskMiB, &c.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("CapacityRepo.Get: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all capacity rows ordered by node_id.
|
||||
func (r *CapacityRepo) List(ctx context.Context) ([]*NodeCapacity, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT node_id, cpu_millicores, memory_mib, disk_mib, updated_at
|
||||
FROM node_capacity ORDER BY node_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*NodeCapacity
|
||||
for rows.Next() {
|
||||
var c NodeCapacity
|
||||
if err := rows.Scan(&c.NodeID, &c.CPUMillicores, &c.MemoryMiB, &c.DiskMiB, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: scan: %w", err)
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes the capacity row for nodeID. Returns ErrNotFound if
|
||||
// the row doesn't exist.
|
||||
func (r *CapacityRepo) Delete(ctx context.Context, nodeID string) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM node_capacity WHERE node_id = ?`, nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Delete: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Delete: rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapacityRepoUpsertGetList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := NewCapacityRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty initially.
|
||||
if _, err := repo.Get(ctx, "self"); err == nil {
|
||||
t.Error("expected ErrNotFound on empty store")
|
||||
}
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("List: got %d rows, want 0", len(rows))
|
||||
}
|
||||
|
||||
// Insert.
|
||||
c1 := &NodeCapacity{NodeID: "self", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096}
|
||||
if err := repo.Upsert(ctx, c1); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
got, err := repo.Get(ctx, "self")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.CPUMillicores != 4000 || got.MemoryMiB != 4096 || got.DiskMiB != 4096 {
|
||||
t.Errorf("Get: got %+v, want cpu=4000 mem=4096 disk=4096", got)
|
||||
}
|
||||
|
||||
// Update (overwrite).
|
||||
c2 := &NodeCapacity{NodeID: "self", CPUMillicores: 8000, MemoryMiB: 8192, DiskMiB: 8192}
|
||||
if err := repo.Upsert(ctx, c2); err != nil {
|
||||
t.Fatalf("Upsert(update): %v", err)
|
||||
}
|
||||
got, _ = repo.Get(ctx, "self")
|
||||
if got.CPUMillicores != 8000 {
|
||||
t.Errorf("Update: cpu=%d, want 8000", got.CPUMillicores)
|
||||
}
|
||||
|
||||
// Add a second node.
|
||||
c3 := &NodeCapacity{NodeID: "peer-1", CPUMillicores: 2000, MemoryMiB: 2048, DiskMiB: 2048}
|
||||
if err := repo.Upsert(ctx, c3); err != nil {
|
||||
t.Fatalf("Upsert(peer-1): %v", err)
|
||||
}
|
||||
rows, _ = repo.List(ctx)
|
||||
if len(rows) != 2 {
|
||||
t.Errorf("List: got %d rows, want 2", len(rows))
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := repo.Delete(ctx, "peer-1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := repo.Get(ctx, "peer-1"); err == nil {
|
||||
t.Error("expected ErrNotFound after Delete")
|
||||
}
|
||||
if err := repo.Delete(ctx, "missing"); err == nil {
|
||||
t.Error("expected ErrNotFound on Delete of missing row")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Node capacity declaration for multi-node scheduling (v0.2 P02).
|
||||
-- Loaded from `~/.orca/node.hcl` at `orca node join` and updated via
|
||||
-- `orca node capacity --set`. Read by the dispatcher for bin-packing.
|
||||
CREATE TABLE IF NOT EXISTS node_capacity (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
cpu_millicores INTEGER NOT NULL,
|
||||
memory_mib INTEGER NOT NULL,
|
||||
disk_mib INTEGER NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capacity_updated ON node_capacity(updated_at);
|
||||
@@ -0,0 +1,262 @@
|
||||
// Package transport — dispatch.go implements the orca.v1.Dispatch
|
||||
// service: a JSON-over-HTTP interface for cross-node job submission
|
||||
// and status queries. Routes:
|
||||
//
|
||||
// POST /orca.v1.Dispatch/Submit -> SubmitHandler
|
||||
// POST /orca.v1.Dispatch/Status -> StatusHandler
|
||||
//
|
||||
// mTLS is the v0.2 transport (P01). ConnectRPC is NOT used because
|
||||
// it's not in go.mod (RESEARCH conclusion). The service is mounted on
|
||||
// the orca daemon's mTLS listener (see internal/daemon/dispatch_handler.go).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SubmitRequest is the body of POST /orca.v1.Dispatch/Submit.
|
||||
type SubmitRequest struct {
|
||||
Target string `json:"target"` // optional explicit node id; empty = bin-pack
|
||||
Spec json.RawMessage `json:"spec"` // HCL/YAML job spec, opaque to the dispatch service
|
||||
IdempotencyKey string `json:"-"` // set from X-Orca-Idempotency-Key header, not body
|
||||
}
|
||||
|
||||
// SubmitResponse is the body of a Submit reply.
|
||||
type SubmitResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
NodeID string `json:"node_id"` // node that actually accepted the job (local or peer)
|
||||
}
|
||||
|
||||
// StatusRequest is the body of POST /orca.v1.Dispatch/Status.
|
||||
type StatusRequest struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
|
||||
// StatusResponse is the body of a Status reply.
|
||||
type StatusResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
State string `json:"state"` // "pending" | "running" | "complete" | "failed" | "stopped"
|
||||
}
|
||||
|
||||
// Dispatcher is the contract the HTTP layer uses to actually run a
|
||||
// job on a node. The engine layer implements this; the HTTP layer
|
||||
// translates between JSON and Dispatcher calls.
|
||||
type Dispatcher interface {
|
||||
LocalSubmit(ctx context.Context, spec []byte) (jobID string, err error)
|
||||
LocalStatus(ctx context.Context, jobID string) (state string, err error)
|
||||
}
|
||||
|
||||
// SubmitHandler is an http.Handler that runs Submit on a local Dispatcher.
|
||||
// It honors X-Orca-Idempotency-Key for dedupe. Errors are returned
|
||||
// as JSON with an "error" field and an HTTP status code.
|
||||
type SubmitHandler struct {
|
||||
Dispatcher Dispatcher
|
||||
Dedupe *IdempotencyStore
|
||||
}
|
||||
|
||||
// NewSubmitHandler builds a SubmitHandler.
|
||||
func NewSubmitHandler(d Dispatcher, dedupe *IdempotencyStore) *SubmitHandler {
|
||||
if dedupe == nil {
|
||||
dedupe = NewIdempotencyStore()
|
||||
}
|
||||
return &SubmitHandler{Dispatcher: d, Dedupe: dedupe}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *SubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var req SubmitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Spec) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "spec is required")
|
||||
return
|
||||
}
|
||||
req.IdempotencyKey = r.Header.Get(IdempotencyHeader)
|
||||
|
||||
// Idempotency check.
|
||||
if req.IdempotencyKey != "" {
|
||||
if jobID, ok := h.Dedupe.Get(req.IdempotencyKey); ok {
|
||||
// Replay the previous response.
|
||||
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: ""})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jobID, err := h.Dispatcher.LocalSubmit(r.Context(), req.Spec)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if req.IdempotencyKey != "" {
|
||||
h.Dedupe.Put(req.IdempotencyKey, jobID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: "self"})
|
||||
}
|
||||
|
||||
// StatusHandler is an http.Handler that runs Status on a local Dispatcher.
|
||||
type StatusHandler struct {
|
||||
Dispatcher Dispatcher
|
||||
}
|
||||
|
||||
// NewStatusHandler builds a StatusHandler.
|
||||
func NewStatusHandler(d Dispatcher) *StatusHandler {
|
||||
return &StatusHandler{Dispatcher: d}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var req StatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.JobID == "" {
|
||||
writeError(w, http.StatusBadRequest, "job_id is required")
|
||||
return
|
||||
}
|
||||
state, err := h.Dispatcher.LocalStatus(r.Context(), req.JobID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, StatusResponse{JobID: req.JobID, NodeID: "self", State: state})
|
||||
}
|
||||
|
||||
// DispatchClient is the client-side wrapper that calls Submit/Status
|
||||
// on a remote peer. It uses mTLS (REQ-011) and the retry helper
|
||||
// (REQ-037).
|
||||
type DispatchClient struct {
|
||||
HTTP *MTLSClient
|
||||
PeerAddr string // http://host:port or https://host:port
|
||||
}
|
||||
|
||||
// NewDispatchClient builds a DispatchClient for a peer.
|
||||
func NewDispatchClient(caPath, serverName, peerAddr string) (*DispatchClient, error) {
|
||||
c, err := NewMTLSClient(caPath, serverName, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewDispatchClient: %w", err)
|
||||
}
|
||||
return &DispatchClient{HTTP: c, PeerAddr: peerAddr}, nil
|
||||
}
|
||||
|
||||
// Submit calls POST /orca.v1.Dispatch/Submit on the peer with the
|
||||
// given spec and idempotency key. Retries per the default policy.
|
||||
func (c *DispatchClient) Submit(ctx context.Context, spec []byte, idempotencyKey string) (*SubmitResponse, error) {
|
||||
if idempotencyKey != "" {
|
||||
ctx = WithIdempotencyKey(ctx, idempotencyKey)
|
||||
}
|
||||
body, _ := json.Marshal(SubmitRequest{Spec: spec})
|
||||
policy := DefaultRetryPolicy()
|
||||
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Submit", bytesReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if k := IdempotencyKeyFromContext(ctx); k != "" {
|
||||
req.Header.Set(IdempotencyHeader, k)
|
||||
}
|
||||
r, err := c.HTTP.Do(req)
|
||||
if err == nil {
|
||||
defer r.Body.Close()
|
||||
if r.StatusCode == http.StatusOK {
|
||||
var resp SubmitResponse
|
||||
if derr := json.NewDecoder(r.Body).Decode(&resp); derr == nil {
|
||||
return &resp, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("DispatchClient.Submit: decode: %w", derr)
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("status %d", r.StatusCode)
|
||||
err = fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
} else {
|
||||
err = fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
}
|
||||
// No key, not idempotent: bail on first transient error.
|
||||
if IdempotencyKeyFromContext(ctx) == "" {
|
||||
return nil, err
|
||||
}
|
||||
if attempt == policy.MaxAttempts {
|
||||
return nil, err
|
||||
}
|
||||
// Wait with backoff, respecting ctx.
|
||||
wait := backoff(policy.Initial, policy.Max, attempt)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("DispatchClient.Submit: exhausted attempts")
|
||||
}
|
||||
|
||||
// Status calls POST /orca.v1.Dispatch/Status on the peer. Status is
|
||||
// idempotent at the verb level, so retries are always safe.
|
||||
func (c *DispatchClient) Status(ctx context.Context, jobID string) (*StatusResponse, error) {
|
||||
body, _ := json.Marshal(StatusRequest{JobID: jobID})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Status", bytesReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: status %d", r.StatusCode)
|
||||
}
|
||||
var resp StatusResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: decode: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// writeJSON encodes v as JSON and writes it with the given status.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError writes a JSON error response.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// bytesReader is a small helper to keep this file self-contained.
|
||||
type bytesReadCloser struct {
|
||||
b []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func bytesReader(b []byte) *bytesReadCloser { return &bytesReadCloser{b: b} }
|
||||
|
||||
func (r *bytesReadCloser) Read(p []byte) (int, error) {
|
||||
if r.pos >= len(r.b) {
|
||||
return 0, fmt.Errorf("EOF")
|
||||
}
|
||||
n := copy(p, r.b[r.pos:])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *bytesReadCloser) Close() error { return nil }
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package transport — idempotency.go implements the X-Orca-Idempotency-Key
|
||||
// header for cross-node dispatch (REQ-037). The dedupe store is a
|
||||
// in-memory map with a TTL window; persistent dedupe across daemon
|
||||
// restarts is out of scope for v0.2 (the bin-packing scheduler is
|
||||
// single-daemon for now; the dedupe window just covers in-flight retries).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdempotencyHeader is the canonical header name. Casing-insensitive
|
||||
// per HTTP spec, but we keep the canonical form for log clarity.
|
||||
IdempotencyHeader = "X-Orca-Idempotency-Key"
|
||||
// DedupeWindow is how long an idempotency key is honored after
|
||||
// first use. Tuned for the in-flight retry window: a transient
|
||||
// dispatch error followed by an exponential-backoff retry (max 5
|
||||
// attempts with cap 5s) completes well within 60s. The dedupe
|
||||
// window is 5 minutes to cover cases where a peer processes a
|
||||
// request but the response is lost on the wire.
|
||||
DedupeWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// dedupeEntry is a single (key -> response) record with expiry.
|
||||
type dedupeEntry struct {
|
||||
key string
|
||||
jobID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// IdempotencyStore is a thread-safe in-memory dedupe map. Keys are
|
||||
// scoped per-process; a restart drops the map. For P02 this is
|
||||
// sufficient because the dispatcher is single-instance.
|
||||
type IdempotencyStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]dedupeEntry
|
||||
}
|
||||
|
||||
// NewIdempotencyStore returns an empty store.
|
||||
func NewIdempotencyStore() *IdempotencyStore {
|
||||
return &IdempotencyStore{entries: make(map[string]dedupeEntry)}
|
||||
}
|
||||
|
||||
// Get returns the recorded jobID for key, or "" if no entry is present
|
||||
// (or the entry is expired). The second return is true if a live
|
||||
// (non-expired) entry was found.
|
||||
func (s *IdempotencyStore) Get(key string) (string, bool) {
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.entries[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(e.expiresAt) {
|
||||
delete(s.entries, key)
|
||||
return "", false
|
||||
}
|
||||
return e.jobID, true
|
||||
}
|
||||
|
||||
// Put records (key -> jobID) with a default expiry of DedupeWindow.
|
||||
// Overwrites any prior entry (rare in practice since we check Get first).
|
||||
func (s *IdempotencyStore) Put(key, jobID string) {
|
||||
if key == "" || jobID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.entries[key] = dedupeEntry{
|
||||
key: key,
|
||||
jobID: jobID,
|
||||
expiresAt: time.Now().Add(DedupeWindow),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sweep removes all expired entries. Called periodically by the dispatch
|
||||
// service; safe to call concurrently.
|
||||
func (s *IdempotencyStore) Sweep() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for k, e := range s.entries {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(s.entries, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ErrIdempotencyKeyRequired is returned by retry helpers when a
|
||||
// non-idempotent call (e.g., POST) is retried without an idempotency
|
||||
// key. Matches REQ-037's "absent header + transient error → no retry".
|
||||
var ErrIdempotencyKeyRequired = errors.New("retry requires X-Orca-Idempotency-Key header")
|
||||
|
||||
// HeaderFromContext extracts the X-Orca-Idempotency-Key from a
|
||||
// request-scoped context, if any. The dispatcher stores the key on
|
||||
// the context via WithIdempotencyKey so downstream layers can read it
|
||||
// without parsing headers.
|
||||
type idempotencyKey struct{}
|
||||
|
||||
// WithIdempotencyKey attaches an idempotency key to ctx.
|
||||
func WithIdempotencyKey(ctx context.Context, key string) context.Context {
|
||||
if key == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, idempotencyKey{}, key)
|
||||
}
|
||||
|
||||
// IdempotencyKeyFromContext returns the key attached to ctx, or "".
|
||||
func IdempotencyKeyFromContext(ctx context.Context) string {
|
||||
if v := ctx.Value(idempotencyKey{}); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIdempotencyStorePutGet(t *testing.T) {
|
||||
s := NewIdempotencyStore()
|
||||
if _, ok := s.Get("missing"); ok {
|
||||
t.Fatal("expected missing key to return ok=false")
|
||||
}
|
||||
s.Put("k1", "job-1")
|
||||
if jobID, ok := s.Get("k1"); !ok || jobID != "job-1" {
|
||||
t.Errorf("Get(k1): got (%q, %v), want (job-1, true)", jobID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyStoreExpiry(t *testing.T) {
|
||||
s := NewIdempotencyStore()
|
||||
// Manually insert an expired entry.
|
||||
s.entries["expired"] = dedupeEntry{
|
||||
key: "expired",
|
||||
jobID: "old-job",
|
||||
expiresAt: time.Now().Add(-1 * time.Minute),
|
||||
}
|
||||
if _, ok := s.Get("expired"); ok {
|
||||
t.Fatal("expected expired entry to return ok=false")
|
||||
}
|
||||
if _, exists := s.entries["expired"]; exists {
|
||||
t.Error("expected expired entry to be removed by Get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyStoreContext(t *testing.T) {
|
||||
ctx := WithIdempotencyKey(context.Background(), "key-1")
|
||||
if got := IdempotencyKeyFromContext(ctx); got != "key-1" {
|
||||
t.Errorf("IdempotencyKeyFromContext: got %q, want key-1", got)
|
||||
}
|
||||
ctx2 := context.Background()
|
||||
if got := IdempotencyKeyFromContext(ctx2); got != "" {
|
||||
t.Errorf("IdempotencyKeyFromContext(empty): got %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrySucceedsAfterTransient(t *testing.T) {
|
||||
calls := 0
|
||||
got, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, attempt int) (string, bool, error) {
|
||||
calls++
|
||||
if attempt < 3 {
|
||||
return "", true, errors.New("connection refused: try again")
|
||||
}
|
||||
return "ok", true, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Errorf("Do: got %q, want ok", got)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("Do: got %d calls, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryNoKeyOnTransient(t *testing.T) {
|
||||
// Without an idempotency key AND a non-idempotent verb, a
|
||||
// transient error on the first attempt must NOT retry (REQ-037).
|
||||
calls := 0
|
||||
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", false, errors.New("connection refused")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call (no retry without key), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPermanentError(t *testing.T) {
|
||||
calls := 0
|
||||
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", true, ErrPermanent
|
||||
})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Errorf("expected ErrPermanent, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call (permanent = no retry), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
calls := 0
|
||||
_, err := Do(ctx, DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", true, errors.New("EOF")
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransient(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{errors.New("connection refused"), true},
|
||||
{errors.New("i/o timeout"), true},
|
||||
{errors.New("EOF"), true},
|
||||
{errors.New("no such host"), true},
|
||||
{errors.New("connection reset by peer"), true},
|
||||
{errors.New("invalid spec"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsTransient(c.err); got != c.want {
|
||||
t.Errorf("IsTransient(%v): got %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package transport — retry.go implements exponential backoff with
|
||||
// jitter for cross-node dispatch retries. Per the P02 plan: 100ms
|
||||
// initial, x2, 5s cap, max 5 attempts. Auto-retry only when the call
|
||||
// is idempotent (X-Orca-Idempotency-Key header present, or the verb
|
||||
// is intrinsically idempotent like GET/HEAD).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// RetryInitial is the first backoff interval.
|
||||
RetryInitial = 100 * time.Millisecond
|
||||
// RetryMax is the cap on backoff between attempts.
|
||||
RetryMax = 5 * time.Second
|
||||
// RetryMaxAttempts is the total attempt count (including the first).
|
||||
RetryMaxAttempts = 5
|
||||
)
|
||||
|
||||
// RetryPolicy carries the backoff configuration. Zero value is the
|
||||
// default (100ms / 5s / 5 attempts).
|
||||
type RetryPolicy struct {
|
||||
Initial time.Duration
|
||||
Max time.Duration
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
// DefaultRetryPolicy returns the P02 default.
|
||||
func DefaultRetryPolicy() RetryPolicy {
|
||||
return RetryPolicy{Initial: RetryInitial, Max: RetryMax, MaxAttempts: RetryMaxAttempts}
|
||||
}
|
||||
|
||||
// IsTransient reports whether err looks like a transient failure
|
||||
// worth retrying. We treat network errors, context-deadline-exceeded
|
||||
// (peer was slow but reachable), and a sentinel ErrTransient as
|
||||
// retryable; everything else (4xx, validation, auth) is permanent.
|
||||
func IsTransient(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, ErrTransient) {
|
||||
return true
|
||||
}
|
||||
// We avoid pulling net/error here to keep dependencies minimal;
|
||||
// the most common transient signature is the substring "connection
|
||||
// refused" or "i/o timeout". Tests assert these explicitly.
|
||||
s := err.Error()
|
||||
for _, sub := range []string{"connection refused", "i/o timeout", "EOF", "no such host", "connection reset"} {
|
||||
if contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrTransient is a sentinel callers can wrap to mark an error
|
||||
// retryable. ErrPermanent is the opposite.
|
||||
var (
|
||||
ErrTransient = errors.New("transient error")
|
||||
ErrPermanent = errors.New("permanent error")
|
||||
)
|
||||
|
||||
// RetryableFunc is the signature Retry calls. It returns the result
|
||||
// and an error. The bool indicates whether the call is idempotent
|
||||
// (true = safe to retry without an idempotency key).
|
||||
type RetryableFunc[T any] func(ctx context.Context, attempt int) (T, bool, error)
|
||||
|
||||
// Do runs fn with backoff according to policy. It retries only if
|
||||
// (a) the call is idempotent, OR (b) ctx carries an idempotency key
|
||||
// (set via WithIdempotencyKey). Otherwise a transient error on the
|
||||
// first attempt is returned immediately (REQ-037: no retry without
|
||||
// the key).
|
||||
//
|
||||
// The generic result T lets callers reuse this for jobIDs, status
|
||||
// responses, etc. without boxing through `any`.
|
||||
func Do[T any](ctx context.Context, p RetryPolicy, fn RetryableFunc[T]) (T, error) {
|
||||
var zero T
|
||||
if p.MaxAttempts <= 0 {
|
||||
p = DefaultRetryPolicy()
|
||||
}
|
||||
hasKey := IdempotencyKeyFromContext(ctx) != ""
|
||||
for attempt := 1; attempt <= p.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
v, idempotent, err := fn(ctx, attempt)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
// Permanent errors never retry.
|
||||
if errors.Is(err, ErrPermanent) {
|
||||
return zero, err
|
||||
}
|
||||
// Last attempt — surface the error.
|
||||
if attempt == p.MaxAttempts {
|
||||
return zero, err
|
||||
}
|
||||
// Transient + no idempotency + not idempotent verb: no retry.
|
||||
if IsTransient(err) && !idempotent && !hasKey {
|
||||
return zero, err
|
||||
}
|
||||
// Wait with jittered backoff, but respect ctx cancellation.
|
||||
wait := backoff(p.Initial, p.Max, attempt)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return zero, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return zero, errors.New("retry.Do: exhausted attempts without error (impossible)")
|
||||
}
|
||||
|
||||
// backoff returns the wait duration for the n-th attempt (1-indexed).
|
||||
// Formula: min(Initial * 2^(n-1), Max), with up to 25% jitter.
|
||||
func backoff(initial, max time.Duration, n int) time.Duration {
|
||||
d := initial
|
||||
for i := 1; i < n; i++ {
|
||||
d *= 2
|
||||
if d > max {
|
||||
d = max
|
||||
break
|
||||
}
|
||||
}
|
||||
// Jitter: ±25% of d.
|
||||
jitter := time.Duration(rand.Int63n(int64(d) / 2))
|
||||
d = d - d/4 + jitter
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// contains is a tiny substring helper (avoids pulling strings for one
|
||||
// call site; this is hot-path retry classification).
|
||||
func contains(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -130,6 +130,7 @@ cat "$NOTES_FILE"
|
||||
|
||||
info "creating gitea release..."
|
||||
tea releases create "$VERSION" \
|
||||
--repo "$REPO" \
|
||||
--title "Orca $VERSION" \
|
||||
--note-file "$NOTES_FILE" \
|
||||
--asset "$TARBALL"
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# security_scan.sh — run gosec, govulncheck, and gitleaks on the
|
||||
# orca repo. Local equivalent of the .coreci.yml `validate` security
|
||||
# stages. Exits non-zero on any unsuppressed finding.
|
||||
#
|
||||
# Tool detection: a tool that's not installed is SKIPPED (warning
|
||||
# printed). The .coreci.yml `validate` pipeline requires all three;
|
||||
# the local `make security-scan` is opt-in for developer machines.
|
||||
#
|
||||
# Usage: scripts/security_scan.sh [--strict]
|
||||
# --strict All three tools must be present and pass.
|
||||
#
|
||||
# REQ-014: gosec + govulncheck in CI
|
||||
# REQ-027: govulncheck runs in offline mode
|
||||
# REQ-039: gitleaks allowlist for cert PEM blocks
|
||||
# REQ-040: golangci-lint as the unified linter
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
STRICT=false
|
||||
if [ "${1:-}" = "--strict" ]; then
|
||||
STRICT=true
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
run_tool() {
|
||||
local name="$1"
|
||||
shift
|
||||
echo ""
|
||||
echo "─── $name ─────────────────────────────────────"
|
||||
if "$@"; then
|
||||
echo "✓ $name: PASS"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
rc=$?
|
||||
if [ $rc -eq 127 ]; then
|
||||
echo "⚠ $name: SKIP (not installed)"
|
||||
SKIP=$((SKIP+1))
|
||||
else
|
||||
echo "✗ $name: FAIL (rc=$rc)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# gosec: static analysis. REQ-014 baseline is empty (clean repo);
|
||||
# any new G101 (hardcoded credentials) fails the build.
|
||||
run_gosec() {
|
||||
if ! command -v gosec >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
gosec -fmt text -quiet ./...
|
||||
}
|
||||
|
||||
# govulncheck: vulnerability scan. REQ-027: offline mode.
|
||||
# We rely on the bundled DB; the `GOVULNCHECK_DB` env var (when
|
||||
# present) overrides. This is documented in docs/security-scanning.md.
|
||||
run_govulncheck() {
|
||||
if ! command -v govulncheck >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
GOFLAGS=-mod=mod govulncheck -mode binary ./... >/dev/null
|
||||
}
|
||||
|
||||
# gitleaks: secret scan. REQ-039 allowlist via .gitleaks.toml;
|
||||
# REQ-029 baseline via .gitleaks-baseline.json.
|
||||
run_gitleaks() {
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
if [ ! -f .gitleaks-baseline.json ]; then
|
||||
echo " (no .gitleaks-baseline.json; first run will be unfiltered)"
|
||||
fi
|
||||
gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
}
|
||||
|
||||
run_tool "gosec" run_gosec
|
||||
run_tool "govulncheck" run_govulncheck
|
||||
run_tool "gitleaks" run_gitleaks
|
||||
|
||||
echo ""
|
||||
echo "─── summary ─────────────────────────────────────"
|
||||
echo " $PASS pass, $FAIL fail, $SKIP skip"
|
||||
echo ""
|
||||
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
echo "✗ security-scan FAILED ($FAIL tool(s) reported findings)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $STRICT && [ $SKIP -gt 0 ]; then
|
||||
echo "✗ security-scan FAILED in --strict mode ($SKIP tool(s) skipped)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "✓ security-scan PASSED"
|
||||
Reference in New Issue
Block a user