Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c523c0c7 | |||
| 759b1b519d | |||
| b25e074e1d | |||
| bb6b5b3e83 | |||
| 857f756319 | |||
| f9a9873341 | |||
| 78334f1f74 | |||
| c7dbcef958 | |||
| 9580f347c6 | |||
| 46e929e4c6 |
@@ -1,10 +1,32 @@
|
||||
module git.cloudinit.dev/coreci/orca
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/spf13/cobra v1.8.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/hcl/v2 v2.24.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
)
|
||||
|
||||
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/go-cmp v0.7.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
|
||||
)
|
||||
|
||||
@@ -1,10 +1,81 @@
|
||||
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
||||
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/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
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/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
auditLimit int
|
||||
)
|
||||
|
||||
var auditCmd = &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "View orca audit log",
|
||||
Long: "Display the most recent audit log entries (security-first observability).",
|
||||
}
|
||||
|
||||
var auditListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List recent audit log entries",
|
||||
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()
|
||||
|
||||
entries, err := store.NewAuditRepo(db).List(ctx, auditLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(entries)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No audit entries.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n", "TIMESTAMP", "ACTOR", "ACTION", "RESOURCE", "RESULT")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n",
|
||||
e.Timestamp.Format("2006-01-02T15:04:05Z"), e.Actor, e.Action, e.Resource, e.Result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
auditListCmd.Flags().IntVar(&auditLimit, "limit", 50, "max entries to show")
|
||||
auditCmd.AddCommand(auditListCmd)
|
||||
rootCmd.AddCommand(auditCmd)
|
||||
}
|
||||
+173
-24
@@ -1,9 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var jobCmd = &cobra.Command{
|
||||
@@ -12,46 +21,187 @@ var jobCmd = &cobra.Command{
|
||||
Long: "Run, list, stop, and inspect orca jobs.",
|
||||
}
|
||||
|
||||
func jobExecutor() (*engine.Executor, func() error, error) {
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
jobs := store.NewJobRepo(db)
|
||||
tasks := store.NewTaskRepo(db)
|
||||
return engine.NewExecutor(jobs, tasks, newLogger()), closer, nil
|
||||
}
|
||||
|
||||
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.",
|
||||
Long: "Submit a job spec, execute its tasks, and persist the result.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job run " + args[0])
|
||||
spec, err := jobspec.ParseFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec, closer, err := jobExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Job.Name,
|
||||
Spec: args[0],
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
if err := exec.Run(ctx, job, toTaskSpecs(spec.Tasks)); err != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "✗ Job %s failed: %v\n", job.ID, err)
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": job.ID, "name": job.Name, "status": "complete"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job complete: %s (%s)\n", job.ID, job.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var jobListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all jobs",
|
||||
Long: "Display all jobs and their status. Implemented in Phase 3.",
|
||||
Long: "Display all jobs and their status.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job list")
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
jobs, err := store.NewJobRepo(db).List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(jobs)
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No jobs. Use 'orca job run <spec.hcl>' to submit one.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-12s %-8s\n", "ID", "NAME", "STATUS", "EXIT")
|
||||
for _, j := range jobs {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-12s %-8d\n", j.ID, j.Name, j.Status, j.ExitCode)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
stopID string
|
||||
)
|
||||
|
||||
var jobStopCmd = &cobra.Command{
|
||||
Use: "stop <job-id>",
|
||||
Use: "stop [job-id]",
|
||||
Short: "Stop a running job",
|
||||
Long: "Stop a job by ID. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: "Mark a job as stopped. Note: this is a soft stop (cancel context for the daemon).",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job stop " + args[0])
|
||||
id := stopID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("job id required (--id or argument)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
repo := store.NewJobRepo(db)
|
||||
job, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("job not found: %s", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := repo.UpdateStatus(ctx, id, model.JobStatusStopped, 130); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": id, "status": "stopped", "previous_status": job.Status})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job stopped: %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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),
|
||||
Use: "logs [job-id]",
|
||||
Short: "Show task output for a job",
|
||||
Long: "Display captured stdout/stderr for all tasks in a job.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca job logs " + args[0])
|
||||
id := stopID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("job id required (--id or argument)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
taskRepo := store.NewTaskRepo(db)
|
||||
tasks, err := taskRepo.ListByJob(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(tasks)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No tasks for this job.")
|
||||
return nil
|
||||
}
|
||||
for i, t := range tasks {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "--- task[%d] %s (%s) exit=%d ---\n", i, t.Command, t.Status, t.ExitCode)
|
||||
if t.Stdout != "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), t.Stdout)
|
||||
}
|
||||
if t.Stderr != "" {
|
||||
fmt.Fprintln(cmd.OutOrStderr(), t.Stderr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
jobStopCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
||||
jobLogsCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
||||
|
||||
jobCmd.AddCommand(jobRunCmd)
|
||||
jobCmd.AddCommand(jobListCmd)
|
||||
jobCmd.AddCommand(jobStopCmd)
|
||||
@@ -59,16 +209,15 @@ func init() {
|
||||
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",
|
||||
})
|
||||
func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
|
||||
out := make([]engine.TaskSpec, len(in))
|
||||
for i, t := range in {
|
||||
out[i] = engine.TaskSpec{
|
||||
Name: t.Name,
|
||||
Command: t.Command,
|
||||
Args: t.Args,
|
||||
Env: t.Env,
|
||||
}
|
||||
}
|
||||
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)
|
||||
return out
|
||||
}
|
||||
|
||||
+142
-11
@@ -1,43 +1,174 @@
|
||||
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)
|
||||
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
|
||||
return engine.NewNodeRegistry(repo, audit, 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 cluster.",
|
||||
Long: "Join, leave, or list orca nodes in the registry.",
|
||||
}
|
||||
|
||||
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.",
|
||||
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 {
|
||||
return notImplemented("orca node join")
|
||||
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",
|
||||
Short: "Remove a node from the orca cluster",
|
||||
Long: "Deregister a node from the orca cluster. Implemented in Phase 2.",
|
||||
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 {
|
||||
return notImplemented("orca node leave")
|
||||
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 cluster",
|
||||
Long: "Display all registered nodes. Implemented in Phase 2.",
|
||||
Short: "List all nodes in the orca registry",
|
||||
Long: "Display all registered nodes and their state.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return notImplemented("orca node list")
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// Audit wraps a slog.Logger and persists structured audit entries to SQLite.
|
||||
type Audit struct {
|
||||
repo *store.AuditRepo
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewAudit(repo *store.AuditRepo, log *slog.Logger) *Audit {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Audit{repo: repo, log: log}
|
||||
}
|
||||
|
||||
func (a *Audit) Record(ctx context.Context, actor, action, resource, result string, err error, meta map[string]any) {
|
||||
entry := &store.AuditEntry{
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Result: result,
|
||||
Metadata: meta,
|
||||
}
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
if persistErr := a.repo.Append(ctx, entry); persistErr != nil {
|
||||
a.log.Error("audit persist failed",
|
||||
slog.String("action", action),
|
||||
slog.String("resource", resource),
|
||||
slog.String("error", persistErr.Error()))
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("actor", actor),
|
||||
slog.String("action", action),
|
||||
slog.String("resource", resource),
|
||||
slog.String("result", result),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
a.log.Warn("audit", attrs...)
|
||||
} else {
|
||||
a.log.Info("audit", attrs...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
jobs *store.JobRepo
|
||||
tasks *store.TaskRepo
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewExecutor(jobs *store.JobRepo, tasks *store.TaskRepo, log *slog.Logger) *Executor {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Executor{jobs: jobs, tasks: tasks, log: log}
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
Env []string
|
||||
}
|
||||
|
||||
func (e *Executor) Run(ctx context.Context, job *model.Job, specs []TaskSpec) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Insert the job first so tasks can reference it via foreign key.
|
||||
if err := e.jobs.Insert(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusRunning, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
failedCount int
|
||||
exitCode int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for _, ts := range specs {
|
||||
wg.Add(1)
|
||||
go func(ts TaskSpec) {
|
||||
defer wg.Done()
|
||||
if err := e.runOne(ctx, job, ts); err != nil {
|
||||
mu.Lock()
|
||||
failedCount++
|
||||
e.log.Error("task failed",
|
||||
slog.String("job_id", job.ID),
|
||||
slog.String("task", ts.Name),
|
||||
slog.String("error", err.Error()))
|
||||
mu.Unlock()
|
||||
}
|
||||
}(ts)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if failedCount > 0 {
|
||||
exitCode = 1
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusFailed, exitCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%d/%d tasks failed", failedCount, len(specs))
|
||||
}
|
||||
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusComplete, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Executor) runOne(ctx context.Context, job *model.Job, ts TaskSpec) error {
|
||||
task := &model.Task{
|
||||
ID: uuid.NewString(),
|
||||
JobID: job.ID,
|
||||
Command: ts.Command,
|
||||
Args: ts.Args,
|
||||
Env: ts.Env,
|
||||
Status: model.TaskStatusPending,
|
||||
}
|
||||
if err := e.tasks.Insert(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ts.Command, ts.Args...)
|
||||
cmd.Env = append(cmd.Environ(), ts.Env...)
|
||||
// WaitDelay (Go 1.25+) bounds the time spent waiting on a child process
|
||||
// that fails to exit after the context is canceled.
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = e.tasks.UpdateKilled(ctx, task.ID)
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
if err := e.tasks.UpdateRunning(ctx, task.ID, cmd.Process.Pid); err != nil {
|
||||
e.log.Warn("update running failed", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
e.log.Info("task started",
|
||||
slog.String("job_id", job.ID),
|
||||
slog.String("task", ts.Name),
|
||||
slog.Int("pid", cmd.Process.Pid))
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
_ = e.tasks.UpdateDone(ctx, task.ID, exitCode, stdout.String(), stderr.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// WaitDelay (set above) gives the process a grace period to exit
|
||||
// cleanly before being killed.
|
||||
_ = e.tasks.UpdateKilled(ctx, task.ID)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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
|
||||
audit *Audit
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewNodeRegistry(repo *store.NodeRepo, audit *Audit, log *slog.Logger) *NodeRegistry {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &NodeRegistry{repo: repo, audit: audit, log: log}
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
if err := r.repo.Insert(ctx, n); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "failure", err, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
return fmt.Errorf("join node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "success", nil, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
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 {
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "failure", err, nil)
|
||||
return fmt.Errorf("leave node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "success", nil, nil)
|
||||
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 {
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "failure", err, nil)
|
||||
return fmt.Errorf("forget node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "success", nil, nil)
|
||||
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,69 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
Job JobSpec `hcl:"job,block"`
|
||||
Tasks []TaskSpec `hcl:"task,block"`
|
||||
}
|
||||
|
||||
type JobSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Type string `hcl:"type,optional"`
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Command string `hcl:"command"`
|
||||
Args []string `hcl:"args,optional"`
|
||||
Env []string `hcl:"env,optional"`
|
||||
}
|
||||
|
||||
func ParseFile(path string) (*Spec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
return Parse(data, path)
|
||||
}
|
||||
|
||||
func Parse(data []byte, filename string) (*Spec, error) {
|
||||
var spec Spec
|
||||
err := hclsimple.Decode(filename, data, nil, &spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode hcl: %w", err)
|
||||
}
|
||||
if spec.Job.Name == "" {
|
||||
return nil, fmt.Errorf("spec missing job name")
|
||||
}
|
||||
if len(spec.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("spec must have at least one task")
|
||||
}
|
||||
for i, t := range spec.Tasks {
|
||||
if t.Command == "" {
|
||||
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
||||
}
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
func (s *Spec) Validate() error {
|
||||
if strings.TrimSpace(s.Job.Name) == "" {
|
||||
return fmt.Errorf("job name is required")
|
||||
}
|
||||
if len(s.Tasks) == 0 {
|
||||
return fmt.Errorf("at least one task is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = hcl.Diagnostics{}
|
||||
var _ = gohcl.DecodeBody
|
||||
@@ -0,0 +1,60 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseValid(t *testing.T) {
|
||||
hcl := `
|
||||
job "demo" {
|
||||
}
|
||||
|
||||
task "build" {
|
||||
command = "/bin/echo"
|
||||
args = ["hello", "world"]
|
||||
}
|
||||
`
|
||||
spec, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if spec.Job.Name != "demo" {
|
||||
t.Errorf("expected job name 'demo', got %q", spec.Job.Name)
|
||||
}
|
||||
if len(spec.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(spec.Tasks))
|
||||
}
|
||||
if spec.Tasks[0].Command != "/bin/echo" {
|
||||
t.Errorf("expected command '/bin/echo', got %q", spec.Tasks[0].Command)
|
||||
}
|
||||
if len(spec.Tasks[0].Args) != 2 {
|
||||
t.Errorf("expected 2 args, got %d", len(spec.Tasks[0].Args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingJob(t *testing.T) {
|
||||
hcl := `task "x" { command = "/bin/echo" }`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing job name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoTasks(t *testing.T) {
|
||||
hcl := `job "empty" {}`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskMissingCommand(t *testing.T) {
|
||||
hcl := `
|
||||
job "x" {}
|
||||
task "no-cmd" {}
|
||||
`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing command")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "pending"
|
||||
JobStatusRunning JobStatus = "running"
|
||||
JobStatusComplete JobStatus = "complete"
|
||||
JobStatusFailed JobStatus = "failed"
|
||||
JobStatusStopped JobStatus = "stopped"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Spec string `json:"spec"`
|
||||
Status JobStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending"
|
||||
TaskStatusRunning TaskStatus = "running"
|
||||
TaskStatusComplete TaskStatus = "complete"
|
||||
TaskStatusFailed TaskStatus = "failed"
|
||||
TaskStatusKilled TaskStatus = "killed"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
PID int `json:"pid"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Status TaskStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
}
|
||||
@@ -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,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AuditRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuditRepo(db *sql.DB) *AuditRepo {
|
||||
return &AuditRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
}
|
||||
if e.Actor == "" {
|
||||
e.Actor = "system"
|
||||
}
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
if e.Error == "" {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit (with error): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, '') FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []*AuditEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e AuditEntry
|
||||
metaJSON string
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &e.Timestamp, &e.Actor, &e.Action, &e.Resource, &e.Result, &e.Error, &metaJSON); err != nil {
|
||||
return nil, fmt.Errorf("scan audit: %w", err)
|
||||
}
|
||||
if metaJSON != "" {
|
||||
_ = json.Unmarshal([]byte(metaJSON), &e.Metadata)
|
||||
}
|
||||
entries = append(entries, &e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openAuditTestDB(t *testing.T) (*AuditRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
return NewAuditRepo(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestAuditRepo_AppendAndList(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
err := repo.Append(ctx, &AuditEntry{
|
||||
Actor: "cli",
|
||||
Action: "node.join",
|
||||
Resource: "node-1",
|
||||
Result: "success",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
entries, err := repo.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(entries) != 5 {
|
||||
t.Errorf("expected 5 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRepo_WithError(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
err := repo.Append(ctx, &AuditEntry{
|
||||
Actor: "system",
|
||||
Action: "task.run",
|
||||
Resource: "task-1",
|
||||
Result: "failure",
|
||||
Error: "exit status 1",
|
||||
Metadata: map[string]any{"exit_code": 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
entries, _ := repo.List(ctx, 1)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Error != "exit status 1" {
|
||||
t.Errorf("expected error 'exit status 1', got %q", entries[0].Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
)
|
||||
|
||||
type JobRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepo(db *sql.DB) *JobRepo {
|
||||
return &JobRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *JobRepo) Insert(ctx context.Context, j *model.Job) error {
|
||||
if j.CreatedAt.IsZero() {
|
||||
j.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if j.Status == "" {
|
||||
j.Status = model.JobStatusPending
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO jobs (id, name, spec, status, exit_code, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
j.ID, j.Name, j.Spec, string(j.Status), j.ExitCode, j.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobRepo) Get(ctx context.Context, id string) (*model.Job, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs WHERE id = ?`, id)
|
||||
return scanJob(row)
|
||||
}
|
||||
|
||||
func (r *JobRepo) List(ctx context.Context) ([]*model.Job, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var jobs []*model.Job
|
||||
for rows.Next() {
|
||||
j, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *JobRepo) UpdateStatus(ctx context.Context, id string, status model.JobStatus, exitCode int) error {
|
||||
now := time.Now().UTC()
|
||||
var startedAt, endedAt *time.Time
|
||||
switch status {
|
||||
case model.JobStatusRunning:
|
||||
startedAt = &now
|
||||
case model.JobStatusComplete, model.JobStatusFailed, model.JobStatusStopped:
|
||||
endedAt = &now
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE jobs SET status = ?, exit_code = ?, started_at = COALESCE(?, started_at), ended_at = COALESCE(?, ended_at) WHERE id = ?`,
|
||||
string(status), exitCode, startedAt, endedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanJob(s scanner) (*model.Job, error) {
|
||||
var (
|
||||
j model.Job
|
||||
status string
|
||||
startedAt sql.NullTime
|
||||
endedAt sql.NullTime
|
||||
)
|
||||
err := s.Scan(&j.ID, &j.Name, &j.Spec, &status, &j.ExitCode, &j.CreatedAt, &startedAt, &endedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan job: %w", err)
|
||||
}
|
||||
j.Status = model.JobStatus(status)
|
||||
if startedAt.Valid {
|
||||
j.StartedAt = &startedAt.Time
|
||||
}
|
||||
if endedAt.Valid {
|
||||
j.EndedAt = &endedAt.Time
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
type TaskRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTaskRepo(db *sql.DB) *TaskRepo {
|
||||
return &TaskRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *TaskRepo) Insert(ctx context.Context, t *model.Task) error {
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = model.TaskStatusPending
|
||||
}
|
||||
argsJSON, _ := json.Marshal(t.Args)
|
||||
envJSON, _ := json.Marshal(t.Env)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (id, job_id, command, args, env, pid, exit_code, status, created_at, stdout, stderr)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.JobID, t.Command, string(argsJSON), string(envJSON),
|
||||
t.PID, t.ExitCode, string(t.Status), t.CreatedAt, t.Stdout, t.Stderr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) Get(ctx context.Context, id string) (*model.Task, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE id = ?`, id)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func (r *TaskRepo) ListByJob(ctx context.Context, jobID string) ([]*model.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE job_id = ? ORDER BY created_at ASC`, jobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var tasks []*model.Task
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateRunning(ctx context.Context, id string, pid int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET pid = ?, status = ?, started_at = ? WHERE id = ?`,
|
||||
pid, string(model.TaskStatusRunning), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task running: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateDone(ctx context.Context, id string, exitCode int, stdout, stderr string) error {
|
||||
now := time.Now().UTC()
|
||||
status := model.TaskStatusComplete
|
||||
if exitCode != 0 {
|
||||
status = model.TaskStatusFailed
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET status = ?, exit_code = ?, ended_at = ?, stdout = ?, stderr = ? WHERE id = ?`,
|
||||
string(status), exitCode, now, stdout, stderr, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task done: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateKilled(ctx context.Context, id string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET status = ?, ended_at = ? WHERE id = ?`,
|
||||
string(model.TaskStatusKilled), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task killed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = errors.New
|
||||
var _ = json.Marshal
|
||||
|
||||
func scanTask(s scanner) (*model.Task, error) {
|
||||
var (
|
||||
t model.Task
|
||||
status string
|
||||
argsJSON string
|
||||
envJSON string
|
||||
startedAt sql.NullTime
|
||||
endedAt sql.NullTime
|
||||
)
|
||||
err := s.Scan(&t.ID, &t.JobID, &t.Command, &argsJSON, &envJSON,
|
||||
&t.PID, &t.ExitCode, &status, &t.CreatedAt, &startedAt, &endedAt, &t.Stdout, &t.Stderr)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan task: %w", err)
|
||||
}
|
||||
t.Status = model.TaskStatus(status)
|
||||
if startedAt.Valid {
|
||||
t.StartedAt = &startedAt.Time
|
||||
}
|
||||
if endedAt.Valid {
|
||||
t.EndedAt = &endedAt.Time
|
||||
}
|
||||
_ = json.Unmarshal([]byte(argsJSON), &t.Args)
|
||||
_ = json.Unmarshal([]byte(envJSON), &t.Env)
|
||||
return &t, nil
|
||||
}
|
||||
@@ -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,34 @@
|
||||
-- Jobs and tasks
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
spec TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
exit_code INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
started_at DATETIME,
|
||||
ended_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
args TEXT NOT NULL DEFAULT '[]',
|
||||
env TEXT NOT NULL DEFAULT '[]',
|
||||
pid INTEGER NOT NULL DEFAULT 0,
|
||||
exit_code INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL,
|
||||
started_at DATETIME,
|
||||
ended_at DATETIME,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_job ON tasks(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Audit log for security-first observability
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
error TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_log(resource);
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,20 @@
|
||||
|
||||
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:-}"
|
||||
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
job "failing-job" {
|
||||
}
|
||||
|
||||
task "fail" {
|
||||
command = "/bin/sh"
|
||||
args = ["-c", "echo oops 1>&2; exit 1"]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
job "hello-orca" {
|
||||
}
|
||||
|
||||
task "greet" {
|
||||
command = "/bin/echo"
|
||||
args = ["hello", "from", "orca"]
|
||||
}
|
||||
Reference in New Issue
Block a user