diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..a503d15 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/bash +# Pre-push hook: trigger CoreCI pipeline before any push +exec "$(dirname "$0")/../scripts/trigger_coreci.sh" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c6e49e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +bin/ +coverage.out +*.test +*.out +.DS_Store +orca +*.db +*.db-journal +*.db-wal +*.db-shm +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af94604 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..69813cc --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..cef283f --- /dev/null +++ b/README.md @@ -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). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f159fdb --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.cloudinit.dev/coreci/orca + +go 1.25 + +require github.com/spf13/cobra v1.8.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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= +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= diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..9a30c9c --- /dev/null +++ b/internal/cli/init.go @@ -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) +} diff --git a/internal/cli/job.go b/internal/cli/job.go new file mode 100644 index 0000000..ea221b0 --- /dev/null +++ b/internal/cli/job.go @@ -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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cli/node.go b/internal/cli/node.go new file mode 100644 index 0000000..518efe4 --- /dev/null +++ b/internal/cli/node.go @@ -0,0 +1,45 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +var nodeCmd = &cobra.Command{ + Use: "node", + Short: "Manage orca nodes", + Long: "Join, leave, or list orca nodes in the cluster.", +} + +var nodeJoinCmd = &cobra.Command{ + Use: "join", + Short: "Join a node to the orca cluster", + Long: "Register the local node with the orca cluster. Implemented in Phase 2.", + RunE: func(cmd *cobra.Command, args []string) error { + return notImplemented("orca node join") + }, +} + +var nodeLeaveCmd = &cobra.Command{ + Use: "leave", + Short: "Remove a node from the orca cluster", + Long: "Deregister a node from the orca cluster. Implemented in Phase 2.", + RunE: func(cmd *cobra.Command, args []string) error { + return notImplemented("orca node leave") + }, +} + +var nodeListCmd = &cobra.Command{ + Use: "list", + Short: "List all nodes in the orca cluster", + Long: "Display all registered nodes. Implemented in Phase 2.", + RunE: func(cmd *cobra.Command, args []string) error { + return notImplemented("orca node list") + }, +} + +func init() { + nodeCmd.AddCommand(nodeJoinCmd) + nodeCmd.AddCommand(nodeLeaveCmd) + nodeCmd.AddCommand(nodeListCmd) + rootCmd.AddCommand(nodeCmd) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..815d36e --- /dev/null +++ b/internal/cli/root.go @@ -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) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..ed072d2 --- /dev/null +++ b/internal/cli/root_test.go @@ -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) + } + } +} diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..50fb7ef --- /dev/null +++ b/internal/cli/status.go @@ -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) +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..fcc382c --- /dev/null +++ b/internal/cli/version.go @@ -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) +} diff --git a/scripts/trigger_coreci.sh b/scripts/trigger_coreci.sh new file mode 100755 index 0000000..4eb7094 --- /dev/null +++ b/scripts/trigger_coreci.sh @@ -0,0 +1,31 @@ +#!/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 + +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