Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78334f1f74 | |||
| c7dbcef958 | |||
| 9580f347c6 | |||
| 46e929e4c6 | |||
| 503923bf1e | |||
| e3f6e1df82 | |||
| aa3cccead5 | |||
| c2038952c7 | |||
| 65eb2e601b | |||
| 6f34f1794b | |||
| bc7ce1caf6 |
+170
-11
@@ -1,13 +1,172 @@
|
||||
# Architecture: Orchestration Engine
|
||||
# Architecture: Orca
|
||||
|
||||
(Initial Draft)
|
||||
The system will consist of:
|
||||
1. **CLI Tool**: The primary interface for users and AI agents.
|
||||
2. **Controller/Server**: A lightweight daemon managing state and scheduling.
|
||||
3. **Agent/Worker**: A daemon running on each node to execute workloads.
|
||||
4. **State Store**: A simple, local-first state persistence mechanism.
|
||||
## System Overview
|
||||
|
||||
## Design Pillars
|
||||
- Security before features.
|
||||
- Bug fixes before features.
|
||||
- NFRs before features.
|
||||
Orca is a single-binary, offline-first orchestration engine. The system consists of three logical components, all compiled into one `orca` binary and selected via subcommands.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ orca (single binary) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ CLI Layer (Cobra) │
|
||||
│ ├── orca version │
|
||||
│ ├── orca init │
|
||||
│ ├── orca status │
|
||||
│ ├── orca node {join,leave,list} │
|
||||
│ └── orca job {run,list,stop,logs} │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Layer (net/http server) │
|
||||
│ ├── /healthz (liveness) │
|
||||
│ ├── /readyz (readiness) │
|
||||
│ ├── /v1/jobs/* (job control API) │
|
||||
│ ├── /v1/nodes/* (node registry API) │
|
||||
│ └── /v1/tasks/* (task lifecycle API) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Core Engine │
|
||||
│ ├── Node Registry (in-memory + SQLite persistence) │
|
||||
│ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │
|
||||
│ ├── Job Scheduler (single-node for v0.1) │
|
||||
│ └── Audit Logger (log/slog JSON handler) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ State Store (modernc/sqlite, CGO-free) │
|
||||
│ ~/.orca/orca.db │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Details
|
||||
|
||||
### 1. CLI Layer (`cmd/orca`, `internal/cli`)
|
||||
- **Framework**: Cobra (industry standard, familiar to operators)
|
||||
- **Subcommands**: `version`, `init`, `status`, `node`, `job`
|
||||
- **Output**: Human-readable by default; `--json` flag for machine consumption
|
||||
- **Discovery**: All subcommands self-document via Cobra's auto-generated help
|
||||
|
||||
### 2. Daemon Layer (`internal/daemon`)
|
||||
- **Server**: `net/http` with `http.ServeMux` (no external router for v0.1)
|
||||
- **TLS**: `crypto/tls` with self-signed certs (mTLS-ready)
|
||||
- **Ports**: Configurable (default `:8443` for API, `:8080` for health)
|
||||
- **Graceful Shutdown**: `signal.NotifyContext` with SIGINT/SIGTERM
|
||||
|
||||
### 3. Core Engine (`internal/engine`)
|
||||
- **Node Registry**: In-memory map of node IDs → metadata, persisted to SQLite
|
||||
- **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for clean process termination
|
||||
- **Job Scheduler**: Single-node FIFO queue (multi-node deferred to v0.2+)
|
||||
- **Audit Logger**: `slog.NewJSONHandler(os.Stderr, ...)` with structured fields
|
||||
|
||||
### 4. State Store (`internal/store`)
|
||||
- **Driver**: `modernc.org/sqlite` (pure Go, CGO-free)
|
||||
- **Location**: `~/.orca/orca.db` (user-mode) or `/var/lib/orca/orca.db` (system-mode)
|
||||
- **Schema**: `nodes`, `jobs`, `tasks`, `audit_log` tables
|
||||
- **Migrations**: Embedded SQL files, applied on startup
|
||||
|
||||
## Data Model
|
||||
|
||||
### Node
|
||||
```go
|
||||
type Node struct {
|
||||
ID string
|
||||
Name string
|
||||
Address string
|
||||
State string
|
||||
JoinedAt time.Time
|
||||
LastSeen time.Time
|
||||
Metadata map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
### Job
|
||||
```go
|
||||
type Job struct {
|
||||
ID string
|
||||
Name string
|
||||
Spec string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### Task
|
||||
```go
|
||||
type Task struct {
|
||||
ID string
|
||||
JobID string
|
||||
Command string
|
||||
Args []string
|
||||
Env []string
|
||||
PID int
|
||||
ExitCode int
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication
|
||||
- **v0.1**: mTLS for all API endpoints (self-signed CA)
|
||||
- **v0.2+**: Token-based auth as alternative
|
||||
|
||||
### Audit Logging
|
||||
- All state-changing operations emit structured log records
|
||||
- Fields: `timestamp`, `actor`, `action`, `resource`, `result`, `error`
|
||||
- Stored in SQLite `audit_log` table and stderr (JSON)
|
||||
|
||||
### Input Validation
|
||||
- All CLI inputs validated via Cobra's `Args`/`ValidArgs` functions
|
||||
- All API inputs validated at handler boundary
|
||||
- HCL/YAML specs parsed with strict schemas
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
| AD-001 | Single binary with subcommands | Simpler distribution, aligns with simplicity pillar |
|
||||
| AD-002 | modernc/sqlite (CGO-free) | Cross-compile friendly, no CGO toolchain needed |
|
||||
| AD-003 | net/http (no framework) | Stdlib suffices; avoids external router dependency |
|
||||
| AD-004 | os/exec with WaitDelay (Go 1.25+) | Clean process termination, native to Go |
|
||||
| AD-005 | Cobra for CLI | Industry standard, familiar to operators |
|
||||
| AD-006 | slog for logging | Native to Go 1.21+, no external dependency |
|
||||
| AD-007 | HCL for job specs | Familiar to Nomad/HashiCorp users |
|
||||
| AD-008 | Single-node scheduling (v0.1) | Multi-node scheduling deferred to v0.2+ |
|
||||
|
||||
## Anti-Patterns (Explicitly Avoided)
|
||||
|
||||
- No controller/agent split (single binary)
|
||||
- No CRDs / custom resource definitions
|
||||
- No web UI (CLI-only)
|
||||
- No service mesh
|
||||
- No container runtime integration
|
||||
- No multi-tenancy
|
||||
- No cloud provider integrations
|
||||
- No auto-scaling
|
||||
- No admission controllers
|
||||
- No complex scheduling algorithms
|
||||
|
||||
## Dependency Map (minimal)
|
||||
|
||||
```
|
||||
github.com/spf13/cobra # CLI framework
|
||||
github.com/hashicorp/hcl/v2 # HCL parser
|
||||
modernc.org/sqlite # SQLite (pure Go)
|
||||
github.com/google/uuid # UUID generation
|
||||
```
|
||||
|
||||
Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework.
|
||||
|
||||
## Deployment Model
|
||||
|
||||
```
|
||||
User Machine Server Node
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ orca CLI │─────── mTLS ──────────▶│ orca daemon │
|
||||
│ │ │ ├── API server │
|
||||
│ │ │ ├── Engine │
|
||||
│ │ │ └── SQLite store │
|
||||
└──────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
For v0.1, the CLI and daemon can be the same binary on the same machine. Multi-node is deferred.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Ideation: Orca v0.1
|
||||
|
||||
Full autonomy mode: all ideas auto-accepted. Three tiers explored.
|
||||
|
||||
## Tier 1: Mechanical (security/quality, automated)
|
||||
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-001 | Add `gosec` to CI pipeline | mechanical | 0.95 |
|
||||
| I-002 | Add `govulncheck` to CI pipeline | mechanical | 0.95 |
|
||||
| I-003 | Enable `gofmt` and `goimports` pre-commit checks | mechanical | 0.90 |
|
||||
| I-004 | Pin Go version in `go.mod` (`go 1.25`) | mechanical | 0.95 |
|
||||
| I-005 | Use `log/slog` for all logging (no `fmt.Println` in production) | mechanical | 0.95 |
|
||||
| I-006 | Add `.gitignore` for `bin/`, `coverage.out`, `*.test` | mechanical | 0.95 |
|
||||
| I-007 | Add `LICENSE` (MIT) | mechanical | 0.90 |
|
||||
| I-008 | Add `README.md` with quickstart | mechanical | 0.90 |
|
||||
| I-009 | Use `context.Context` for all I/O | mechanical | 0.95 |
|
||||
| I-010 | Wrap errors with `fmt.Errorf("...: %w", err)` | mechanical | 0.95 |
|
||||
|
||||
## Tier 2: Backend-Enriched (architecture/coverage)
|
||||
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-011 | Use Cobra for CLI (industry standard) | backend | 0.95 |
|
||||
| I-012 | Use `viper` for config OR hand-rolled HCL parser | backend | 0.85 |
|
||||
| I-013 | Use `hashicorp/hcl` for HCL parsing | backend | 0.90 |
|
||||
| I-014 | Use `modernc.org/sqlite` (CGO-free) | backend | 0.92 |
|
||||
| I-015 | Repository pattern for state access | backend | 0.85 |
|
||||
| I-016 | Use `os/exec` for task execution with `cmd.WaitDelay` (Go 1.25+) | backend | 0.95 |
|
||||
| I-017 | Use `iter.Seq` (Go 1.25+) for streaming job lists | backend | 0.90 |
|
||||
| I-018 | Use `crypto/tls` with self-signed cert generation for mTLS | backend | 0.80 |
|
||||
| I-019 | Use `slog.NewJSONHandler` for structured logs | backend | 0.95 |
|
||||
| I-020 | Add health check HTTP endpoint on configurable port | backend | 0.90 |
|
||||
|
||||
## Tier 3: Cross-Project (from backlog/coreci patterns)
|
||||
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-021 | Mirror `.coreci.yml` pattern from coreci (validate/build/test/release) | cross-project | 0.95 |
|
||||
| I-022 | Mirror `tea` CLI integration for releases | cross-project | 0.90 |
|
||||
| I-023 | Mirror `lead-developer` persona-driven decomposition | cross-project | 0.90 |
|
||||
| I-024 | Mirror `phase/NN-*` → `milestone/*` → `main` branching | cross-project | 0.95 |
|
||||
| I-025 | Mirror `---ci---` commit block discipline | cross-project | 0.95 |
|
||||
| I-026 | Mirror pre-push hook pattern from coreci (if exists) | cross-project | 0.85 |
|
||||
| I-027 | Mirror Go module structure: `cmd/orca`, `internal/`, `pkg/` | cross-project | 0.95 |
|
||||
| I-028 | Mirror persona territory enforcement (`warn` mode) | cross-project | 0.90 |
|
||||
| I-029 | Mirror security audit logging in all write paths | cross-project | 0.90 |
|
||||
| I-030 | Mirror `Makefile` with `build`, `test`, `lint`, `fmt` targets | cross-project | 0.95 |
|
||||
|
||||
## Accepted Ideas (auto-accepted, full autonomy)
|
||||
|
||||
All 30 ideas accepted. Implementation in subsequent EXECUTE phases.
|
||||
|
||||
## Resulting REQ Additions
|
||||
- REQ-014: `gosec` + `govulncheck` in CI (I-001, I-002)
|
||||
- REQ-015: MIT LICENSE (I-007)
|
||||
- REQ-016: README.md with quickstart (I-008)
|
||||
- REQ-017: `context.Context` propagation (I-009)
|
||||
- REQ-018: Error wrapping with `%w` (I-010)
|
||||
- REQ-019: Cobra CLI framework (I-011)
|
||||
- REQ-020: HCL parser integration (I-013)
|
||||
- REQ-021: `os/exec` with `WaitDelay` (I-016)
|
||||
- REQ-022: `iter.Seq` for streaming (I-017)
|
||||
- REQ-023: Self-signed mTLS cert generation (I-018)
|
||||
- REQ-024: `Makefile` with standard targets (I-030)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
active_personas:
|
||||
- lead-developer
|
||||
- backend-engineer
|
||||
- data-engineer
|
||||
- cli-engineer
|
||||
- security-engineer
|
||||
deactivated_personas:
|
||||
- frontend-engineer
|
||||
- devops-sre
|
||||
phase_specific: []
|
||||
reason: |
|
||||
Orca is a CLI-first, offline-first orchestration engine with no web UI and
|
||||
a single-binary distribution model. The persona roster reflects this:
|
||||
|
||||
- lead-developer: coordination and task decomposition
|
||||
- backend-engineer: core engine and API handlers
|
||||
- data-engineer: SQLite state store and migrations
|
||||
- cli-engineer: Cobra subcommands and CLI UX
|
||||
- security-engineer: mTLS, audit logging, input validation
|
||||
|
||||
Deactivated:
|
||||
- frontend-engineer: no web UI in v0.1
|
||||
- devops-sre: no container/cloud integrations; release flow is
|
||||
handled by CoreCI (not a persona territory)
|
||||
---
|
||||
|
||||
# Personas: Orca
|
||||
|
||||
## Roster
|
||||
|
||||
### lead-developer
|
||||
- **Domain**: coordination
|
||||
- **Frameworks**: `cobra`
|
||||
- **Constraints**: `boundary-enforcement`, `offline-first`, `no-redundant-implementations`
|
||||
- **Territory**: `**/*.go`, `cmd/**`, `internal/**`
|
||||
- **Active**: true
|
||||
|
||||
### backend-engineer
|
||||
- **Domain**: backend
|
||||
- **Frameworks**: `cobra`, `net/http`
|
||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`
|
||||
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`
|
||||
- **Active**: true
|
||||
|
||||
### data-engineer
|
||||
- **Domain**: data
|
||||
- **Frameworks**: `modernc/sqlite`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`
|
||||
- **Active**: true
|
||||
|
||||
### cli-engineer (custom)
|
||||
- **Domain**: CLI/UX
|
||||
- **Frameworks**: `cobra`, `pflag`
|
||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`
|
||||
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
||||
- **Active**: true
|
||||
- **Reason**: Orca is CLI-first; this persona ensures CLI quality and discoverability.
|
||||
|
||||
### security-engineer (custom)
|
||||
- **Domain**: security
|
||||
- **Frameworks**: `crypto/tls`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`
|
||||
- **Active**: true
|
||||
- **Reason**: mTLS, audit logging, and input validation are first-class concerns.
|
||||
|
||||
### frontend-engineer
|
||||
- **Active**: false
|
||||
- **Reason**: No web UI in v0.1.
|
||||
|
||||
### devops-sre
|
||||
- **Active**: false
|
||||
- **Reason**: No container/cloud integrations. Release flow is handled by CoreCI.
|
||||
|
||||
## Territory Enforcement
|
||||
|
||||
- **Mode**: `warn` (per `config.json`)
|
||||
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
||||
- **Rationale**: Allows flexibility during early development; tighten to `strict` post-v0.1.
|
||||
|
||||
## Phase-Specific Personas
|
||||
|
||||
None for v0.1. All personas persist across all 6 phases.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Phase Plans: Orca v0.1
|
||||
|
||||
All 6 phases with vertical-slice structure, wave ordering, and REQ-ID mapping.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: CLI Skeleton (Wave 1)
|
||||
|
||||
**Branch**: `phase/01-cli-skeleton`
|
||||
**REQ Coverage**: REQ-001, REQ-002, REQ-013, REQ-015, REQ-016, REQ-019, REQ-024
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `go.mod` with `go 1.25`
|
||||
- [ ] `cmd/orca/main.go` — entry point
|
||||
- [ ] `internal/cli/root.go` — Cobra root command with `--json` global flag
|
||||
- [ ] `internal/cli/version.go` — `orca version` subcommand
|
||||
- [ ] `internal/cli/init.go` — `orca init` subcommand (stub)
|
||||
- [ ] `internal/cli/status.go` — `orca status` subcommand (stub)
|
||||
- [ ] `internal/cli/node.go` — `orca node {join,leave,list}` stubs
|
||||
- [ ] `internal/cli/job.go` — `orca job {run,list,stop,logs}` stubs
|
||||
- [ ] `Makefile` with `build`, `test`, `lint`, `fmt` targets
|
||||
- [ ] `LICENSE` (MIT)
|
||||
- [ ] `README.md` with quickstart
|
||||
- [ ] `.gitignore` for `bin/`, `coverage.out`, `*.test`
|
||||
- [ ] `.githooks/pre-push` → `scripts/trigger_coreci.sh`
|
||||
- [ ] `scripts/trigger_coreci.sh` — curl-based CoreCI trigger
|
||||
|
||||
### Verification
|
||||
- `go build ./cmd/orca` succeeds
|
||||
- `orca --help` lists all subcommands
|
||||
- `orca version` prints version
|
||||
- `orca --json version` prints JSON
|
||||
- `make build`, `make test`, `make lint`, `make fmt` all succeed
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Node Management (Wave 2)
|
||||
|
||||
**Branch**: `phase/02-node-mgmt`
|
||||
**REQ Coverage**: REQ-002, REQ-005, REQ-012, REQ-017, REQ-018
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `internal/store/sqlite.go` — SQLite connection (modernc/sqlite)
|
||||
- [ ] `internal/store/migrations/0001_nodes.sql` — nodes table schema
|
||||
- [ ] `internal/store/node_repo.go` — Node repository (CRUD)
|
||||
- [ ] `internal/engine/registry.go` — In-memory node registry with SQLite persistence
|
||||
- [ ] Wire `orca node join` to registry
|
||||
- [ ] Wire `orca node leave` to registry
|
||||
- [ ] Wire `orca node list` to registry
|
||||
- [ ] Audit log on all node operations
|
||||
- [ ] Config loading from `~/.orca/config.hcl`
|
||||
|
||||
### Verification
|
||||
- `orca node join --name test --addr localhost:8443` adds node
|
||||
- `orca node list` shows added node
|
||||
- `orca node leave <id>` removes node
|
||||
- Restart daemon, node state persists
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Task Execution Engine (Wave 2)
|
||||
|
||||
**Branch**: `phase/03-task-exec`
|
||||
**REQ Coverage**: REQ-004, REQ-009, REQ-021, REQ-022
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `internal/store/migrations/0002_jobs_tasks.sql` — jobs + tasks tables
|
||||
- [ ] `internal/store/job_repo.go` — Job repository
|
||||
- [ ] `internal/store/task_repo.go` — Task repository
|
||||
- [ ] `internal/engine/executor.go` — `os/exec` with `WaitDelay` (Go 1.25+)
|
||||
- [ ] `internal/engine/scheduler.go` — Single-node FIFO scheduler
|
||||
- [ ] `internal/jobspec/hcl.go` — HCL job spec parser
|
||||
- [ ] Wire `orca job run <spec.hcl>` to executor
|
||||
- [ ] Wire `orca job list` to repository
|
||||
- [ ] Wire `orca job stop <id>` to executor
|
||||
- [ ] Wire `orca job logs <id>` to task output
|
||||
|
||||
### Verification
|
||||
- `orca job run` with valid HCL spec executes command
|
||||
- Task status transitions: pending → running → complete
|
||||
- `orca job list` shows job history
|
||||
- `orca job stop` kills running process cleanly (WaitDelay)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Local State Persistence Hardening (Wave 3)
|
||||
|
||||
**Branch**: `phase/04-state-persistence`
|
||||
**REQ Coverage**: REQ-005, REQ-018
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `internal/store/migrate.go` — Migration runner
|
||||
- [ ] `internal/store/audit_repo.go` — Audit log repository
|
||||
- [ ] `internal/store/migrations/0003_audit_log.sql` — audit_log table
|
||||
- [ ] Embed migrations via `//go:embed`
|
||||
- [ ] Transaction wrapping for all writes
|
||||
- [ ] Connection pool tuning
|
||||
- [ ] Graceful shutdown flushes pending writes
|
||||
|
||||
### Verification
|
||||
- Migrations apply on first run
|
||||
- Audit log entries persist across restarts
|
||||
- Concurrent writes don't corrupt state (test with `go test -race`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Health Checks (Wave 3)
|
||||
|
||||
**Branch**: `phase/05-health-checks`
|
||||
**REQ Coverage**: REQ-006, REQ-017
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `internal/daemon/server.go` — `net/http` server with `http.ServeMux`
|
||||
- [ ] `internal/daemon/health.go` — `/healthz` and `/readyz` handlers
|
||||
- [ ] `internal/daemon/jobs_handler.go` — `/v1/jobs/*` handlers
|
||||
- [ ] `internal/daemon/nodes_handler.go` — `/v1/nodes/*` handlers
|
||||
- [ ] `internal/daemon/tasks_handler.go` — `/v1/tasks/*` handlers
|
||||
- [ ] Graceful shutdown via `signal.NotifyContext`
|
||||
- [ ] Health endpoint checks SQLite connectivity
|
||||
- [ ] Wire CLI subcommands to daemon API
|
||||
|
||||
### Verification
|
||||
- `curl http://localhost:8080/healthz` returns 200
|
||||
- `curl http://localhost:8080/readyz` returns 200 when ready
|
||||
- `curl http://localhost:8080/v1/jobs` returns job list as JSON
|
||||
- Daemon shuts down cleanly on SIGTERM
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: CoreCI Full Release Flow (Wave 4)
|
||||
|
||||
**Branch**: `phase/06-coreci-release`
|
||||
**REQ Coverage**: REQ-007, REQ-014
|
||||
|
||||
### Must-Haves
|
||||
- [ ] `.coreci.yml` — validate, build, test, release pipelines
|
||||
- [ ] `scripts/release.sh` — `tea releases create` wrapper
|
||||
- [ ] `Makefile` `release` target invokes release script
|
||||
- [ ] Tarball generation in release pipeline
|
||||
- [ ] Version injection via `-ldflags`
|
||||
- [ ] `CHANGELOG.md` (auto-generated from `---ci---` blocks)
|
||||
|
||||
### Verification
|
||||
- `make release` creates Gitea release with tarball
|
||||
- Tarball contains `orca` binary
|
||||
- Release notes include phase summary
|
||||
- CoreCI `validate`, `build`, `test`, `release` pipelines all green
|
||||
|
||||
---
|
||||
|
||||
## Wave Ordering
|
||||
|
||||
- **Wave 1** (Phase 1): Foundation — CLI skeleton, build system, hooks
|
||||
- **Wave 2** (Phases 2-3): Core functionality — node registry, task execution
|
||||
- **Wave 3** (Phases 4-5): Hardening — state persistence, health checks
|
||||
- **Wave 4** (Phase 6): Release — CoreCI integration
|
||||
|
||||
Phases within a wave can be parallelized if `parallelization.enabled=true`.
|
||||
For v0.1, `parallelization.enabled=false` — phases run sequentially.
|
||||
|
||||
## Versioning
|
||||
|
||||
- **Milestone type**: `feature` (Phases 1-6 all produce features)
|
||||
- **Patch per phase**: `v0.1.1`, `v0.1.2`, ..., `v0.1.6`
|
||||
- **Final tag on COMPLETE**: `v0.2.0` (next minor per `run.md` versioning logic)
|
||||
@@ -21,7 +21,26 @@ Build a lightweight system to manage and execute workloads across a set of nodes
|
||||
- Only CI system allowed: CoreCI (git.cloudinit.dev/coreci/coreci).
|
||||
- Gitea remote: git.cloudinit.dev/coreci/orca.
|
||||
|
||||
## Clarified Decisions (D-series, full autonomy)
|
||||
|
||||
| ID | Question | Decision | Rationale | Confidence |
|
||||
|----|----------|----------|-----------|------------|
|
||||
| D-001 | Single binary or multi-binary distribution? | **Single binary** | Simpler distribution; subcommands baked into one `orca` binary. Aligns with simplicity pillar. | 0.95 |
|
||||
| D-002 | Local state store technology? | **modernc/sqlite (pure Go, CGO-free)** | Cross-compile friendly, no CGO dependency, single file on disk, mature. | 0.92 |
|
||||
| D-003 | Inter-node communication? | **Embedded HTTP (net/http) over loopback, mTLS for cross-node** | No external RPC framework needed for v0.1. HTTP suffices. | 0.85 |
|
||||
| D-004 | Scheduling algorithm for v0.1? | **Single-node only (no scheduling)** | Multi-node scheduling is out of scope for v0.1. Tasks run on the node they're submitted to. | 0.90 |
|
||||
| D-005 | CLI output format? | **Human-readable by default, `--json` flag for machine consumption** | Serves both humans and AI agents. | 0.95 |
|
||||
| D-006 | Job/task definition format? | **HCL or YAML in `.hcl`/`.yaml` files** | Familiar to Nomad/HashiCorp users; simpler than JSON for humans. | 0.88 |
|
||||
| D-007 | Authentication? | **mTLS for v0.1, token-based deferred** | mTLS is the most secure default. Tokens can be added later if needed. | 0.80 |
|
||||
| D-008 | Container runtime? | **Direct process execution (no container runtime) for v0.1** | Avoids the Docker/container dependency. Pure process management. | 0.85 |
|
||||
| D-009 | Configuration file location? | **`~/.orca/config.hcl` and `/etc/orca/orca.hcl`** | Standard XDG-style paths. | 0.90 |
|
||||
| D-010 | Logging format? | **Structured JSON via `log/slog`** | Native Go 1.21+ slog, no external dependency. | 0.95 |
|
||||
|
||||
## Out of Scope
|
||||
- Full-blown Kubernetes-compatible API.
|
||||
- Complex cloud-provider integrations.
|
||||
- GUI-based management consoles.
|
||||
- Multi-node scheduling.
|
||||
- Container runtime integration.
|
||||
- Service mesh / sidecar injection.
|
||||
- Auto-scaling / horizontal pod autoscaler.
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
# Requirements: Orca
|
||||
|
||||
## Milestone v0.1: Foundation
|
||||
|
||||
| ID | Requirement | Priority | Status |
|
||||
|----|-------------|----------|--------|
|
||||
| REQ-001 | Go 1.25+ Toolchain Support | High | Pending |
|
||||
| REQ-002 | CLI-first interface for all operations | High | Pending |
|
||||
| REQ-003 | Offline-first operational mode | High | Pending |
|
||||
| REQ-004 | Basic task deployment (single node) | Medium | Pending |
|
||||
| REQ-005 | Local state storage without external DB | Medium | Pending |
|
||||
| REQ-006 | Security-first audit logging | High | Pending |
|
||||
| REQ-007 | CoreCI full release flow integration | High | Pending |
|
||||
| REQ-001 | Go 1.25+ toolchain support | High | Pending |
|
||||
| REQ-002 | CLI-first interface for all operations (single binary) | High | Pending |
|
||||
| REQ-003 | Offline-first operational mode (no cloud deps) | High | Pending |
|
||||
| REQ-004 | Basic task deployment (single-node process execution) | Medium | Pending |
|
||||
| REQ-005 | Local state storage via modernc/sqlite (CGO-free) | Medium | Pending |
|
||||
| REQ-006 | Security-first audit logging via `log/slog` | High | Pending |
|
||||
| REQ-007 | CoreCI full release flow integration via `.coreci.yml` | High | Pending |
|
||||
| REQ-008 | Structured JSON logging (slog) | High | Pending |
|
||||
| REQ-009 | HCL/YAML job spec parsing | Medium | Pending |
|
||||
| REQ-010 | `--json` output flag for machine consumption | High | Pending |
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | Deferred (v0.2) |
|
||||
| REQ-012 | `~/.orca/config.hcl` and `/etc/orca/orca.hcl` config locations | Low | Pending |
|
||||
| REQ-013 | Pre-push git hook triggers CoreCI on every push | High | Pending |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | Pending |
|
||||
| REQ-015 | MIT LICENSE | Low | Pending |
|
||||
| REQ-016 | README.md with quickstart | Medium | Pending |
|
||||
| REQ-017 | `context.Context` propagation in all I/O | High | Pending |
|
||||
| REQ-018 | Error wrapping with `fmt.Errorf("...: %w", err)` | High | Pending |
|
||||
| REQ-019 | Cobra CLI framework | High | Pending |
|
||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | Pending |
|
||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | Pending |
|
||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | Pending |
|
||||
| REQ-023 | Self-signed mTLS cert generation | Medium | Pending |
|
||||
| REQ-024 | `Makefile` with standard targets | High | Pending |
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Pre-push hook: trigger CoreCI pipeline before any push
|
||||
exec "$(dirname "$0")/../scripts/trigger_coreci.sh"
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
bin/
|
||||
coverage.out
|
||||
*.test
|
||||
*.out
|
||||
.DS_Store
|
||||
orca
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env.local
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Orca Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,42 @@
|
||||
.PHONY: build test lint fmt clean run release help
|
||||
|
||||
BINARY := bin/orca
|
||||
GOFLAGS := -trimpath
|
||||
LDFLAGS := -s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") \
|
||||
-X main.gitCommit=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") \
|
||||
-X main.buildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
help:
|
||||
@echo "orca — make targets"
|
||||
@echo " build Build binary to $(BINARY)"
|
||||
@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 " release Build release artifact with version injection"
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
go build $(GOFLAGS) -o $(BINARY) ./cmd/orca
|
||||
|
||||
test:
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
lint:
|
||||
gofmt -l .
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
clean:
|
||||
rm -rf bin coverage.out
|
||||
|
||||
run: build
|
||||
./$(BINARY) $(ARGS)
|
||||
|
||||
release:
|
||||
@mkdir -p bin
|
||||
go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY) ./cmd/orca
|
||||
@echo "Release build complete: $(BINARY)"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Orca
|
||||
|
||||
Offline/CLI-first orchestration engine inspired by HashiCorp Nomad, far simpler than Kubernetes.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.1: Foundation** — see [.ciagent/ROADMAP.md](.ciagent/ROADMAP.md) for the 6-phase plan.
|
||||
|
||||
## Pillars
|
||||
|
||||
- **Simplicity** — single binary, minimal dependencies
|
||||
- **AI-first** — CLI designed for both humans and AI agents
|
||||
- **Offline-first** — no cloud dependencies
|
||||
- **CLI-first** — primary interface is the command line
|
||||
- **Security before features** — NFRs ship before new functionality
|
||||
- **Bug fixes before features** — stability is paramount
|
||||
- **NFRs before features** — observability and auditability first
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make build
|
||||
|
||||
# Run
|
||||
./bin/orca version
|
||||
./bin/orca --help
|
||||
|
||||
# Initialize local state
|
||||
./bin/orca init
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Command | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `orca version` | Print version info | ✅ Phase 1 |
|
||||
| `orca init` | Initialize local orca state | ✅ Phase 1 (stub) |
|
||||
| `orca status` | Show orca daemon status | ✅ Phase 1 (stub) |
|
||||
| `orca node` | Node management (`join`, `leave`, `list`) | Phase 2 |
|
||||
| `orca job` | Job management (`run`, `list`, `stop`, `logs`) | Phase 3 |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make build # Build binary to ./bin/orca
|
||||
make test # Run tests with race detection
|
||||
make lint # Run golangci-lint
|
||||
make fmt # Format code
|
||||
make release # Build + create Gitea release (Phase 6)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [.ciagent/ARCHITECTURE.md](.ciagent/ARCHITECTURE.md) for full architecture details.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,29 @@
|
||||
module git.cloudinit.dev/coreci/orca
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/spf13/cobra v1.8.1
|
||||
|
||||
require (
|
||||
github.com/agext/levenshtein v1.2.1 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.51.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
|
||||
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
|
||||
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
|
||||
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
@@ -0,0 +1,38 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize local orca state directory",
|
||||
Long: "Create the local orca state directory at ~/.orca/ and write a default config file.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
orcaDir := filepath.Join(home, ".orca")
|
||||
if err := os.MkdirAll(orcaDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create orca dir: %w", err)
|
||||
}
|
||||
result := map[string]string{
|
||||
"path": orcaDir,
|
||||
"status": "initialized",
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(result)
|
||||
}
|
||||
printText("✓ Initialized orca state at %s\n", orcaDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var jobCmd = &cobra.Command{
|
||||
Use: "job",
|
||||
Short: "Manage orca jobs",
|
||||
Long: "Run, list, stop, and inspect orca jobs.",
|
||||
}
|
||||
|
||||
var jobRunCmd = &cobra.Command{
|
||||
Use: "run <spec.hcl>",
|
||||
Short: "Run a job from an HCL spec file",
|
||||
Long: "Submit a job spec and execute it. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job run " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var jobListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all jobs",
|
||||
Long: "Display all jobs and their status. Implemented in Phase 3.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job list")
|
||||
},
|
||||
}
|
||||
|
||||
var jobStopCmd = &cobra.Command{
|
||||
Use: "stop <job-id>",
|
||||
Short: "Stop a running job",
|
||||
Long: "Stop a job by ID. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job stop " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var jobLogsCmd = &cobra.Command{
|
||||
Use: "logs <job-id>",
|
||||
Short: "Show logs for a job",
|
||||
Long: "Display the logs for a job by ID. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job logs " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
jobCmd.AddCommand(jobRunCmd)
|
||||
jobCmd.AddCommand(jobListCmd)
|
||||
jobCmd.AddCommand(jobStopCmd)
|
||||
jobCmd.AddCommand(jobLogsCmd)
|
||||
rootCmd.AddCommand(jobCmd)
|
||||
}
|
||||
|
||||
func notImplemented(cmd string) error {
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"command": cmd,
|
||||
"status": "not_implemented",
|
||||
"phase": "1-cli-skeleton",
|
||||
"next": "Phase 2-6 will implement this",
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(rootCmd.ErrOrStderr(), "✗ %s: not yet implemented (Phase 1: CLI skeleton only)\n", cmd)
|
||||
fmt.Fprintf(rootCmd.ErrOrStderr(), " see .ciagent/ROADMAP.md for the full 6-phase plan\n")
|
||||
return fmt.Errorf("not implemented: %s", cmd)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func dbPath() string {
|
||||
if p := os.Getenv("ORCA_DB"); p != "" {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".orca", "orca.db")
|
||||
}
|
||||
|
||||
func openDB() (*sql.DB, func() error, error) {
|
||||
db, err := store.Open(dbPath())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return db, db.Close, nil
|
||||
}
|
||||
|
||||
func newLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
|
||||
func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
repo := store.NewNodeRepo(db)
|
||||
return engine.NewNodeRegistry(repo, newLogger()), closer, nil
|
||||
}
|
||||
|
||||
var (
|
||||
joinName string
|
||||
joinAddr string
|
||||
leaveID string
|
||||
)
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
Use: "node",
|
||||
Short: "Manage orca nodes",
|
||||
Long: "Join, leave, or list orca nodes in the registry.",
|
||||
}
|
||||
|
||||
var nodeJoinCmd = &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Join a node to the orca registry",
|
||||
Long: "Register a node in the local orca registry. Persisted to SQLite.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if joinName == "" {
|
||||
return fmt.Errorf("--name is required")
|
||||
}
|
||||
if joinAddr == "" {
|
||||
joinAddr = "localhost:8443"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registry, closer, err := nodeRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
node := &model.Node{
|
||||
ID: uuid.NewString(),
|
||||
Name: joinName,
|
||||
Address: joinAddr,
|
||||
State: model.NodeStateReady,
|
||||
JoinedAt: time.Now().UTC(),
|
||||
LastSeen: time.Now().UTC(),
|
||||
}
|
||||
if err := registry.Join(ctx, node); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(node)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeLeaveCmd = &cobra.Command{
|
||||
Use: "leave [node-id]",
|
||||
Short: "Remove a node from the orca registry",
|
||||
Long: "Mark a node as left. Use --id to specify, or pass as argument.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := leaveID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("node id required (use --id or pass as argument)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registry, closer, err := nodeRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
if err := registry.Leave(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]string{"id": id, "state": "left"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node left: %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all nodes in the orca registry",
|
||||
Long: "Display all registered nodes and their state.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registry, closer, err := nodeRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
nodes, err := registry.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(nodes)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No nodes registered. Use 'orca node join' to add one.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
|
||||
for _, n := range nodes {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
||||
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
||||
|
||||
nodeCmd.AddCommand(nodeJoinCmd)
|
||||
nodeCmd.AddCommand(nodeLeaveCmd)
|
||||
nodeCmd.AddCommand(nodeListCmd)
|
||||
rootCmd.AddCommand(nodeCmd)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "0.1.0-dev"
|
||||
gitCommit = "unknown"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "orca",
|
||||
Short: "Orca — offline/CLI-first orchestration engine",
|
||||
Long: `Orca is a minimalist, offline-first, CLI-first orchestration engine
|
||||
inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity
|
||||
over feature richness.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
var jsonOutput bool
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format")
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func printJSON(v any) error {
|
||||
enc := json.NewEncoder(rootCmd.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
func printText(format string, args ...any) {
|
||||
fmt.Fprintf(rootCmd.OutOrStdout(), format, args...)
|
||||
}
|
||||
|
||||
func printResult(text string, jsonObj any) {
|
||||
if jsonOutput {
|
||||
_ = printJSON(jsonObj)
|
||||
return
|
||||
}
|
||||
printText("%s\n", text)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionCommandExists(t *testing.T) {
|
||||
found := false
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if cmd.Name() == "version" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("version command not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHasAllSubcommands(t *testing.T) {
|
||||
expected := []string{"version", "init", "status", "node", "job"}
|
||||
registered := make(map[string]bool)
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
registered[cmd.Name()] = true
|
||||
}
|
||||
for _, name := range expected {
|
||||
if !registered[name] {
|
||||
t.Errorf("expected subcommand %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSubcommands(t *testing.T) {
|
||||
expected := []string{"join", "leave", "list"}
|
||||
registered := make(map[string]bool)
|
||||
for _, cmd := range nodeCmd.Commands() {
|
||||
registered[cmd.Name()] = true
|
||||
}
|
||||
for _, name := range expected {
|
||||
if !registered[name] {
|
||||
t.Errorf("expected node subcommand %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSubcommands(t *testing.T) {
|
||||
expected := []string{"run", "list", "stop", "logs"}
|
||||
registered := make(map[string]bool)
|
||||
for _, cmd := range jobCmd.Commands() {
|
||||
registered[cmd.Name()] = true
|
||||
}
|
||||
for _, name := range expected {
|
||||
if !registered[name] {
|
||||
t.Errorf("expected job subcommand %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpMentionsKeyPillars(t *testing.T) {
|
||||
help := rootCmd.Long
|
||||
for _, pillar := range []string{"offline", "CLI", "Nomad", "simplicity"} {
|
||||
if !strings.Contains(strings.ToLower(help), strings.ToLower(pillar)) {
|
||||
t.Errorf("root help does not mention pillar %q", pillar)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show orca daemon status",
|
||||
Long: "Display the current status of the local orca daemon, including version, uptime, and connection info.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
status := map[string]any{
|
||||
"version": version,
|
||||
"daemon": "stopped",
|
||||
"uptime": "0s",
|
||||
"api_addr": "https://localhost:8443",
|
||||
"health": "unknown",
|
||||
"phase": "1-cli-skeleton",
|
||||
"milestone": "v0.1",
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(status)
|
||||
}
|
||||
printText("orca daemon status\n")
|
||||
printText(" version: %s\n", version)
|
||||
printText(" daemon: %s\n", "stopped (daemon not yet implemented in Phase 1)")
|
||||
printText(" api_addr: %s\n", "https://localhost:8443")
|
||||
printText(" phase: %s\n", "1-cli-skeleton")
|
||||
printText(" milestone: %s\n", "v0.1")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Long: "Print the orca version, git commit, and build time.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
info := map[string]string{
|
||||
"version": version,
|
||||
"git_commit": gitCommit,
|
||||
"build_time": buildTime,
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(info)
|
||||
}
|
||||
printText("orca version %s\n", version)
|
||||
printText(" git commit: %s\n", gitCommit)
|
||||
printText(" build time: %s\n", buildTime)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
type NodeRegistry struct {
|
||||
repo *store.NodeRepo
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewNodeRegistry(repo *store.NodeRepo, log *slog.Logger) *NodeRegistry {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &NodeRegistry{repo: repo, log: log}
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
if err := r.repo.Insert(ctx, n); err != nil {
|
||||
return fmt.Errorf("join node: %w", err)
|
||||
}
|
||||
r.log.Info("node joined",
|
||||
slog.String("node_id", n.ID),
|
||||
slog.String("name", n.Name),
|
||||
slog.String("address", n.Address))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Leave(ctx context.Context, id string) error {
|
||||
if err := r.repo.UpdateState(ctx, id, model.NodeStateLeft); err != nil {
|
||||
return fmt.Errorf("leave node: %w", err)
|
||||
}
|
||||
r.log.Info("node left", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
|
||||
if err := r.repo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("forget node: %w", err)
|
||||
}
|
||||
r.log.Info("node removed from registry", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) List(ctx context.Context) ([]*model.Node, error) {
|
||||
return r.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Get(ctx context.Context, id string) (*model.Node, error) {
|
||||
return r.repo.Get(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type NodeState string
|
||||
|
||||
const (
|
||||
NodeStatePending NodeState = "pending"
|
||||
NodeStateReady NodeState = "ready"
|
||||
NodeStateLeft NodeState = "left"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
State NodeState `json:"state"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
if _, err := db.ExecContext(context.Background(), `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
var existing string
|
||||
err := db.QueryRowContext(context.Background(), `SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&existing)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := db.ExecContext(context.Background(), string(sqlBytes)); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := db.ExecContext(context.Background(), `INSERT INTO schema_migrations (name, applied_at) VALUES (?, datetime('now'))`, name); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Node registry
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
joined_at DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_state ON nodes(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
||||
@@ -0,0 +1,122 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type NodeRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewNodeRepo(db *sql.DB) *NodeRepo {
|
||||
return &NodeRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
|
||||
if n.JoinedAt.IsZero() {
|
||||
n.JoinedAt = time.Now().UTC()
|
||||
}
|
||||
if n.LastSeen.IsZero() {
|
||||
n.LastSeen = n.JoinedAt
|
||||
}
|
||||
if n.State == "" {
|
||||
n.State = model.NodeStateReady
|
||||
}
|
||||
metaJSON, err := json.Marshal(n.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert node: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes WHERE id = ?`, id)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list nodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var nodes []*model.Node
|
||||
for rows.Next() {
|
||||
n, err := scanNode(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
|
||||
string(state), time.Now().UTC(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update node state: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRepo) Delete(ctx context.Context, id string) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete node: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanNode(s scanner) (*model.Node, error) {
|
||||
var (
|
||||
n model.Node
|
||||
state string
|
||||
metaJSON sql.NullString
|
||||
)
|
||||
err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan node: %w", err)
|
||||
}
|
||||
n.State = model.NodeState(state)
|
||||
if metaJSON.Valid && metaJSON.String != "" {
|
||||
if err := json.Unmarshal([]byte(metaJSON.String), &n.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) (*NodeRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
return NewNodeRepo(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestNodeRepo_InsertAndGet(t *testing.T) {
|
||||
repo, cleanup := openTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
n := &model.Node{
|
||||
ID: "test-id-1",
|
||||
Name: "alpha",
|
||||
Address: "localhost:8443",
|
||||
State: model.NodeStateReady,
|
||||
JoinedAt: time.Now().UTC(),
|
||||
LastSeen: time.Now().UTC(),
|
||||
}
|
||||
if err := repo.Insert(ctx, n); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
got, err := repo.Get(ctx, "test-id-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "alpha" || got.Address != "localhost:8443" {
|
||||
t.Errorf("unexpected node: %+v", got)
|
||||
}
|
||||
if got.State != model.NodeStateReady {
|
||||
t.Errorf("expected state ready, got %s", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeRepo_List(t *testing.T) {
|
||||
repo, cleanup := openTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
for _, name := range []string{"a", "b", "c"} {
|
||||
_ = repo.Insert(ctx, &model.Node{
|
||||
ID: name, Name: name, Address: "addr",
|
||||
JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
nodes, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(nodes) != 3 {
|
||||
t.Errorf("expected 3 nodes, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeRepo_UpdateState(t *testing.T) {
|
||||
repo, cleanup := openTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
_ = repo.Insert(ctx, &model.Node{
|
||||
ID: "x", Name: "x", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||
})
|
||||
if err := repo.UpdateState(ctx, "x", model.NodeStateLeft); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := repo.Get(ctx, "x")
|
||||
if got.State != model.NodeStateLeft {
|
||||
t.Errorf("expected left, got %s", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeRepo_Delete(t *testing.T) {
|
||||
repo, cleanup := openTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
_ = repo.Insert(ctx, &model.Node{
|
||||
ID: "y", Name: "y", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||
})
|
||||
if err := repo.Delete(ctx, "y"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
_, err := repo.Get(ctx, "y")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
path = filepath.Join(home, ".orca", "orca.db")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# trigger_coreci.sh - Trigger CoreCI pipeline for the pushed branch
|
||||
# Invoked by .githooks/pre-push
|
||||
# Gracefully degrades if CoreCI API is unreachable (Gitea webhook is secondary path).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Source .env if present (provides GITEA_TOKEN)
|
||||
# Look in repo root, then cwd, then script dir
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
for env_file in "$REPO_ROOT/.env" "$PWD/.env" "./.env"; do
|
||||
if [ -f "$env_file" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
set +a
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
CORECI_URL="${CORECI_URL:-https://git.cloudinit.dev/coreci/coreci}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo " (skip: GITEA_TOKEN not set)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while read local_ref local_sha remote_ref remote_sha; do
|
||||
branch="${remote_ref#refs/heads/}"
|
||||
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
|
||||
continue
|
||||
fi
|
||||
echo "→ Triggering CoreCI for branch: $branch (${local_sha:0:7})"
|
||||
payload=$(printf '{"repo":"coreci/orca","branch":"%s","ref":"%s"}' "$branch" "$local_sha")
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS -X POST "${CORECI_URL}/api/pipeline/run" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" >/dev/null 2>&1 \
|
||||
&& echo " ✓ CoreCI triggered" \
|
||||
|| echo " (CoreCI trigger failed; pipeline may run via webhook)"
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user