feat: cli client with spec schema validation

This commit is contained in:
wpetit 2023-02-28 15:50:35 +01:00
parent 2a828afc89
commit 3310c09320
51 changed files with 1929 additions and 82 deletions

View File

@ -53,11 +53,13 @@ archives:
files:
- README.md
- migrations
- misc/packaging/common/config-server.yml
- id: agent
builds: ["emissary-agent"]
name_template: '{{ .ProjectName }}-agent_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- README.md
- misc/packaging/common/config-agent.yml
checksum:
name_template: 'checksums.txt'
snapshot:

View File

@ -5,6 +5,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/agent"
"forge.cadoles.com/Cadoles/emissary/internal/command/client"
)
// nolint: gochecknoglobals
@ -16,5 +17,5 @@ var (
)
func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root())
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), client.Root())
}

View File

@ -4,6 +4,7 @@ import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/server"
_ "github.com/jackc/pgx/v5/stdlib"
@ -19,5 +20,5 @@ var (
)
func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root())
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), client.Root())
}

11
go.mod
View File

@ -16,7 +16,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/santhosh-tekuri/jsonschema/v5 v5.1.1
github.com/urfave/cli/v2 v2.23.7
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9
gitlab.com/wpetit/goweb v0.0.0-20230227162855-a1f09bafccb3
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.20.3
)
@ -29,6 +29,7 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dlclark/regexp2 v1.8.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@ -39,12 +40,17 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.1.2 // indirect
github.com/jedib0t/go-pretty/v6 v6.4.4 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/leodido/go-urn v1.2.2 // indirect
github.com/lib/pq v1.10.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/qri-io/jsonpointer v0.1.1 // indirect
github.com/qri-io/jsonschema v0.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
@ -60,6 +66,7 @@ require (
golang.org/x/tools v0.1.12 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/evanphx/json-patch.v5 v5.6.0 // indirect
gopkg.in/go-playground/validator.v9 v9.31.0 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect

23
go.sum
View File

@ -452,6 +452,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
@ -785,6 +787,8 @@ github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.1.2 h1:0f7vaaXINONKTsxYDn4otOAiJanX/BMeAtY//BXqzlg=
github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
github.com/jedib0t/go-pretty/v6 v6.4.4 h1:N+gz6UngBPF4M288kiMURPHELDMIhF/Em35aYuKrsSc=
github.com/jedib0t/go-pretty/v6 v6.4.4/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
@ -852,6 +856,8 @@ github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
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/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -896,6 +902,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
@ -1029,6 +1037,7 @@ github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -1067,9 +1076,15 @@ github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA=
github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0=
github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI=
github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@ -1084,6 +1099,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
@ -1152,9 +1168,12 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
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.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
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/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
@ -1209,6 +1228,8 @@ github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxt
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 h1:6JlkcdjYVQglPWYuemK2MoZAtRE4vFx85zLXflGIyI8=
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
gitlab.com/wpetit/goweb v0.0.0-20230227162855-a1f09bafccb3 h1:ddXRTeqEr7LcHQEtkd6gogZOh9tI1Y6Gappr0a1oa2I=
gitlab.com/wpetit/goweb v0.0.0-20230227162855-a1f09bafccb3/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@ -1868,6 +1889,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v5 v5.6.0 h1:BMT6KIwBD9CaU91PJCZIe46bDmBWa9ynTQgJIOpfQBk=
gopkg.in/evanphx/json-patch.v5 v5.6.0/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=

View File

@ -4,13 +4,13 @@ import (
"context"
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/internal/spec/gateway"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Controller struct {
proxies map[spec.GatewayID]*ReverseProxy
proxies map[gateway.ID]*ReverseProxy
currentSpecRevision int
}
@ -21,9 +21,9 @@ func (c *Controller) Name() string {
// Reconcile implements node.Controller.
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
gatewaySpec := spec.NewGatewaySpec()
gatewaySpec := gateway.NewSpec()
if err := state.GetSpec(spec.NameGateway, gatewaySpec); err != nil {
if err := state.GetSpec(gateway.NameGateway, gatewaySpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find gateway spec, stopping all remaining proxies")
@ -67,7 +67,7 @@ func (c *Controller) stopAllProxies(ctx context.Context) {
}
}
func (c *Controller) updateProxies(ctx context.Context, spec *spec.Gateway) {
func (c *Controller) updateProxies(ctx context.Context, spec *gateway.Spec) {
// Stop and remove obsolete gateways
for gatewayID, proxy := range c.proxies {
if _, exists := spec.Gateways[gatewayID]; exists {
@ -116,7 +116,7 @@ func (c *Controller) updateProxies(ctx context.Context, spec *spec.Gateway) {
func NewController() *Controller {
return &Controller{
proxies: make(map[spec.GatewayID]*ReverseProxy),
proxies: make(map[gateway.ID]*ReverseProxy),
currentSpecRevision: -1,
}
}

View File

@ -8,7 +8,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
ucispec "forge.cadoles.com/Cadoles/emissary/internal/spec/uci"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
@ -25,9 +25,9 @@ func (*UCIController) Name() string {
// Reconcile implements node.Controller.
func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
uciSpec := spec.NewUCISpec()
uciSpec := ucispec.NewSpec()
if err := state.GetSpec(spec.NameUCI, uciSpec); err != nil {
if err := state.GetSpec(ucispec.NameUCI, uciSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find uci spec, doing nothing")
@ -57,7 +57,7 @@ func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error
return nil
}
func (c *UCIController) updateConfiguration(ctx context.Context, spec *spec.UCI) error {
func (c *UCIController) updateConfiguration(ctx context.Context, spec *ucispec.Spec) error {
logger.Info(ctx, "importing uci config")
if err := c.importConfig(ctx, spec.Config); err != nil {
@ -91,7 +91,7 @@ func (c *UCIController) importConfig(ctx context.Context, uci *uci.UCI) error {
return nil
}
func (c *UCIController) execPostImportCommands(ctx context.Context, commands []*spec.UCIPostImportCommand) error {
func (c *UCIController) execPostImportCommands(ctx context.Context, commands []*ucispec.UCIPostImportCommand) error {
for _, postImportCmd := range commands {
cmd := exec.CommandContext(ctx, postImportCmd.Command, postImportCmd.Args...)

View File

@ -33,6 +33,22 @@ func (c *Client) apiPost(ctx context.Context, path string, payload any, result a
return nil
}
func (c *Client) apiPut(ctx context.Context, path string, payload any, result any) error {
if err := c.apiDo(ctx, http.MethodPut, path, payload, result); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDelete(ctx context.Context, path string, payload any, result any) error {
if err := c.apiDo(ctx, http.MethodDelete, path, payload, result); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDo(ctx context.Context, method string, path string, payload any, response any) error {
url := c.serverURL + path

View File

@ -0,0 +1,50 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
type UpdateAgentOptions struct {
Status *int
}
type UpdateAgentOptionFunc func(*UpdateAgentOptions)
func WithAgentStatus(status int) UpdateAgentOptionFunc {
return func(opts *UpdateAgentOptions) {
opts.Status = &status
}
}
func (c *Client) UpdateAgent(ctx context.Context, agentID datastore.AgentID, funcs ...UpdateAgentOptionFunc) (*datastore.Agent, error) {
opts := &UpdateAgentOptions{}
for _, fn := range funcs {
fn(opts)
}
payload := map[string]any{}
if opts.Status != nil {
payload["status"] = *opts.Status
}
response := withResponse[struct {
Agent *datastore.Agent `json:"agent"`
}]()
path := fmt.Sprintf("/api/v1/agents/%d", agentID)
if err := c.apiPut(ctx, path, payload, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Agent, nil
}

View File

@ -0,0 +1,38 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
func (c *Client) UpdateAgentSpec(ctx context.Context, agentID datastore.AgentID, name spec.Name, revision int, data any) (*datastore.Spec, error) {
payload := struct {
Name spec.Name `json:"name"`
Revision int `json:"revision"`
Data any `json:"data"`
}{
Name: name,
Revision: revision,
Data: data,
}
response := withResponse[struct {
Spec *datastore.Spec `json:"spec"`
}]()
path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID)
if err := c.apiPost(ctx, path, payload, &response); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Spec, nil
}

View File

@ -0,0 +1,47 @@
package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func CountCommand() *cli.Command {
return &cli.Command{
Name: "count",
Usage: "Count agents",
Flags: clientFlag.ComposeFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
client := client.New(baseFlags.ServerURL)
_, total, err := client.QueryAgents(ctx.Context)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
results := []struct {
Total int `json:"total"`
}{
{
Total: total,
},
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(results)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,34 @@
package flag
import (
"errors"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/urfave/cli/v2"
)
func WithAgentFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := clientFlag.ComposeFlags(
&cli.Int64Flag{
Name: "agent-id",
Aliases: []string{"a"},
Usage: "use `AGENT_ID` as selected agent",
Value: -1,
},
)
flags = append(flags, baseFlags...)
return flags
}
func AssertAgentID(ctx *cli.Context) (datastore.AgentID, error) {
rawAgentID := ctx.Int64("agent-id")
if rawAgentID == -1 {
return -1, errors.New("flag 'agent-id' is required")
}
return datastore.AgentID(rawAgentID), nil
}

View File

@ -0,0 +1,39 @@
package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func QueryCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query agents",
Flags: clientFlag.ComposeFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
client := client.New(baseFlags.ServerURL)
agents, _, err := client.QueryAgents(ctx.Context)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(agents)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,19 @@
package agent
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/spec"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "agent",
Usage: "Agents related commands",
Subcommands: []*cli.Command{
QueryCommand(),
CountCommand(),
UpdateCommand(),
spec.Root(),
},
}
}

View File

@ -0,0 +1,45 @@
package spec
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/client"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "Get agent specifications",
Flags: agentFlag.WithAgentFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
agentID, err := agentFlag.AssertAgentID(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL)
specs, err := client.GetAgentSpecs(ctx.Context, agentID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,16 @@
package spec
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "spec",
Usage: "Specifications related commands",
Subcommands: []*cli.Command{
GetCommand(),
UpdateCommand(),
},
}
}

View File

@ -0,0 +1,163 @@
package spec
import (
"encoding/json"
"os"
"forge.cadoles.com/Cadoles/emissary/internal/client"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Update agent specification",
Flags: agentFlag.WithAgentFlags(
&cli.StringFlag{
Name: "spec-name",
Usage: "use `NAME` as spec name",
},
&cli.StringFlag{
Name: "spec-data",
Usage: "use `DATA` as spec data",
},
&cli.BoolFlag{
Name: "no-patch",
Usage: "Dont use spec-data as a patch to existing specification",
},
&cli.IntFlag{
Name: "revision",
Usage: "Use `REVISION` as specification revision number",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
agentID, err := agentFlag.AssertAgentID(ctx)
if err != nil {
return errors.WithStack(err)
}
specName, err := assertSpecName(ctx)
if err != nil {
return errors.WithStack(err)
}
specData, err := assertSpecData(ctx)
if err != nil {
return errors.WithStack(err)
}
noPatch := ctx.Bool("no-patch")
client := client.New(baseFlags.ServerURL)
specs, err := client.GetAgentSpecs(ctx.Context, agentID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
var existingSpec spec.Spec
for _, s := range specs {
if s.SpecName() != specName {
continue
}
existingSpec = s
break
}
revision := 0
if existingSpec != nil {
originSpecData := existingSpec.SpecData()
if !noPatch {
specData, err = applyPatch(originSpecData, specData)
if err != nil {
return errors.WithStack(err)
}
}
revision = existingSpec.SpecRevision()
}
if specificRevision := ctx.Int("revision"); specificRevision != 0 {
revision = specificRevision
}
spec, err := client.UpdateAgentSpec(ctx.Context, agentID, specName, revision, specData)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}
func assertSpecName(ctx *cli.Context) (spec.Name, error) {
specName := ctx.String("spec-name")
if specName == "" {
return "", errors.New("flag 'spec-name' is required")
}
return spec.Name(specName), nil
}
func assertSpecData(ctx *cli.Context) (any, error) {
rawSpecData := ctx.String("spec-data")
if rawSpecData == "" {
return nil, errors.New("flag 'spec-data' is required")
}
var specData any
if err := json.Unmarshal([]byte(rawSpecData), &specData); err != nil {
return nil, errors.WithStack(err)
}
return specData, nil
}
func applyPatch(origin any, patch any) (any, error) {
originJSON, err := json.Marshal(origin)
if err != nil {
return nil, errors.WithStack(err)
}
patchJSON, err := json.Marshal(patch)
if err != nil {
return nil, errors.WithStack(err)
}
result, err := jsonpatch.MergePatch(originJSON, patchJSON)
if err != nil {
return nil, errors.WithStack(err)
}
var specData any
if err := json.Unmarshal(result, &specData); err != nil {
}
return specData, nil
}

View File

@ -0,0 +1,59 @@
package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/client"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Updata agent",
Flags: agentFlag.WithAgentFlags(
&cli.IntFlag{
Name: "status",
Usage: "Set `STATUS` to selected agent",
Value: -1,
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
agentID, err := agentFlag.AssertAgentID(ctx)
if err != nil {
return errors.WithStack(err)
}
options := make([]client.UpdateAgentOptionFunc, 0)
status := ctx.Int("status")
if status != -1 {
options = append(options, client.WithAgentStatus(status))
}
client := client.New(baseFlags.ServerURL)
agent, err := client.UpdateAgent(ctx.Context, agentID, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -0,0 +1,91 @@
package apierr
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
)
func Wrap(err error) error {
apiErr := &api.Error{}
if !errors.As(err, &apiErr) {
return err
}
switch apiErr.Code {
case api.ErrCodeInvalidFieldValue:
return wrapInvalidFieldValueErr(apiErr)
default:
return wrapApiErrorWithMessage(apiErr)
}
}
func wrapApiErrorWithMessage(err *api.Error) error {
data, ok := err.Data.(map[string]any)
if !ok {
return err
}
rawMessage, exists := data["message"]
if !exists {
return err
}
message, ok := rawMessage.(string)
if !ok {
return err
}
return errors.Wrapf(err, message)
}
func wrapInvalidFieldValueErr(err *api.Error) error {
data, ok := err.Data.(map[string]any)
if !ok {
return err
}
rawFields, exists := data["Fields"]
if !exists {
return err
}
fields, ok := rawFields.([]any)
if !ok {
return err
}
var (
field string
rule string
)
if len(fields) == 0 {
return err
}
firstField, ok := fields[0].(map[string]any)
if !ok {
return err
}
param, ok := firstField["Param"].(string)
if !ok {
return err
}
tag, ok := firstField["Tag"].(string)
if !ok {
return err
}
fieldName, ok := firstField["Field"].(string)
if !ok {
return err
}
field = fieldName
rule = tag + "=" + param
return errors.Wrapf(err, "server expected field '%s' to match rule '%s'", field, rule)
}

View File

@ -0,0 +1,54 @@
package flag
import (
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/format/table"
"github.com/urfave/cli/v2"
)
func ComposeFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := []cli.Flag{
&cli.StringFlag{
Name: "server",
Aliases: []string{"s"},
Usage: "use `SERVER` as server url",
Value: "http://127.0.0.1:3000",
},
&cli.StringFlag{
Name: "format",
Aliases: []string{"f"},
Usage: fmt.Sprintf("use `FORMAT` as output format (available: %s)", format.Available()),
Value: string(table.Format),
},
&cli.StringFlag{
Name: "output-mode",
Aliases: []string{"m"},
Usage: fmt.Sprintf("use `MODE` as output mode (available: %s)", []format.OutputMode{format.OutputModeCompact, format.OutputModeWide}),
Value: string(format.OutputModeCompact),
},
}
flags = append(flags, baseFlags...)
return flags
}
type BaseFlags struct {
ServerURL string
Format format.Format
OutputMode format.OutputMode
}
func GetBaseFlags(ctx *cli.Context) *BaseFlags {
serverURL := ctx.String("server")
rawFormat := ctx.String("format")
rawOutputMode := ctx.String("output-mode")
return &BaseFlags{
ServerURL: serverURL,
Format: format.Format(rawFormat),
OutputMode: format.OutputMode(rawOutputMode),
}
}

View File

@ -0,0 +1,11 @@
package flag
func AsAnySlice[T any](src []T) []any {
dst := make([]any, len(src))
for i, s := range src {
dst[i] = s
}
return dst
}

View File

@ -0,0 +1,20 @@
package client
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/client/agent"
"github.com/urfave/cli/v2"
// Output format
_ "forge.cadoles.com/Cadoles/emissary/internal/format/json"
_ "forge.cadoles.com/Cadoles/emissary/internal/format/table"
)
func Root() *cli.Command {
return &cli.Command{
Name: "client",
Usage: "Client related commands",
Subcommands: []*cli.Command{
agent.Root(),
},
}
}

View File

@ -9,6 +9,10 @@ import (
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
// Spec validation
_ "forge.cadoles.com/Cadoles/emissary/internal/spec/gateway"
_ "forge.cadoles.com/Cadoles/emissary/internal/spec/uci"
)
func Main(buildDate, projectVersion, gitRef, defaultConfigPath string, commands ...*cli.Command) {

View File

@ -4,7 +4,6 @@ import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
type SpecID int64
@ -26,10 +25,6 @@ func (s *Spec) SpecRevision() int {
return s.Revision
}
func (s *Spec) SpecData() any {
func (s *Spec) SpecData() map[string]any {
return s.Data
}
func (s *Spec) SpecValid() (bool, error) {
return false, errors.WithStack(spec.ErrSchemaUnknown)
}

View File

@ -0,0 +1,38 @@
package json
import (
"encoding/json"
"io"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
const Format format.Format = "json"
func init() {
format.Register(Format, NewWriter())
}
type Writer struct{}
// Format implements format.Writer.
func (*Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
encoder := json.NewEncoder(writer)
if hints.OutputMode == format.OutputModeWide {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(data); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewWriter() *Writer {
return &Writer{}
}
var _ format.Writer = &Writer{}

18
internal/format/prop.go Normal file
View File

@ -0,0 +1,18 @@
package format
type Prop struct {
name string
label string
}
func (p *Prop) Name() string {
return p.name
}
func (p *Prop) Label() string {
return p.label
}
func NewProp(name, label string) Prop {
return Prop{name, label}
}

View File

@ -0,0 +1,46 @@
package format
import (
"io"
"github.com/pkg/errors"
)
type Format string
type Registry map[Format]Writer
var defaultRegistry = Registry{}
var ErrUnknownFormat = errors.New("unknown format")
func Write(format Format, writer io.Writer, hints Hints, data ...any) error {
formatWriter, exists := defaultRegistry[format]
if !exists {
return errors.WithStack(ErrUnknownFormat)
}
if hints.OutputMode == "" {
hints.OutputMode = OutputModeCompact
}
if err := formatWriter.Write(writer, hints, data...); err != nil {
return errors.WithStack(err)
}
return nil
}
func Available() []Format {
formats := make([]Format, 0, len(defaultRegistry))
for f := range defaultRegistry {
formats = append(formats, f)
}
return formats
}
func Register(format Format, writer Writer) {
defaultRegistry[format] = writer
}

View File

@ -0,0 +1,49 @@
package table
import (
"encoding/json"
"fmt"
"reflect"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
func getProps(d any) []format.Prop {
props := make([]format.Prop, 0)
v := reflect.Indirect(reflect.ValueOf(d))
typeOf := v.Type()
for i := 0; i < v.NumField(); i++ {
name := typeOf.Field(i).Name
props = append(props, format.NewProp(name, name))
}
return props
}
func getFieldValue(obj any, name string) string {
v := reflect.Indirect(reflect.ValueOf(obj))
fieldValue := v.FieldByName(name)
switch fieldValue.Kind() {
case reflect.Map:
fallthrough
case reflect.Struct:
fallthrough
case reflect.Slice:
fallthrough
case reflect.Interface:
json, err := json.Marshal(fieldValue.Interface())
if err != nil {
panic(errors.WithStack(err))
}
return string(json)
default:
return fmt.Sprintf("%v", fieldValue.Interface())
}
}

View File

@ -0,0 +1,75 @@
package table
import (
"io"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/jedib0t/go-pretty/v6/table"
)
const Format format.Format = "table"
const DefaultCompactModeMaxColumnWidth = 30
func init() {
format.Register(Format, NewWriter(DefaultCompactModeMaxColumnWidth))
}
type Writer struct {
compactModeMaxColumnWidth int
}
// Write implements format.Writer.
func (w *Writer) Write(writer io.Writer, hints format.Hints, data ...any) error {
t := table.NewWriter()
t.SetOutputMirror(writer)
var props []format.Prop
if hints.Props != nil {
props = hints.Props
} else {
if len(data) > 0 {
props = getProps(data[0])
} else {
props = make([]format.Prop, 0)
}
}
labels := table.Row{}
for _, p := range props {
labels = append(labels, p.Label())
}
t.AppendHeader(labels)
isCompactMode := hints.OutputMode == format.OutputModeCompact
for _, d := range data {
row := table.Row{}
for _, p := range props {
value := getFieldValue(d, p.Name())
if isCompactMode && len(value) > w.compactModeMaxColumnWidth {
value = value[:w.compactModeMaxColumnWidth] + "..."
}
row = append(row, value)
}
t.AppendRow(row)
}
t.Render()
return nil
}
func NewWriter(compactModeMaxColumnWidth int) *Writer {
return &Writer{compactModeMaxColumnWidth}
}
var _ format.Writer = &Writer{}

View File

@ -0,0 +1,86 @@
package table
import (
"bytes"
"strings"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"github.com/pkg/errors"
)
type dummyItem struct {
MyString string
MyInt int
MySub subItem
}
type subItem struct {
MyBool bool
}
var dummyItems = []any{
dummyItem{
MyString: "Foo",
MyInt: 1,
MySub: subItem{
MyBool: false,
},
},
dummyItem{
MyString: "Bar",
MyInt: 0,
MySub: subItem{
MyBool: true,
},
},
}
func TestWriterNoHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
if err := writer.Write(&buf, format.Hints{}, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+------------------+
| MYSTRING | MYINT | MYSUB |
+----------+-------+------------------+
| Foo | 1 | {"MyBool":false} |
| Bar | 0 | {"MyBool":true} |
+----------+-------+------------------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}
func TestWriterWithPropHints(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(DefaultCompactModeMaxColumnWidth)
hints := format.Hints{
Props: []format.Prop{
format.NewProp("MyString", "MyString"),
format.NewProp("MyInt", "MyInt"),
},
}
if err := writer.Write(&buf, hints, dummyItems...); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
expected := `+----------+-------+
| MYSTRING | MYINT |
+----------+-------+
| Foo | 1 |
| Bar | 0 |
+----------+-------+`
if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g {
t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g)
}
}

19
internal/format/writer.go Normal file
View File

@ -0,0 +1,19 @@
package format
import "io"
type OutputMode string
const (
OutputModeWide OutputMode = "wide"
OutputModeCompact OutputMode = "compact"
)
type Hints struct {
Props []Prop
OutputMode OutputMode
}
type Writer interface {
Write(writer io.Writer, hints Hints, data ...any) error
}

View File

@ -57,7 +57,7 @@ func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) {
}
type updateAgentRequest struct {
Status *datastore.AgentStatus `json:"status"`
Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"`
}
func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) {

View File

@ -5,6 +5,7 @@ import (
"strconv"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/go-chi/chi"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
@ -16,9 +17,7 @@ const (
)
type updateSpecRequest struct {
Name string `json:"name"`
Revision int `json:"revision"`
Data map[string]any `json:"data"`
spec.RawSpec
}
func (s *Server) updateSpec(w http.ResponseWriter, r *http.Request) {
@ -34,12 +33,29 @@ func (s *Server) updateSpec(w http.ResponseWriter, r *http.Request) {
return
}
if ok, err := spec.Validate(ctx, updateSpecReq); !ok || err != nil {
data := struct {
Message string `json:"message"`
}{}
var validationErr *spec.ValidationError
if errors.As(err, &validationErr) {
data.Message = validationErr.Error()
}
logger.Error(ctx, "could not validate spec", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data)
return
}
spec, err := s.agentRepo.UpdateSpec(
ctx,
datastore.AgentID(agentID),
updateSpecReq.Name,
updateSpecReq.Revision,
updateSpecReq.Data,
string(updateSpecReq.SpecName()),
updateSpecReq.SpecRevision(),
updateSpecReq.SpecData(),
)
if err != nil {
if errors.Is(err, datastore.ErrUnexpectedRevision) {

View File

@ -1,5 +1,36 @@
package spec
import "errors"
import (
"strings"
var ErrSchemaUnknown = errors.New("schema unknown")
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
var ErrUnknownSchema = errors.New("unknown schema")
type ValidationError struct {
keyErrors []jsonschema.KeyError
}
func (e *ValidationError) Error() string {
var sb strings.Builder
if _, err := sb.WriteString("validation error: "); err != nil {
panic(errors.WithStack(err))
}
for i, err := range e.keyErrors {
if i != 0 {
if _, err := sb.WriteString(", "); err != nil {
panic(errors.WithStack(err))
}
}
if _, err := sb.WriteString(err.Error()); err != nil {
panic(errors.WithStack(err))
}
}
return sb.String()
}

View File

@ -1,35 +0,0 @@
package spec
const NameGateway Name = "gateway.emissary.cadoles.com"
type GatewayID string
type Gateway struct {
Revision int `json:"revision"`
Gateways map[GatewayID]GatewayEntry `json:"gateways"`
}
type GatewayEntry struct {
Address string `json:"address"`
Target string `json:"target"`
}
func (g *Gateway) SpecName() Name {
return NameGateway
}
func (g *Gateway) SpecRevision() int {
return g.Revision
}
func (g *Gateway) SpecData() any {
return struct {
Gateways map[GatewayID]GatewayEntry
}{Gateways: g.Gateways}
}
func NewGatewaySpec() *Gateway {
return &Gateway{
Gateways: make(map[GatewayID]GatewayEntry),
}
}

View File

@ -0,0 +1,17 @@
package gateway
import (
_ "embed"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
//go:embed schema.json
var schema []byte
func init() {
if err := spec.Register(NameGateway, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gateway.emissary.cadoles.com/spec.json",
"title": "GatewaySpec",
"description": "Emissary 'Gateway' specification",
"type": "object",
"properties": {
"gateways": {
"type": "object",
"patternProperties": {
".*": {
"type": "object",
"properties": {
"address": {
"type": "string"
},
"target": {
"type": "string"
}
},
"required": ["address", "target"],
"additionalProperties": false
}
}
}
},
"required": ["gateways"],
"additionalProperties": false
}

View File

@ -0,0 +1,37 @@
package gateway
import "forge.cadoles.com/Cadoles/emissary/internal/spec"
const NameGateway spec.Name = "gateway.emissary.cadoles.com"
type ID string
type Spec struct {
Revision int `json:"revision"`
Gateways map[ID]GatewayEntry `json:"gateways"`
}
type GatewayEntry struct {
Address string `json:"address"`
Target string `json:"target"`
}
func (s *Spec) SpecName() spec.Name {
return NameGateway
}
func (s *Spec) SpecRevision() int {
return s.Revision
}
func (s *Spec) SpecData() any {
return struct {
Gateways map[ID]GatewayEntry
}{Gateways: s.Gateways}
}
func NewSpec() *Spec {
return &Spec{
Gateways: make(map[ID]GatewayEntry),
}
}

View File

@ -0,0 +1,13 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003",
"target": "https://www.cadoles.com",
"foo": "bar"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,11 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,12 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003",
"target": "https://www.cadoles.com"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,75 @@
package gateway
import (
"context"
"encoding/json"
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
type validatorTestCase struct {
Name string
Source string
ExpectedResult bool
}
var validatorTestCases = []validatorTestCase{
{
Name: "SpecOK",
Source: "testdata/spec-ok.json",
ExpectedResult: true,
},
{
Name: "SpecMissingProp",
Source: "testdata/spec-missing-prop.json",
ExpectedResult: false,
},
{
Name: "SpecAdditionalProp",
Source: "testdata/spec-additional-prop.json",
ExpectedResult: false,
},
}
func TestValidator(t *testing.T) {
t.Parallel()
validator := spec.NewValidator()
if err := validator.Register(NameGateway, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
for _, tc := range validatorTestCases {
func(tc *validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
rawSpec, err := ioutil.ReadFile(tc.Source)
if err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
var spec spec.RawSpec
if err := json.Unmarshal(rawSpec, &spec); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
ctx := context.Background()
result, err := validator.Validate(ctx, &spec)
if e, g := tc.ExpectedResult, result; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)
}
if tc.ExpectedResult && err != nil {
t.Errorf("+%v", errors.WithStack(err))
}
})
}(&tc)
}
}

View File

@ -3,13 +3,13 @@ package spec
type Spec interface {
SpecName() Name
SpecRevision() int
SpecData() any
SpecData() map[string]any
}
type RawSpec struct {
Name Name `json:"name"`
Revision int `json:"revision"`
Data any `json:"data"`
Data map[string]any `json:"data"`
}
func (s *RawSpec) SpecName() Name {
@ -20,6 +20,6 @@ func (s *RawSpec) SpecRevision() int {
return s.Revision
}
func (s *RawSpec) SpecData() any {
func (s *RawSpec) SpecData() map[string]any {
return s.Data
}

17
internal/spec/uci/init.go Normal file
View File

@ -0,0 +1,17 @@
package uci
import (
_ "embed"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
//go:embed schema.json
var schema []byte
func init() {
if err := spec.Register(NameUCI, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -0,0 +1,97 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://uci.emissary.cadoles.com/spec.json",
"title": "UCISpec",
"description": "Emissary 'UCI' specification",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"packages": {
"type": "array",
"items": {
"$ref": "#/$defs/package"
}
}
},
"required": ["packages"],
"additionalProperties": false
},
"postImportCommands": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["command", "args"],
"additionalProperties": false
}
}
},
"required": ["config", "postImportCommands"],
"additionalProperties": false,
"$defs": {
"package": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"configs": {
"type": "array",
"items": {
"$ref": "#/$defs/config"
}
}
},
"required": ["name", "configs"],
"additionalProperties": false
},
"config": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"section": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"$ref": "#/$defs/option"
}
}
},
"required": ["name", "section", "options"],
"additionalProperties": false
},
"option": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["list", "option"]
},
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["type", "name", "value"],
"additionalProperties": false
}
}
}

View File

@ -1,10 +1,13 @@
package spec
package uci
import "forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
import (
"forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
)
const NameUCI Name = "uci.emissary.cadoles.com"
const NameUCI spec.Name = "uci.emissary.cadoles.com"
type UCI struct {
type Spec struct {
Revision int `json:"revisions"`
Config *uci.UCI `json:"config"`
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
@ -15,23 +18,23 @@ type UCIPostImportCommand struct {
Args []string `json:"args"`
}
func (u *UCI) SpecName() Name {
func (s *Spec) SpecName() spec.Name {
return NameUCI
}
func (u *UCI) SpecRevision() int {
return u.Revision
func (s *Spec) SpecRevision() int {
return s.Revision
}
func (u *UCI) SpecData() any {
func (s *Spec) SpecData() any {
return struct {
Config *uci.UCI `json:"config"`
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
}{Config: u.Config, PostImportCommands: u.PostImportCommands}
}{Config: s.Config, PostImportCommands: s.PostImportCommands}
}
func NewUCISpec() *UCI {
return &UCI{
func NewSpec() *Spec {
return &Spec{
PostImportCommands: make([]*UCIPostImportCommand, 0),
}
}

View File

@ -0,0 +1,162 @@
{
"name": "uci.emissary.cadoles.com",
"data": {
"config": {
"packages": [
{
"name": "uhttpd",
"configs": [
{
"name": "uhttpd",
"section": "main",
"options": [
{
"type": "list",
"name": "listen_http",
"value": "0.0.0.0:8080"
},
{
"type": "list",
"name": "listen_http",
"value": "[::]:8080"
},
{
"type": "list",
"name": "listen_https",
"value": "0.0.0.0:8443"
},
{
"type": "list",
"name": "listen_https",
"value": "[::]:8443"
},
{
"type": "option",
"name": "redirect_https",
"value": "0"
},
{
"type": "option",
"name": "home",
"value": "/www"
},
{
"type": "option",
"name": "rfc1918_filter",
"value": "1"
},
{
"type": "option",
"name": "max_requests",
"value": "3"
},
{
"type": "option",
"name": "max_connections",
"value": "100"
},
{
"type": "option",
"name": "cert",
"value": "/etc/uhttpd.crt"
},
{
"type": "option",
"name": "key",
"value": "/etc/uhttpd.key"
},
{
"type": "option",
"name": "cgi_prefix",
"value": "/cgi-bin"
},
{
"type": "list",
"name": "lua_prefix",
"value": "/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua"
},
{
"type": "option",
"name": "script_timeout",
"value": "60"
},
{
"type": "option",
"name": "network_timeout",
"value": "30"
},
{
"type": "option",
"name": "http_keepalive",
"value": "20"
},
{
"type": "option",
"name": "tcp_keepalive",
"value": "1"
},
{
"type": "option",
"name": "ubus_prefix"
}
]
},
{
"name": "cert",
"section": "defaults",
"options": [
{
"type": "option",
"name": "days",
"value": "730"
},
{
"type": "option",
"name": "key_type",
"value": "ec"
},
{
"type": "option",
"name": "bits",
"value": "2048"
},
{
"type": "option",
"name": "ec_curve",
"value": "P-256"
},
{
"type": "option",
"name": "country",
"value": "ZZ"
},
{
"type": "option",
"name": "state",
"value": "Somewhere"
},
{
"type": "option",
"name": "location",
"value": "Unknown"
},
{
"type": "option",
"name": "commonname",
"value": "OpenWrt"
}
]
}
]
}
]
},
"postImportCommands": [
{
"command": "reload_config",
"args": []
}
]
},
"revision": 0
}

163
internal/spec/uci/testdata/spec-ok.json vendored Normal file
View File

@ -0,0 +1,163 @@
{
"name": "uci.emissary.cadoles.com",
"data": {
"config": {
"packages": [
{
"name": "uhttpd",
"configs": [
{
"name": "uhttpd",
"section": "main",
"options": [
{
"type": "list",
"name": "listen_http",
"value": "0.0.0.0:8080"
},
{
"type": "list",
"name": "listen_http",
"value": "[::]:8080"
},
{
"type": "list",
"name": "listen_https",
"value": "0.0.0.0:8443"
},
{
"type": "list",
"name": "listen_https",
"value": "[::]:8443"
},
{
"type": "option",
"name": "redirect_https",
"value": "0"
},
{
"type": "option",
"name": "home",
"value": "/www"
},
{
"type": "option",
"name": "rfc1918_filter",
"value": "1"
},
{
"type": "option",
"name": "max_requests",
"value": "3"
},
{
"type": "option",
"name": "max_connections",
"value": "100"
},
{
"type": "option",
"name": "cert",
"value": "/etc/uhttpd.crt"
},
{
"type": "option",
"name": "key",
"value": "/etc/uhttpd.key"
},
{
"type": "option",
"name": "cgi_prefix",
"value": "/cgi-bin"
},
{
"type": "list",
"name": "lua_prefix",
"value": "/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua"
},
{
"type": "option",
"name": "script_timeout",
"value": "60"
},
{
"type": "option",
"name": "network_timeout",
"value": "30"
},
{
"type": "option",
"name": "http_keepalive",
"value": "20"
},
{
"type": "option",
"name": "tcp_keepalive",
"value": "1"
},
{
"type": "option",
"name": "ubus_prefix",
"value": "/ubus"
}
]
},
{
"name": "cert",
"section": "defaults",
"options": [
{
"type": "option",
"name": "days",
"value": "730"
},
{
"type": "option",
"name": "key_type",
"value": "ec"
},
{
"type": "option",
"name": "bits",
"value": "2048"
},
{
"type": "option",
"name": "ec_curve",
"value": "P-256"
},
{
"type": "option",
"name": "country",
"value": "ZZ"
},
{
"type": "option",
"name": "state",
"value": "Somewhere"
},
{
"type": "option",
"name": "location",
"value": "Unknown"
},
{
"type": "option",
"name": "commonname",
"value": "OpenWrt"
}
]
}
]
}
]
},
"postImportCommands": [
{
"command": "reload_config",
"args": []
}
]
},
"revision": 0
}

View File

@ -0,0 +1,70 @@
package uci
import (
"context"
"encoding/json"
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
type validatorTestCase struct {
Name string
Source string
ExpectedResult bool
}
var validatorTestCases = []validatorTestCase{
{
Name: "SpecOK",
Source: "testdata/spec-ok.json",
ExpectedResult: true,
},
{
Name: "SpecMissingProp",
Source: "testdata/spec-missing-prop.json",
ExpectedResult: false,
},
}
func TestValidator(t *testing.T) {
t.Parallel()
validator := spec.NewValidator()
if err := validator.Register(NameUCI, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
for _, tc := range validatorTestCases {
func(tc *validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
rawSpec, err := ioutil.ReadFile(tc.Source)
if err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
var spec spec.RawSpec
if err := json.Unmarshal(rawSpec, &spec); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
ctx := context.Background()
result, err := validator.Validate(ctx, &spec)
if e, g := tc.ExpectedResult, result; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)
}
if tc.ExpectedResult && err != nil {
t.Errorf("+%v", errors.WithStack(err))
}
})
}(&tc)
}
}

View File

@ -0,0 +1,63 @@
package spec
import (
"context"
"encoding/json"
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
type Validator struct {
schemas map[Name]*jsonschema.Schema
}
func (v *Validator) Register(name Name, rawSchema []byte) error {
schema := &jsonschema.Schema{}
if err := json.Unmarshal(rawSchema, schema); err != nil {
return errors.Wrapf(err, "could not register spec shema '%s'", name)
}
v.schemas[name] = schema
return nil
}
func (v *Validator) Validate(ctx context.Context, spec Spec) (bool, error) {
schema, exists := v.schemas[spec.SpecName()]
if !exists {
return false, errors.WithStack(ErrUnknownSchema)
}
state := schema.Validate(ctx, spec.SpecData())
if !state.IsValid() {
return false, errors.WithStack(&ValidationError{*state.Errs})
}
return true, nil
}
func NewValidator() *Validator {
return &Validator{
schemas: make(map[Name]*jsonschema.Schema),
}
}
var defaultValidator = NewValidator()
func Register(name Name, rawSchema []byte) error {
if err := defaultValidator.Register(name, rawSchema); err != nil {
return errors.WithStack(err)
}
return nil
}
func Validate(ctx context.Context, spec Spec) (bool, error) {
ok, err := defaultValidator.Validate(ctx, spec)
if err != nil {
return ok, errors.WithStack(err)
}
return ok, nil
}

View File

@ -7,5 +7,5 @@ tmp/config.yml
prep: make tmp/server.yml
prep: make tmp/agent.yml
daemon: make run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server run"
daemon: make run-emissary-agent EMISSARY_CMD="--debug --config tmp/agent.yml agent run"
# daemon: make run-emissary-agent EMISSARY_CMD="--debug --config tmp/agent.yml agent run"
}