diff --git a/README.md b/README.md index 7d649d5..3ff7b09 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,29 @@ Orchard is an orchestration system for [Tart](https://github.com/cirruslabs/tart). Create a cluster of bare-metal Apple Silicon machines and manage dozens of VMs with ease! + +## Installation + +``` +go install github.com/cirruslabs/orchard/...@latest +``` + +## Quick start + +Start the Orchard Controller and the Worker in a single inocation: + +```shell +orchard dev +``` + +Create a Virtual Machine resource: + +```shell +orchard create vm --image ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base +``` + +Check a list of VM resources to see if the Virtual Machine we've created above is already running: + +```shell +orchard list vms +``` diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..0b31bee --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,148 @@ +openapi: 3.0.0 +info: + title: Orchard + description: Orchard orchestration API + version: 0.1.0 +paths: + /workers: + post: + summary: "Create a Worker" + tags: + - workers + responses: + '200': + description: Worker resource was successfully created + content: + application/json: + schema: + $ref: '#components/schemas/Worker' + '409': + description: Worker resource with with the same name already exists + get: + summary: "List Workers" + tags: + - workers + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#components/schemas/Worker' + /workers/{name}: + get: + summary: "Retrieve a Worker" + tags: + - workers + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#components/schemas/Worker' + '404': + description: Worker resource with the given name doesn't exist + put: + summary: "Update a Worker" + tags: + - workers + responses: + '200': + description: Worker object was successfully updated + content: + application/json: + schema: + $ref: '#components/schemas/Worker' + '404': + description: Worker resource with the given name doesn't exist + delete: + summary: "Delete a Worker" + tags: + - workers + responses: + '200': + description: Worker resource was successfully deleted + '404': + description: Worker resource with the given name doesn't exist + /vms: + post: + summary: "Create a VM" + tags: + - vms + responses: + '200': + description: VM resource was successfully created + content: + application/json: + schema: + $ref: '#components/schemas/VM' + '409': + description: VM resource with with the same name already exists + get: + summary: "List VMs" + tags: + - vms + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#components/schemas/VM' + /vms/{name}: + get: + summary: "Retrieve a VM" + tags: + - vms + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#components/schemas/VM' + '404': + description: VM resource with the given name doesn't exist + put: + summary: "Update a VM" + tags: + - vms + responses: + '200': + description: VM object was successfully updated + content: + application/json: + schema: + $ref: '#components/schemas/VM' + '404': + description: VM resource with the given name doesn't exist + delete: + summary: "Delete a VM" + tags: + - vms + responses: + '200': + description: VM resource was successfully deleted + '404': + description: VM resource with the given name doesn't exist +components: + schemas: + Worker: + title: Worker node + type: object + properties: + name: + type: string + description: Node name + VM: + title: Virtual Machine + type: object + properties: + name: + type: string + description: VM name diff --git a/cmd/orchard/main.go b/cmd/orchard/main.go new file mode 100644 index 0000000..d2891c1 --- /dev/null +++ b/cmd/orchard/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "github.com/cirruslabs/orchard/internal/command" + "log" + "os" + "os/signal" +) + +func main() { + // Set up a signal-interruptible context + ctx, cancel := context.WithCancel(context.Background()) + + interruptCh := make(chan os.Signal, 1) + signal.Notify(interruptCh, os.Interrupt) + + go func() { + select { + case <-interruptCh: + cancel() + case <-ctx.Done(): + } + }() + + // Run the command + if err := command.NewRootCmd().ExecuteContext(ctx); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e05dc09 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module github.com/cirruslabs/orchard + +go 1.19 + +require ( + github.com/dgraph-io/badger/v3 v3.2103.5 + github.com/dustin/go-humanize v1.0.0 + github.com/gin-gonic/gin v1.8.2 + github.com/google/uuid v1.3.0 + github.com/gosuri/uitable v0.0.4 + github.com/spf13/cobra v1.6.0 + go.uber.org/zap v1.24.0 +) + +require ( + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.11.1 // indirect + github.com/goccy/go-json v0.9.11 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/snappy v0.0.3 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.12.3 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/ugorji/go/codec v1.2.7 // indirect + go.opencensus.io v0.22.5 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/net v0.5.0 // indirect + golang.org/x/sys v0.4.0 // indirect + golang.org/x/text v0.6.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a4affb --- /dev/null +++ b/go.sum @@ -0,0 +1,254 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= +github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY= +github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= +github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +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/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/command/controller/controller.go b/internal/command/controller/controller.go new file mode 100644 index 0000000..d64ec90 --- /dev/null +++ b/internal/command/controller/controller.go @@ -0,0 +1,29 @@ +package controller + +import ( + "github.com/cirruslabs/orchard/internal/orchardhome" + "github.com/spf13/cobra" + "log" + "path/filepath" +) + +var dataDir string + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "controller", + Short: "Initialize and run a controller on the local machine", + } + + command.AddCommand(newRunCommand()) + + orchardHome, err := orchardhome.Path() + if err != nil { + log.Fatal(err) + } + + command.PersistentFlags().StringVar(&dataDir, "data-dir", filepath.Join(orchardHome, "controller"), + "path to the data directory") + + return command +} diff --git a/internal/command/controller/run.go b/internal/command/controller/run.go new file mode 100644 index 0000000..db6f0ac --- /dev/null +++ b/internal/command/controller/run.go @@ -0,0 +1,34 @@ +package controller + +import ( + "github.com/cirruslabs/orchard/internal/controller" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +func newRunCommand() *cobra.Command { + return &cobra.Command{ + Use: "run", + RunE: runController, + } +} + +func runController(cmd *cobra.Command, args []string) (err error) { + // Initialize the logger + logger, err := zap.NewProduction() + if err != nil { + return err + } + defer func() { + if syncErr := logger.Sync(); syncErr != nil && err == nil { + err = syncErr + } + }() + + controller, err := controller.New(controller.WithDataDir(dataDir), controller.WithLogger(logger)) + if err != nil { + return err + } + + return controller.Run(cmd.Context()) +} diff --git a/internal/command/create/create.go b/internal/command/create/create.go new file mode 100644 index 0000000..b41c584 --- /dev/null +++ b/internal/command/create/create.go @@ -0,0 +1,16 @@ +package create + +import ( + "github.com/spf13/cobra" +) + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "create", + Short: "Create resources on the controller (VMs)", + } + + command.AddCommand(newCreateVMCommand()) + + return command +} diff --git a/internal/command/create/vm.go b/internal/command/create/vm.go new file mode 100644 index 0000000..95d73b0 --- /dev/null +++ b/internal/command/create/vm.go @@ -0,0 +1,49 @@ +package create + +import ( + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/spf13/cobra" +) + +var image string +var cpu uint64 +var memory uint64 +var softnet bool +var headless bool + +func newCreateVMCommand() *cobra.Command { + command := &cobra.Command{ + Use: "vm", + RunE: runCreateVM, + Args: cobra.ExactArgs(1), + } + + command.PersistentFlags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-ventura-base:latest", "image to use") + command.PersistentFlags().Uint64Var(&cpu, "cpu", 4, "number of CPUs to use") + command.PersistentFlags().Uint64Var(&memory, "memory", 8, "gigabytes of memory to use") + command.PersistentFlags().BoolVar(&softnet, "softnet", false, "whether to use Softnet network isolation") + command.PersistentFlags().BoolVar(&headless, "headless", true, "whether to run without graphics") + + return command +} + +func runCreateVM(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + return client.VMs().Create(cmd.Context(), &v1.VM{ + Meta: v1.Meta{ + Name: name, + }, + Image: image, + CPU: cpu, + Memory: memory, + Softnet: softnet, + Headless: headless, + }) +} diff --git a/internal/command/delete/delete.go b/internal/command/delete/delete.go new file mode 100644 index 0000000..7b5f856 --- /dev/null +++ b/internal/command/delete/delete.go @@ -0,0 +1,16 @@ +package delete + +import ( + "github.com/spf13/cobra" +) + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "delete", + Short: "Delete resources from the controller (VMs)", + } + + command.AddCommand(newDeleteVMCommand()) + + return command +} diff --git a/internal/command/delete/vm.go b/internal/command/delete/vm.go new file mode 100644 index 0000000..63add87 --- /dev/null +++ b/internal/command/delete/vm.go @@ -0,0 +1,25 @@ +package delete + +import ( + "github.com/cirruslabs/orchard/pkg/client" + "github.com/spf13/cobra" +) + +func newDeleteVMCommand() *cobra.Command { + return &cobra.Command{ + Use: "vm", + Args: cobra.ExactArgs(1), + RunE: runDeleteVM, + } +} + +func runDeleteVM(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + return client.VMs().Delete(cmd.Context(), name, false) +} diff --git a/internal/command/dev/dev.go b/internal/command/dev/dev.go new file mode 100644 index 0000000..148bedd --- /dev/null +++ b/internal/command/dev/dev.go @@ -0,0 +1,63 @@ +package dev + +import ( + "github.com/cirruslabs/orchard/internal/controller" + "github.com/cirruslabs/orchard/internal/worker" + "github.com/spf13/cobra" + "go.uber.org/zap" + "os" +) + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "dev", + Short: "Run a controller and a worker for development purposes", + RunE: runDev, + } + + return command +} + +func runDev(cmd *cobra.Command, args []string) error { + tempDir, err := os.MkdirTemp("", "") + if err != nil { + return err + } + + // Initialize the logger + logger, err := zap.NewDevelopment() + if err != nil { + return err + } + defer func() { + if syncErr := logger.Sync(); syncErr != nil && err == nil { + err = syncErr + } + }() + + controller, err := controller.New(controller.WithDataDir(tempDir), controller.WithLogger(logger)) + if err != nil { + return err + } + + worker, err := worker.New(worker.WithDataDir(tempDir), worker.WithLogger(logger)) + if err != nil { + return err + } + + errChan := make(chan error, 2) + + go func() { + if err := controller.Run(cmd.Context()); err != nil { + errChan <- err + } + }() + + go func() { + if err := worker.Run(cmd.Context()); err != nil { + errChan <- err + } + }() + + return <-errChan +} diff --git a/internal/command/list/list.go b/internal/command/list/list.go new file mode 100644 index 0000000..7624e33 --- /dev/null +++ b/internal/command/list/list.go @@ -0,0 +1,20 @@ +package list + +import ( + "github.com/spf13/cobra" +) + +var quiet bool + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "list", + Short: "List resources on the controller (workers, VMs)", + } + + command.AddCommand(newListWorkersCommand(), newListVMsCommand()) + + command.PersistentFlags().BoolVarP(&quiet, "", "q", false, "only show resource names") + + return command +} diff --git a/internal/command/list/vms.go b/internal/command/list/vms.go new file mode 100644 index 0000000..8692d32 --- /dev/null +++ b/internal/command/list/vms.go @@ -0,0 +1,49 @@ +package list + +import ( + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" +) + +func newListVMsCommand() *cobra.Command { + command := &cobra.Command{ + Use: "vms", + RunE: runListVMs, + } + + return command +} + +func runListVMs(cmd *cobra.Command, args []string) error { + client, err := client.New() + if err != nil { + return err + } + + vms, err := client.VMs().List(cmd.Context()) + if err != nil { + return err + } + + if quiet { + for _, vm := range vms { + fmt.Println(vm.Name) + } + + return nil + } + + table := uitable.New() + + table.AddRow("Name", "Image", "Status") + + for _, vm := range vms { + table.AddRow(vm.Name, vm.Image, vm.Status) + } + + fmt.Println(table) + + return nil +} diff --git a/internal/command/list/workers.go b/internal/command/list/workers.go new file mode 100644 index 0000000..875c691 --- /dev/null +++ b/internal/command/list/workers.go @@ -0,0 +1,51 @@ +package list + +import ( + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/dustin/go-humanize" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" +) + +func newListWorkersCommand() *cobra.Command { + command := &cobra.Command{ + Use: "workers", + Short: "List workers", + RunE: runListWorkers, + } + + return command +} + +func runListWorkers(cmd *cobra.Command, args []string) error { + client, err := client.New() + if err != nil { + return err + } + + workers, err := client.Workers().List(cmd.Context()) + if err != nil { + return err + } + + if quiet { + for _, worker := range workers { + fmt.Println(worker.Name) + } + + return nil + } + + table := uitable.New() + + table.AddRow("Name", "Last seen") + + for _, worker := range workers { + table.AddRow(worker.Name, humanize.Time(worker.LastSeen)) + } + + fmt.Println(table) + + return nil +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..a1836a6 --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,46 @@ +package command + +import ( + "github.com/cirruslabs/orchard/internal/command/controller" + "github.com/cirruslabs/orchard/internal/command/create" + deletepkg "github.com/cirruslabs/orchard/internal/command/delete" + "github.com/cirruslabs/orchard/internal/command/dev" + "github.com/cirruslabs/orchard/internal/command/list" + "github.com/cirruslabs/orchard/internal/command/worker" + "github.com/spf13/cobra" +) + +func NewRootCmd() *cobra.Command { + command := &cobra.Command{ + Use: "orchard", + SilenceUsage: true, + SilenceErrors: true, + } + + addGroupedCommands(command, "Working With Resources:", + create.NewCommand(), + list.NewCommand(), + deletepkg.NewCommand(), + ) + + addGroupedCommands(command, "Administrative Tasks:", + controller.NewCommand(), + worker.NewCommand(), + dev.NewCommand(), + ) + + return command +} + +func addGroupedCommands(parent *cobra.Command, title string, commands ...*cobra.Command) { + group := &cobra.Group{ + ID: title, + Title: title, + } + parent.AddGroup(group) + + for _, command := range commands { + command.GroupID = group.ID + parent.AddCommand(command) + } +} diff --git a/internal/command/worker/run.go b/internal/command/worker/run.go new file mode 100644 index 0000000..eec98f1 --- /dev/null +++ b/internal/command/worker/run.go @@ -0,0 +1,34 @@ +package worker + +import ( + "github.com/cirruslabs/orchard/internal/worker" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +func newRunCommand() *cobra.Command { + return &cobra.Command{ + Use: "run", + RunE: runWorker, + } +} + +func runWorker(cmd *cobra.Command, args []string) (err error) { + // Initialize the logger + logger, err := zap.NewProduction() + if err != nil { + return err + } + defer func() { + if syncErr := logger.Sync(); syncErr != nil && err == nil { + err = syncErr + } + }() + + worker, err := worker.New(worker.WithDataDir(dataDir), worker.WithLogger(logger)) + if err != nil { + return err + } + + return worker.Run(cmd.Context()) +} diff --git a/internal/command/worker/worker.go b/internal/command/worker/worker.go new file mode 100644 index 0000000..512a0b9 --- /dev/null +++ b/internal/command/worker/worker.go @@ -0,0 +1,29 @@ +package worker + +import ( + "github.com/cirruslabs/orchard/internal/orchardhome" + "github.com/spf13/cobra" + "log" + "path/filepath" +) + +var dataDir string + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "worker", + Short: "Run a worker on the local machine", + } + + command.AddCommand(newRunCommand()) + + orchardHome, err := orchardhome.Path() + if err != nil { + log.Fatal(err) + } + + command.PersistentFlags().StringVar(&dataDir, "data-dir", filepath.Join(orchardHome, "worker"), + "path to the data directory") + + return command +} diff --git a/internal/controller/api.go b/internal/controller/api.go new file mode 100644 index 0000000..7c6b58c --- /dev/null +++ b/internal/controller/api.go @@ -0,0 +1,77 @@ +package controller + +import ( + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/internal/responder" + "github.com/gin-gonic/gin" + "net/http" +) + +type storeTxFunc func(cb func(txn *storepkg.Txn) error) error +type apiTxFunc func(txn *storepkg.Txn) responder.Responder + +func (controller *Controller) initAPI() *gin.Engine { + gin.SetMode(gin.DebugMode) + ginEngine := gin.Default() + + // v1 API + v1 := ginEngine.Group("/v1") + + // Workers + v1.POST("/workers", func(c *gin.Context) { + controller.createWorker(c).Respond(c) + }) + v1.PUT("/workers/:name", func(c *gin.Context) { + controller.updateWorker(c).Respond(c) + }) + v1.GET("/workers/:name", func(c *gin.Context) { + controller.getWorker(c).Respond(c) + }) + v1.GET("/workers", func(c *gin.Context) { + controller.listWorkers(c).Respond(c) + }) + v1.DELETE("/workers/:name", func(c *gin.Context) { + controller.deleteWorker(c).Respond(c) + }) + + // VMs + v1.POST("/vms", func(c *gin.Context) { + controller.createVM(c).Respond(c) + }) + v1.PUT("/vms/:name", func(c *gin.Context) { + controller.updateVM(c).Respond(c) + }) + v1.GET("/vms/:name", func(c *gin.Context) { + controller.getVM(c).Respond(c) + }) + v1.GET("/vms", func(c *gin.Context) { + controller.listVMs(c).Respond(c) + }) + v1.DELETE("/vms/:name", func(c *gin.Context) { + controller.deleteVM(c).Respond(c) + }) + + return ginEngine +} + +func (controller *Controller) storeView(cb apiTxFunc) responder.Responder { + return mapTxFuncs(controller.store.View, cb) +} + +func (controller *Controller) storeUpdate(cb apiTxFunc) responder.Responder { + return mapTxFuncs(controller.store.Update, cb) +} + +func mapTxFuncs(txFunc storeTxFunc, cb apiTxFunc) responder.Responder { + var result responder.Responder + + if err := txFunc(func(txn *storepkg.Txn) error { + result = cb(txn) + + return nil + }); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return result +} diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go new file mode 100644 index 0000000..0d35536 --- /dev/null +++ b/internal/controller/api_vms.go @@ -0,0 +1,145 @@ +package controller + +import ( + "errors" + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/internal/responder" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "net/http" + "time" +) + +func (controller *Controller) createVM(ctx *gin.Context) responder.Responder { + var vm v1.VM + + if err := ctx.ShouldBindJSON(&vm); err != nil { + return responder.Code(http.StatusBadRequest) + } + + if vm.Name == "" || vm.Image == "" || vm.CPU == 0 || vm.Memory == 0 { + return responder.Code(http.StatusPreconditionFailed) + } + + vm.Status = v1.VMStatusPending + vm.CreatedAt = time.Now() + vm.DeletedAt = time.Time{} + vm.UID = uuid.New().String() + vm.Generation = 0 + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + // Does the VM resource with this name already exists? + _, err := txn.GetVM(vm.Name) + if !errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusConflict) + } + + if err := txn.SetVM(&vm); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &vm) + }) +} + +func (controller *Controller) updateVM(ctx *gin.Context) responder.Responder { + var userVM v1.VM + + if err := ctx.ShouldBindJSON(&userVM); err != nil { + return responder.Code(http.StatusBadRequest) + } + + if userVM.Name == "" { + return responder.Code(http.StatusPreconditionFailed) + } + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + dbVM, err := txn.GetVM(userVM.Name) + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + dbVM.Status = userVM.Status + dbVM.Generation++ + + if err := txn.SetVM(dbVM); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &dbVM) + }) +} + +func (controller *Controller) getVM(ctx *gin.Context) responder.Responder { + name := ctx.Param("name") + + return controller.storeView(func(txn *storepkg.Txn) responder.Responder { + vm, err := txn.GetVM(name) + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &vm) + }) +} + +func (controller *Controller) listVMs(ctx *gin.Context) responder.Responder { + return controller.storeView(func(txn *storepkg.Txn) responder.Responder { + vms, err := txn.ListVMs() + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &vms) + }) +} + +func (controller *Controller) deleteVM(ctx *gin.Context) responder.Responder { + name := ctx.Param("name") + + if ctx.Query("force") != "" { + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + if err := txn.DeleteVM(name); err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.Code(http.StatusOK) + }) + } + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + vm, err := txn.GetVM(name) + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + vm.DeletedAt = time.Now() + + if err := txn.SetVM(vm); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.Code(http.StatusOK) + }) +} diff --git a/internal/controller/api_workers.go b/internal/controller/api_workers.go new file mode 100644 index 0000000..03b982e --- /dev/null +++ b/internal/controller/api_workers.go @@ -0,0 +1,123 @@ +package controller + +import ( + "errors" + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/internal/responder" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "net/http" + "time" +) + +func (controller *Controller) createWorker(ctx *gin.Context) responder.Responder { + var worker v1.Worker + + if err := ctx.ShouldBindJSON(&worker); err != nil { + return responder.Code(http.StatusBadRequest) + } + + if worker.Name == "" { + return responder.Code(http.StatusPreconditionFailed) + } + + currentTime := time.Now() + if worker.LastSeen.IsZero() { + worker.LastSeen = currentTime + } + worker.CreatedAt = currentTime + worker.DeletedAt = time.Time{} + worker.UID = uuid.New().String() + worker.Generation = 0 + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + // Does the worker resource with this name already exists? + _, err := txn.GetWorker(worker.Name) + if !errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusConflict) + } + + if err := txn.SetWorker(&worker); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(200, &worker) + }) +} + +func (controller *Controller) updateWorker(ctx *gin.Context) responder.Responder { + var userWorker v1.Worker + + if err := ctx.ShouldBindJSON(&userWorker); err != nil { + return responder.Code(http.StatusBadRequest) + } + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + dbWorker, err := txn.GetWorker(userWorker.Name) + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + dbWorker.LastSeen = userWorker.LastSeen + dbWorker.Generation++ + + if err := txn.SetWorker(dbWorker); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(200, &dbWorker) + }) +} + +func (controller *Controller) getWorker(ctx *gin.Context) responder.Responder { + name := ctx.Param("name") + + return controller.storeView(func(txn *storepkg.Txn) responder.Responder { + worker, err := txn.GetWorker(name) + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &worker) + }) +} + +func (controller *Controller) listWorkers(ctx *gin.Context) responder.Responder { + return controller.storeView(func(txn *storepkg.Txn) responder.Responder { + workers, err := txn.ListWorkers() + if err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &workers) + }) +} + +func (controller *Controller) deleteWorker(ctx *gin.Context) responder.Responder { + name := ctx.Param("name") + + return controller.storeUpdate(func(txn *storepkg.Txn) responder.Responder { + if err := txn.DeleteWorker(name); err != nil { + if errors.Is(err, storepkg.ErrNotFound) { + return responder.Code(http.StatusNotFound) + } + + return responder.Code(http.StatusInternalServerError) + } + + return responder.Code(http.StatusOK) + }) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go new file mode 100644 index 0000000..26fa1e6 --- /dev/null +++ b/internal/controller/controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "context" + "crypto/tls" + "fmt" + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "go.uber.org/zap" + "net" + "net/http" +) + +type Controller struct { + dataDir string + listenAddr string + tlsConfig *tls.Config + listener net.Listener + httpServer *http.Server + store *storepkg.Store + logger *zap.SugaredLogger +} + +func New(opts ...Option) (*Controller, error) { + controller := &Controller{} + + // Apply options + for _, opt := range opts { + opt(controller) + } + + // Apply defaults + if controller.dataDir == "" { + return nil, fmt.Errorf("%w: please specify the data directory path with WithDataDir()") + } + if controller.listenAddr == "" { + controller.listenAddr = ":6120" + } + if controller.logger == nil { + controller.logger = zap.NewNop().Sugar() + } + + // Instantiate controller + store, err := storepkg.New(controller.dbPath()) + if err != nil { + return nil, err + } + controller.store = store + + listener, err := net.Listen("tcp", controller.listenAddr) + if err != nil { + return nil, err + } + if controller.tlsConfig != nil { + controller.listener = tls.NewListener(listener, controller.tlsConfig) + } else { + controller.listener = listener + } + + controller.httpServer = &http.Server{ + Handler: controller.initAPI(), + } + + return controller, nil +} + +func (controller *Controller) Run(ctx context.Context) error { + // Run the scheduler so that each VM will eventually + // be assigned to a specific Worker + go func() { + err := runScheduler(controller.store) + if err != nil { + panic(err) + } + }() + + // Run the janitor so that inactive workers + // will eventually be removed from the DB + go func() { + err := controller.runJanitor(controller.store) + if err != nil { + panic(err) + } + }() + + // A helper function to shut down the HTTP server on context cancellation + go func() { + <-ctx.Done() + controller.httpServer.Shutdown(ctx) + }() + + if err := controller.httpServer.Serve(controller.listener); err != nil { + return err + } + + return nil +} diff --git a/internal/controller/datadir.go b/internal/controller/datadir.go new file mode 100644 index 0000000..dcf70a3 --- /dev/null +++ b/internal/controller/datadir.go @@ -0,0 +1,15 @@ +package controller + +import "path/filepath" + +func (controller *Controller) dbPath() string { + return filepath.Join(controller.dataDir, "db") +} + +func (controller *Controller) caCertPath() string { + return filepath.Join(controller.dataDir, "ca.crt") +} + +func (controller *Controller) caKeyPath() string { + return filepath.Join(controller.dataDir, "ca.key") +} diff --git a/internal/controller/janitor.go b/internal/controller/janitor.go new file mode 100644 index 0000000..b03a6fe --- /dev/null +++ b/internal/controller/janitor.go @@ -0,0 +1,50 @@ +package controller + +import ( + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "time" +) + +const janitorInterval = 5 * time.Second + +func (controller *Controller) runJanitor(store *storepkg.Store) error { + ticker := time.Tick(janitorInterval) + + for { + if err := controller.runJanitorInner(store); err != nil { + return err + } + + <-ticker + } +} + +func (controller *Controller) runJanitorInner(store *storepkg.Store) error { + var workers []*v1.Worker + var err error + + err = store.View(func(txn *storepkg.Txn) error { + workers, err = txn.ListWorkers() + + return err + }) + if err != nil { + return err + } + + for _, worker := range workers { + if time.Now().Sub(worker.LastSeen).Minutes() > 1 { + controller.logger.Debugf("removing outdated worker %s", worker.Name) + + err := store.Update(func(txn *storepkg.Txn) error { + return txn.DeleteWorker(worker.Name) + }) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/controller/option.go b/internal/controller/option.go new file mode 100644 index 0000000..86569f6 --- /dev/null +++ b/internal/controller/option.go @@ -0,0 +1,32 @@ +package controller + +import ( + "crypto/tls" + "go.uber.org/zap" +) + +type Option func(*Controller) + +func WithDataDir(dataDir string) Option { + return func(controller *Controller) { + controller.dataDir = dataDir + } +} + +func WithListenAddr(listenAddr string) Option { + return func(controller *Controller) { + controller.listenAddr = listenAddr + } +} + +func WithTLSConfig(tlsConfig *tls.Config) Option { + return func(controller *Controller) { + controller.tlsConfig = tlsConfig + } +} + +func WithLogger(logger *zap.Logger) Option { + return func(controller *Controller) { + controller.logger = logger.Sugar() + } +} diff --git a/internal/controller/scheduler.go b/internal/controller/scheduler.go new file mode 100644 index 0000000..37bd9dd --- /dev/null +++ b/internal/controller/scheduler.go @@ -0,0 +1,72 @@ +package controller + +import ( + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "sort" + "time" +) + +const schedulerInterval = 5 * time.Second + +func runScheduler(store *storepkg.Store) error { + ticker := time.Tick(schedulerInterval) + + for { + if err := runSchedulerInner(store); err != nil { + return err + } + + <-ticker + } +} + +func runSchedulerInner(store *storepkg.Store) error { + var vms []*v1.VM + var workers []*v1.Worker + var err error + + err = store.View(func(txn *storepkg.Txn) error { + vms, err = txn.ListVMs() + if err != nil { + return err + } + + workers, err = txn.ListWorkers() + if err != nil { + return err + } + + return nil + }) + if err != nil { + return err + } + + // Sort VMs by date of creation + sort.Slice(vms, func(i, j int) bool { + return vms[i].CreatedAt.Before(vms[j].CreatedAt) + }) + + for _, vm := range vms { + if vm.Worker != "" { + continue + } + + // Find an appropriate worker to run this VM on + for _, worker := range workers { + vm.Worker = worker.Name + + err := store.Update(func(txn *storepkg.Txn) error { + return txn.SetVM(vm) + }) + if err != nil { + return err + } + + break + } + } + + return nil +} diff --git a/internal/controller/store/errors.go b/internal/controller/store/errors.go new file mode 100644 index 0000000..a44827d --- /dev/null +++ b/internal/controller/store/errors.go @@ -0,0 +1,20 @@ +package store + +import ( + "errors" + "fmt" + "github.com/dgraph-io/badger/v3" +) + +var ( + ErrNotFound = errors.New("DB entry not found") + ErrBadgerFailed = errors.New("BadgerDB failed") +) + +func mapErr(err error) error { + if errors.Is(err, badger.ErrKeyNotFound) { + return ErrNotFound + } + + return fmt.Errorf("%w: %v", ErrBadgerFailed, err) +} diff --git a/internal/controller/store/store.go b/internal/controller/store/store.go new file mode 100644 index 0000000..71f8a12 --- /dev/null +++ b/internal/controller/store/store.go @@ -0,0 +1,24 @@ +package store + +import ( + "github.com/dgraph-io/badger/v3" +) + +type Store struct { + db *badger.DB +} + +func New(dbPath string) (*Store, error) { + opts := badger.DefaultOptions(dbPath) + + opts.SyncWrites = true + + db, err := badger.Open(opts) + if err != nil { + return nil, err + } + + return &Store{ + db: db, + }, nil +} diff --git a/internal/controller/store/txn.go b/internal/controller/store/txn.go new file mode 100644 index 0000000..2f1cffd --- /dev/null +++ b/internal/controller/store/txn.go @@ -0,0 +1,23 @@ +package store + +import "github.com/dgraph-io/badger/v3" + +type Txn struct { + badgerTxn *badger.Txn +} + +func (store *Store) View(cb func(txn *Txn) error) error { + return store.db.View(func(txn *badger.Txn) error { + return cb(&Txn{ + badgerTxn: txn, + }) + }) +} + +func (store *Store) Update(cb func(txn *Txn) error) error { + return store.db.Update(func(txn *badger.Txn) error { + return cb(&Txn{ + badgerTxn: txn, + }) + }) +} diff --git a/internal/controller/store/vm.go b/internal/controller/store/vm.go new file mode 100644 index 0000000..795c5d7 --- /dev/null +++ b/internal/controller/store/vm.go @@ -0,0 +1,96 @@ +package store + +import ( + "encoding/json" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/dgraph-io/badger/v3" + "path" +) + +const SpaceVMs = "/vms" + +func VMKey(name string) []byte { + return []byte(path.Join(SpaceVMs, name)) +} + +func (txn *Txn) GetVM(name string) (result *v1.VM, err error) { + defer func() { + err = mapErr(err) + }() + + key := VMKey(name) + + item, err := txn.badgerTxn.Get(key) + if err != nil { + return nil, err + } + + valueBytes, err := item.ValueCopy(nil) + if err != nil { + return nil, err + } + + var vm v1.VM + + err = json.Unmarshal(valueBytes, &vm) + if err != nil { + return nil, err + } + + return &vm, nil +} + +func (txn *Txn) SetVM(vm *v1.VM) (err error) { + defer func() { + err = mapErr(err) + }() + + key := VMKey(vm.Name) + + valueBytes, err := json.Marshal(vm) + if err != nil { + return err + } + + return txn.badgerTxn.Set(key, valueBytes) +} + +func (txn *Txn) DeleteVM(name string) (err error) { + defer func() { + err = mapErr(err) + }() + + key := VMKey(name) + + return txn.badgerTxn.Delete(key) +} + +func (txn *Txn) ListVMs() (result []*v1.VM, err error) { + defer func() { + err = mapErr(err) + }() + + it := txn.badgerTxn.NewIterator(badger.IteratorOptions{ + Prefix: []byte(SpaceVMs), + }) + defer it.Close() + + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + + vmBytes, err := item.ValueCopy(nil) + if err != nil { + return nil, err + } + + var vm v1.VM + + if err := json.Unmarshal(vmBytes, &vm); err != nil { + return nil, err + } + + result = append(result, &vm) + } + + return result, nil +} diff --git a/internal/controller/store/worker.go b/internal/controller/store/worker.go new file mode 100644 index 0000000..968e6d4 --- /dev/null +++ b/internal/controller/store/worker.go @@ -0,0 +1,96 @@ +package store + +import ( + "encoding/json" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/dgraph-io/badger/v3" + "path" +) + +const SpaceWorkers = "/workers" + +func WorkerKey(name string) []byte { + return []byte(path.Join(SpaceWorkers, name)) +} + +func (txn *Txn) GetWorker(name string) (result *v1.Worker, err error) { + defer func() { + err = mapErr(err) + }() + + key := WorkerKey(name) + + item, err := txn.badgerTxn.Get(key) + if err != nil { + return nil, err + } + + valueBytes, err := item.ValueCopy(nil) + if err != nil { + return nil, err + } + + var worker v1.Worker + + err = json.Unmarshal(valueBytes, &worker) + if err != nil { + return nil, err + } + + return &worker, nil +} + +func (txn *Txn) SetWorker(worker *v1.Worker) (err error) { + defer func() { + err = mapErr(err) + }() + + key := WorkerKey(worker.Name) + + valueBytes, err := json.Marshal(worker) + if err != nil { + return err + } + + return txn.badgerTxn.Set(key, valueBytes) +} + +func (txn *Txn) DeleteWorker(name string) (err error) { + defer func() { + err = mapErr(err) + }() + + key := WorkerKey(name) + + return txn.badgerTxn.Delete(key) +} + +func (txn *Txn) ListWorkers() (result []*v1.Worker, err error) { + defer func() { + err = mapErr(err) + }() + + it := txn.badgerTxn.NewIterator(badger.IteratorOptions{ + Prefix: []byte(SpaceWorkers), + }) + defer it.Close() + + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + + vmBytes, err := item.ValueCopy(nil) + if err != nil { + return nil, err + } + + var worker v1.Worker + + if err := json.Unmarshal(vmBytes, &worker); err != nil { + return nil, err + } + + result = append(result, &worker) + } + + return result, nil +} diff --git a/internal/orchardhome/orchardhome.go b/internal/orchardhome/orchardhome.go new file mode 100644 index 0000000..48d68cf --- /dev/null +++ b/internal/orchardhome/orchardhome.go @@ -0,0 +1,21 @@ +package orchardhome + +import ( + "os" + "path/filepath" +) + +func Path() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + orchardDir := filepath.Join(homeDir, ".orchard") + + if err := os.Mkdir(orchardDir, 0700); err != nil && !os.IsExist(err) { + return "", err + } + + return orchardDir, nil +} diff --git a/internal/responder/code.go b/internal/responder/code.go new file mode 100644 index 0000000..f3b5a46 --- /dev/null +++ b/internal/responder/code.go @@ -0,0 +1,35 @@ +package responder + +import "github.com/gin-gonic/gin" + +type CodeResponder struct { + code int + headers map[string]string + + DefaultResponder +} + +func Code(code int, opts ...Option) *CodeResponder { + responder := &CodeResponder{ + code: code, + headers: map[string]string{}, + } + + for _, opt := range opts { + opt(responder) + } + + return responder +} + +func (responder *CodeResponder) SetHeader(key string, value string) { + responder.headers[key] = value +} + +func (responder *CodeResponder) Respond(c *gin.Context) { + for key, value := range responder.headers { + c.Header(key, value) + } + + c.Status(responder.code) +} diff --git a/internal/responder/json.go b/internal/responder/json.go new file mode 100644 index 0000000..69eaea4 --- /dev/null +++ b/internal/responder/json.go @@ -0,0 +1,39 @@ +package responder + +import ( + "github.com/gin-gonic/gin" +) + +type JSONResponder struct { + code int + headers map[string]string + obj interface{} + + DefaultResponder +} + +func JSON(code int, obj interface{}, opts ...Option) *JSONResponder { + responder := &JSONResponder{ + code: code, + headers: map[string]string{}, + obj: obj, + } + + for _, opt := range opts { + opt(responder) + } + + return responder +} + +func (responder *JSONResponder) SetHeader(key string, value string) { + responder.headers[key] = value +} + +func (responder *JSONResponder) Respond(c *gin.Context) { + for key, value := range responder.headers { + c.Header(key, value) + } + + c.JSON(responder.code, responder.obj) +} diff --git a/internal/responder/option.go b/internal/responder/option.go new file mode 100644 index 0000000..3f18597 --- /dev/null +++ b/internal/responder/option.go @@ -0,0 +1,11 @@ +package responder + +type Option func(responder Responder) + +func WithHeaders(headers map[string]string) Option { + return func(responder Responder) { + for key, value := range headers { + responder.SetHeader(key, value) + } + } +} diff --git a/internal/responder/responder.go b/internal/responder/responder.go new file mode 100644 index 0000000..1e217f0 --- /dev/null +++ b/internal/responder/responder.go @@ -0,0 +1,15 @@ +package responder + +import ( + "github.com/gin-gonic/gin" +) + +type Responder interface { + Respond(c *gin.Context) + SetHeader(key string, value string) +} + +type DefaultResponder struct{} + +func (dr DefaultResponder) Respond(c *gin.Context) {} +func (dr DefaultResponder) SetHeader(key string, value string) {} diff --git a/internal/worker/option.go b/internal/worker/option.go new file mode 100644 index 0000000..67dbce7 --- /dev/null +++ b/internal/worker/option.go @@ -0,0 +1,17 @@ +package worker + +import "go.uber.org/zap" + +type Option func(*Worker) + +func WithDataDir(dataDir string) Option { + return func(worker *Worker) { + worker.dataDir = dataDir + } +} + +func WithLogger(logger *zap.Logger) Option { + return func(worker *Worker) { + worker.logger = logger.Sugar() + } +} diff --git a/internal/worker/vmmanager/tart.go b/internal/worker/vmmanager/tart.go new file mode 100644 index 0000000..1c39ed4 --- /dev/null +++ b/internal/worker/vmmanager/tart.go @@ -0,0 +1,57 @@ +package vmmanager + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +const tartCommandName = "tart" + +var ( + ErrTartNotFound = errors.New("tart command not found") + ErrTartFailed = errors.New("tart command returned non-zero exit code") +) + +func Tart( + ctx context.Context, + args ...string, +) (string, string, error) { + cmd := exec.CommandContext(ctx, tartCommandName, args...) + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", "", fmt.Errorf("%w: %s command not found in PATH, make sure Tart is installed", + ErrTartNotFound, tartCommandName) + } + + if _, ok := err.(*exec.ExitError); ok { + // Tart command failed, redefine the error + // to be the Tart-specific output + err = fmt.Errorf("%w: %q", ErrTartFailed, firstNonEmptyLine(stderr.String(), stdout.String())) + } + } + + return stdout.String(), stderr.String(), err +} + +func firstNonEmptyLine(outputs ...string) string { + for _, output := range outputs { + for _, line := range strings.Split(output, "\n") { + if line != "" { + return line + } + } + } + + return "" +} diff --git a/internal/worker/vmmanager/vm.go b/internal/worker/vmmanager/vm.go new file mode 100644 index 0000000..f2d2cf2 --- /dev/null +++ b/internal/worker/vmmanager/vm.go @@ -0,0 +1,73 @@ +package vmmanager + +import ( + "context" + "fmt" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "sync" +) + +type VM struct { + id string + vmResource *v1.VM + + ctx context.Context + cancel context.CancelFunc + + wg *sync.WaitGroup +} + +func NewVM(vmResource *v1.VM) *VM { + ctx, cancel := context.WithCancel(context.Background()) + + vm := &VM{ + id: fmt.Sprintf("orchard-%s-%s", vmResource.Name, vmResource.UID), + vmResource: vmResource, + + ctx: ctx, + cancel: cancel, + + wg: &sync.WaitGroup{}, + } + + vm.wg.Add(1) + + go func() { + defer vm.wg.Done() + + if err := vm.run(vm.ctx); err != nil { + vmResource.Status = v1.VMStatusFailed + } + }() + + return vm +} + +func (vm *VM) run(ctx context.Context) error { + _, _, err := Tart(ctx, "clone", vm.vmResource.Image, vm.id) + if err != nil { + return err + } + + _, _, err = Tart(ctx, "run", vm.id) + if err != nil { + return err + } + + return nil +} + +func (vm *VM) Close() error { + _, _, _ = Tart(context.Background(), "stop", "--timeout", "5", vm.id) + + vm.cancel() + + vm.wg.Wait() + + _, _, err := Tart(context.Background(), "delete", vm.id) + if err != nil { + return fmt.Errorf("failed to delete VM %s: %v", vm.id, err) + } + + return nil +} diff --git a/internal/worker/vmmanager/vmmanager.go b/internal/worker/vmmanager/vmmanager.go new file mode 100644 index 0000000..f3e59fc --- /dev/null +++ b/internal/worker/vmmanager/vmmanager.go @@ -0,0 +1,58 @@ +package vmmanager + +import ( + "fmt" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" +) + +type VMManager struct { + vms map[string]*VM +} + +func New() *VMManager { + return &VMManager{ + vms: map[string]*VM{}, + } +} + +func (vmm *VMManager) Exists(vmResource *v1.VM) bool { + _, ok := vmm.vms[vmResource.UID] + + return ok +} + +func (vmm *VMManager) Get(vmResource *v1.VM) (*VM, error) { + managedVM, ok := vmm.vms[vmResource.UID] + if !ok { + return nil, fmt.Errorf("VM does not exist") + } + + return managedVM, nil +} + +func (vmm *VMManager) Create(vmResource *v1.VM) (*VM, error) { + if _, ok := vmm.vms[vmResource.UID]; ok { + return nil, fmt.Errorf("VM already exists") + } + + managedVM := NewVM(vmResource) + + vmm.vms[vmResource.UID] = managedVM + + return managedVM, nil +} + +func (vmm *VMManager) Delete(vmResource *v1.VM) error { + managedVM, ok := vmm.vms[vmResource.UID] + if !ok { + return fmt.Errorf("VM does not exist") + } + + if err := managedVM.Close(); err != nil { + return err + } + + delete(vmm.vms, vmResource.UID) + + return nil +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go new file mode 100644 index 0000000..c5c61d9 --- /dev/null +++ b/internal/worker/worker.go @@ -0,0 +1,208 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "github.com/cirruslabs/orchard/internal/worker/vmmanager" + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "go.uber.org/zap" + "os" + "time" +) + +const pollInterval = 5 * time.Second + +var ErrPollFailed = errors.New("failed to poll controller") + +type Worker struct { + dataDir string + name string + uid string + vmm *vmmanager.VMManager + client *client.Client + logger *zap.SugaredLogger +} + +func New(opts ...Option) (*Worker, error) { + worker := &Worker{ + vmm: vmmanager.New(), + } + + // Apply options + for _, opt := range opts { + opt(worker) + } + + // Apply defaults + if worker.name == "" { + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + + worker.name = hostname + } + if worker.logger == nil { + worker.logger = zap.NewNop().Sugar() + } + + // Instantiate worker + client, err := client.New() + if err != nil { + return nil, err + } + worker.client = client + + return worker, nil +} + +func (worker *Worker) Run(ctx context.Context) error { + tickCh := time.Tick(pollInterval) + + for { + if err := worker.registerWorker(ctx); err != nil { + worker.logger.Warnf("failed to register worker: %v", err) + + select { + case <-tickCh: + // continue + case <-ctx.Done(): + return ctx.Err() + } + + continue + } + + for { + if err := worker.updateWorker(ctx); err != nil { + worker.logger.Errorf("failed to update worker resource: %v", err) + + select { + case <-tickCh: + // continue + case <-ctx.Done(): + return ctx.Err() + } + + break + } + + if err := worker.syncVMs(ctx); err != nil { + worker.logger.Warnf("failed to sync VMs: %v", err) + + select { + case <-tickCh: + // continue + case <-ctx.Done(): + return ctx.Err() + } + + break + } + + select { + case <-tickCh: + // continue + case <-ctx.Done(): + return ctx.Err() + } + } + } +} + +func (worker *Worker) registerWorker(ctx context.Context) error { + workerResource := &v1.Worker{ + Meta: v1.Meta{ + Name: worker.name, + }, + LastSeen: time.Now(), + } + + workerResource, err := worker.client.Workers().Create(ctx, workerResource) + if err != nil { + return err + } + + worker.uid = workerResource.UID + + worker.logger.Infof("registered worker %s with UID %s", worker.name, workerResource.UID) + + return nil +} + +func (worker *Worker) updateWorker(ctx context.Context) error { + workerResource, err := worker.client.Workers().Get(ctx, worker.name) + if err != nil { + return fmt.Errorf("%w: failed to retrieve worker from the API: %v", ErrPollFailed, err) + } + + if workerResource.UID != worker.uid { + return fmt.Errorf("%w: our UID is %s, controller's ID is %s", ErrPollFailed) + } + + worker.logger.Debugf("got worker from the API") + + workerResource.LastSeen = time.Now() + + if err := worker.client.Workers().Update(ctx, workerResource); err != nil { + return fmt.Errorf("%w: failed to update worker in the API: %v", err) + } + + worker.logger.Debugf("updated worker in the API") + + return nil +} + +func (worker *Worker) syncVMs(ctx context.Context) error { + vms, err := worker.client.VMs().List(ctx) + if err != nil { + return err + } + + worker.logger.Infof("syncing %d VMs...", len(vms)) + + for _, vmResource := range vms { + if vmResource.Worker != worker.name { + continue + } + + if !vmResource.DeletedAt.IsZero() { + worker.logger.Debugf("deleting VM %s (%s)", vmResource.Name, vmResource.UID) + + // Delete VM locally, report to the controller + if worker.vmm.Exists(&vmResource) { + if err := worker.vmm.Delete(&vmResource); err != nil { + return err + } + } + + if err := worker.client.VMs().Delete(ctx, vmResource.Name, true); err != nil { + return fmt.Errorf("%w: failed to delete VM %s (%s) from the API: %v", + ErrPollFailed, vmResource.Name, vmResource.UID, err) + } + + worker.logger.Infof("deleted VM %s (%s)", vmResource.Name, vmResource.UID) + } else if !worker.vmm.Exists(&vmResource) { + worker.logger.Debugf("creating VM %s (%s)", vmResource.Name, vmResource.UID) + + // Create or update VM locally, report to controller + _, err := worker.vmm.Create(&vmResource) + if err != nil { + return err + } + + vmResource.Status = v1.VMStatusRunning + + if err := worker.client.VMs().Update(ctx, &vmResource); err != nil { + return fmt.Errorf("%w: failed to update VM %s (%s) in the API: %v", + ErrPollFailed, vmResource.Name, vmResource.UID, err) + } + + worker.logger.Infof("spawned VM %s (%s)", vmResource.Name, vmResource.UID) + } + } + + return nil +} diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..014db70 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,140 @@ +package client + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +var ( + ErrFailed = errors.New("API client failed") +) + +type Client struct { + address string + tlsConfig *tls.Config + + httpClient *http.Client + baseURL *url.URL +} + +type Config struct { + Address string + TLSConfig *tls.Config +} + +func New(opts ...Option) (*Client, error) { + client := &Client{} + + // Apply options + for _, opt := range opts { + opt(client) + } + + // Apply defaults + if client.address == "" { + client.address = "http://127.0.0.1:6120" + } + + // Instantiate client + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: client.tlsConfig, + }, + } + + url, err := url.Parse(client.address) + if err != nil { + return nil, err + } + + return &Client{ + httpClient: httpClient, + baseURL: url, + }, nil +} + +func (client *Client) request( + ctx context.Context, + method string, + path string, + in interface{}, + out interface{}, + params map[string]string, +) (*http.Response, error) { + var body io.Reader + + if in != nil { + jsonBytes, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("%w to marshal request body: %v", ErrFailed, err) + } + + body = bytes.NewBuffer(jsonBytes) + } + + endpointURL, err := url.Parse("v1/" + path) + if err != nil { + return nil, fmt.Errorf("%w to parse API endpoint path: %v", ErrFailed, err) + } + + endpointURL = &url.URL{ + Scheme: client.baseURL.Scheme, + User: client.baseURL.User, + Host: client.baseURL.Host, + Path: endpointURL.Path, + RawPath: endpointURL.RawPath, + } + + values := endpointURL.Query() + for key, value := range params { + values.Set(key, value) + } + endpointURL.RawQuery = values.Encode() + + request, err := http.NewRequestWithContext(ctx, method, endpointURL.String(), body) + if err != nil { + return nil, fmt.Errorf("%w instantiate a request: %v", ErrFailed, err) + } + + response, err := client.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("%w to make a request: %v", ErrFailed, err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w to make a request: %d %s", + ErrFailed, response.StatusCode, http.StatusText(response.StatusCode)) + } + + if out != nil { + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("%w to read response body: %v", ErrFailed, err) + } + + if err := json.Unmarshal(bodyBytes, out); err != nil { + return nil, fmt.Errorf("%w to unmarshal response body: %v", ErrFailed, err) + } + } + + return response, nil +} + +func (client *Client) Workers() *WorkersService { + return &WorkersService{ + client: client, + } +} + +func (client *Client) VMs() *VMsService { + return &VMsService{ + client: client, + } +} diff --git a/pkg/client/option.go b/pkg/client/option.go new file mode 100644 index 0000000..c50be81 --- /dev/null +++ b/pkg/client/option.go @@ -0,0 +1,17 @@ +package client + +import "crypto/tls" + +type Option func(*Client) + +func WithAddress(address string) Option { + return func(client *Client) { + client.address = address + } +} + +func WithTLSConfig(tlsConfig *tls.Config) Option { + return func(client *Client) { + client.tlsConfig = tlsConfig + } +} diff --git a/pkg/client/vms.go b/pkg/client/vms.go new file mode 100644 index 0000000..696240a --- /dev/null +++ b/pkg/client/vms.go @@ -0,0 +1,72 @@ +package client + +import ( + "context" + "fmt" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "net/http" +) + +type VMsService struct { + client *Client +} + +func (service *VMsService) Create(ctx context.Context, vm *v1.VM) error { + _, err := service.client.request(ctx, http.MethodPost, "vms", + vm, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (service *VMsService) List(ctx context.Context) ([]v1.VM, error) { + var vms []v1.VM + + _, err := service.client.request(ctx, http.MethodGet, "vms", + nil, &vms, nil) + if err != nil { + return nil, err + } + + return vms, nil +} + +func (service *VMsService) Get(ctx context.Context, name string) (*v1.VM, error) { + var vm v1.VM + + _, err := service.client.request(ctx, http.MethodGet, fmt.Sprintf("vms/%s", name), + nil, &vm, nil) + if err != nil { + return nil, err + } + + return &vm, nil +} + +func (service *VMsService) Update(ctx context.Context, vm *v1.VM) error { + _, err := service.client.request(ctx, http.MethodPut, fmt.Sprintf("vms/%s", vm.Name), + vm, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (service *VMsService) Delete(ctx context.Context, name string, force bool) error { + params := map[string]string{} + + if force { + params["force"] = "true" + } + + _, err := service.client.request(ctx, http.MethodDelete, fmt.Sprintf("vms/%s", name), + nil, nil, params) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/client/workers.go b/pkg/client/workers.go new file mode 100644 index 0000000..0ad5812 --- /dev/null +++ b/pkg/client/workers.go @@ -0,0 +1,66 @@ +package client + +import ( + "context" + "fmt" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "net/http" +) + +type WorkersService struct { + client *Client +} + +func (service *WorkersService) Create(ctx context.Context, worker *v1.Worker) (*v1.Worker, error) { + _, err := service.client.request(ctx, http.MethodPost, "workers", + worker, &worker, nil) + if err != nil { + return nil, err + } + + return worker, nil +} + +func (service *WorkersService) List(ctx context.Context) ([]v1.Worker, error) { + var workers []v1.Worker + + _, err := service.client.request(ctx, http.MethodGet, "workers", + nil, &workers, nil) + if err != nil { + return nil, err + } + + return workers, nil +} + +func (service *WorkersService) Get(ctx context.Context, name string) (*v1.Worker, error) { + var worker v1.Worker + + _, err := service.client.request(ctx, http.MethodGet, fmt.Sprintf("workers/%s", name), + nil, &worker, nil) + if err != nil { + return nil, err + } + + return &worker, nil +} + +func (service *WorkersService) Update(ctx context.Context, worker *v1.Worker) error { + _, err := service.client.request(ctx, http.MethodPut, fmt.Sprintf("workers/%s", worker.Name), + worker, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (service *WorkersService) Delete(ctx context.Context, name string) error { + _, err := service.client.request(ctx, http.MethodDelete, fmt.Sprintf("workers/%s", name), + nil, nil, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/resource/v1/v1.go b/pkg/resource/v1/v1.go new file mode 100644 index 0000000..bc54ba0 --- /dev/null +++ b/pkg/resource/v1/v1.go @@ -0,0 +1,74 @@ +package v1 + +import ( + "time" +) + +// Meta is a common set of fields that apply to all resources managed by the Controller. +type Meta struct { + // Name is a human-readable resource identifier populated by the Worker or Client. + // + // There can't be multiple resources with the same Name in the DB at any given time. + Name string `json:"name"` + + // CreatedAt is a useful field for scheduler prioritization. + // + // It is populated by the Controller with the current time + // when receiving a POST request. + CreatedAt time.Time `json:"createdAt"` + + // DeletedAt is a useful field for graceful resource termination. + // + // It is populated by the Controller with the current time + // when receiving a DELETE request. + DeletedAt time.Time `json:"deletedAt"` + + // UID is a useful field for avoiding data races within a single Name. + // + // It is populated by the Controller when receiving a POST request. + UID string `json:"uid"` + + // Generation is a useful field for avoiding data races within a single UID. + // + // It is populated by the controller when receiving POST or PUT requests. + Generation int64 `json:"generation"` +} + +type Worker struct { + // LastSeen is set by the Worker and is used by the Controller + // to track unhealthy Workers. + LastSeen time.Time + + Meta +} + +type VM struct { + Image string `json:"image"` + CPU uint64 `json:"cpu"` + Memory uint64 `json:"memory"` + Softnet bool `json:"softnet"` + Headless bool `json:"headless"` + + // Status field is used to track the lifecycle of the VM associated with this resource. + Status VMStatus `json:"status"` + + // Worker field is set by the Controller to assign this VM to a specific Worker. + Worker string `json:"worker"` + + Meta +} + +type VMStatus string + +const ( + // VMStatusPending is set by the Controller for all newly-created VM resources. + VMStatusPending VMStatus = "pending" + + // VMStatusRunning is set by the Worker once it starts running + // the Virtual Machine associated with this VM resource. + VMStatusRunning VMStatus = "running" + + // VMStatusFailed is set by both the Controller and the Worker to indicate a failure + // that prevented the VM resource from reaching the VMStatusRunning state. + VMStatusFailed VMStatus = "failed" +)