From 3310c09320edddc74a6dca9f3c32aaebe04bf045 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 28 Feb 2023 15:50:35 +0100 Subject: [PATCH] feat: cli client with spec schema validation --- .goreleaser.yaml | 2 + cmd/agent/main.go | 3 +- cmd/server/main.go | 3 +- go.mod | 11 +- go.sum | 23 +++ .../agent/controller/gateway/controller.go | 12 +- .../controller/openwrt/uci_controller.go | 10 +- internal/client/client.go | 16 ++ internal/client/update_agent.go | 50 ++++++ internal/client/update_agent_spec.go | 38 ++++ internal/command/client/agent/count.go | 47 +++++ internal/command/client/agent/flag/flag.go | 34 ++++ internal/command/client/agent/query.go | 39 +++++ internal/command/client/agent/root.go | 19 ++ internal/command/client/agent/spec/get.go | 45 +++++ internal/command/client/agent/spec/root.go | 16 ++ internal/command/client/agent/spec/update.go | 163 ++++++++++++++++++ internal/command/client/agent/update.go | 59 +++++++ internal/command/client/apierr/wrap.go | 91 ++++++++++ internal/command/client/flag/flag.go | 54 ++++++ internal/command/client/flag/util.go | 11 ++ internal/command/client/root.go | 20 +++ internal/command/main.go | 4 + internal/datastore/spec.go | 7 +- internal/format/json/writer.go | 38 ++++ internal/format/prop.go | 18 ++ internal/format/registry.go | 46 +++++ internal/format/table/prop.go | 49 ++++++ internal/format/table/writer.go | 75 ++++++++ internal/format/table/writer_test.go | 86 +++++++++ internal/format/writer.go | 19 ++ internal/server/agent_api.go | 2 +- internal/server/spec_api.go | 28 ++- internal/spec/error.go | 35 +++- internal/spec/gateway.go | 35 ---- internal/spec/gateway/init.go | 17 ++ internal/spec/gateway/schema.json | 29 ++++ internal/spec/gateway/spec.go | 37 ++++ .../testdata/spec-additional-prop.json | 13 ++ .../gateway/testdata/spec-missing-prop.json | 11 ++ internal/spec/gateway/testdata/spec-ok.json | 12 ++ internal/spec/gateway/validator_test.go | 75 ++++++++ internal/spec/spec.go | 10 +- internal/spec/uci/init.go | 17 ++ internal/spec/uci/schema.json | 97 +++++++++++ internal/spec/{uci.go => uci/spec.go} | 25 +-- .../spec/uci/testdata/spec-missing-prop.json | 162 +++++++++++++++++ internal/spec/uci/testdata/spec-ok.json | 163 ++++++++++++++++++ internal/spec/uci/validator_test.go | 70 ++++++++ internal/spec/validator.go | 63 +++++++ modd.conf | 2 +- 51 files changed, 1929 insertions(+), 82 deletions(-) create mode 100644 internal/client/update_agent.go create mode 100644 internal/client/update_agent_spec.go create mode 100644 internal/command/client/agent/count.go create mode 100644 internal/command/client/agent/flag/flag.go create mode 100644 internal/command/client/agent/query.go create mode 100644 internal/command/client/agent/root.go create mode 100644 internal/command/client/agent/spec/get.go create mode 100644 internal/command/client/agent/spec/root.go create mode 100644 internal/command/client/agent/spec/update.go create mode 100644 internal/command/client/agent/update.go create mode 100644 internal/command/client/apierr/wrap.go create mode 100644 internal/command/client/flag/flag.go create mode 100644 internal/command/client/flag/util.go create mode 100644 internal/command/client/root.go create mode 100644 internal/format/json/writer.go create mode 100644 internal/format/prop.go create mode 100644 internal/format/registry.go create mode 100644 internal/format/table/prop.go create mode 100644 internal/format/table/writer.go create mode 100644 internal/format/table/writer_test.go create mode 100644 internal/format/writer.go delete mode 100644 internal/spec/gateway.go create mode 100644 internal/spec/gateway/init.go create mode 100644 internal/spec/gateway/schema.json create mode 100644 internal/spec/gateway/spec.go create mode 100644 internal/spec/gateway/testdata/spec-additional-prop.json create mode 100644 internal/spec/gateway/testdata/spec-missing-prop.json create mode 100644 internal/spec/gateway/testdata/spec-ok.json create mode 100644 internal/spec/gateway/validator_test.go create mode 100644 internal/spec/uci/init.go create mode 100644 internal/spec/uci/schema.json rename internal/spec/{uci.go => uci/spec.go} (55%) create mode 100644 internal/spec/uci/testdata/spec-missing-prop.json create mode 100644 internal/spec/uci/testdata/spec-ok.json create mode 100644 internal/spec/uci/validator_test.go create mode 100644 internal/spec/validator.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 276fbee..18db002 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: diff --git a/cmd/agent/main.go b/cmd/agent/main.go index b04b8e8..68e2837 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -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()) } diff --git a/cmd/server/main.go b/cmd/server/main.go index ebcc12f..47b033b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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()) } diff --git a/go.mod b/go.mod index 6b28c81..2bb8b42 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 59a7364..0ff94a0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/controller/gateway/controller.go b/internal/agent/controller/gateway/controller.go index 5c26e3e..b3b4982 100644 --- a/internal/agent/controller/gateway/controller.go +++ b/internal/agent/controller/gateway/controller.go @@ -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, } } diff --git a/internal/agent/controller/openwrt/uci_controller.go b/internal/agent/controller/openwrt/uci_controller.go index 1b4b3b6..ea37059 100644 --- a/internal/agent/controller/openwrt/uci_controller.go +++ b/internal/agent/controller/openwrt/uci_controller.go @@ -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...) diff --git a/internal/client/client.go b/internal/client/client.go index fab59af..03c32c1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 diff --git a/internal/client/update_agent.go b/internal/client/update_agent.go new file mode 100644 index 0000000..69bb3c5 --- /dev/null +++ b/internal/client/update_agent.go @@ -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 +} diff --git a/internal/client/update_agent_spec.go b/internal/client/update_agent_spec.go new file mode 100644 index 0000000..4f89351 --- /dev/null +++ b/internal/client/update_agent_spec.go @@ -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 +} diff --git a/internal/command/client/agent/count.go b/internal/command/client/agent/count.go new file mode 100644 index 0000000..a9723d0 --- /dev/null +++ b/internal/command/client/agent/count.go @@ -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 + }, + } +} diff --git a/internal/command/client/agent/flag/flag.go b/internal/command/client/agent/flag/flag.go new file mode 100644 index 0000000..ad48034 --- /dev/null +++ b/internal/command/client/agent/flag/flag.go @@ -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 +} diff --git a/internal/command/client/agent/query.go b/internal/command/client/agent/query.go new file mode 100644 index 0000000..86117f2 --- /dev/null +++ b/internal/command/client/agent/query.go @@ -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 + }, + } +} diff --git a/internal/command/client/agent/root.go b/internal/command/client/agent/root.go new file mode 100644 index 0000000..d7392b2 --- /dev/null +++ b/internal/command/client/agent/root.go @@ -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(), + }, + } +} diff --git a/internal/command/client/agent/spec/get.go b/internal/command/client/agent/spec/get.go new file mode 100644 index 0000000..2fae053 --- /dev/null +++ b/internal/command/client/agent/spec/get.go @@ -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 + }, + } +} diff --git a/internal/command/client/agent/spec/root.go b/internal/command/client/agent/spec/root.go new file mode 100644 index 0000000..695add5 --- /dev/null +++ b/internal/command/client/agent/spec/root.go @@ -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(), + }, + } +} diff --git a/internal/command/client/agent/spec/update.go b/internal/command/client/agent/spec/update.go new file mode 100644 index 0000000..612e67d --- /dev/null +++ b/internal/command/client/agent/spec/update.go @@ -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 +} diff --git a/internal/command/client/agent/update.go b/internal/command/client/agent/update.go new file mode 100644 index 0000000..ed53bd3 --- /dev/null +++ b/internal/command/client/agent/update.go @@ -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 + }, + } +} diff --git a/internal/command/client/apierr/wrap.go b/internal/command/client/apierr/wrap.go new file mode 100644 index 0000000..952c2c3 --- /dev/null +++ b/internal/command/client/apierr/wrap.go @@ -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) +} diff --git a/internal/command/client/flag/flag.go b/internal/command/client/flag/flag.go new file mode 100644 index 0000000..f9bc8af --- /dev/null +++ b/internal/command/client/flag/flag.go @@ -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), + } +} diff --git a/internal/command/client/flag/util.go b/internal/command/client/flag/util.go new file mode 100644 index 0000000..de2af79 --- /dev/null +++ b/internal/command/client/flag/util.go @@ -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 +} diff --git a/internal/command/client/root.go b/internal/command/client/root.go new file mode 100644 index 0000000..148ae2d --- /dev/null +++ b/internal/command/client/root.go @@ -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(), + }, + } +} diff --git a/internal/command/main.go b/internal/command/main.go index dea056a..de9c4f2 100644 --- a/internal/command/main.go +++ b/internal/command/main.go @@ -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) { diff --git a/internal/datastore/spec.go b/internal/datastore/spec.go index eb9231f..b4b93c8 100644 --- a/internal/datastore/spec.go +++ b/internal/datastore/spec.go @@ -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) -} diff --git a/internal/format/json/writer.go b/internal/format/json/writer.go new file mode 100644 index 0000000..e7da083 --- /dev/null +++ b/internal/format/json/writer.go @@ -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{} diff --git a/internal/format/prop.go b/internal/format/prop.go new file mode 100644 index 0000000..13c0c49 --- /dev/null +++ b/internal/format/prop.go @@ -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} +} diff --git a/internal/format/registry.go b/internal/format/registry.go new file mode 100644 index 0000000..bfe1e58 --- /dev/null +++ b/internal/format/registry.go @@ -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 +} diff --git a/internal/format/table/prop.go b/internal/format/table/prop.go new file mode 100644 index 0000000..1501faa --- /dev/null +++ b/internal/format/table/prop.go @@ -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()) + } +} diff --git a/internal/format/table/writer.go b/internal/format/table/writer.go new file mode 100644 index 0000000..bbf7227 --- /dev/null +++ b/internal/format/table/writer.go @@ -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{} diff --git a/internal/format/table/writer_test.go b/internal/format/table/writer_test.go new file mode 100644 index 0000000..b23dae9 --- /dev/null +++ b/internal/format/table/writer_test.go @@ -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) + } +} diff --git a/internal/format/writer.go b/internal/format/writer.go new file mode 100644 index 0000000..bfc214a --- /dev/null +++ b/internal/format/writer.go @@ -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 +} diff --git a/internal/server/agent_api.go b/internal/server/agent_api.go index abba7d0..6f0d6d3 100644 --- a/internal/server/agent_api.go +++ b/internal/server/agent_api.go @@ -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) { diff --git a/internal/server/spec_api.go b/internal/server/spec_api.go index f334511..3d47b7e 100644 --- a/internal/server/spec_api.go +++ b/internal/server/spec_api.go @@ -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) { diff --git a/internal/spec/error.go b/internal/spec/error.go index e41cc75..af56ec7 100644 --- a/internal/spec/error.go +++ b/internal/spec/error.go @@ -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() +} diff --git a/internal/spec/gateway.go b/internal/spec/gateway.go deleted file mode 100644 index bdf697b..0000000 --- a/internal/spec/gateway.go +++ /dev/null @@ -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), - } -} diff --git a/internal/spec/gateway/init.go b/internal/spec/gateway/init.go new file mode 100644 index 0000000..af01b4d --- /dev/null +++ b/internal/spec/gateway/init.go @@ -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)) + } +} diff --git a/internal/spec/gateway/schema.json b/internal/spec/gateway/schema.json new file mode 100644 index 0000000..d307067 --- /dev/null +++ b/internal/spec/gateway/schema.json @@ -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 +} \ No newline at end of file diff --git a/internal/spec/gateway/spec.go b/internal/spec/gateway/spec.go new file mode 100644 index 0000000..31f4e97 --- /dev/null +++ b/internal/spec/gateway/spec.go @@ -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), + } +} diff --git a/internal/spec/gateway/testdata/spec-additional-prop.json b/internal/spec/gateway/testdata/spec-additional-prop.json new file mode 100644 index 0000000..7f40339 --- /dev/null +++ b/internal/spec/gateway/testdata/spec-additional-prop.json @@ -0,0 +1,13 @@ +{ + "name": "gateway.emissary.cadoles.com", + "data": { + "gateways": { + "cadoles.com": { + "address": ":3003", + "target": "https://www.cadoles.com", + "foo": "bar" + } + } + }, + "revision": 0 +} \ No newline at end of file diff --git a/internal/spec/gateway/testdata/spec-missing-prop.json b/internal/spec/gateway/testdata/spec-missing-prop.json new file mode 100644 index 0000000..623e027 --- /dev/null +++ b/internal/spec/gateway/testdata/spec-missing-prop.json @@ -0,0 +1,11 @@ +{ + "name": "gateway.emissary.cadoles.com", + "data": { + "gateways": { + "cadoles.com": { + "address": ":3003" + } + } + }, + "revision": 0 +} \ No newline at end of file diff --git a/internal/spec/gateway/testdata/spec-ok.json b/internal/spec/gateway/testdata/spec-ok.json new file mode 100644 index 0000000..1cd94c8 --- /dev/null +++ b/internal/spec/gateway/testdata/spec-ok.json @@ -0,0 +1,12 @@ +{ + "name": "gateway.emissary.cadoles.com", + "data": { + "gateways": { + "cadoles.com": { + "address": ":3003", + "target": "https://www.cadoles.com" + } + } + }, + "revision": 0 +} \ No newline at end of file diff --git a/internal/spec/gateway/validator_test.go b/internal/spec/gateway/validator_test.go new file mode 100644 index 0000000..b3c41ab --- /dev/null +++ b/internal/spec/gateway/validator_test.go @@ -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) + } +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go index 83f7253..70ca457 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -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"` + Name Name `json:"name"` + Revision int `json:"revision"` + 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 } diff --git a/internal/spec/uci/init.go b/internal/spec/uci/init.go new file mode 100644 index 0000000..348be8a --- /dev/null +++ b/internal/spec/uci/init.go @@ -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)) + } +} diff --git a/internal/spec/uci/schema.json b/internal/spec/uci/schema.json new file mode 100644 index 0000000..fedb60b --- /dev/null +++ b/internal/spec/uci/schema.json @@ -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 + } + } +} \ No newline at end of file diff --git a/internal/spec/uci.go b/internal/spec/uci/spec.go similarity index 55% rename from internal/spec/uci.go rename to internal/spec/uci/spec.go index 4a3ee4b..b41c7d0 100644 --- a/internal/spec/uci.go +++ b/internal/spec/uci/spec.go @@ -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), } } diff --git a/internal/spec/uci/testdata/spec-missing-prop.json b/internal/spec/uci/testdata/spec-missing-prop.json new file mode 100644 index 0000000..ba8e4ac --- /dev/null +++ b/internal/spec/uci/testdata/spec-missing-prop.json @@ -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 +} \ No newline at end of file diff --git a/internal/spec/uci/testdata/spec-ok.json b/internal/spec/uci/testdata/spec-ok.json new file mode 100644 index 0000000..0b666ed --- /dev/null +++ b/internal/spec/uci/testdata/spec-ok.json @@ -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 +} \ No newline at end of file diff --git a/internal/spec/uci/validator_test.go b/internal/spec/uci/validator_test.go new file mode 100644 index 0000000..f058306 --- /dev/null +++ b/internal/spec/uci/validator_test.go @@ -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) + } +} diff --git a/internal/spec/validator.go b/internal/spec/validator.go new file mode 100644 index 0000000..00c31ed --- /dev/null +++ b/internal/spec/validator.go @@ -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 +} diff --git a/modd.conf b/modd.conf index b677f4a..cf0fdfa 100644 --- a/modd.conf +++ b/modd.conf @@ -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" } \ No newline at end of file