diff --git a/Makefile b/Makefile index 51b8277..c26dd25 100644 --- a/Makefile +++ b/Makefile @@ -32,3 +32,6 @@ clean: rm jx lint: golangci-lint run --verbose ./... +vulncheck: + govulncheck ./... + go vet ./... diff --git a/README.md b/README.md index 5090c57..dcba9d1 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,14 @@ Read a resource document from an http endpoint. Resources: -* [container](examples/container.yaml) [schema](internal/resource/schemas/container.schema.json) -* [container-image](examples/container-image.yaml) [schema](internal/resource/schemas/container-image.schema.json) -* [container-network](examples/container-network.yaml) [schema](internal/resource/schemas/container-network.schema.json) -* [exec](examples/exec.yaml) [schema](internal/resource/schemas/exec.schema.json) -* [file](examples/file.yaml) [schema](internal/resource/schemas/file.schema.json) -* [http](examples/http.yaml) [schema](internal/resource/schemas/http.schema.json) -* [iptable](examples/iptable.yaml) [schema](internal/resource/schemas/iptable.schema.json) -* [network_route](examples/network_route.yaml) [schema](internal/resource/schemas/network_route.schema.json) -* [package](examples/package.yaml) [schema](internal/resource/schemas/package.schema.json) -* [user](examples/user.yaml) [schema](internal/resource/schemas/user.schema.json) +* [container](examples/container.jx.yaml) [schema](internal/resource/schemas/container.schema.json) +* [container-image](examples/container-image.jx.yaml) [schema](internal/resource/schemas/container-image.schema.json) +* [container-network](examples/container-network.jx.yaml) [schema](internal/resource/schemas/container-network.schema.json) +* [exec](examples/exec.jx.yaml) [schema](internal/resource/schemas/exec.schema.json) +* [file](examples/file.jx.yaml) [schema](internal/resource/schemas/file.schema.json) +* [group](examples/group.jx.yaml) [schema](internal/resource/schemas/group.schema.json) +* [http](examples/http.jx.yaml) [schema](internal/resource/schemas/http.schema.json) +* [iptable](examples/iptable.jx.yaml) [schema](internal/resource/schemas/iptable.schema.json) +* [network_route](examples/network_route.jx.yaml) [schema](internal/resource/schemas/network_route.schema.json) +* [package](examples/package.jx.yaml) [schema](internal/resource/schemas/package.schema.json) +* [user](examples/user.jx.yaml) [schema](internal/resource/schemas/user.schema.json) diff --git a/cmd/cli/main.go b/cmd/cli/main.go index c3a1c83..e019fa9 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -4,7 +4,6 @@ package main import ( "context" - "decl/internal/codec" "decl/internal/config" "decl/internal/resource" "decl/internal/source" @@ -170,15 +169,6 @@ func ImportSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { } } - /* - switch *GlobalOformat { - case FormatYaml: - encoder = resource.NewYAMLEncoder(output) - case FormatJson: - encoder = resource.NewJSONEncoder(output) - } - */ - slog.Info("main.ImportResource", "args", os.Args, "output", GlobalOutput) outputTarget, err := target.TargetTypes.New(GlobalOutput) if err != nil { @@ -209,7 +199,7 @@ func ImportSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { if *GlobalQuiet { for _, dr := range d.Resources() { - if _, e := output.Write([]byte(dr.Resource().URI())); e != nil { + if _, e := output.Write([]byte(fmt.Sprintf("%s\n", dr.Resource().URI()))); e != nil { return e } } @@ -245,7 +235,6 @@ func ApplySubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { } } - var encoder codec.Encoder documents := make([]*resource.Document, 0, 100) for _, source := range cmd.Args() { loaded := LoadSourceURI(source) @@ -270,12 +259,12 @@ func ApplySubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { return e } - switch *GlobalOformat { - case FormatYaml: - encoder = codec.NewYAMLEncoder(output) - case FormatJson: - encoder = codec.NewJSONEncoder(output) + outputTarget, err := target.TargetTypes.New(GlobalOutput) + if err != nil { + slog.Error("Failed opening target", "error", err) } + defer outputTarget.Close() + if *GlobalQuiet { for _, dr := range d.Resources() { if _, e := output.Write([]byte(dr.Resource().URI())); e != nil { @@ -283,8 +272,9 @@ func ApplySubCommand(cmd *flag.FlagSet, output io.Writer) (err error) { } } } else { - if documentGenerateErr := encoder.Encode(d); documentGenerateErr != nil { - return documentGenerateErr + slog.Info("main.Apply", "outputTarget", outputTarget, "type", outputTarget.Type()) + if outputErr := outputTarget.EmitResources([]*resource.Document{d}, nil); outputErr != nil { + return outputErr } } } diff --git a/examples/certificate.jx.yaml b/examples/certificate.jx.yaml new file mode 100644 index 0000000..42a3501 --- /dev/null +++ b/examples/certificate.jx.yaml @@ -0,0 +1,15 @@ +resources: +- type: pki + transition: create + config: myca + attributes: + privatekeyref: file://myca_privkey.pem + publickeyref: file://myca_pubkey.pem + certificateref: file://myca_cert.pem +- type: pki + transition: update + attributes: + signedbyref: pki://myca_privkey.pem + privatekeyref: file://mycert_key.pem + publickeyref: file://mycert_pubkey.pem + certificateref: file://mycert.pem diff --git a/examples/config/cert.cfg.jx.yaml b/examples/config/cert.cfg.jx.yaml new file mode 100644 index 0000000..5ea7612 --- /dev/null +++ b/examples/config/cert.cfg.jx.yaml @@ -0,0 +1,43 @@ +configurations: +- name: myca + type: certificate + values: + certtemplate: + serialnumber: 2024 + subject: + organization: + - RKH + country: + - US + province: + - CA + locality: + - San Francisco + streetaddress: + - 0 cert st + postalcode: + - 94101 + notbefore: 2024-07-10 + notafter: 2025-07-10 + basicconstraintsvalid: true + isca: true +- name: mycert + type: certificate + values: + certtemplate: + serialnumber: 2025 + subject: + organization: + - RKH + country: + - US + province: + - CA + locality: + - San Francisco + streetaddress: + - 0 cert st + postalcode: + - 94101 + notbefore: 2024-07-10 + notafter: 2025-07-10 diff --git a/examples/container-image.jx.yaml b/examples/container-image.jx.yaml new file mode 100644 index 0000000..1c8c5c6 --- /dev/null +++ b/examples/container-image.jx.yaml @@ -0,0 +1,5 @@ +resources: +- type: container-image + transition: read + attributes: + name: nginx:latest diff --git a/examples/container.yaml b/examples/container.jx.yaml similarity index 100% rename from examples/container.yaml rename to examples/container.jx.yaml diff --git a/examples/file.yaml b/examples/file.jx.yaml similarity index 100% rename from examples/file.yaml rename to examples/file.jx.yaml diff --git a/examples/group.jx.yaml b/examples/group.jx.yaml new file mode 100644 index 0000000..feb103b --- /dev/null +++ b/examples/group.jx.yaml @@ -0,0 +1,6 @@ +resources: +- type: group + transition: create + attributes: + name: "testgroup" + gid: "12001" diff --git a/examples/iptable.jx.yaml b/examples/iptable.jx.yaml new file mode 100644 index 0000000..7b4bec4 --- /dev/null +++ b/examples/iptable.jx.yaml @@ -0,0 +1,11 @@ +resources: +- type: iptable + transition: create + attributes: + id: 1 + table: filter + chain: INPUT + jump: LIBVIRT_INP + state: present + resourcetype: rule + diff --git a/examples/package.jx.yaml b/examples/package.jx.yaml new file mode 100644 index 0000000..976bff0 --- /dev/null +++ b/examples/package.jx.yaml @@ -0,0 +1,9 @@ +resources: + - type: package + transition: create + attributes: + name: zip + version: 3.0-12build2 + type: apt + state: present + diff --git a/examples/user.yaml b/examples/user.jx.yaml similarity index 100% rename from examples/user.yaml rename to examples/user.jx.yaml diff --git a/go.mod b/go.mod index 266ef8b..cf931ca 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module decl -go 1.22.1 +go 1.22.5 require ( gitea.rosskeen.house/pylon/luaruntime v0.0.0-20240513200425-f413d8adf7b3 gitea.rosskeen.house/rosskeen.house/machine v0.0.0-20240520193117-1835255b6d02 - github.com/docker/docker v25.0.5+incompatible + github.com/docker/docker v27.0.3+incompatible github.com/docker/go-connections v0.5.0 github.com/opencontainers/image-spec v1.1.0 github.com/sters/yaml-diff v1.3.2 @@ -28,6 +28,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -41,7 +42,9 @@ require ( go.opentelemetry.io/otel/metric v1.25.0 // indirect go.opentelemetry.io/otel/sdk v1.25.0 // indirect go.opentelemetry.io/otel/trace v1.25.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index 5d7501b..9fbd16c 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= -github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v27.0.3+incompatible h1:aBGI9TeQ4MPlhquTQKq9XbK79rKFVwXNUAYz9aXyEBE= +github.com/docker/docker v27.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -58,6 +58,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -111,16 +113,16 @@ go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7e golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -131,12 +133,12 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/codec/types.go b/internal/codec/types.go index 6c6b26b..49fe0fb 100644 --- a/internal/codec/types.go +++ b/internal/codec/types.go @@ -3,6 +3,8 @@ package codec import ( + "io" + "fmt" "errors" "encoding/json" "gopkg.in/yaml.v3" @@ -23,7 +25,7 @@ func (f *Format) Validate() error { case FormatYaml, FormatJson, FormatProtoBuf: return nil default: - return ErrInvalidFormat + return fmt.Errorf("%w: %s", ErrInvalidFormat, *f) } } @@ -59,3 +61,20 @@ func (f *Format) UnmarshalYAML(value *yaml.Node) error { } return f.UnmarshalValue(s) } + +func (f Format) Encoder(w io.Writer) Encoder { + return NewEncoder(w, f) +} + +func (f Format) Decoder(r io.Reader) Decoder { + return NewDecoder(r, f) +} + +func (f Format) Serialize(object any, w io.Writer) error { + return f.Encoder(w).Encode(object) +} + +func (f Format) Deserialize(r io.Reader, object any) error { + return f.Decoder(r).Decode(object) +} + diff --git a/internal/command/command.go b/internal/command/command.go index 45a8882..461e69b 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -6,6 +6,7 @@ import ( _ "context" "encoding/json" "fmt" + "errors" "gopkg.in/yaml.v3" "io" "log/slog" @@ -17,8 +18,11 @@ import ( "decl/internal/codec" ) +var ErrUnknownCommand error = errors.New("Unable to find command in path") + type CommandExecutor func(value any) ([]byte, error) type CommandExtractAttributes func(output []byte, target any) error +type CommandExists func() error type CommandArg string @@ -30,10 +34,17 @@ type Command struct { FailOnError bool `json:"failonerror" yaml:"failonerror"` Executor CommandExecutor `json:"-" yaml:"-"` Extractor CommandExtractAttributes `json:"-" yaml:"-"` + CommandExists CommandExists `json:"-" yaml:"-"` } func NewCommand() *Command { c := &Command{ Split: true, FailOnError: true } + c.CommandExists = func() error { + if _, err := exec.LookPath(c.Path); err != nil { + return fmt.Errorf("%w - %w", ErrUnknownCommand, err) + } + return nil + } c.Executor = func(value any) ([]byte, error) { args, err := c.Template(value) if err != nil { @@ -87,10 +98,7 @@ func (c *Command) SetCmdEnv(cmd *exec.Cmd) { } func (c *Command) Exists() bool { - if _, err := exec.LookPath(c.Path); err != nil { - return false - } - return true + return c.CommandExists() == nil } func (c *Command) Template(value any) ([]string, error) { diff --git a/internal/config/certificate.go b/internal/config/certificate.go new file mode 100644 index 0000000..4bd9ecb --- /dev/null +++ b/internal/config/certificate.go @@ -0,0 +1,83 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package config + +import ( + "context" + "io" + "net/url" + "decl/internal/codec" + "encoding/json" + "gopkg.in/yaml.v3" + "crypto/x509" +) + +func init() { + ConfigTypes.Register([]string{"certificate"}, func(u *url.URL) Configuration { + c := NewCertificate() + return c + }) +} + +type Certificate map[string]*x509.Certificate + +func NewCertificate() *Certificate { + c := make(Certificate) + return &c +} + +func (c *Certificate) Read(ctx context.Context) ([]byte, error) { + return nil, nil +} + +func (c *Certificate) Load(r io.Reader) (err error) { + err = codec.NewYAMLDecoder(r).Decode(c) + if err == nil { + _, err = c.Read(context.Background()) + } + return err +} + +func (c *Certificate) LoadYAML(yamlData string) (err error) { + err = codec.NewYAMLStringDecoder(yamlData).Decode(c) + if err == nil { + _, err = c.Read(context.Background()) + } + return err +} + +func (c *Certificate) UnmarshalJSON(data []byte) error { + if unmarshalErr := json.Unmarshal(data, c); unmarshalErr != nil { + return unmarshalErr + } + return nil +} + +func (c *Certificate) UnmarshalYAML(value *yaml.Node) error { + type decodeCertificate Certificate + if unmarshalErr := value.Decode((*decodeCertificate)(c)); unmarshalErr != nil { + return unmarshalErr + } + return nil +} + +func (c *Certificate) Clone() Configuration { + jsonGeneric, _ := json.Marshal(c) + clone := NewCertificate() + if unmarshalErr := json.Unmarshal(jsonGeneric, &clone); unmarshalErr != nil { + panic(unmarshalErr) + } + return clone +} + +func (c *Certificate) Type() string { + return "certificate" +} + +func (c *Certificate) GetValue(name string) (result any, err error) { + var ok bool + if result, ok = (*c)[name]; !ok { + err = ErrUnknownConfigurationKey + } + return +} diff --git a/internal/config/certificate_test.go b/internal/config/certificate_test.go new file mode 100644 index 0000000..1739208 --- /dev/null +++ b/internal/config/certificate_test.go @@ -0,0 +1,33 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package config + +import ( + "github.com/stretchr/testify/assert" + "testing" + "crypto/x509" +) + +func TestNewCertificateConfig(t *testing.T) { + c := NewCertificate() + assert.NotNil(t, c) +} + +func TestNewCertificateConfigYAML(t *testing.T) { + c := NewCertificate() + assert.NotNil(t, c) + + config := ` +catemplate: + subject: + organization: + - RKH + notbefore: 2024-07-10 +` + + yamlErr := c.LoadYAML(config) + assert.Nil(t, yamlErr) + crt, err := c.GetValue("catemplate") + assert.Nil(t, err) + assert.Equal(t, []string{"RKH"}, crt.(*x509.Certificate).Subject.Organization) +} diff --git a/internal/config/file.go b/internal/config/file.go index 67599db..7eea628 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -43,8 +43,11 @@ func NewConfigFileFromURI(u *url.URL) *ConfigFile { func NewConfigFileSource(u *url.URL) *ConfigFile { t := NewConfigFileFromURI(u) t.reader,_ = transport.NewReader(u) - if formatErr := t.Format.Set(t.reader.ContentType()); formatErr != nil { - panic(formatErr) + contentType := codec.Format(t.reader.ContentType()) + if contentType.Validate() == nil { + if formatErr := t.Format.Set(t.reader.ContentType()); formatErr != nil { + panic(formatErr) + } } t.decoder = codec.NewDecoder(t.reader, t.Format) return t @@ -53,8 +56,11 @@ func NewConfigFileSource(u *url.URL) *ConfigFile { func NewConfigFileTarget(u *url.URL) *ConfigFile { t := NewConfigFileFromURI(u) t.writer,_ = transport.NewWriter(u) - if formatErr := t.Format.Set(t.writer.ContentType()); formatErr != nil { - panic(formatErr) + contentType := codec.Format(t.writer.ContentType()) + if contentType.Validate() == nil { + if formatErr := t.Format.Set(t.writer.ContentType()); formatErr != nil { + panic(formatErr) + } } t.encoder = codec.NewEncoder(t.writer, t.Format) return t diff --git a/internal/config/generic.go b/internal/config/generic.go index 8ea7194..65c7d3f 100644 --- a/internal/config/generic.go +++ b/internal/config/generic.go @@ -10,36 +10,36 @@ import ( func init() { ConfigTypes.Register([]string{"generic"}, func(u *url.URL) Configuration { - g := NewGeneric() + g := NewGeneric[any]() return g }) } -type Generic map[string]any +type Generic[Value any] map[string]Value -func NewGeneric() *Generic { - g := make(Generic) +func NewGeneric[Value any]() *Generic[Value] { + g := make(Generic[Value]) return &g } -func (g *Generic) Clone() Configuration { +func (g *Generic[Value]) Clone() Configuration { jsonGeneric, _ := json.Marshal(g) - clone := make(Generic) - if unmarshalErr := json.Unmarshal(jsonGeneric, &clone); unmarshalErr != nil { + clone := NewGeneric[Value]() + if unmarshalErr := json.Unmarshal(jsonGeneric, clone); unmarshalErr != nil { panic(unmarshalErr) } - return &clone + return clone } -func (g *Generic) Type() string { +func (g *Generic[Value]) Type() string { return "generic" } -func (g *Generic) Read(context.Context) ([]byte, error) { +func (g *Generic[Value]) Read(context.Context) ([]byte, error) { return nil, nil } -func (g *Generic) GetValue(name string) (result any, err error) { +func (g *Generic[Value]) GetValue(name string) (result any, err error) { var ok bool if result, ok = (*g)[name]; !ok { err = ErrUnknownConfigurationKey diff --git a/internal/config/generic_test.go b/internal/config/generic_test.go index ac25699..6dc278b 100644 --- a/internal/config/generic_test.go +++ b/internal/config/generic_test.go @@ -8,6 +8,6 @@ import ( ) func TestNewGenericConfig(t *testing.T) { - g := NewGeneric() + g := NewGeneric[any]() assert.NotNil(t, g) } diff --git a/internal/config/schemas/block.schema.json b/internal/config/schemas/block.schema.json index cdd2d23..6d33ae6 100644 --- a/internal/config/schemas/block.schema.json +++ b/internal/config/schemas/block.schema.json @@ -13,10 +13,13 @@ "type": { "type": "string", "description": "Config type name.", - "enum": [ "generic", "exec" ] + "enum": [ "generic", "exec", "certificate" ] }, "values": { - "type": "object" + "oneOf": [ + { "type": "object" }, + { "$ref": "certificate.schema.json" } + ] } } } diff --git a/internal/config/schemas/certificate.schema.json b/internal/config/schemas/certificate.schema.json new file mode 100644 index 0000000..27bc4d6 --- /dev/null +++ b/internal/config/schemas/certificate.schema.json @@ -0,0 +1,62 @@ +{ + "$id": "certificate.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "certificate", + "type": "object", + "required": [ "path", "filetype" ], + "properties": { + "SerialNumber": { + "type": "integer", + "description": "Serial number", + "minLength": 1 + }, + "Issuer": { + "$ref": "pkixname.schema.json" + }, + "Subject": { + "$ref": "pkixname.schema.json" + }, + "NotBefore": { + "type": "string", + "format": "date-time", + "description": "Cert is not valid before time in YYYY-MM-DDTHH:MM:SS.sssssssssZ format." + }, + "NotAfter": { + "type": "string", + "format": "date-time", + "description": "Cert is not valid after time in YYYY-MM-DDTHH:MM:SS.sssssssssZ format." + }, + "KeyUsage": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "description": "Actions valid for a key. E.g. 1 = KeyUsageDigitalSignature" + }, + "ExtKeyUsage": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 13 + }, + "description": "Extended set of actions valid for a key" + }, + "BasicConstraintsValid": { + "type": "boolean", + "description": "BasicConstraintsValid indicates whether IsCA, MaxPathLen, and MaxPathLenZero are valid" + }, + "IsCA": { + "type": "boolean", + "description": "" + } + } +} diff --git a/internal/config/schemas/config.schema.json b/internal/config/schemas/config.schema.json new file mode 100644 index 0000000..d4d8e41 --- /dev/null +++ b/internal/config/schemas/config.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "config.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "config", + "type": "object", + "required": [ "configurations" ], + "properties": { + "configurations": { + "type": "array", + "description": "Configurations list", + "items": { + "oneOf": [ + { "$ref": "block.schema.json" } + ] + } + } + } +} diff --git a/internal/config/schemas/pkixname.schema.json b/internal/config/schemas/pkixname.schema.json new file mode 100644 index 0000000..4a8dcdf --- /dev/null +++ b/internal/config/schemas/pkixname.schema.json @@ -0,0 +1,65 @@ +{ + "$id": "pkixname.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "pkixname", + "type": "object", + "properties": { + "Country": { + "type": "array", + "description": "Country name", + "items": { + "type": "string" + } + }, + "Organization": { + "type": "array", + "description": "Organization name", + "items": { + "type": "string" + } + }, + "OrganizationalUnit": { + "type": "array", + "description": "Organizational Unit name", + "items": { + "type": "string" + } + }, + "Locality": { + "type": "array", + "description": "Locality name", + "items": { + "type": "string" + } + }, + "Province": { + "type": "array", + "description": "Province name", + "items": { + "type": "string" + } + }, + "StreetAddress": { + "type": "array", + "description": "Street address", + "items": { + "type": "string" + } + }, + "PostalCode": { + "type": "array", + "description": "Postal Code", + "items": { + "type": "string" + } + }, + "SerialNumber": { + "type": "string", + "description": "" + }, + "CommonName": { + "type": "string", + "description": "Name" + } + } +} diff --git a/internal/resource/container.go b/internal/resource/container.go index c4248d2..d2dc4c3 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -36,7 +36,7 @@ type ContainerClient interface { ContainerInspect(context.Context, string) (types.ContainerJSON, error) ContainerRemove(context.Context, string, container.RemoveOptions) error ContainerStop(context.Context, string, container.StopOptions) error - ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) + ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) Close() error } @@ -79,6 +79,7 @@ type Container struct { config ConfigurationValueGetter apiClient ContainerClient + Resources ResourceMapper `json:"-" yaml:"-"` } func init() { @@ -103,6 +104,10 @@ func NewContainer(containerClientApi ContainerClient) *Container { } } +func (c *Container) SetResourceMapper(resources ResourceMapper) { + c.Resources = resources +} + func (c *Container) Clone() Resource { return &Container { Id: c.Id, diff --git a/internal/resource/container_image.go b/internal/resource/container_image.go index d769129..f945ad2 100644 --- a/internal/resource/container_image.go +++ b/internal/resource/container_image.go @@ -23,9 +23,9 @@ _ "os/exec" ) type ContainerImageClient interface { - ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) + ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) - ImageRemove(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]image.DeleteResponse, error) + ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) Close() error } @@ -44,12 +44,14 @@ type ContainerImage struct { config ConfigurationValueGetter apiClient ContainerImageClient + Resources ResourceMapper `json:"-" yaml:"-"` } func init() { ResourceTypes.Register([]string{"container-image"}, func(u *url.URL) Resource { c := NewContainerImage(nil) - c.Name = strings.Join([]string{u.Hostname(), u.Path}, ":") + c.Name = ContainerImageNameFromURI(u) + slog.Info("NewContainerImage", "container", c) return c }) } @@ -68,6 +70,10 @@ func NewContainerImage(containerClientApi ContainerImageClient) *ContainerImage } } +func (c *ContainerImage) SetResourceMapper(resources ResourceMapper) { + c.Resources = resources +} + func (c *ContainerImage) Clone() Resource { return &ContainerImage { Id: c.Id, @@ -91,9 +97,9 @@ func (c *ContainerImage) StateMachine() machine.Stater { return c.stater } -func (c *ContainerImage) URI() string { +func URIFromContainerImageName(imageName string) string { var host, namespace, repo string - elements := strings.Split(c.Name, "/") + elements := strings.Split(imageName, "/") switch len(elements) { case 1: repo = elements[0] @@ -111,6 +117,31 @@ func (c *ContainerImage) URI() string { return fmt.Sprintf("container-image://%s/%s", host, strings.Join([]string{namespace, repo}, "/")) } +// Reconstruct the image name from a given parsed URL +func ContainerImageNameFromURI(u *url.URL) string { + var host string = u.Hostname() +// var host, namespace, repo string + elements := strings.FieldsFunc(u.RequestURI(), func(c rune) bool { return c == '/' }) + slog.Info("ContainerImageNameFromURI", "url", u, "elements", elements) +/* + switch len(elements) { + case 1: + repo = elements[0] + case 2: + namespace = elements[0] + repo = elements[1] + } +*/ + if host == "" { + return strings.Join(elements, "/") + } + return fmt.Sprintf("%s/%s", host, strings.Join(elements, "/")) +} + +func (c *ContainerImage) URI() string { + return URIFromContainerImageName(c.Name) +} + func (c *ContainerImage) SetURI(uri string) error { resourceUri, e := url.Parse(uri) if e == nil { @@ -209,30 +240,49 @@ func (c *ContainerImage) Create(ctx context.Context) error { return nil } -func (c *ContainerImage) Read(ctx context.Context) ([]byte, error) { - out, err := c.apiClient.ImagePull(ctx, c.Name, types.ImagePullOptions{}) - slog.Info("Read()", "name", c.Name, "error", err) - - _, outputErr := io.ReadAll(out) - +func (c *ContainerImage) Pull(ctx context.Context) error { + out, err := c.apiClient.ImagePull(ctx, c.Name, image.PullOptions{}) + slog.Info("ContainerImage.Pull()", "name", c.Name, "error", err) + if err == nil { + if _, outputErr := io.ReadAll(out); outputErr != nil { + return fmt.Errorf("%w: %s %s", outputErr, c.Type(), c.Name) + } + } else { + return fmt.Errorf("%w: %s %s", err, c.Type(), c.Name) + } + return nil +} +func (c *ContainerImage) Inspect(ctx context.Context) (imageInspect types.ImageInspect) { + var err error + imageInspect, _, err = c.apiClient.ImageInspectWithRaw(ctx, c.Name) if err != nil { - return nil, fmt.Errorf("%w: %s %s", err, c.Type(), c.Name) + panic(err) } + return +} - if outputErr != nil { - return nil, fmt.Errorf("%w: %s %s", outputErr, c.Type(), c.Name) - } +func (c *ContainerImage) Read(ctx context.Context) (resourceYaml []byte, err error) { + defer func() { + if r := recover(); r != nil { + c.State = "absent" + resourceYaml = nil + err = fmt.Errorf("%w", r.(error)) + } + }() - imageInspect, _, err := c.apiClient.ImageInspectWithRaw(ctx, c.Name) + var imageInspect types.ImageInspect + imageInspect, _, err = c.apiClient.ImageInspectWithRaw(ctx, c.Name) + slog.Info("ContainerImage.Read()", "name", c.Name, "error", err) if err != nil { if client.IsErrNotFound(err) { - slog.Info("ContainerImage.Read()", "oldstate", c.State, "newstate", "absent", "error", err) - c.State = "absent" + if pullErr := c.Pull(ctx); pullErr != nil { + panic(pullErr) + } + imageInspect = c.Inspect(ctx) } else { panic(err) } - return nil, err } c.State = "present" @@ -249,13 +299,13 @@ func (c *ContainerImage) Read(ctx context.Context) ([]byte, error) { c.OS = imageInspect.Os c.Size = imageInspect.Size c.Comment = imageInspect.Comment - slog.Info("Read() ", "type", c.Type(), "name", c.Name, "Id", c.Id) + slog.Info("ContainerImage.Read()", "type", c.Type(), "name", c.Name, "Id", c.Id, "state", c.State, "error", err) return yaml.Marshal(c) } func (c *ContainerImage) Delete(ctx context.Context) error { slog.Info("ContainerImage.Delete()", "image", c) - options := types.ImageRemoveOptions{ + options := image.RemoveOptions{ Force: false, PruneChildren: false, } diff --git a/internal/resource/container_image_test.go b/internal/resource/container_image_test.go index b24d488..6333cf0 100644 --- a/internal/resource/container_image_test.go +++ b/internal/resource/container_image_test.go @@ -13,7 +13,7 @@ _ "fmt" "io" _ "net/http" _ "net/http/httptest" -_ "net/url" + "net/url" _ "os" "strings" "testing" @@ -24,6 +24,45 @@ func TestNewContainerImageResource(t *testing.T) { assert.NotNil(t, c) } +func TestContainerImageURI(t *testing.T) { + case0URI := URIFromContainerImageName("foo") + assert.Equal(t, "container-image:///foo", case0URI) + case1URI := URIFromContainerImageName("foo:bar") + assert.Equal(t, "container-image:///foo:bar", case1URI) + case2URI := URIFromContainerImageName("quuz/foo:bar") + assert.Equal(t, "container-image:///quuz/foo:bar", case2URI) + case3URI := URIFromContainerImageName("myhost/quuz/foo:bar") + assert.Equal(t, "container-image://myhost/quuz/foo:bar", case3URI) +} + +func TestLoadFromContainerImageURI(t *testing.T) { + testURI := URIFromContainerImageName("myhost/quuz/foo:bar") + newResource, resourceErr := ResourceTypes.New(testURI) + assert.Nil(t, resourceErr) + assert.NotNil(t, newResource) + assert.IsType(t, &ContainerImage{}, newResource) + assert.Equal(t, "myhost/quuz/foo:bar", newResource.(*ContainerImage).Name) + assert.Equal(t, testURI, newResource.URI()) +} + +func TestContainerImageNameFromURI(t *testing.T) { + case0u,_ := url.Parse("container-image:///foo") + case0Image := ContainerImageNameFromURI(case0u) + assert.Equal(t, "foo", case0Image) + + case1u,_ := url.Parse("container-image:///foo:bar") + case1Image := ContainerImageNameFromURI(case1u) + assert.Equal(t, "foo:bar", case1Image) + + case2u,_ := url.Parse("container-image:///quuz/foo:bar") + case2Image := ContainerImageNameFromURI(case2u) + assert.Equal(t, "quuz/foo:bar", case2Image) + + case3u,_ := url.Parse("container-image://myhost/quuz/foo:bar") + case3Image := ContainerImageNameFromURI(case3u) + assert.Equal(t, "myhost/quuz/foo:bar", case3Image) +} + func TestReadContainerImage(t *testing.T) { output := io.NopCloser(strings.NewReader("testdata")) ctx := context.Background() @@ -32,10 +71,10 @@ func TestReadContainerImage(t *testing.T) { state: present ` m := &mocks.MockContainerClient{ - InjectImagePull: func(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) { + InjectImagePull: func(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) { return output, nil }, - InjectImageRemove: func(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]image.DeleteResponse, error) { + InjectImageRemove: func(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) { return nil, nil }, InjectImageInspectWithRaw: func(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) { diff --git a/internal/resource/container_network.go b/internal/resource/container_network.go index 890e4fc..2a1baa7 100644 --- a/internal/resource/container_network.go +++ b/internal/resource/container_network.go @@ -1,16 +1,13 @@ // Copyright 2024 Matthew Rich . All rights reserved. -// Container resource package resource import ( "context" "fmt" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" -_ "github.com/docker/docker/api/types/mount" -_ "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/network" _ "github.com/docker/docker/api/types/strslice" "github.com/docker/docker/client" "gopkg.in/yaml.v3" @@ -24,22 +21,31 @@ _ "strings" "io" "gitea.rosskeen.house/rosskeen.house/machine" "decl/internal/codec" + "log/slog" + "time" ) type ContainerNetworkClient interface { ContainerClient - NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) + NetworkCreate(ctx context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) + NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) + NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) } type ContainerNetwork struct { stater machine.Stater `json:"-" yaml:"-"` - Id string `json:"ID,omitempty" yaml:"ID,omitempty"` - Name string `json:"name" yaml:"name"` - + Id string `json:"ID,omitempty" yaml:"ID,omitempty"` + Name string `json:"name" yaml:"name"` + Driver string `json:"driver,omitempty" yaml:"driver,omitempty"` + EnableIPv6 bool `json:"enableipv6,omitempty" yaml:"enableipv6,omitempty"` + Internal bool `json:"internal,omitempty" yaml:"internal,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Created time.Time `json:"created" yaml:"created"` State string `yaml:"state"` config ConfigurationValueGetter apiClient ContainerNetworkClient + Resources ResourceMapper `json:"-" yaml:"-"` } func init() { @@ -64,6 +70,10 @@ func NewContainerNetwork(containerClientApi ContainerNetworkClient) *ContainerNe } } +func (n *ContainerNetwork) SetResourceMapper(resources ResourceMapper) { + n.Resources = resources +} + func (n *ContainerNetwork) Clone() Resource { return &ContainerNetwork { Id: n.Id, @@ -82,22 +92,48 @@ func (n *ContainerNetwork) StateMachine() machine.Stater { func (n *ContainerNetwork) Notify(m *machine.EventMessage) { ctx := context.Background() + slog.Info("Notify()", "ContainerNetwork", n, "m", m) switch m.On { case machine.ENTERSTATEEVENT: switch m.Dest { + case "start_read": + if _,readErr := n.Read(ctx); readErr == nil { + if triggerErr := n.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + n.State = "absent" + panic(triggerErr) + } + } else { + n.State = "absent" + panic(readErr) + } + case "start_delete": + if deleteErr := n.Delete(ctx); deleteErr == nil { + if triggerErr := n.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + n.State = "present" + panic(triggerErr) + } + } else { + n.State = "present" + panic(deleteErr) + } case "start_create": if e := n.Create(ctx); e == nil { - if triggerErr := n.stater.Trigger("created"); triggerErr == nil { + if triggerErr := n.StateMachine().Trigger("created"); triggerErr == nil { return } } n.State = "absent" - case "present": + case "absent": + n.State = "absent" + case "present", "created", "read": n.State = "present" } case machine.EXITSTATEEVENT: } - } func (n *ContainerNetwork) URI() string { @@ -148,7 +184,7 @@ func (n *ContainerNetwork) LoadDecl(yamlResourceDeclaration string) error { } func (n *ContainerNetwork) Create(ctx context.Context) error { - networkResp, err := n.apiClient.NetworkCreate(ctx, n.Name, types.NetworkCreate{ + networkResp, err := n.apiClient.NetworkCreate(ctx, n.Name, network.CreateOptions{ Driver: "bridge", }) if err != nil { @@ -159,9 +195,52 @@ func (n *ContainerNetwork) Create(ctx context.Context) error { return nil } -// produce yaml representation of any resource +func (n *ContainerNetwork) Inspect(ctx context.Context, networkID string) error { + networkInspect, err := n.apiClient.NetworkInspect(ctx, networkID, network.InspectOptions{}) + if client.IsErrNotFound(err) { + n.State = "absent" + } else { + n.State = "present" + n.Id = networkInspect.ID + if n.Name == "" { + if networkInspect.Name[0] == '/' { + n.Name = networkInspect.Name[1:] + } else { + n.Name = networkInspect.Name + } + } + n.Created = networkInspect.Created + n.Internal = networkInspect.Internal + n.Driver = networkInspect.Driver + n.Labels = networkInspect.Labels + n.EnableIPv6 = networkInspect.EnableIPv6 + } + return nil +} func (n *ContainerNetwork) Read(ctx context.Context) ([]byte, error) { + var networkID string + filterArgs := filters.NewArgs() + filterArgs.Add("name", n.Name) + networks, err := n.apiClient.NetworkList(ctx, network.ListOptions{ + Filters: filterArgs, + }) + + if err != nil { + return nil, fmt.Errorf("%w: %s %s", err, n.Type(), n.Name) + } + + for _, net := range networks { + if net.Name == n.Name { + networkID = net.ID + } + } + + if inspectErr := n.Inspect(ctx, networkID); inspectErr != nil { + return nil, fmt.Errorf("%w: network %s", inspectErr, networkID) + } + slog.Info("Read() ", "type", n.Type(), "name", n.Name, "Id", n.Id) + return yaml.Marshal(n) } diff --git a/internal/resource/container_network_test.go b/internal/resource/container_network_test.go index 55ea308..6ef9c9a 100644 --- a/internal/resource/container_network_test.go +++ b/internal/resource/container_network_test.go @@ -9,7 +9,7 @@ _ "encoding/json" _ "fmt" _ "github.com/docker/docker/api/types" _ "github.com/docker/docker/api/types/container" -_ "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/network" "github.com/stretchr/testify/assert" _ "io" _ "net/http" @@ -32,6 +32,19 @@ func TestReadContainerNetwork(t *testing.T) { state: present ` m := &mocks.MockContainerClient{ + InjectNetworkList: func(ctx context.Context, options network.ListOptions) ([]network.Summary, error) { + return []network.Summary{ + {ID: "123456789abc"}, + {ID: "123456789def"}, + }, nil + }, + InjectNetworkInspect: func(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) { + return network.Inspect{ + ID: "123456789abc", + Name: "test", + Driver: "bridge", + }, nil + }, } n := NewContainerNetwork(m) diff --git a/internal/resource/declaration.go b/internal/resource/declaration.go index 7bbafe9..33c8890 100644 --- a/internal/resource/declaration.go +++ b/internal/resource/declaration.go @@ -46,8 +46,15 @@ func NewDeclaration() *Declaration { return &Declaration{} } +func NewDeclarationFromDocument(document *Document) *Declaration { + return &Declaration{ document: document } +} + func (d *Declaration) SetDocument(newDocument *Document) { + slog.Info("Declaration.SetDocument()") d.document = newDocument + d.SetConfig(d.document.config) + d.Attributes.SetResourceMapper(d.document.uris) } func (d *Declaration) ResolveId(ctx context.Context) string { @@ -101,18 +108,22 @@ func (d *Declaration) Apply() (result error) { }() stater := d.Attributes.StateMachine() + slog.Info("Declaration.Apply()", "machine", stater, "machine.state", stater.CurrentState(), "uri", d.Attributes.URI()) switch d.Transition { case "read": result = stater.Trigger("read") case "delete", "absent": - slog.Info("Declaration.Apply()", "machine", stater, "machine.state", stater.CurrentState(), "uri", d.Attributes.URI()) if stater.CurrentState() == "present" { result = stater.Trigger("delete") } + case "update": + if result = stater.Trigger("update"); result != nil { + return result + } + result = stater.Trigger("read") default: fallthrough case "create", "present": - slog.Info("Declaration.Apply()", "machine", stater, "machine.state", stater.CurrentState(), "uri", d.Attributes.URI()) if stater.CurrentState() == "absent" || stater.CurrentState() == "unknown" { if result = stater.Trigger("create"); result != nil { return result @@ -150,6 +161,7 @@ func (d *Declaration) UnmarshalValue(value *DeclarationType) error { d.Config = value.Config newResource, resourceErr := ResourceTypes.New(fmt.Sprintf("%s://", value.Type)) if resourceErr != nil { + slog.Info("Declaration.UnmarshalValue", "value", value, "error", resourceErr) return resourceErr } d.Attributes = newResource diff --git a/internal/resource/document.go b/internal/resource/document.go index fd2bf7a..7d8a45f 100644 --- a/internal/resource/document.go +++ b/internal/resource/document.go @@ -19,6 +19,15 @@ _ "net/url" type ResourceMap[Value any] map[string]Value +func (rm ResourceMap[Value]) Get(key string) (any, bool) { + v, ok := rm[key] + return v, ok +} + +type ResourceMapper interface { + Get(key string) (any, bool) +} + type Document struct { uris ResourceMap[*Declaration] ResourceDecls []Declaration `json:"resources" yaml:"resources"` @@ -66,10 +75,10 @@ func (d *Document) Clone() *Document { func (d *Document) Load(r io.Reader) (err error) { c := codec.NewYAMLDecoder(r) err = c.Decode(d) + slog.Info("Document.Load()", "error", err) if err == nil { for i := range d.ResourceDecls { d.ResourceDecls[i].SetDocument(d) - d.ResourceDecls[i].SetConfig(d.config) } } return @@ -158,22 +167,23 @@ func (d *Document) MapResourceURI(uri string, declaration *Declaration) { } func (d *Document) AddResourceDeclaration(resourceType string, resourceDeclaration Resource) { - decl := NewDeclaration() + slog.Info("Document.AddResourceDeclaration()", "type", resourceType, "resource", resourceDeclaration) + decl := NewDeclarationFromDocument(d) decl.Type = TypeName(resourceType) decl.Attributes = resourceDeclaration - decl.SetDocument(d) d.ResourceDecls = append(d.ResourceDecls, *decl) d.MapResourceURI(decl.Attributes.URI(), decl) + decl.SetDocument(d) } func (d *Document) AddResource(uri string) error { - decl := NewDeclaration() + decl := NewDeclarationFromDocument(d) if e := decl.SetURI(uri); e != nil { return e } - decl.SetDocument(d) d.ResourceDecls = append(d.ResourceDecls, *decl) d.MapResourceURI(decl.Attributes.URI(), decl) + decl.SetDocument(d) return nil } @@ -228,3 +238,31 @@ func (d *Document) Diff(with *Document, output io.Writer) (returnOutput string, } return "", nil } + + +func (d *Document) UnmarshalYAML(value *yaml.Node) error { + type decodeDocument Document + t := (*decodeDocument)(d) + if unmarshalDocumentErr := value.Decode(t); unmarshalDocumentErr != nil { + return unmarshalDocumentErr + } + for i := range d.ResourceDecls { + d.ResourceDecls[i].SetDocument(d) + d.MapResourceURI(d.ResourceDecls[i].Attributes.URI(), &d.ResourceDecls[i]) + } + return nil +} + +func (d *Document) UnmarshalJSON(data []byte) error { + type decodeDocument Document + t := (*decodeDocument)(d) + if unmarshalDocumentErr := json.Unmarshal(data, t); unmarshalDocumentErr != nil { + return unmarshalDocumentErr + } + for i := range d.ResourceDecls { + d.ResourceDecls[i].SetDocument(d) + d.MapResourceURI(d.ResourceDecls[i].Attributes.URI(), &d.ResourceDecls[i]) + } + return nil +} + diff --git a/internal/resource/exec.go b/internal/resource/exec.go index 305d0ef..d7cce74 100644 --- a/internal/resource/exec.go +++ b/internal/resource/exec.go @@ -28,6 +28,7 @@ type Exec struct { config ConfigurationValueGetter // state attributes State string `yaml:"state"` + Resources ResourceMapper `yaml:"-" json:"-"` } func init() { @@ -41,6 +42,10 @@ func NewExec() *Exec { return &Exec{} } +func (x *Exec) SetResourceMapper(resources ResourceMapper) { + x.Resources = resources +} + func (x *Exec) Clone() Resource { return &Exec { Id: x.Id, diff --git a/internal/resource/file.go b/internal/resource/file.go index 947b31d..64de732 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -24,8 +24,10 @@ import ( "strings" ) +// Describes the type of file the resource represents type FileType string +// Supported file types const ( RegularFile FileType = "regular" DirectoryFile FileType = "directory" @@ -50,7 +52,15 @@ func init() { }) } -// Manage the state of file system objects +/* + +Manage the state of file system objects +The file content may be serialized directly in the `Content` field +or the `ContentSourceRef/sourceref` may be used to refer to the source +of the content from which to stream the content. +The `SerializeContent` the flag allows forcing the content to be serialized in the output. + +*/ type File struct { stater machine.Stater `json:"-" yaml:"-"` normalizePath bool `json:"-" yaml:"-"` @@ -72,6 +82,7 @@ type File struct { State string `json:"state,omitempty" yaml:"state,omitempty"` SerializeContent bool `json:"serializecontent,omitempty" yaml:"serializecontent,omitempty"` config ConfigurationValueGetter + Resources ResourceMapper `json:"-" yaml:"-"` } type ResourceFileInfo struct { @@ -92,6 +103,10 @@ func NewNormalizedFile() *File { return f } +func (f *File) SetResourceMapper(resources ResourceMapper) { + f.Resources = resources +} + func (f *File) Clone() Resource { return &File { normalizePath: f.normalizePath, @@ -305,7 +320,7 @@ func (f *File) Create(ctx context.Context) error { f.Size = 0 var contentReader io.ReadCloser if len(f.Content) == 0 && len(f.ContentSourceRef) != 0 { - if refReader, err := f.ContentSourceRef.ContentReaderStream(); err == nil { + if refReader, err := f.ContentSourceRef.Lookup(nil).ContentReaderStream(); err == nil { contentReader = refReader } else { return err diff --git a/internal/resource/filter.go b/internal/resource/filter.go new file mode 100644 index 0000000..3023eae --- /dev/null +++ b/internal/resource/filter.go @@ -0,0 +1,18 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( +) + +type FilterTerm string + +type FilterDefinition struct { + FilterTerms []FilterTerm `yaml:"term" json:"term"` +} + +func NewFilterDefinition() *FilterDefinition { + return &FilterDefinition{} +} + + diff --git a/internal/resource/group.go b/internal/resource/group.go new file mode 100644 index 0000000..9556704 --- /dev/null +++ b/internal/resource/group.go @@ -0,0 +1,407 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "fmt" + "gopkg.in/yaml.v3" +_ "log/slog" + "net/url" +_ "os" + "os/exec" + "os/user" + "io" + "encoding/json" + "errors" + "strings" + "gitea.rosskeen.house/rosskeen.house/machine" + "decl/internal/codec" + "decl/internal/command" +) + +type decodeGroup Group + +type GroupType string + +const ( + GroupTypeAddGroup = "addgroup" + GroupTypeGroupAdd = "groupadd" +) + +var ErrUnsupportedGroupType error = errors.New("The GroupType is not supported on this system") +var ErrInvalidGroupType error = errors.New("invalid GroupType value") + +var SystemGroupType GroupType = FindSystemGroupType() + +type Group struct { + stater machine.Stater `json:"-" yaml:"-"` + Name string `json:"name" yaml:"name"` + GID string `json:"gid,omitempty" yaml:"gid,omitempty"` + GroupType GroupType `json:"-" yaml:"-"` + + CreateCommand *command.Command `json:"-" yaml:"-"` + ReadCommand *command.Command `json:"-" yaml:"-"` + UpdateCommand *command.Command `json:"-" yaml:"-"` + DeleteCommand *command.Command `json:"-" yaml:"-"` + State string `json:"state,omitempty" yaml:"state,omitempty"` + config ConfigurationValueGetter + Resources ResourceMapper `json:"-" yaml:"-"` +} + +func NewGroup() *Group { + return &Group{} +} + +func init() { + ResourceTypes.Register([]string{"group"}, func(u *url.URL) Resource { + group := NewGroup() + group.Name = u.Hostname() + group.GID = LookupGIDString(u.Hostname()) + if _, addGroupPathErr := exec.LookPath("addgroup"); addGroupPathErr == nil { + group.GroupType = GroupTypeAddGroup + } + if _, pathErr := exec.LookPath("groupadd"); pathErr == nil { + group.GroupType = GroupTypeGroupAdd + } + group.CreateCommand, group.ReadCommand, group.UpdateCommand, group.DeleteCommand = group.GroupType.NewCRUD() + return group + }) +} + +func FindSystemGroupType() GroupType { + for _, groupType := range []GroupType{GroupTypeAddGroup, GroupTypeGroupAdd} { + c := groupType.NewCreateCommand() + if c.Exists() { + return groupType + } + } + return GroupTypeAddGroup +} + +func (g *Group) SetResourceMapper(resources ResourceMapper) { + g.Resources = resources +} + +func (g *Group) Clone() Resource { + newg := &Group { + Name: g.Name, + GID: g.GID, + State: g.State, + GroupType: g.GroupType, + } + newg.CreateCommand, newg.ReadCommand, newg.UpdateCommand, newg.DeleteCommand = g.GroupType.NewCRUD() + return newg +} + +func (g *Group) StateMachine() machine.Stater { + if g.stater == nil { + g.stater = StorageMachine(g) + } + return g.stater +} + +func (g *Group) Notify(m *machine.EventMessage) { + ctx := context.Background() + switch m.On { + case machine.ENTERSTATEEVENT: + switch m.Dest { + case "start_create": + if e := g.Create(ctx); e == nil { + if triggerErr := g.stater.Trigger("created"); triggerErr == nil { + return + } + } + g.State = "absent" + case "present": + g.State = "present" + } + case machine.EXITSTATEEVENT: + } +} + +func (g *Group) SetURI(uri string) error { + resourceUri, e := url.Parse(uri) + if e == nil { + if resourceUri.Scheme == "group" { + g.Name = resourceUri.Hostname() + } else { + e = fmt.Errorf("%w: %s is not a group", ErrInvalidResourceURI, uri) + } + } + return e +} + +func (g *Group) URI() string { + return fmt.Sprintf("group://%s", g.Name) +} + +func (g *Group) UseConfig(config ConfigurationValueGetter) { + g.config = config +} + +func (g *Group) ResolveId(ctx context.Context) string { + return LookupUIDString(g.Name) +} + +func (g *Group) Validate() error { + return fmt.Errorf("failed") +} + +func (g *Group) Apply() error { + switch g.State { + case "present": + _, NoGroupExists := LookupGID(g.Name) + if NoGroupExists != nil { + cmdErr := g.Create(context.Background()) + return cmdErr + } + case "absent": + cmdErr := g.Delete() + return cmdErr + } + return nil +} + +func (g *Group) Load(r io.Reader) error { + return codec.NewYAMLDecoder(r).Decode(g) +} + +func (g *Group) LoadDecl(yamlResourceDeclaration string) error { + return codec.NewYAMLStringDecoder(yamlResourceDeclaration).Decode(g) +} + +func (g *Group) Type() string { return "group" } + +func (g *Group) Create(ctx context.Context) (error) { + _, err := g.CreateCommand.Execute(g) + if err != nil { + return err + } + + _,e := g.Read(ctx) + return e +} + +func (g *Group) Read(ctx context.Context) ([]byte, error) { + exErr := g.ReadCommand.Extractor(nil, g) + if exErr != nil { + g.State = "absent" + } + if yaml, yamlErr := yaml.Marshal(g); yamlErr != nil { + return yaml, yamlErr + } else { + return yaml, exErr + } +} + +func (g *Group) Delete() (error) { + _, err := g.DeleteCommand.Execute(g) + if err != nil { + return err + } + + return err +} + +func (g *Group) UnmarshalJSON(data []byte) error { + if unmarshalErr := json.Unmarshal(data, (*decodeGroup)(g)); unmarshalErr != nil { + return unmarshalErr + } + g.CreateCommand, g.ReadCommand, g.UpdateCommand, g.DeleteCommand = g.GroupType.NewCRUD() + return nil +} + +func (g *Group) UnmarshalYAML(value *yaml.Node) error { + if unmarshalErr := value.Decode((*decodeGroup)(g)); unmarshalErr != nil { + return unmarshalErr + } + g.CreateCommand, g.ReadCommand, g.UpdateCommand, g.DeleteCommand = g.GroupType.NewCRUD() + return nil +} + +func (g *GroupType) NewCRUD() (create *command.Command, read *command.Command, update *command.Command, del *command.Command) { + switch *g { + case GroupTypeGroupAdd: + return NewGroupAddCreateCommand(), NewGroupReadCommand(), NewGroupUpdateCommand(), NewGroupDelDeleteCommand() + case GroupTypeAddGroup: + return NewAddGroupCreateCommand(), NewGroupReadCommand(), NewGroupUpdateCommand(), NewDelGroupDeleteCommand() + default: + if _, addGroupPathErr := exec.LookPath("addgroup"); addGroupPathErr == nil { + *g = GroupTypeAddGroup + return NewAddGroupCreateCommand(), NewGroupReadCommand(), NewGroupUpdateCommand(), NewDelGroupDeleteCommand() + } + if _, pathErr := exec.LookPath("groupadd"); pathErr == nil { + *g = GroupTypeGroupAdd + return NewGroupAddCreateCommand(), NewGroupReadCommand(), NewGroupUpdateCommand(), NewGroupDelDeleteCommand() + } + return NewGroupAddCreateCommand(), NewGroupReadCommand(), NewGroupUpdateCommand(), NewGroupDelDeleteCommand() + } +} + +func (g *GroupType) NewCreateCommand() (create *command.Command) { + switch *g { + case GroupTypeGroupAdd: + return NewGroupAddCreateCommand() + case GroupTypeAddGroup: + return NewAddGroupCreateCommand() + default: + } + return nil +} + +func (g *GroupType) NewReadGroupsCommand() *command.Command { + return NewReadGroupsCommand() +} + +func (g *GroupType) UnmarshalValue(value string) error { + switch value { + case string(GroupTypeGroupAdd), string(GroupTypeAddGroup): + *g = GroupType(value) + return nil + default: + return errors.New("invalid GroupType value") + } +} + +func (g *GroupType) UnmarshalJSON(data []byte) error { + var s string + if unmarshalGroupTypeErr := json.Unmarshal(data, &s); unmarshalGroupTypeErr != nil { + return unmarshalGroupTypeErr + } + return g.UnmarshalValue(s) +} + +func (g *GroupType) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + return g.UnmarshalValue(s) +} + +func NewGroupAddCreateCommand() *command.Command { + c := command.NewCommand() + c.Path = "groupadd" + c.Args = []command.CommandArg{ + command.CommandArg("{{ if .GID }}-g {{ .GID }}{{ end }}"), + command.CommandArg("{{ .Name }}"), + } + c.Extractor = func(out []byte, target any) error { + return nil +/* + for _,line := range strings.Split(string(out), "\n") { + if line == "iptables: Chain already exists." { + return nil + } + } + return fmt.Errorf(string(out)) +*/ + } + return c +} + +func NewAddGroupCreateCommand() *command.Command { + c := command.NewCommand() + c.Path = "addgroup" + c.Args = []command.CommandArg{ + command.CommandArg("{{ if .GID }}-g {{ .GID }}{{ end }}"), + command.CommandArg("{{ .Name }}"), + } + c.Extractor = func(out []byte, target any) error { + return nil +/* + for _,line := range strings.Split(string(out), "\n") { + if line == "iptables: Chain already exists." { + return nil + } + } + return fmt.Errorf(string(out)) +*/ + } + return c +} + +func NewGroupReadCommand() *command.Command { + c := command.NewCommand() + c.Extractor = func(out []byte, target any) error { + g := target.(*Group) + g.State = "absent" + var readGroup *user.Group + var e error + if g.Name != "" { + readGroup, e = user.LookupGroup(g.Name) + } else { + if g.GID != "" { + readGroup, e = user.LookupGroupId(g.GID) + } + } + + if e == nil { + g.Name = readGroup.Name + g.GID = readGroup.Gid + if g.GID != "" { + g.State = "present" + } + } + return e + } + return c +} + +func NewGroupUpdateCommand() *command.Command { + return nil +} + +func NewGroupDelDeleteCommand() *command.Command { + c := command.NewCommand() + c.Path = "groupdel" + c.Args = []command.CommandArg{ + command.CommandArg("{{ .Name }}"), + } + c.Extractor = func(out []byte, target any) error { + return nil + } + return c +} + +func NewDelGroupDeleteCommand() *command.Command { + c := command.NewCommand() + c.Path = "delgroup" + c.Args = []command.CommandArg{ + command.CommandArg("{{ .Name }}"), + } + c.Extractor = func(out []byte, target any) error { + return nil + } + return c +} + + +func NewReadGroupsCommand() *command.Command { + c := command.NewCommand() + c.Path = "getent" + c.Args = []command.CommandArg{ + command.CommandArg("passwd"), + } + + c.Extractor = func(out []byte, target any) error { + Groups := target.(*[]*Group) + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lineIndex := 0 + for _, line := range lines { + groupRecord := strings.Split(strings.TrimSpace(line), ":") + if len(*Groups) <= lineIndex + 1 { + *Groups = append(*Groups, NewGroup()) + } + g := (*Groups)[lineIndex] + g.Name = groupRecord[0] + g.GID = groupRecord[2] + g.State = "present" + g.GroupType = SystemGroupType + lineIndex++ + } + return nil + } + return c +} diff --git a/internal/resource/group_test.go b/internal/resource/group_test.go new file mode 100644 index 0000000..d5b2ec8 --- /dev/null +++ b/internal/resource/group_test.go @@ -0,0 +1,75 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + _ "encoding/json" + "github.com/stretchr/testify/assert" + _ "io" + _ "net/http" + _ "net/http/httptest" + _ "net/url" + _ "os" + _ "strings" + "testing" +) + +func TestNewGroupResource(t *testing.T) { + g := NewGroup() + assert.NotNil(t, g) +} + +func TestReadGroup(t *testing.T) { + ctx := context.Background() + decl := ` +name: "syslog" +` + + g := NewGroup() + e := g.LoadDecl(decl) + assert.Nil(t, e) + assert.Equal(t, "syslog", g.Name) + + _, readErr := g.Read(ctx) + assert.Nil(t, readErr) + + assert.Equal(t, "111", g.GID) + +} + +func TestCreateGroup(t *testing.T) { + + decl := ` + name: "testgroup" + gid: 12001 + state: present +` + + g := NewGroup() + e := g.LoadDecl(decl) + assert.Equal(t, nil, e) + assert.Equal(t, "testgroup", g.Name) + + g.CreateCommand.Executor = func(value any) ([]byte, error) { + return []byte(``), nil + } + + g.ReadCommand.Extractor = func(out []byte, target any) error { + return nil + } + + g.DeleteCommand.Executor = func(value any) ([]byte, error) { + return nil, nil + } + + applyErr := g.Apply() + assert.Nil(t, applyErr) + + assert.Equal(t, "12001", g.GID) + + g.State = "absent" + + applyDeleteErr := g.Apply() + assert.Nil(t, applyDeleteErr) +} diff --git a/internal/resource/http.go b/internal/resource/http.go index 52c7ea9..cf6b083 100644 --- a/internal/resource/http.go +++ b/internal/resource/http.go @@ -44,12 +44,17 @@ type HTTP struct { StatusCode int `yaml:"statuscode,omitempty" json:"statuscode,omitempty"` State string `yaml:"state,omitempty" json:"state,omitempty"` config ConfigurationValueGetter + Resources ResourceMapper `yaml:"-" json:"-"` } func NewHTTP() *HTTP { return &HTTP{ client: &http.Client{} } } +func (h *HTTP) SetResourceMapper(resources ResourceMapper) { + h.Resources = resources +} + func (h *HTTP) Clone() Resource { return &HTTP { client: h.client, diff --git a/internal/resource/iptables.go b/internal/resource/iptables.go index 9bc97e3..3e01913 100644 --- a/internal/resource/iptables.go +++ b/internal/resource/iptables.go @@ -129,6 +129,7 @@ type Iptable struct { DeleteCommand *command.Command `yaml:"-" json:"-"` config ConfigurationValueGetter + Resources ResourceMapper `yaml:"-" json:"-"` } func NewIptable() *Iptable { @@ -137,6 +138,10 @@ func NewIptable() *Iptable { return i } +func (i *Iptable) SetResourceMapper(resources ResourceMapper) { + i.Resources = resources +} + func (i *Iptable) Clone() Resource { newIpt := &Iptable { Id: i.Id, diff --git a/internal/resource/network_route.go b/internal/resource/network_route.go index ce96058..026cae2 100644 --- a/internal/resource/network_route.go +++ b/internal/resource/network_route.go @@ -126,6 +126,7 @@ type NetworkRoute struct { State string `json:"state" yaml:"state"` config ConfigurationValueGetter + Resources ResourceMapper `json:"-" yaml:"-"` } func NewNetworkRoute() *NetworkRoute { @@ -134,6 +135,10 @@ func NewNetworkRoute() *NetworkRoute { return n } +func (n *NetworkRoute) SetResourceMapper(resources ResourceMapper) { + n.Resources = resources +} + func (n *NetworkRoute) Clone() Resource { newn := &NetworkRoute { Id: n.Id, diff --git a/internal/resource/os.go b/internal/resource/os.go index 1708a72..ee1335c 100644 --- a/internal/resource/os.go +++ b/internal/resource/os.go @@ -77,3 +77,12 @@ func LookupGID(groupName string) (int, error) { return gid, nil } + +func LookupGIDString(groupName string) string { + group, groupLookupErr := user.LookupGroup(groupName) + if groupLookupErr != nil { + return "" + } + return group.Gid +} + diff --git a/internal/resource/package.go b/internal/resource/package.go index a7144bb..c2fea38 100644 --- a/internal/resource/package.go +++ b/internal/resource/package.go @@ -32,6 +32,9 @@ const ( PackageTypeYum PackageType = "yum" ) +var ErrUnsupportedPackageType error = errors.New("The PackageType is not supported on this system") +var ErrInvalidPackageType error = errors.New("invalid PackageType value") + var SystemPackageType PackageType = FindSystemPackageType() type Package struct { @@ -49,6 +52,7 @@ type Package struct { // state attributes State string `yaml:"state,omitempty" json:"state,omitempty"` config ConfigurationValueGetter + Resources ResourceMapper `yaml:"-" json:"-"` } func init() { @@ -72,6 +76,10 @@ func NewPackage() *Package { return &Package{ PackageType: SystemPackageType } } +func (p *Package) SetResourceMapper(resources ResourceMapper) { + p.Resources = resources +} + func (p *Package) Clone() Resource { newp := &Package { Name: p.Name, @@ -228,15 +236,19 @@ func (p *Package) LoadDecl(yamlResourceDeclaration string) error { func (p *Package) Type() string { return "package" } func (p *Package) Read(ctx context.Context) ([]byte, error) { - out, err := p.ReadCommand.Execute(p) - if err != nil { - return nil, err + if p.ReadCommand.Exists() { + out, err := p.ReadCommand.Execute(p) + if err != nil { + return nil, err + } + exErr := p.ReadCommand.Extractor(out, p) + if exErr != nil { + return nil, exErr + } + return yaml.Marshal(p) + } else { + return nil, ErrUnsupportedPackageType } - exErr := p.ReadCommand.Extractor(out, p) - if exErr != nil { - return nil, exErr - } - return yaml.Marshal(p) } func (p *Package) UnmarshalJSON(data []byte) error { @@ -325,7 +337,7 @@ func (p *PackageType) UnmarshalValue(value string) error { *p = PackageType(value) return nil default: - return errors.New("invalid PackageType value") + return ErrInvalidPackageType } } diff --git a/internal/resource/package_test.go b/internal/resource/package_test.go index 823582e..fbd4bde 100644 --- a/internal/resource/package_test.go +++ b/internal/resource/package_test.go @@ -42,6 +42,7 @@ type: apk p := NewPackage() assert.NotNil(t, p) m := &MockCommand{ + CommandExists: func() error { return nil }, Executor: func(value any) ([]byte, error) { return nil, nil }, @@ -134,3 +135,27 @@ Version: 1.2.2 assert.Equal(t, "1.2.2", p.Version) assert.Nil(t, p.Validate()) } + +func TestPackageTypeErr(t *testing.T) { + + decl := ` +name: vim +source: vim-8.2.3995-1ubuntu2.17.deb +type: deb +` + p := NewPackage() + assert.NotNil(t, p) + loadErr := p.LoadDecl(decl) + assert.Nil(t, loadErr) + p.ReadCommand = NewDebReadCommand() + p.ReadCommand.CommandExists = func() error { return command.ErrUnknownCommand } + p.ReadCommand.Executor = func(value any) ([]byte, error) { + return []byte(` +Package: vim +Version: 1.2.2 +`), nil + } + _, readErr := p.Read(context.Background()) + assert.ErrorIs(t, readErr, ErrUnsupportedPackageType) + +} diff --git a/internal/resource/pki.go b/internal/resource/pki.go new file mode 100644 index 0000000..999c978 --- /dev/null +++ b/internal/resource/pki.go @@ -0,0 +1,522 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "errors" + "fmt" + "log/slog" + "gopkg.in/yaml.v3" + "net/url" + "path/filepath" + "gitea.rosskeen.house/rosskeen.house/machine" + "decl/internal/codec" + "decl/internal/ext" + "decl/internal/transport" + "crypto/x509" + "crypto/x509/pkix" + "crypto/rsa" + "crypto/rand" + "encoding/pem" + "encoding/json" + "time" + "math/big" + "io" + "strings" +) + +// Describes the type of certificate file the resource represents +type EncodingType string + +// Supported file types +const ( + EncodingTypePem EncodingType = "pem" +) + +var ErrPKIInvalidEncodingType error = errors.New("Invalid EncodingType") +var ErrPKIFailedDecodingPemBlock error = errors.New("Failed decoding pem block") + +func init() { + ResourceTypes.Register([]string{"pki"}, func(u *url.URL) Resource { + k := NewPKI() + ref := ResourceReference(filepath.Join(u.Hostname(), u.Path)) + if len(ref) > 0 { + k.PrivateKeyRef = ref + } + return k + }) +} + +type Certificate struct { + *x509.Certificate `yaml:",inline" json:",inline"` + Raw []byte `yaml:"-" json:"-"` + RawTBSCertificate []byte `yaml:"-" json:"-"` + RawSubjectPublicKeyInfo []byte `yaml:"-" json:"-"` + RawSubject []byte `yaml:"-" json:"-"` + RawIssuer []byte `yaml:"-" json:"-"` +} + +type PKI struct { + stater machine.Stater `json:"-" yaml:"-"` + PrivateKeyPem string `json:"privatekey,omitempty" yaml:"privatekey,omitempty"` + PublicKeyPem string `json:"publickey,omitempty" yaml:"publickey,omitempty"` + CertificatePem string `json:"certificate,omitempty" yaml:"certificate,omitempty"` + + PrivateKeyRef ResourceReference `json:"privatekeyref,omitempty" yaml:"privatekeyref,omitempty"` // Describes a resource URI to read/write the private key content + PublicKeyRef ResourceReference `json:"publickeyref,omitempty" yaml:"publickeyref,omitempty"` // Describes a resource URI to read/write the public key content + CertificateRef ResourceReference `json:"certificateref,omitempty" yaml:"certificateref,omitempty"` // Describes a resource URI to read/write the certificate content + + SignedByRef ResourceReference `json:"signedbyref,omitempty" yaml:"signedbyref,omitempty"` // Describes a resource URI for the signing cert + + privateKey *rsa.PrivateKey `json:"-" yaml:"-"` + publicKey *rsa.PublicKey `json:"-" yaml:"-"` + Values *Certificate `json:"values,omitempty" yaml:"values,omitempty"` + Certificate *x509.Certificate `json:"-" yaml:"-"` + + Bits int `json:"bits" yaml:"bits"` + EncodingType EncodingType `json:"type" yaml:"type"` + //State string `json:"state,omitempty" yaml:"state,omitempty"` + config ConfigurationValueGetter + Resources ResourceMapper `json:"-" yaml:"-"` +} + +func NewPKI() *PKI { + p := &PKI{ EncodingType: EncodingTypePem, Bits: 2048 } + return p +} + +func (k *PKI) SetResourceMapper(resources ResourceMapper) { + slog.Info("PKI.SetResourceMapper()", "resources", resources) + k.Resources = resources +} + +func (k *PKI) Clone() Resource { + return &PKI { + EncodingType: k.EncodingType, + //State: k.State, + } +} + +func (k *PKI) StateMachine() machine.Stater { + if k.stater == nil { + k.stater = StorageMachine(k) + } + return k.stater +} + +func (k *PKI) Notify(m *machine.EventMessage) { + ctx := context.Background() + slog.Info("Notify()", k.Type(), k, "m", m) + switch m.On { + case machine.ENTERSTATEEVENT: + switch m.Dest { + case "start_read": + if _,readErr := k.Read(ctx); readErr == nil { + if triggerErr := k.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + //k.State = "absent" + panic(triggerErr) + } + } else { + //k.State = "absent" + panic(readErr) + } + case "start_create": + if ! (k.PrivateKeyRef.Exists() || k.PublicKeyRef.Exists() || k.CertificateRef.Exists()) { + if e := k.Create(ctx); e == nil { + if triggerErr := k.StateMachine().Trigger("created"); triggerErr == nil { + return + } + } + } + //k.State = "absent" + case "start_update": + if e := k.Update(ctx); e == nil { + if triggerErr := k.StateMachine().Trigger("updated"); triggerErr == nil { + return + } + } + case "start_delete": + if deleteErr := k.Delete(ctx); deleteErr == nil { + if triggerErr := k.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + //k.State = "present" + panic(triggerErr) + } + } else { + //k.State = "present" + panic(deleteErr) + } + case "absent": + //k.State = "absent" + case "present", "updated", "created", "read": + //k.State = "present" + } + case machine.EXITSTATEEVENT: + } +} + +func (k *PKI) URI() string { + u := k.PrivateKeyRef.Parse() + return fmt.Sprintf("pki://%s", filepath.Join(u.Hostname(), u.RequestURI())) +} + +func (k *PKI) SetURI(uri string) error { + resourceUri, e := url.Parse(uri) + if e == nil { + if resourceUri.Scheme == "pki" { + k.PrivateKeyRef = ResourceReference(fmt.Sprintf("pki://%s", filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI()))) + } else { + e = fmt.Errorf("%w: %s is not a cert", ErrInvalidResourceURI, uri) + } + } + return e +} + +func (k *PKI) UseConfig(config ConfigurationValueGetter) { + k.config = config +} + +func (k *PKI) Validate() error { + return fmt.Errorf("failed") +} + +func (k *PKI) Apply() error { +/* + ctx := context.Background() + switch k.State { + case "absent": + return k.Delete(ctx) + case "present": + return k.Create(ctx) + } +*/ + + return nil +} + +func (k *PKI) LoadDecl(yamlResourceDeclaration string) (err error) { + d := codec.NewYAMLStringDecoder(yamlResourceDeclaration) + err = d.Decode(k) + return +} + +func (k *PKI) ResolveId(ctx context.Context) string { + return string(k.PrivateKeyRef) +} + +func (k *PKI) GenerateKey() (err error) { + k.privateKey, err = rsa.GenerateKey(rand.Reader, k.Bits) + return +} + +func (k *PKI) PublicKey() { + if k.privateKey != nil { + k.publicKey = k.privateKey.Public().(*rsa.PublicKey) + } +} + +func (k *PKI) Encode() { + var privFileStream, pubFileStream io.WriteCloser + switch k.EncodingType { + case EncodingTypePem: + if len(k.PrivateKeyRef) > 0 { + privFileStream, _ = k.PrivateKeyRef.Lookup(k.Resources).ContentWriterStream() + } else { + var privateKeyContent strings.Builder + privFileStream = ext.WriteNopCloser(&privateKeyContent) + defer func() { k.PrivateKeyPem = privateKeyContent.String() }() + } + defer privFileStream.Close() + privEncodeErr := pem.Encode(privFileStream, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k.privateKey)}) + if privEncodeErr != nil { + panic(privEncodeErr) + } + if len(k.PublicKeyRef) > 0 { + pubFileStream, _ = k.PublicKeyRef.Lookup(k.Resources).ContentWriterStream() + } else { + var publicKeyContent strings.Builder + pubFileStream = ext.WriteNopCloser(&publicKeyContent) + defer func() { k.PublicKeyPem = publicKeyContent.String() }() + } + defer pubFileStream.Close() + pubEncodeErr := pem.Encode(pubFileStream, &pem.Block{Type: "RSA PUBLIC KEY", Bytes: x509.MarshalPKCS1PublicKey(k.publicKey)}) + if pubEncodeErr != nil { + panic(pubEncodeErr) + } + } +} + +func (k *PKI) Decode() { + slog.Info("PKI.Decode()", "privatekey", k.PrivateKeyRef, "publickey", k.PublicKeyRef, "certificate", k.CertificateRef) + var err error + switch k.EncodingType { + case EncodingTypePem: + if len(k.PrivateKeyRef) > 0 && k.PrivateKeyRef.Exists() { + privReader := k.PrivateKeyRef.Lookup(k.Resources) + privFileStream, _ := privReader.ContentReaderStream() + defer privFileStream.Close() + PrivateKeyPemData, readErr := io.ReadAll(privFileStream) + if readErr != nil { + panic(readErr) + } + k.PrivateKeyPem = string(PrivateKeyPemData) + block, _ := pem.Decode(PrivateKeyPemData) + if block != nil { + k.privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + panic(err) + } + } else { + panic(ErrPKIFailedDecodingPemBlock) + } + } + slog.Info("PKI.Decode() decoded private key", "error", err) + if len(k.PublicKeyRef) > 0 && k.PublicKeyRef.Exists() { + pubReader := k.PublicKeyRef.Lookup(k.Resources) + pubFileStream, _ := pubReader.ContentReaderStream() + defer pubFileStream.Close() + PublicKeyPemData, readErr := io.ReadAll(pubFileStream) + if readErr != nil { + panic(err) + } + k.PublicKeyPem = string(PublicKeyPemData) + block, _ := pem.Decode(PublicKeyPemData) + if block != nil { + k.publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + panic(err) + } + } else { + panic(ErrPKIFailedDecodingPemBlock) + } + } + slog.Info("PKI.Decode() decoded public key", "publickey", k.PublicKeyPem, "error", err) + if len(k.CertificateRef) > 0 && k.CertificateRef.Exists() { + certReader := k.CertificateRef.Lookup(k.Resources) + certFileStream, _ := certReader.ContentReaderStream() + if certFileStream != nil { + defer certFileStream.Close() + CertificatePemData, readErr := io.ReadAll(certFileStream) + if readErr != nil { + panic(readErr) + } + slog.Info("PKI.Decode() certificate", "pem", CertificatePemData, "error", err) + k.CertificatePem = string(CertificatePemData) + block, _ := pem.Decode(CertificatePemData) + if block != nil { + k.Certificate, err = x509.ParseCertificate(block.Bytes) + if err != nil { + panic(err) + } + } else { + panic(ErrPKIFailedDecodingPemBlock) + } + } + } + } + slog.Info("PKI.Decode()", "error", err) +} + +func (k *PKI) ContentReaderStream() (*transport.Reader, error) { + return nil, nil +} + +func (k *PKI) ContentWriterStream() (*transport.Writer, error) { + return nil, nil +} + +func (k *PKI) SignedBy() (cert *x509.Certificate, pub *rsa.PublicKey, priv *rsa.PrivateKey) { + if len(k.SignedByRef) > 0 { + r := k.SignedByRef.Dereference(k.Resources) + if r != nil { + pkiResource := r.(*PKI) + return pkiResource.Certificate, pkiResource.publicKey, pkiResource.privateKey + } + } + return nil, nil, nil +} + +func (k *PKI) CertConfig() (certTemplate *x509.Certificate) { + if k.Values != nil { + certTemplate = k.Values.Certificate + } else { + var caEpoch int64 = 1721005200000 + certTemplate = &x509.Certificate { + SerialNumber: big.NewInt(time.Now().UnixMilli() - caEpoch), + Subject: pkix.Name { + Organization: []string{""}, + Country: []string{""}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress: []string{""}, + PostalCode: []string{""}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + } + if k.config != nil { + if value, configErr := k.config.GetValue("certtemplate"); configErr == nil { + certTemplate = value.(*x509.Certificate) + } + } + slog.Info("PKI.CertConfig()", "template", certTemplate) + return +} + +func (k *PKI) GenerateCertificate() (error) { + var signingCert *x509.Certificate + var signingPubKey *rsa.PublicKey + var signingPrivKey *rsa.PrivateKey + var certFileStream io.WriteCloser + + certTemplate := k.CertConfig() + signingCert, signingPubKey, signingPrivKey = k.SignedBy() + if signingCert != nil && signingPubKey != nil && signingPrivKey != nil { + slog.Info("PKI.Certificate()", "signedby", k.SignedByRef) + } else { + signingCert = certTemplate + signingPubKey = k.publicKey + signingPrivKey = k.privateKey + } + if k.Certificate != nil { + certTemplate = k.Certificate + } + cert, err := x509.CreateCertificate(rand.Reader, certTemplate, signingCert, signingPubKey, signingPrivKey) + if err != nil { + slog.Error("PKI.Certificate() - x509.CreateCertificate", "cert", cert, "error", err) + return err + } + + if len(k.CertificateRef) > 0 { + certFileStream, _ = k.CertificateRef.Lookup(k.Resources).ContentWriterStream() + } else { + var certContent strings.Builder + certFileStream = ext.WriteNopCloser(&certContent) + defer func() { k.CertificatePem = certContent.String() }() + } + defer certFileStream.Close() + certEncodeErr := pem.Encode(certFileStream, &pem.Block{Type: "CERTIFICATE", Bytes: cert}) + return certEncodeErr +} + +func (k *PKI) Create(ctx context.Context) (err error) { + if err = k.GenerateKey(); err == nil { + k.PublicKey() + k.Encode() + if err = k.GenerateCertificate(); err == nil { + return + } + } + return +} + +func (f *PKI) Delete(ctx context.Context) error { + return nil +} + +func (k *PKI) Read(ctx context.Context) ([]byte, error) { + k.Decode() + return yaml.Marshal(k) +} + +func (k *PKI) Update(ctx context.Context) (err error) { + if err = k.GenerateKey(); err == nil { + k.PublicKey() + k.Encode() + if err = k.GenerateCertificate(); err == nil { + return + } + } + return +} + +func (k *PKI) Type() string { return "pki" } + +/* +func (k *PKI) UnmarshalValue(value *PKIContent) error { + d.Type = value.Type + d.Transition = value.Transition + d.Config = value.Config + newResource, resourceErr := ResourceTypes.New(fmt.Sprintf("%s://", value.Type)) + if resourceErr != nil { + slog.Info("Declaration.UnmarshalValue", "value", value, "error", resourceErr) + return resourceErr + } + d.Attributes = newResource + return nil +} + +func (k *PKI) UnmarshalYAML(value *yaml.Node) error { + t := &DeclarationType{} + if unmarshalResourceTypeErr := value.Decode(t); unmarshalResourceTypeErr != nil { + return unmarshalResourceTypeErr + } + + if err := d.UnmarshalValue(t); err != nil { + return err + } + + resourceAttrs := struct { + Attributes yaml.Node `json:"attributes"` + }{} + if unmarshalAttributesErr := value.Decode(&resourceAttrs); unmarshalAttributesErr != nil { + return unmarshalAttributesErr + } + if unmarshalResourceErr := resourceAttrs.Attributes.Decode(d.Attributes); unmarshalResourceErr != nil { + return unmarshalResourceErr + } + return nil +} + +func (d *Declaration) UnmarshalJSON(data []byte) error { + t := &DeclarationType{} + if unmarshalResourceTypeErr := json.Unmarshal(data, t); unmarshalResourceTypeErr != nil { + return unmarshalResourceTypeErr + } + + if err := d.UnmarshalValue(t); err != nil { + return err + } + + resourceAttrs := struct { + Attributes Resource `json:"attributes"` + }{Attributes: d.Attributes} + if unmarshalAttributesErr := json.Unmarshal(data, &resourceAttrs); unmarshalAttributesErr != nil { + return unmarshalAttributesErr + } +return nil +} +*/ + +func (t *EncodingType) UnmarshalValue(value string) error { + switch value { + case string(EncodingTypePem): + *t = EncodingType(value) + return nil + default: + return ErrPKIInvalidEncodingType + } +} + +func (t *EncodingType) UnmarshalJSON(data []byte) error { + var s string + if unmarshalEncodingTypeErr := json.Unmarshal(data, &s); unmarshalEncodingTypeErr != nil { + return unmarshalEncodingTypeErr + } + return t.UnmarshalValue(s) +} + +func (t *EncodingType) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + return t.UnmarshalValue(s) +} diff --git a/internal/resource/pki_test.go b/internal/resource/pki_test.go new file mode 100644 index 0000000..dfd85dc --- /dev/null +++ b/internal/resource/pki_test.go @@ -0,0 +1,164 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" +_ "encoding/json" + "fmt" + "github.com/stretchr/testify/assert" +_ "gopkg.in/yaml.v3" +_ "io" +_ "log" +_ "os" + "decl/internal/transport" + "decl/internal/ext" + "decl/internal/codec" + "strings" + "testing" + "path/filepath" +) + +type TestResourceMapper func(key string) (any, bool) + +func (rm TestResourceMapper) Get(key string) (any, bool) { + return rm(key) +} + +type StringContentReadWriter func() (any, error) + +func (s StringContentReadWriter) ContentWriterStream() (*transport.Writer, error) { + w, e := s() + return w.(*transport.Writer), e +} + +func (s StringContentReadWriter) ContentReaderStream() (*transport.Reader, error) { + r, e := s() + return r.(*transport.Reader), e +} + +func TestNewPKIKeysResource(t *testing.T) { + r := NewPKI() + assert.NotNil(t, r) +} + +func TestPKIPrivateKey(t *testing.T) { + r := NewPKI() + assert.NotNil(t, r) + assert.Equal(t, 2048, r.Bits) + assert.Nil(t, r.GenerateKey()) + assert.NotNil(t, r.privateKey) + r.PublicKey() + assert.NotNil(t, r.publicKey) +} + +func TestPKIEncodeKeys(t *testing.T) { + var privateTarget, publicTarget, certTarget strings.Builder + r := NewPKI() + assert.NotNil(t, r) + assert.Equal(t, 2048, r.Bits) + assert.Nil(t, r.GenerateKey()) + assert.NotNil(t, r.privateKey) + r.PublicKey() + assert.NotNil(t, r.publicKey) + + r.Resources = TestResourceMapper(func(key string) (any, bool) { + switch key { + case "buffer://privatekey": + return StringContentReadWriter(func() (any, error) { + w := &transport.Writer{} + w.SetStream(ext.WriteNopCloser(&privateTarget)) + return w, nil + }), true + case "buffer://publickey": + return StringContentReadWriter(func() (any, error) { + w := &transport.Writer{} + w.SetStream(ext.WriteNopCloser(&publicTarget)) + return w, nil + }), true + case "buffer://certificate": + return StringContentReadWriter(func() (any, error) { + w := &transport.Writer{} + w.SetStream(ext.WriteNopCloser(&certTarget)) + return w, nil + }), true + } + return nil, false + }) + + r.PrivateKeyRef = ResourceReference("buffer://privatekey") + r.PublicKeyRef = ResourceReference("buffer://publickey") + + r.Encode() + assert.Equal(t, "-----BEGIN RSA PRIVATE KEY-----", strings.Split(privateTarget.String(), "\n")[0]) + assert.Equal(t, "-----BEGIN RSA PUBLIC KEY-----", strings.Split(publicTarget.String(), "\n")[0]) + + r.CertificateRef = ResourceReference("buffer://certificate") + + e := r.GenerateCertificate() + assert.Nil(t, e) + assert.Equal(t, "-----BEGIN CERTIFICATE-----", strings.Split(certTarget.String(), "\n")[0]) +} + +func TestPKIResource(t *testing.T) { + privateKeyFile, _ := filepath.Abs(filepath.Join(TempDir, "fooprivatekey.pem")) + publicKeyFile, _ := filepath.Abs(filepath.Join(TempDir, "foopublickey.pem")) + certFile, _ := filepath.Abs(filepath.Join(TempDir, "foocert.pem")) + var resourceYaml, readResourceYaml strings.Builder + + expected := fmt.Sprintf(` +privatekeyref: "file://%s" +publickeyref: "file://%s" +certificateref: "file://%s" +bits: 2048 +type: pem +`, privateKeyFile, publicKeyFile, certFile) + + r := NewPKI() + assert.NotNil(t, r) + assert.Equal(t, 2048, r.Bits) + assert.Nil(t, r.GenerateKey()) + assert.NotNil(t, r.privateKey) + r.PublicKey() + assert.NotNil(t, r.publicKey) + r.PrivateKeyRef = ResourceReference(fmt.Sprintf("file://%s", privateKeyFile)) + r.PublicKeyRef = ResourceReference(fmt.Sprintf("file://%s", publicKeyFile)) + r.CertificateRef = ResourceReference(fmt.Sprintf("file://%s", certFile)) + createErr := r.Create(context.Background()) + assert.Nil(t, createErr) + assert.FileExists(t, privateKeyFile) + assert.FileExists(t, publicKeyFile) + assert.FileExists(t, certFile) + + serializeErr := codec.FormatYaml.Serialize(r, &resourceYaml) + assert.Nil(t, serializeErr) + assert.YAMLEq(t, expected, resourceYaml.String()) + + read := NewPKI() + assert.NotNil(t, read) + read.PrivateKeyRef = ResourceReference(fmt.Sprintf("file://%s", privateKeyFile)) + read.PublicKeyRef = ResourceReference(fmt.Sprintf("file://%s", publicKeyFile)) + read.CertificateRef = ResourceReference(fmt.Sprintf("file://%s", certFile)) + + _, readErr := read.Read(context.Background()) + assert.Nil(t, readErr) + + expectedContent := fmt.Sprintf(` +privatekey: | + %s +publickey: | + %s +certificate: | + %s +privatekeyref: "file://%s" +publickeyref: "file://%s" +certificateref: "file://%s" +bits: 2048 +type: pem +`, strings.Replace(read.PrivateKeyPem, "\n", "\n ", -1), strings.Replace(read.PublicKeyPem, "\n", "\n ", -1), strings.Replace(read.CertificatePem, "\n", "\n ", -1), privateKeyFile, publicKeyFile, certFile) + + readSerializeErr := codec.FormatYaml.Serialize(read, &readResourceYaml) + assert.Nil(t, readSerializeErr) + assert.YAMLEq(t, expectedContent, readResourceYaml.String()) + +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 935ae41..b12cdfb 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -8,9 +8,10 @@ import ( _ "encoding/json" _ "fmt" _ "gopkg.in/yaml.v3" - _ "net/url" + "net/url" "gitea.rosskeen.house/rosskeen.house/machine" "decl/internal/transport" + "log/slog" ) type ResourceReference string @@ -29,6 +30,7 @@ type Resource interface { ResourceReader ResourceValidator Clone() Resource + SetResourceMapper(resources ResourceMapper) } type ContentReader interface { @@ -39,6 +41,11 @@ type ContentWriter interface { ContentWriterStream() (*transport.Writer, error) } +type ContentReadWriter interface { + ContentReader + ContentWriter +} + type ResourceValidator interface { Validate() error } @@ -77,6 +84,39 @@ func NewResource(uri string) Resource { return nil } +// Return a Content ReadWriter for the resource referred to. +func (r ResourceReference) Lookup(look ResourceMapper) ContentReadWriter { + slog.Info("ResourceReference.Lookup()", "resourcereference", r, "resourcemapper", look) + if look != nil { + if v,ok := look.Get(string(r)); ok { + return v.(ContentReadWriter) + } + } + return r +} + +func (r ResourceReference) Dereference(look ResourceMapper) Resource { + slog.Info("ResourceReference.Dereference()", "resourcereference", r, "resourcemapper", look) + if look != nil { + if v,ok := look.Get(string(r)); ok { + return v.(*Declaration).Attributes + } + } + return nil +} + +func (r ResourceReference) Parse() *url.URL { + u, e := url.Parse(string(r)) + if e == nil { + return u + } + return nil +} + +func (r ResourceReference) Exists() bool { + return transport.ExistsURI(string(r)) +} + func (r ResourceReference) ContentReaderStream() (*transport.Reader, error) { return transport.NewReaderURI(string(r)) } diff --git a/internal/resource/schema.go b/internal/resource/schema.go index 5b6632d..ff4f86c 100644 --- a/internal/resource/schema.go +++ b/internal/resource/schema.go @@ -13,7 +13,6 @@ import ( "log/slog" ) -//go:embed schemas/*.jsonschema //go:embed schemas/*.schema.json var schemaFiles embed.FS @@ -22,7 +21,7 @@ type Schema struct { } func NewSchema(name string) *Schema { - path := fmt.Sprintf("file://schemas/%s.jsonschema", name) + path := fmt.Sprintf("file://schemas/%s.schema.json", name) return &Schema{schema: gojsonschema.NewReferenceLoaderFileSystem(path, http.FS(schemaFiles))} //return &Schema{schema: gojsonschema.NewReferenceLoader(path)} @@ -44,6 +43,7 @@ func (s *Schema) Validate(source string) error { for _, err := range result.Errors() { schemaErrors.WriteString(err.String() + "\n") } + schemaErrors.WriteString(source) return errors.New(schemaErrors.String()) } return nil diff --git a/internal/resource/schemas/container-declaration.jsonschema b/internal/resource/schemas/container-declaration.schema.json similarity index 78% rename from internal/resource/schemas/container-declaration.jsonschema rename to internal/resource/schemas/container-declaration.schema.json index 1fe02d2..5279188 100644 --- a/internal/resource/schemas/container-declaration.jsonschema +++ b/internal/resource/schemas/container-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "container-declaration.jsonschema", + "$id": "container-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "declaration", "type": "object", @@ -11,7 +11,7 @@ "enum": [ "container" ] }, "attributes": { - "$ref": "container.jsonschema" + "$ref": "container.schema.json" } } } diff --git a/internal/resource/schemas/container-image.schema.json b/internal/resource/schemas/container-image.schema.json index 7469982..7e78a89 100644 --- a/internal/resource/schemas/container-image.schema.json +++ b/internal/resource/schemas/container-image.schema.json @@ -8,7 +8,7 @@ "properties": { "name": { "type": "string", - "pattern": "^[a-z]([-_a-z0-9:]{0,31})$" + "pattern": "^(?:[-0-9A-Za-z_.]+((?::[0-9]+|)(?:/[-a-z0-9._]+/[-a-z0-9._]+))|)(?:/|)(?:[-a-z0-9._]+(?:/[-a-z0-9._]+|))(:(?:[-0-9A-Za-z_.]{1,127})|)$" } } } diff --git a/internal/resource/schemas/container-network.schema.json b/internal/resource/schemas/container-network.schema.json index 91f7e60..8498270 100644 --- a/internal/resource/schemas/container-network.schema.json +++ b/internal/resource/schemas/container-network.schema.json @@ -8,7 +8,7 @@ "properties": { "name": { "type": "string", - "pattern": "^[a-z]([-_a-z0-9]{0,31})$" + "pattern": "^[a-zA-Z]([-_a-zA-Z0-9]+)$" } } } diff --git a/internal/resource/schemas/container.jsonschema b/internal/resource/schemas/container.schema.json similarity index 89% rename from internal/resource/schemas/container.jsonschema rename to internal/resource/schemas/container.schema.json index fc792bb..6027b4a 100644 --- a/internal/resource/schemas/container.jsonschema +++ b/internal/resource/schemas/container.schema.json @@ -1,5 +1,5 @@ { - "$id": "container.jsonschema", + "$id": "container.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "container", "description": "A docker container", diff --git a/internal/resource/schemas/document.jsonschema b/internal/resource/schemas/document.jsonschema deleted file mode 100644 index ae26641..0000000 --- a/internal/resource/schemas/document.jsonschema +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$id": "document.jsonschema", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "document", - "type": "object", - "required": [ "resources" ], - "properties": { - "resources": { - "type": "array", - "description": "Resources list", - "items": { - "oneOf": [ - { "$ref": "package-declaration.jsonschema" }, - { "$ref": "file-declaration.jsonschema" }, - { "$ref": "http-declaration.jsonschema" }, - { "$ref": "user-declaration.jsonschema" }, - { "$ref": "exec-declaration.jsonschema" }, - { "$ref": "network-route-declaration.schema.json" }, - { "$ref": "iptable-declaration.jsonschema" }, - { "$ref": "container-declaration.jsonschema" }, - { "$ref": "container-network-declaration.schema.json" }, - { "$ref": "container-image-declaration.schema.json" } - ] - } - } - } -} - diff --git a/internal/resource/schemas/document.schema.json b/internal/resource/schemas/document.schema.json new file mode 100644 index 0000000..e9db505 --- /dev/null +++ b/internal/resource/schemas/document.schema.json @@ -0,0 +1,31 @@ +{ + "$id": "document.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "document", + "type": "object", + "required": [ "resources" ], + "properties": { + "resources": { + "type": "array", + "description": "Resources list", + "items": { + "oneOf": [ + { "$ref": "certificate-declaration.schema.json" }, + { "$ref": "container-declaration.schema.json" }, + { "$ref": "container-network-declaration.schema.json" }, + { "$ref": "container-image-declaration.schema.json" }, + { "$ref": "exec-declaration.schema.json" }, + { "$ref": "file-declaration.schema.json" }, + { "$ref": "group-declaration.schema.json" }, + { "$ref": "http-declaration.schema.json" }, + { "$ref": "iptable-declaration.schema.json" }, + { "$ref": "network-route-declaration.schema.json" }, + { "$ref": "package-declaration.schema.json" }, + { "$ref": "pki-declaration.schema.json" }, + { "$ref": "user-declaration.schema.json" } + ] + } + } + } +} + diff --git a/internal/resource/schemas/exec-declaration.jsonschema b/internal/resource/schemas/exec-declaration.schema.json similarity index 80% rename from internal/resource/schemas/exec-declaration.jsonschema rename to internal/resource/schemas/exec-declaration.schema.json index 63816a6..0564f33 100644 --- a/internal/resource/schemas/exec-declaration.jsonschema +++ b/internal/resource/schemas/exec-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "exec-declaration.jsonschema", + "$id": "exec-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "exec-declaration", "type": "object", @@ -11,7 +11,7 @@ "enum": [ "exec" ] }, "attributes": { - "$ref": "exec.jsonschema" + "$ref": "exec.schema.json" } } } diff --git a/internal/resource/schemas/exec.jsonschema b/internal/resource/schemas/exec.schema.json similarity index 92% rename from internal/resource/schemas/exec.jsonschema rename to internal/resource/schemas/exec.schema.json index c7a03ab..7d297ab 100644 --- a/internal/resource/schemas/exec.jsonschema +++ b/internal/resource/schemas/exec.schema.json @@ -1,5 +1,5 @@ { - "$id": "exec.jsonschema", + "$id": "exec.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "exec", "type": "object", diff --git a/internal/resource/schemas/file-declaration.jsonschema b/internal/resource/schemas/file-declaration.schema.json similarity index 84% rename from internal/resource/schemas/file-declaration.jsonschema rename to internal/resource/schemas/file-declaration.schema.json index 8c16fe5..5b88be8 100644 --- a/internal/resource/schemas/file-declaration.jsonschema +++ b/internal/resource/schemas/file-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "file-declaration.jsonschema", + "$id": "file-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "file-declaration", "type": "object", @@ -15,7 +15,7 @@ "description": "Config name" }, "attributes": { - "$ref": "file.jsonschema" + "$ref": "file.schema.json" } } } diff --git a/internal/resource/schemas/file.jsonschema b/internal/resource/schemas/file.schema.json similarity index 97% rename from internal/resource/schemas/file.jsonschema rename to internal/resource/schemas/file.schema.json index 1dea4ad..79f82ee 100644 --- a/internal/resource/schemas/file.jsonschema +++ b/internal/resource/schemas/file.schema.json @@ -1,5 +1,5 @@ { - "$id": "file.jsonschema", + "$id": "file.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "file", "type": "object", diff --git a/internal/resource/schemas/group-declaration.schema.json b/internal/resource/schemas/group-declaration.schema.json new file mode 100644 index 0000000..f747361 --- /dev/null +++ b/internal/resource/schemas/group-declaration.schema.json @@ -0,0 +1,20 @@ +{ + "$id": "group-declaration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "declaration", + "type": "object", + "required": [ "type", "attributes" ], + "properties": { + "type": { + "type": "string", + "description": "Resource type name.", + "enum": [ "group" ] + }, + "transition": { + "$ref": "storagetransition.schema.json" + }, + "attributes": { + "$ref": "group.schema.json" + } + } +} diff --git a/internal/resource/schemas/group.schema.json b/internal/resource/schemas/group.schema.json new file mode 100644 index 0000000..272cf80 --- /dev/null +++ b/internal/resource/schemas/group.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "group.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "group", + "description": "A group account", + "type": "object", + "required": [ "name" ], + "properties": { + "name": { + "type": "string", + "pattern": "^[_a-z]([-_a-z0-9]{0,31})$" + }, + "gid": { + "type": "string", + "pattern": "^[0-9]*$" + } + } +} diff --git a/internal/resource/schemas/http-declaration.jsonschema b/internal/resource/schemas/http-declaration.schema.json similarity index 84% rename from internal/resource/schemas/http-declaration.jsonschema rename to internal/resource/schemas/http-declaration.schema.json index d344d20..829fb54 100644 --- a/internal/resource/schemas/http-declaration.jsonschema +++ b/internal/resource/schemas/http-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "http-declaration.jsonschema", + "$id": "http-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "http-declaration", "type": "object", @@ -15,7 +15,7 @@ "description": "Config name." }, "attributes": { - "$ref": "http.jsonschema" + "$ref": "http.schema.json" } } } diff --git a/internal/resource/schemas/http.jsonschema b/internal/resource/schemas/http.schema.json similarity index 95% rename from internal/resource/schemas/http.jsonschema rename to internal/resource/schemas/http.schema.json index 4f6c0c6..6a84dfd 100644 --- a/internal/resource/schemas/http.jsonschema +++ b/internal/resource/schemas/http.schema.json @@ -1,5 +1,5 @@ { - "$id": "http.jsonschema", + "$id": "http.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "http", "type": "object", diff --git a/internal/resource/schemas/iptable-declaration.jsonschema b/internal/resource/schemas/iptable-declaration.schema.json similarity index 83% rename from internal/resource/schemas/iptable-declaration.jsonschema rename to internal/resource/schemas/iptable-declaration.schema.json index f6c0cc5..3d3fb6e 100644 --- a/internal/resource/schemas/iptable-declaration.jsonschema +++ b/internal/resource/schemas/iptable-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "iptable-declaration.jsonschema", + "$id": "iptable-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "iptable-declaration", "type": "object", @@ -15,7 +15,7 @@ "description": "Config name" }, "attributes": { - "$ref": "iptable.jsonschema" + "$ref": "iptable.schema.json" } } } diff --git a/internal/resource/schemas/iptable.jsonschema b/internal/resource/schemas/iptable.schema.json similarity index 97% rename from internal/resource/schemas/iptable.jsonschema rename to internal/resource/schemas/iptable.schema.json index d0158c2..434f376 100644 --- a/internal/resource/schemas/iptable.jsonschema +++ b/internal/resource/schemas/iptable.schema.json @@ -1,5 +1,5 @@ { - "$id": "iptable.jsonschema", + "$id": "iptable.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "iptable", "type": "object", diff --git a/internal/resource/schemas/network-route-declaration.schema.json b/internal/resource/schemas/network-route-declaration.schema.json index 910bfe5..38d160d 100644 --- a/internal/resource/schemas/network-route-declaration.schema.json +++ b/internal/resource/schemas/network-route-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "network-route-declaration.jsonschema", + "$id": "network-route-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "network-route-declaration", "type": "object", diff --git a/internal/resource/schemas/package-declaration.jsonschema b/internal/resource/schemas/package-declaration.schema.json similarity index 79% rename from internal/resource/schemas/package-declaration.jsonschema rename to internal/resource/schemas/package-declaration.schema.json index 6b1dba4..00941c2 100644 --- a/internal/resource/schemas/package-declaration.jsonschema +++ b/internal/resource/schemas/package-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "package-declaration.jsonschema", + "$id": "package-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "package-declaration", "type": "object", @@ -11,7 +11,7 @@ "enum": [ "package" ] }, "attributes": { - "$ref": "package.jsonschema" + "$ref": "package.schema.json" } } } diff --git a/internal/resource/schemas/package.jsonschema b/internal/resource/schemas/package.schema.json similarity index 94% rename from internal/resource/schemas/package.jsonschema rename to internal/resource/schemas/package.schema.json index b99f130..d310db0 100644 --- a/internal/resource/schemas/package.jsonschema +++ b/internal/resource/schemas/package.schema.json @@ -1,5 +1,5 @@ { - "$id": "package.jsonschema", + "$id": "package.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "package", "type": "object", diff --git a/internal/resource/schemas/pki-declaration.schema.json b/internal/resource/schemas/pki-declaration.schema.json new file mode 100644 index 0000000..d00603e --- /dev/null +++ b/internal/resource/schemas/pki-declaration.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "pki-declaration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "declaration", + "type": "object", + "required": [ "type", "attributes" ], + "properties": { + "type": { + "type": "string", + "description": "Resource type name.", + "enum": [ "pki" ] + }, + "attributes": { + "$ref": "pki.schema.json" + } + } +} diff --git a/internal/resource/schemas/pki.schema.json b/internal/resource/schemas/pki.schema.json new file mode 100644 index 0000000..4d863c0 --- /dev/null +++ b/internal/resource/schemas/pki.schema.json @@ -0,0 +1,76 @@ +{ + "$id": "pki.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "pki", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "pem" + ] + }, + "publickey": { + "type": "string" + }, + "privatekey": { + "type": "string" + }, + "certificate": { + "type": "string" + }, + "SerialNumber": { + "type": "integer", + "description": "Serial number", + "minLength": 1 + }, + "Issuer": { + "$ref": "pkixname.schema.json" + }, + "Subject": { + "$ref": "pkixname.schema.json" + }, + "NotBefore": { + "type": "string", + "format": "date-time", + "description": "Cert is not valid before time in YYYY-MM-DDTHH:MM:SS.sssssssssZ format." + }, + "NotAfter": { + "type": "string", + "format": "date-time", + "description": "Cert is not valid after time in YYYY-MM-DDTHH:MM:SS.sssssssssZ format." + }, + "KeyUsage": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "description": "Actions valid for a key. E.g. 1 = KeyUsageDigitalSignature" + }, + "ExtKeyUsage": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 13 + }, + "description": "Extended set of actions valid for a key" + }, + "BasicConstraintsValid": { + "type": "boolean", + "description": "BasicConstraintsValid indicates whether IsCA, MaxPathLen, and MaxPathLenZero are valid" + }, + "IsCA": { + "type": "boolean", + "description": "" + } + } +} diff --git a/internal/resource/schemas/pkixname.schema.json b/internal/resource/schemas/pkixname.schema.json new file mode 100644 index 0000000..4a8dcdf --- /dev/null +++ b/internal/resource/schemas/pkixname.schema.json @@ -0,0 +1,65 @@ +{ + "$id": "pkixname.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "pkixname", + "type": "object", + "properties": { + "Country": { + "type": "array", + "description": "Country name", + "items": { + "type": "string" + } + }, + "Organization": { + "type": "array", + "description": "Organization name", + "items": { + "type": "string" + } + }, + "OrganizationalUnit": { + "type": "array", + "description": "Organizational Unit name", + "items": { + "type": "string" + } + }, + "Locality": { + "type": "array", + "description": "Locality name", + "items": { + "type": "string" + } + }, + "Province": { + "type": "array", + "description": "Province name", + "items": { + "type": "string" + } + }, + "StreetAddress": { + "type": "array", + "description": "Street address", + "items": { + "type": "string" + } + }, + "PostalCode": { + "type": "array", + "description": "Postal Code", + "items": { + "type": "string" + } + }, + "SerialNumber": { + "type": "string", + "description": "" + }, + "CommonName": { + "type": "string", + "description": "Name" + } + } +} diff --git a/internal/resource/schemas/user-declaration.jsonschema b/internal/resource/schemas/user-declaration.schema.json similarity index 83% rename from internal/resource/schemas/user-declaration.jsonschema rename to internal/resource/schemas/user-declaration.schema.json index 7c230b7..40e0a12 100644 --- a/internal/resource/schemas/user-declaration.jsonschema +++ b/internal/resource/schemas/user-declaration.schema.json @@ -1,5 +1,5 @@ { - "$id": "user-declaration.jsonschema", + "$id": "user-declaration.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "declaration", "type": "object", @@ -14,7 +14,7 @@ "$ref": "storagetransition.schema.json" }, "attributes": { - "$ref": "user.jsonschema" + "$ref": "user.schema.json" } } } diff --git a/internal/resource/schemas/user.jsonschema b/internal/resource/schemas/user.schema.json similarity index 90% rename from internal/resource/schemas/user.jsonschema rename to internal/resource/schemas/user.schema.json index c0deba2..7eb40f6 100644 --- a/internal/resource/schemas/user.jsonschema +++ b/internal/resource/schemas/user.schema.json @@ -1,5 +1,5 @@ { - "$id": "user.jsonschema", + "$id": "user.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "title": "user", "description": "A user account", @@ -8,7 +8,7 @@ "properties": { "name": { "type": "string", - "pattern": "^[a-z]([-_a-z0-9]{0,31})$" + "pattern": "^[_a-z]([-_a-z0-9]{0,31})$" }, "uid": { "type": "string", diff --git a/internal/resource/service.go b/internal/resource/service.go index a1f9656..5289160 100644 --- a/internal/resource/service.go +++ b/internal/resource/service.go @@ -36,6 +36,7 @@ type Service struct { State string `yaml:"state,omitempty" json:"state,omitempty"` config ConfigurationValueGetter + Resources ResourceMapper `yaml:"-" json:"-"` } func init() { @@ -107,6 +108,10 @@ func (s *Service) Validate() error { return nil } +func (s *Service) SetResourceMapper(resources ResourceMapper) { + s.Resources = resources +} + func (s *Service) Clone() Resource { news := &Service{ Name: s.Name, diff --git a/internal/resource/user.go b/internal/resource/user.go index 0e55803..e272edc 100644 --- a/internal/resource/user.go +++ b/internal/resource/user.go @@ -12,11 +12,12 @@ _ "os" "os/exec" "os/user" "io" - "strings" "encoding/json" "errors" "gitea.rosskeen.house/rosskeen.house/machine" + "strings" "decl/internal/codec" + "decl/internal/command" ) type decodeUser User @@ -28,6 +29,11 @@ const ( UserTypeUserAdd = "useradd" ) +var ErrUnsupportedUserType error = errors.New("The UserType is not supported on this system") +var ErrInvalidUserType error = errors.New("invalid UserType value") + +var SystemUserType UserType = FindSystemUserType() + type User struct { stater machine.Stater `json:"-" yaml:"-"` Name string `json:"name" yaml:"name"` @@ -40,12 +46,13 @@ type User struct { Shell string `json:"shell,omitempty" yaml:"shell,omitempty"` UserType UserType `json:"-" yaml:"-"` - CreateCommand *Command `json:"-" yaml:"-"` - ReadCommand *Command `json:"-" yaml:"-"` - UpdateCommand *Command `json:"-" yaml:"-"` - DeleteCommand *Command `json:"-" yaml:"-"` + CreateCommand *command.Command `json:"-" yaml:"-"` + ReadCommand *command.Command `json:"-" yaml:"-"` + UpdateCommand *command.Command `json:"-" yaml:"-"` + DeleteCommand *command.Command `json:"-" yaml:"-"` State string `json:"state,omitempty" yaml:"state,omitempty"` config ConfigurationValueGetter + Resources ResourceMapper `json:"-" yaml:"-"` } func NewUser() *User { @@ -68,6 +75,20 @@ func init() { }) } +func FindSystemUserType() UserType { + for _, userType := range []UserType{UserTypeAddUser, UserTypeUserAdd} { + c := userType.NewCreateCommand() + if c.Exists() { + return userType + } + } + return UserTypeAddUser +} + +func (u *User) SetResourceMapper(resources ResourceMapper) { + u.Resources = resources +} + func (u *User) Clone() Resource { newu := &User { Name: u.Name, @@ -97,6 +118,30 @@ func (u *User) Notify(m *machine.EventMessage) { switch m.On { case machine.ENTERSTATEEVENT: switch m.Dest { + case "start_read": + if _,readErr := u.Read(ctx); readErr == nil { + if triggerErr := u.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + u.State = "absent" + panic(triggerErr) + } + } else { + u.State = "absent" + panic(readErr) + } + case "start_delete": + if deleteErr := u.Delete(ctx); deleteErr == nil { + if triggerErr := u.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + u.State = "present" + panic(triggerErr) + } + } else { + u.State = "present" + panic(deleteErr) + } case "start_create": if e := u.Create(ctx); e == nil { if triggerErr := u.stater.Trigger("created"); triggerErr == nil { @@ -104,7 +149,9 @@ func (u *User) Notify(m *machine.EventMessage) { } } u.State = "absent" - case "present": + case "absent": + u.State = "absent" + case "present", "created", "read": u.State = "present" } case machine.EXITSTATEEVENT: @@ -140,6 +187,7 @@ func (u *User) Validate() error { } func (u *User) Apply() error { + ctx := context.Background() switch u.State { case "present": _, NoUserExists := LookupUID(u.Name) @@ -148,7 +196,7 @@ func (u *User) Apply() error { return cmdErr } case "absent": - cmdErr := u.Delete() + cmdErr := u.Delete(ctx) return cmdErr } return nil @@ -162,33 +210,6 @@ func (u *User) LoadDecl(yamlResourceDeclaration string) error { return codec.NewYAMLStringDecoder(yamlResourceDeclaration).Decode(u) } -func (u *User) AddUserCommand(args *[]string) error { - *args = append(*args, "-D") - if u.Group != "" { - *args = append(*args, "-G", u.Group) - } - if u.Home != "" { - *args = append(*args, "-h", u.Home) - } - return nil -} - -func (u *User) UserAddCommand(args *[]string) error { - if u.Group != "" { - *args = append(*args, "-g", u.Group) - } - if len(u.Groups) > 0 { - *args = append(*args, "-G", strings.Join(u.Groups, ",")) - } - if u.Home != "" { - *args = append(*args, "-d", u.Home) - } - if u.CreateHome { - *args = append(*args, "-m") - } - return nil -} - func (u *User) Type() string { return "user" } func (u *User) Create(ctx context.Context) (error) { @@ -213,7 +234,7 @@ func (u *User) Read(ctx context.Context) ([]byte, error) { } } -func (u *User) Delete() (error) { +func (u *User) Delete(ctx context.Context) (error) { _, err := u.DeleteCommand.Execute(u) if err != nil { return err @@ -238,7 +259,7 @@ func (u *User) UnmarshalYAML(value *yaml.Node) error { return nil } -func (u *UserType) NewCRUD() (create *Command, read *Command, update *Command, del *Command) { +func (u *UserType) NewCRUD() (create *command.Command, read *command.Command, update *command.Command, del *command.Command) { switch *u { case UserTypeUserAdd: return NewUserAddCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewUserDelDeleteCommand() @@ -257,13 +278,33 @@ func (u *UserType) NewCRUD() (create *Command, read *Command, update *Command, d } } +func (u *UserType) NewCreateCommand() (create *command.Command) { + switch *u { + case UserTypeUserAdd: + return NewUserAddCreateCommand() + case UserTypeAddUser: + return NewAddUserCreateCommand() + default: + } + return nil +} + +func (u *UserType) NewReadCommand() (*command.Command) { + return NewUserReadCommand() +} + +func (p *UserType) NewReadUsersCommand() (*command.Command) { + return NewReadUsersCommand() +} + + func (u *UserType) UnmarshalValue(value string) error { switch value { case string(UserTypeUserAdd), string(UserTypeAddUser): *u = UserType(value) return nil default: - return errors.New("invalid UserType value") + return ErrInvalidUserType } } @@ -283,17 +324,60 @@ func (u *UserType) UnmarshalYAML(value *yaml.Node) error { return u.UnmarshalValue(s) } -func NewUserAddCreateCommand() *Command { - c := NewCommand() +func NewReadUsersCommand() *command.Command { + c := command.NewCommand() + c.Path = "getent" + c.Args = []command.CommandArg{ + command.CommandArg("passwd"), + } + + c.Extractor = func(out []byte, target any) error { + Users := target.(*[]*User) + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lineIndex := 0 + for _, line := range lines { + userRecord := strings.Split(strings.TrimSpace(line), ":") + if len(*Users) <= lineIndex + 1 { + *Users = append(*Users, NewUser()) + } + u := (*Users)[lineIndex] + u.Name = userRecord[0] + u.UID = userRecord[2] + u.Gecos = userRecord[4] + u.Home = userRecord[5] + u.Shell = userRecord[6] + if readUser, userErr := user.Lookup(u.Name); userErr == nil { + if groups, groupsErr := readUser.GroupIds(); groupsErr == nil { + for _, secondaryGroup := range groups { + if readGroup, groupErr := user.LookupGroupId(secondaryGroup); groupErr == nil { + u.Groups = append(u.Groups, readGroup.Name) + } + } + } + } + if readGroup, groupErr := user.LookupGroupId(userRecord[3]); groupErr == nil { + u.Group = readGroup.Name + } + u.State = "present" + u.UserType = SystemUserType + lineIndex++ + } + return nil + } + return c +} + +func NewUserAddCreateCommand() *command.Command { + c := command.NewCommand() c.Path = "useradd" - c.Args = []CommandArg{ - CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"), - CommandArg("{{ if .Gecos }}-c {{ .Gecos }}{{ end }}"), - CommandArg("{{ if .Group }}-g {{ .Group }}{{ end }}"), - CommandArg("{{ if .Groups }}-G {{ range .Groups }}{{ . }},{{- end }}{{ end }}"), - CommandArg("{{ if .Home }}-d {{ .Home }}{{ end }}"), - CommandArg("{{ if .CreateHome }}-m{{ else }}-M{{ end }}"), - CommandArg("{{ .Name }}"), + c.Args = []command.CommandArg{ + command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"), + command.CommandArg("{{ if .Gecos }}-c {{ .Gecos }}{{ end }}"), + command.CommandArg("{{ if .Group }}-g {{ .Group }}{{ end }}"), + command.CommandArg("{{ if .Groups }}-G {{ range .Groups }}{{ . }},{{- end }}{{ end }}"), + command.CommandArg("{{ if .Home }}-d {{ .Home }}{{ end }}"), + command.CommandArg("{{ if .CreateHome }}-m{{ else }}-M{{ end }}"), + command.CommandArg("{{ .Name }}"), } c.Extractor = func(out []byte, target any) error { return nil @@ -309,17 +393,17 @@ func NewUserAddCreateCommand() *Command { return c } -func NewAddUserCreateCommand() *Command { - c := NewCommand() +func NewAddUserCreateCommand() *command.Command { + c := command.NewCommand() c.Path = "adduser" - c.Args = []CommandArg{ - CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"), - CommandArg("{{ if .Gecos }}-g {{ .Gecos }}{{ end }}"), - CommandArg("{{ if .Group }}-G {{ .Group }}{{ end }}"), - CommandArg("{{ if .Home }}-h {{ .Home }}{{ end }}"), - CommandArg("{{ if not .CreateHome }}-H{{ end }}"), - CommandArg("-D"), - CommandArg("{{ .Name }}"), + c.Args = []command.CommandArg{ + command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"), + command.CommandArg("{{ if .Gecos }}-g {{ .Gecos }}{{ end }}"), + command.CommandArg("{{ if .Group }}-G {{ .Group }}{{ end }}"), + command.CommandArg("{{ if .Home }}-h {{ .Home }}{{ end }}"), + command.CommandArg("{{ if not .CreateHome }}-H{{ end }}"), + command.CommandArg("-D"), + command.CommandArg("{{ .Name }}"), } c.Extractor = func(out []byte, target any) error { return nil @@ -335,8 +419,8 @@ func NewAddUserCreateCommand() *Command { return c } -func NewUserReadCommand() *Command { - c := NewCommand() +func NewUserReadCommand() *command.Command { + c := command.NewCommand() c.Extractor = func(out []byte, target any) error { u := target.(*User) u.State = "absent" @@ -369,15 +453,15 @@ func NewUserReadCommand() *Command { return c } -func NewUserUpdateCommand() *Command { +func NewUserUpdateCommand() *command.Command { return nil } -func NewUserDelDeleteCommand() *Command { - c := NewCommand() +func NewUserDelDeleteCommand() *command.Command { + c := command.NewCommand() c.Path = "userdel" - c.Args = []CommandArg{ - CommandArg("{{ .Name }}"), + c.Args = []command.CommandArg{ + command.CommandArg("{{ .Name }}"), } c.Extractor = func(out []byte, target any) error { return nil @@ -385,11 +469,11 @@ func NewUserDelDeleteCommand() *Command { return c } -func NewDelUserDeleteCommand() *Command { - c := NewCommand() +func NewDelUserDeleteCommand() *command.Command { + c := command.NewCommand() c.Path = "deluser" - c.Args = []CommandArg{ - CommandArg("{{ .Name }}"), + c.Args = []command.CommandArg{ + command.CommandArg("{{ .Name }}"), } c.Extractor = func(out []byte, target any) error { return nil diff --git a/internal/source/decl.go b/internal/source/decl.go index 2d4b9fe..cec349c 100644 --- a/internal/source/decl.go +++ b/internal/source/decl.go @@ -80,6 +80,7 @@ func (d *DeclFile) ExtractResources(filter ResourceSelector) ([]*resource.Docume for { doc := resource.NewDocument() e := decoder.Decode(doc) + slog.Info("ExtractResources().Decode()", "document", doc, "error", e) if errors.Is(e, io.EOF) { break } diff --git a/internal/source/group.go b/internal/source/group.go new file mode 100644 index 0000000..5964506 --- /dev/null +++ b/internal/source/group.go @@ -0,0 +1,68 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package source + +import ( +_ "context" +_ "encoding/json" +_ "fmt" +_ "gopkg.in/yaml.v3" + "net/url" +_ "path/filepath" + "decl/internal/resource" +_ "os" +_ "io" + "log/slog" +) + +type Group struct { + GroupType resource.GroupType `yaml:"type" json:"type"` +} + +func NewGroup() *Group { + return &Group{ GroupType: resource.SystemGroupType } +} + +func init() { + SourceTypes.Register([]string{"group"}, func(u *url.URL) DocSource { + groupSource := NewGroup() + groupType := u.Query().Get("type") + if len(groupType) > 0 { + groupSource.GroupType = resource.GroupType(groupType) + } + return groupSource + }) + +} + +func (g *Group) Type() string { return "group" } + +func (g *Group) ExtractResources(filter ResourceSelector) ([]*resource.Document, error) { + documents := make([]*resource.Document, 0, 100) + + slog.Info("group source ExtractResources()", "group", g) + Groups := make([]*resource.Group, 0, 100) + cmd := g.GroupType.NewReadGroupsCommand() + if cmd == nil { + return documents, resource.ErrUnsupportedGroupType + } + if out, err := cmd.Execute(g); err == nil { + slog.Info("group source ExtractResources()", "output", out) + if exErr := cmd.Extractor(out, &Groups); exErr != nil { + return documents, exErr + } + document := resource.NewDocument() + for _, grp := range Groups { + if grp == nil { + grp = resource.NewGroup() + } + grp.GroupType = g.GroupType + document.AddResourceDeclaration("group", grp) + } + documents = append(documents, document) + } else { + slog.Info("group source ExtractResources()", "output", out, "error", err) + return documents, err + } + return documents, nil +} diff --git a/internal/source/package.go b/internal/source/package.go index 8014495..fc1f716 100644 --- a/internal/source/package.go +++ b/internal/source/package.go @@ -26,7 +26,10 @@ func NewPackage() *Package { func init() { SourceTypes.Register([]string{"package"}, func(u *url.URL) DocSource { p := NewPackage() - p.PackageType = resource.PackageType(u.Query().Get("type")) + packageType := u.Query().Get("type") + if len(packageType) > 0 { + p.PackageType = resource.PackageType(packageType) + } return p }) @@ -40,6 +43,9 @@ func (p *Package) ExtractResources(filter ResourceSelector) ([]*resource.Documen slog.Info("package source ExtractResources()", "package", p) installedPackages := make([]*resource.Package, 0, 100) cmd := p.PackageType.NewReadPackagesCommand() + if cmd == nil { + return documents, resource.ErrUnsupportedPackageType + } if out, err := cmd.Execute(p); err == nil { slog.Info("package source ExtractResources()", "output", out) if exErr := cmd.Extractor(out, &installedPackages); exErr != nil { @@ -51,7 +57,6 @@ func (p *Package) ExtractResources(filter ResourceSelector) ([]*resource.Documen pkg = resource.NewPackage() } pkg.PackageType = p.PackageType - document.AddResourceDeclaration("package", pkg) } documents = append(documents, document) diff --git a/internal/source/user.go b/internal/source/user.go new file mode 100644 index 0000000..2ed1a6d --- /dev/null +++ b/internal/source/user.go @@ -0,0 +1,68 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package source + +import ( +_ "context" +_ "encoding/json" +_ "fmt" +_ "gopkg.in/yaml.v3" + "net/url" +_ "path/filepath" + "decl/internal/resource" +_ "os" +_ "io" + "log/slog" +) + +type User struct { + UserType resource.UserType `yaml:"type" json:"type"` +} + +func NewUser() *User { + return &User{ UserType: resource.SystemUserType } +} + +func init() { + SourceTypes.Register([]string{"user"}, func(u *url.URL) DocSource { + userSource := NewUser() + userType := u.Query().Get("type") + if len(userType) > 0 { + userSource.UserType = resource.UserType(userType) + } + return userSource + }) + +} + +func (u *User) Type() string { return "user" } + +func (u *User) ExtractResources(filter ResourceSelector) ([]*resource.Document, error) { + documents := make([]*resource.Document, 0, 100) + + slog.Info("user source ExtractResources()", "user", u) + Users := make([]*resource.User, 0, 100) + cmd := u.UserType.NewReadUsersCommand() + if cmd == nil { + return documents, resource.ErrUnsupportedUserType + } + if out, err := cmd.Execute(u); err == nil { + slog.Info("user source ExtractResources()", "output", out) + if exErr := cmd.Extractor(out, &Users); exErr != nil { + return documents, exErr + } + document := resource.NewDocument() + for _, usr := range Users { + if usr == nil { + usr = resource.NewUser() + } + usr.UserType = u.UserType + document.AddResourceDeclaration("user", usr) + } + documents = append(documents, document) + } else { + slog.Info("user source ExtractResources()", "output", out, "error", err) + return documents, err + } + return documents, nil +} diff --git a/internal/source/user_test.go b/internal/source/user_test.go new file mode 100644 index 0000000..404393e --- /dev/null +++ b/internal/source/user_test.go @@ -0,0 +1,23 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package source + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewUserSource(t *testing.T) { + s := NewUser() + assert.NotNil(t, s) +} + +func TestExtractUsers(t *testing.T) { + u := NewUser() + assert.NotNil(t, u) + + document, err := u.ExtractResources(nil) + assert.Nil(t, err) + assert.NotNil(t, document) + assert.Greater(t, len(document), 0) +} diff --git a/tests/mocks/container.go b/tests/mocks/container.go index 8bb67ca..a55408c 100644 --- a/tests/mocks/container.go +++ b/tests/mocks/container.go @@ -15,15 +15,17 @@ import ( type MockContainerClient struct { InjectContainerStart func(ctx context.Context, containerID string, options container.StartOptions) error InjectContainerCreate func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) - InjectNetworkCreate func(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) + InjectNetworkCreate func(ctx context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) + InjectNetworkList func(ctx context.Context, options network.ListOptions) ([]network.Summary, error) + InjectNetworkInspect func(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) InjectContainerList func(context.Context, container.ListOptions) ([]types.Container, error) InjectContainerInspect func(context.Context, string) (types.ContainerJSON, error) InjectContainerRemove func(context.Context, string, container.RemoveOptions) error InjectContainerStop func(context.Context, string, container.StopOptions) error InjectContainerWait func(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) - InjectImagePull func(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) + InjectImagePull func(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) InjectImageInspectWithRaw func(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) - InjectImageRemove func(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]image.DeleteResponse, error) + InjectImageRemove func(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) InjectClose func() error } @@ -31,11 +33,11 @@ func (m *MockContainerClient) ContainerWait(ctx context.Context, containerID str return m.InjectContainerWait(ctx, containerID, condition) } -func (m *MockContainerClient) ImageRemove(ctx context.Context, imageID string, options types.ImageRemoveOptions) ([]image.DeleteResponse, error) { +func (m *MockContainerClient) ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) { return m.InjectImageRemove(ctx, imageID, options) } -func (m *MockContainerClient) ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) { +func (m *MockContainerClient) ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) { return m.InjectImagePull(ctx, refStr, options) } @@ -77,6 +79,14 @@ func (m *MockContainerClient) Close() error { return m.InjectClose() } -func (m *MockContainerClient) NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) { +func (m *MockContainerClient) NetworkCreate(ctx context.Context, name string, options network.CreateOptions) (network.CreateResponse, error) { return m.InjectNetworkCreate(ctx, name, options) } + +func (m *MockContainerClient) NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) { + return m.InjectNetworkList(ctx, options) +} + +func (m *MockContainerClient) NetworkInspect(ctx context.Context, networkID string, options network.InspectOptions) (network.Inspect, error) { + return m.InjectNetworkInspect(ctx, networkID, options) +}