diff --git a/go.mod b/go.mod index d8d075c..e36190a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.5.0 // indirect - github.com/docker/docker v25.0.3+incompatible // indirect + github.com/docker/docker v25.0.5+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -19,6 +19,9 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect diff --git a/go.sum b/go.sum index e511819..6a23ffc 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,14 @@ github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/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.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ= github.com/docker/docker v25.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +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/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= @@ -33,10 +36,18 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= diff --git a/internal/resource/container.go b/internal/resource/container.go index 11ace3c..1bcafae 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -35,31 +35,31 @@ type ContainerClient interface { type Container struct { loader YamlLoader - Id string `yaml:"ID",omitempty` - Name string `yaml:"name"` - Path string `yaml:"path"` - Cmd []string `yaml:"cmd",omitempty` - Entrypoint strslice.StrSlice `yaml:"entrypoint",omitempty` - Args []string `yaml:"args",omitempty` - Environment map[string]string `yaml:"environment"` - Image string `yaml:"image"` - ResolvConfPath string `yaml:"resolvconfpath"` - HostnamePath string `yaml:"hostnamepath"` - HostsPath string `yaml:"hostspath"` - LogPath string `yaml:"logpath"` - Created string `yaml:"created"` - ContainerState types.ContainerState `yaml:"containerstate"` - RestartCount int `yaml:"restartcount"` - Driver string `yaml:"driver"` - Platform string `yaml:"platform"` - MountLabel string `yaml:"mountlabel"` - ProcessLabel string `yaml:"processlabel"` - AppArmorProfile string `yaml:"apparmorprofile"` - ExecIDs []string `yaml:"execids"` - HostConfig container.HostConfig `yaml:"hostconfig"` - GraphDriver types.GraphDriverData `yaml:"graphdriver"` - SizeRw *int64 `json:",omitempty"` - SizeRootFs *int64 `json:",omitempty"` + Id string `json:"ID,omitempty" yaml:"ID,omitempty"` + Name string `json:"name" yaml:"name"` + Path string `json:"path" yaml:"path"` + Cmd []string `json:"cmd,omitempty" yaml:"cmd,omitempty"` + Entrypoint strslice.StrSlice `json:"entrypoint,omitempty" yaml:"entrypoint,omitempty"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` + Environment map[string]string `json:"environment" yaml:"environment"` + Image string `json:"image" yaml:"image"` + ResolvConfPath string `json:"resolvconfpath" yaml:"resolvconfpath"` + HostnamePath string `json:"hostnamepath" yaml:"hostnamepath"` + HostsPath string `json:"hostpath" yaml:"hostspath"` + LogPath string `json:"logpath" yaml:"logpath"` + Created string `json:"created" yaml:"created"` + ContainerState types.ContainerState `json:"containerstate" yaml:"containerstate"` + RestartCount int `json:"restartcount" yaml:"restartcount"` + Driver string `json:"driver" yaml:"driver"` + Platform string `json:"platform" yaml:"platform"` + MountLabel string `json:"mountlabel" yaml:"mountlabel"` + ProcessLabel string `json:"processlabel" yaml:"processlabel"` + AppArmorProfile string `json:"apparmorprofile" yaml:"apparmorprofile"` + ExecIDs []string `json:"execids" yaml:"execids"` + HostConfig container.HostConfig `json:"hostconfig" yaml:"hostconfig"` + GraphDriver types.GraphDriverData `json:"graphdriver" yaml:"graphdriver"` + SizeRw *int64 `json:",omitempty" yaml:",omitempty"` + SizeRootFs *int64 `json:",omitempty" yaml:",omitempty"` /* Mounts []MountPoint Config *container.Config diff --git a/internal/resource/declaration.go b/internal/resource/declaration.go index 53b0b2f..2f07690 100644 --- a/internal/resource/declaration.go +++ b/internal/resource/declaration.go @@ -4,15 +4,19 @@ package resource import ( "context" + "encoding/json" "fmt" "gopkg.in/yaml.v3" "log/slog" ) +type DeclarationType struct { + Type TypeName `json:"type" yaml:"type"` +} + type Declaration struct { - Type string `yaml:"type"` - Attributes yaml.Node `yaml:"attributes"` - Implementation Resource `-` + Type TypeName `json:"type" yaml:"type"` + Attributes Resource `json:"attributes" yaml:"attributes"` } type ResourceLoader interface { @@ -43,41 +47,83 @@ func (d *Declaration) LoadDecl(yamlResourceDeclaration string) error { func (d *Declaration) NewResource() error { uri := fmt.Sprintf("%s://", d.Type) newResource, err := ResourceTypes.New(uri) - d.Implementation = newResource + d.Attributes = newResource return err } -func (d *Declaration) LoadResourceFromYaml() (Resource, error) { - var errResource error - if d.Implementation == nil { - errResource = d.NewResource() - if errResource != nil { - return nil, errResource - } - } - d.Attributes.Decode(d.Implementation) - d.Implementation.ResolveId(context.Background()) - return d.Implementation, errResource -} - -func (d *Declaration) UpdateYamlFromResource() error { - if d.Implementation != nil { - return d.Attributes.Encode(d.Implementation) - } - return nil -} - func (d *Declaration) Resource() Resource { - return d.Implementation + return d.Attributes } func (d *Declaration) SetURI(uri string) error { slog.Info("SetURI()", "uri", uri) - d.Implementation = NewResource(uri) - if d.Implementation == nil { + d.Attributes = NewResource(uri) + if d.Attributes == nil { panic("unknown resource") } - d.Type = d.Implementation.Type() - d.Implementation.Read(context.Background()) // fix context + d.Type = TypeName(d.Attributes.Type()) + d.Attributes.Read(context.Background()) // fix context return nil } + +func (d *Declaration) UnmarshalYAML(value *yaml.Node) error { + t := &DeclarationType{} + if unmarshalResourceTypeErr := value.Decode(t); unmarshalResourceTypeErr != nil { + return unmarshalResourceTypeErr + } + + d.Type = t.Type + newResource, resourceErr := ResourceTypes.New(fmt.Sprintf("%s://", d.Type)) + if resourceErr != nil { + return resourceErr + } + d.Attributes = newResource + 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 + } + + d.Type = t.Type + + newResource, resourceErr := ResourceTypes.New(fmt.Sprintf("%s://", d.Type)) + if resourceErr != nil { + return resourceErr + } + d.Attributes = newResource + + resourceAttrs := struct { + Attributes Resource `json:"attributes"` + }{Attributes: newResource} + if unmarshalAttributesErr := json.Unmarshal(data, &resourceAttrs); unmarshalAttributesErr != nil { + return unmarshalAttributesErr + } + + return nil +} + +/* +func (d *Declaration) MarshalJSON() ([]byte, error) { + buf := new(bytes.Buffer) + buf.WriteByte('"') + buf.WriteString("value")) + buf.WriteByte('"') + return buf.Bytes(), nil +} +*/ + +func (d *Declaration) MarshalYAML() (any, error) { + return d, nil +} diff --git a/internal/resource/declaration_test.go b/internal/resource/declaration_test.go index 8f26d71..76cb89a 100644 --- a/internal/resource/declaration_test.go +++ b/internal/resource/declaration_test.go @@ -2,6 +2,7 @@ package resource import ( + "encoding/json" "fmt" "github.com/stretchr/testify/assert" _ "log" @@ -55,7 +56,7 @@ func TestNewResourceDeclarationType(t *testing.T) { assert.NotEqual(t, nil, resourceDeclaration) resourceDeclaration.LoadDecl(decl) - assert.Equal(t, "file", resourceDeclaration.Type) + assert.Equal(t, TypeName("file"), resourceDeclaration.Type) assert.NotEqual(t, nil, resourceDeclaration.Attributes) } @@ -70,6 +71,38 @@ func TestDeclarationNewResource(t *testing.T) { errNewFileResource := resourceDeclaration.NewResource() assert.Nil(t, errNewFileResource) - //assert.NotNil(t, resourceDeclaration.Implementation) assert.NotNil(t, resourceDeclaration.Attributes) } + +func TestDeclarationJson(t *testing.T) { + fileDeclJson := ` +{ + "type": "file", + "attributes": { + "path": "foo" + } +} +` + resourceDeclaration := NewDeclaration() + e := json.Unmarshal([]byte(fileDeclJson), resourceDeclaration) + assert.Nil(t, e) + assert.Equal(t, TypeName("file"), resourceDeclaration.Type) + assert.Equal(t, "foo", resourceDeclaration.Attributes.(*File).Path) + + userDeclJson := ` +{ + "type": "user", + "attributes": { + "name": "testuser", + "uid": 10012 + } +} +` + userResourceDeclaration := NewDeclaration() + ue := json.Unmarshal([]byte(userDeclJson), userResourceDeclaration) + assert.Nil(t, ue) + assert.Equal(t, TypeName("user"), userResourceDeclaration.Type) + assert.Equal(t, "testuser", userResourceDeclaration.Attributes.(*User).Name) + assert.Equal(t, 10012, userResourceDeclaration.Attributes.(*User).UID) + +} diff --git a/internal/resource/decoder.go b/internal/resource/decoder.go new file mode 100644 index 0000000..3d8cf7d --- /dev/null +++ b/internal/resource/decoder.go @@ -0,0 +1,34 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "encoding/json" + _ "fmt" + _ "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" + "io" + _ "log" +) + +//type JSONDecoder json.Decoder + +type Decoder interface { + Decode(v any) error +} + +func NewDecoder() *Decoder { + return nil +} + +func NewJSONDecoder(r io.Reader) Decoder { + return json.NewDecoder(r) +} + +func NewYAMLDecoder(r io.Reader) Decoder { + return yaml.NewDecoder(r) +} + +func NewProtoBufDecoder(r io.Reader) Decoder { + return nil +} diff --git a/internal/resource/decoder_test.go b/internal/resource/decoder_test.go new file mode 100644 index 0000000..10afa67 --- /dev/null +++ b/internal/resource/decoder_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + _ "fmt" + "github.com/stretchr/testify/assert" + _ "log" + "strings" + "testing" +) + +func TestNewYAMLDecoder(t *testing.T) { + e := NewYAMLDecoder(strings.NewReader("")) + assert.NotNil(t, e) +} + +func TestNewDecoderDecodeJSON(t *testing.T) { + decl := `{ + "name": "testuser", + "uid": 12001, + "group": "12001", + "home": "/home/testuser", + "state": "present" +}` + + jsonReader := strings.NewReader(decl) + user := NewUser() + + e := NewJSONDecoder(jsonReader) + assert.NotNil(t, e) + docErr := e.Decode(user) + assert.Nil(t, docErr) + + s := NewSchema(user.Type()) + + validateErr := s.Validate(decl) + assert.Nil(t, validateErr) +} diff --git a/internal/resource/document.go b/internal/resource/document.go index b6faad1..9b49cac 100644 --- a/internal/resource/document.go +++ b/internal/resource/document.go @@ -3,15 +3,19 @@ package resource import ( + "encoding/json" + "errors" _ "fmt" + "github.com/xeipuuv/gojsonschema" "gopkg.in/yaml.v3" "io" _ "log" _ "net/url" + "strings" ) type Document struct { - ResourceDecls []Declaration `yaml:"resources"` + ResourceDecls []Declaration `json:"resources" yaml:"resources"` } func NewDocument() *Document { @@ -20,10 +24,28 @@ func NewDocument() *Document { func (d *Document) Load(r io.Reader) error { yamlDecoder := yaml.NewDecoder(r) - yamlDecoder.Decode(d) - for i := range d.ResourceDecls { - if _, e := d.ResourceDecls[i].LoadResourceFromYaml(); e != nil { - return e + if e := yamlDecoder.Decode(d); e != nil { + return e + } + return nil +} + +func (d *Document) Validate() error { + jsonDocument, jsonErr := d.JSON() + if jsonErr == nil { + schemaLoader := gojsonschema.NewReferenceLoader("file://schemas/document.jsonschema") + documentLoader := gojsonschema.NewBytesLoader(jsonDocument) + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return err + } + + if !result.Valid() { + schemaErrors := strings.Builder{} + for _, err := range result.Errors() { + schemaErrors.WriteString(err.String() + "\n") + } + return errors.New(schemaErrors.String()) } } return nil @@ -43,16 +65,15 @@ func (d *Document) Apply() error { } func (d *Document) Generate(w io.Writer) error { - yamlEncoder := yaml.NewEncoder(w) - yamlEncoder.Encode(d) - return yamlEncoder.Close() + e := NewYAMLEncoder(w) + e.Encode(d) + return e.Close() } func (d *Document) AddResourceDeclaration(resourceType string, resourceDeclaration Resource) { decl := NewDeclaration() - decl.Type = resourceType - decl.Implementation = resourceDeclaration - decl.UpdateYamlFromResource() + decl.Type = TypeName(resourceType) + decl.Attributes = resourceDeclaration d.ResourceDecls = append(d.ResourceDecls, *decl) } @@ -61,8 +82,15 @@ func (d *Document) AddResource(uri string) error { //if e == nil { decl := NewDeclaration() decl.SetURI(uri) - decl.UpdateYamlFromResource() d.ResourceDecls = append(d.ResourceDecls, *decl) //} return nil } + +func (d *Document) JSON() ([]byte, error) { + return json.Marshal(d) +} + +func (d *Document) YAML() ([]byte, error) { + return yaml.Marshal(d) +} diff --git a/internal/resource/document_test.go b/internal/resource/document_test.go index 493c69e..6066d83 100644 --- a/internal/resource/document_test.go +++ b/internal/resource/document_test.go @@ -44,19 +44,18 @@ resources: - type: user attributes: name: "testuser" - uid: "10022" - gid: "10022" + uid: 10022 + group: "10022" home: "/home/testuser" state: present `, file) - d := NewDocument() assert.NotEqual(t, nil, d) docReader := strings.NewReader(document) e := d.Load(docReader) - assert.Equal(t, nil, e) + assert.Nil(t, e) resources := d.Resources() assert.Equal(t, 2, len(resources)) @@ -110,7 +109,6 @@ resources: f.(*File).Path = filepath.Join(TempDir, "foo.txt") f.(*File).Read(ctx) d.AddResourceDeclaration("file", f) - ey := d.Generate(&documentYaml) assert.Equal(t, nil, ey) @@ -127,3 +125,59 @@ func TestDocumentAddResource(t *testing.T) { assert.NotNil(t, d) d.AddResource(fmt.Sprintf("file://%s", file)) } + +func TestDocumentJSON(t *testing.T) { + document := fmt.Sprintf(` +--- +resources: +- type: user + attributes: + name: "testuser" + uid: 10022 + group: "10022" + home: "/home/testuser" + state: present +`) + d := NewDocument() + assert.NotNil(t, d) + docReader := strings.NewReader(document) + + e := d.Load(docReader) + assert.Nil(t, e) + + marshalledJSON, jsonErr := d.JSON() + assert.Nil(t, jsonErr) + assert.Greater(t, len(marshalledJSON), 0) +} + +func TestDocumentJSONSchema(t *testing.T) { + document := NewDocument() + document.ResourceDecls = []Declaration{} + e := document.Validate() + assert.Nil(t, e) +} + +func TestDocumentYAML(t *testing.T) { + document := fmt.Sprintf(` +--- +resources: +- type: user + attributes: + name: "testuser" + uid: 10022 + group: "10022" + home: "/home/testuser" + state: present +`) + d := NewDocument() + assert.NotNil(t, d) + docReader := strings.NewReader(document) + + e := d.Load(docReader) + assert.Nil(t, e) + + marshalledYAML, yamlErr := d.YAML() + assert.Nil(t, yamlErr) + assert.YAMLEq(t, string(document), string(marshalledYAML)) + +} diff --git a/internal/resource/encoder.go b/internal/resource/encoder.go new file mode 100644 index 0000000..a3ecb2d --- /dev/null +++ b/internal/resource/encoder.go @@ -0,0 +1,42 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "encoding/json" + _ "fmt" + _ "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" + "io" + _ "log" +) + +type JSONEncoder json.Encoder + +type Encoder interface { + Encode(v any) error + Close() error +} + +func NewEncoder() *Encoder { + return nil +} + +func NewJSONEncoder(w io.Writer) Encoder { + return (*JSONEncoder)(json.NewEncoder(w)) +} + +func NewYAMLEncoder(w io.Writer) Encoder { + return yaml.NewEncoder(w) +} + +func NewProtoBufEncoder(w io.Writer) Encoder { + return nil +} + +func (j *JSONEncoder) Encode(v any) error { + return (*json.Encoder)(j).Encode(v) +} +func (j *JSONEncoder) Close() error { + return nil +} diff --git a/internal/resource/encoder_test.go b/internal/resource/encoder_test.go new file mode 100644 index 0000000..f4b42e1 --- /dev/null +++ b/internal/resource/encoder_test.go @@ -0,0 +1,33 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + _ "fmt" + "github.com/stretchr/testify/assert" + _ "log" + "strings" + "testing" +) + +func TestNewYAMLEncoder(t *testing.T) { + var yamlDoc strings.Builder + e := NewYAMLEncoder(&yamlDoc) + assert.NotNil(t, e) +} + +func TestNewEncoderEncodeJSON(t *testing.T) { + var jsonDoc strings.Builder + file := NewFile() + file.Path = "foo" + + e := NewJSONEncoder(&jsonDoc) + assert.NotNil(t, e) + docErr := e.Encode(file) + assert.Nil(t, docErr) + + s := NewSchema(file.Type()) + + validateErr := s.Validate(jsonDoc.String()) + assert.Nil(t, validateErr) +} diff --git a/internal/resource/file.go b/internal/resource/file.go index c748fba..d8241bb 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -42,22 +42,25 @@ func init() { // Manage the state of file system objects type File struct { loader YamlLoader - Path string `yaml:"path"` - Owner string `yaml:"owner"` - Group string `yaml:"group"` - Mode string `yaml:"mode"` + Path string `json:"path" yaml:"path"` + Owner string `json:"owner" yaml:"owner"` + Group string `json:"group" yaml:"group"` + Mode string `json:"mode" yaml:"mode"` - Atime time.Time `yaml:"atime",omitempty` - Ctime time.Time `yaml:"ctime",omitempty` - Mtime time.Time `yaml:"mtime",omitempty` + Atime time.Time `json:"atime,omitempty" yaml:"atime,omitempty"` + Ctime time.Time `json:"ctime,omitempty" yaml:"ctime,omitempty"` + Mtime time.Time `json:"mtime,omitempty" yaml:"mtime,omitempty"` - Content string `yaml:"content",omitempty` - FileType FileType `yaml:"filetype"` - State string `yaml:"state"` + Content string `json:"content,omitempty" yaml:"content,omitempty"` + Target string `json:"target,omitempty" yaml:"target,omitempty"` + FileType FileType `json:"filetype" yaml:"filetype"` + State string `json:"state" yaml:"state"` } func NewFile() *File { - return &File{loader: YamlLoadDecl, FileType: RegularFile} + currentUser, _ := user.Current() + group, _ := user.LookupGroupId(currentUser.Gid) + return &File{loader: YamlLoadDecl, Owner: currentUser.Username, Group: group.Name, Mode: "0666", FileType: RegularFile} } func (f *File) URI() string { @@ -75,7 +78,6 @@ func (f *File) SetURI(uri string) error { } func (f *File) Apply() error { - switch f.State { case "absent": removeErr := os.Remove(f.Path) @@ -102,6 +104,11 @@ func (f *File) Apply() error { //e := os.Stat(f.path) //if os.IsNotExist(e) { switch f.FileType { + case SymbolicLinkFile: + linkErr := os.Symlink(f.Target, f.Path) + if linkErr != nil { + return linkErr + } case DirectoryFile: os.MkdirAll(f.Path, os.FileMode(mode)) default: @@ -150,18 +157,19 @@ func (f *File) ResolveId(ctx context.Context) string { return filePath } -func (f *File) Read(ctx context.Context) ([]byte, error) { +func (f *File) NormalizePath() error { filePath, fileAbsErr := filepath.Abs(f.Path) - if fileAbsErr != nil { - panic(fileAbsErr) + if fileAbsErr == nil { + f.Path = filePath } - f.Path = filePath - - info, e := os.Stat(f.Path) + return fileAbsErr +} +func (f *File) ReadStat() error { + info, e := os.Lstat(f.Path) if e != nil { f.State = "absent" - return nil, e + return e } f.Mtime = info.ModTime() @@ -187,19 +195,38 @@ func (f *File) Read(ctx context.Context) ([]byte, error) { f.Group = fileGroup.Name } } - f.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) + f.FileType.SetMode(info.Mode()) + return nil +} - file, fileErr := os.Open(f.Path) - if fileErr != nil { - panic(fileErr) +func (f *File) Read(ctx context.Context) ([]byte, error) { + f.NormalizePath() + + statErr := f.ReadStat() + if statErr != nil { + return nil, statErr } - fileContent, ioErr := io.ReadAll(file) - if ioErr != nil { - panic(ioErr) + switch f.FileType { + case RegularFile: + file, fileErr := os.Open(f.Path) + if fileErr != nil { + panic(fileErr) + } + + fileContent, ioErr := io.ReadAll(file) + if ioErr != nil { + panic(ioErr) + } + f.Content = string(fileContent) + case SymbolicLinkFile: + linkTarget, pathErr := os.Readlink(f.Path) + if pathErr != nil { + return nil, pathErr + } + f.Target = linkTarget } - f.Content = string(fileContent) f.State = "present" return yaml.Marshal(f) } @@ -220,3 +247,22 @@ func (f *FileType) UnmarshalYAML(value *yaml.Node) error { return errors.New("invalid FileType value") } } + +func (f *FileType) SetMode(mode os.FileMode) { + switch true { + case mode.IsRegular(): + *f = RegularFile + case mode.IsDir(): + *f = DirectoryFile + case mode&os.ModeSymlink != 0: + *f = SymbolicLinkFile + case mode&os.ModeNamedPipe != 0: + *f = NamedPipeFile + case mode&os.ModeSocket != 0: + *f = SocketFile + case mode&os.ModeCharDevice != 0: + *f = CharacterDeviceFile + case mode&os.ModeDevice != 0: + *f = BlockDeviceFile + } +} diff --git a/internal/resource/file_test.go b/internal/resource/file_test.go index 5860f7d..b0a8bbc 100644 --- a/internal/resource/file_test.go +++ b/internal/resource/file_test.go @@ -188,3 +188,58 @@ func TestFileSetURI(t *testing.T) { assert.Equal(t, "file", f.Type()) assert.Equal(t, file, f.Path) } + +func TestFileNormalizePath(t *testing.T) { + absFile, absFilePathErr := filepath.Abs(filepath.Join(TempDir, "./testuri.txt")) + assert.Nil(t, absFilePathErr) + + file := filepath.Join(TempDir, "./testuri.txt") + + f := NewFile() + assert.NotNil(t, f) + + f.Path = file + e := f.NormalizePath() + assert.Nil(t, e) + + assert.Equal(t, absFile, f.Path) +} + +func TestFileReadStat(t *testing.T) { + ctx := context.Background() + link := filepath.Join(TempDir, "link.txt") + linkTargetFile := filepath.Join(TempDir, "testuri.txt") + + f := NewFile() + assert.NotNil(t, f) + + f.Path = linkTargetFile + e := f.NormalizePath() + assert.Nil(t, e) + + statErr := f.ReadStat() + assert.Error(t, statErr) + + f.Owner = "nobody" + f.Group = "nobody" + f.State = "present" + assert.Nil(t, f.Apply()) + + assert.Nil(t, f.ReadStat()) + + l := NewFile() + assert.NotNil(t, l) + + l.FileType = SymbolicLinkFile + l.Path = link + l.Target = linkTargetFile + l.State = "present" + + l.Apply() + l.ReadStat() + + testRead := NewFile() + testRead.Path = link + testRead.Read(ctx) + assert.Equal(t, linkTargetFile, testRead.Target) +} diff --git a/internal/resource/http.go b/internal/resource/http.go new file mode 100644 index 0000000..46eb027 --- /dev/null +++ b/internal/resource/http.go @@ -0,0 +1,75 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + _ "errors" + "fmt" + "gopkg.in/yaml.v3" + _ "io" + "net/url" + _ "os" +) + +func init() { + ResourceTypes.Register("http", HTTPFactory) + ResourceTypes.Register("https", HTTPFactory) +} + +func HTTPFactory(u *url.URL) Resource { + h := NewHTTP() + return h +} + +// Manage the state of an HTTP endpoint +type HTTP struct { + loader YamlLoader + Endpoint string `yaml:"endpoint"` + + Body string `yaml:"body,omitempty"` + State string `yaml:"state"` +} + +func NewHTTP() *HTTP { + return &HTTP{loader: YamlLoadDecl} +} + +func (h *HTTP) URI() string { + return h.Endpoint +} + +func (h *HTTP) SetURI(uri string) error { + if _, e := url.Parse(uri); e != nil { + return fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, uri) + } + h.Endpoint = uri + return nil +} + +func (h *HTTP) Apply() error { + + switch h.State { + case "absent": + case "present": + } + + return nil +} + +func (h *HTTP) LoadDecl(yamlFileResourceDeclaration string) error { + return h.loader(yamlFileResourceDeclaration, h) +} + +func (h *HTTP) ResolveId(ctx context.Context) string { + return h.Endpoint +} + +func (h *HTTP) Read(ctx context.Context) ([]byte, error) { + return yaml.Marshal(h) +} + +func (h *HTTP) Type() string { + u, _ := url.Parse(h.Endpoint) + return u.Scheme +} diff --git a/internal/resource/http_test.go b/internal/resource/http_test.go new file mode 100644 index 0000000..b0408b9 --- /dev/null +++ b/internal/resource/http_test.go @@ -0,0 +1,25 @@ +// 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" + _ "net/http" + _ "net/http/httptest" + _ "net/url" + _ "os" + _ "path/filepath" + _ "strings" + "testing" +) + +func TestNewHTTPResource(t *testing.T) { + f := NewHTTP() + assert.NotEqual(t, nil, f) +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 1d0236f..8d06333 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -5,6 +5,7 @@ package resource import ( "context" + _ "encoding/json" _ "fmt" _ "gopkg.in/yaml.v3" _ "net/url" diff --git a/internal/resource/schema.go b/internal/resource/schema.go new file mode 100644 index 0000000..55c0555 --- /dev/null +++ b/internal/resource/schema.go @@ -0,0 +1,41 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + _ "encoding/json" + "errors" + "fmt" + "github.com/xeipuuv/gojsonschema" + "strings" +) + +type Schema struct { + schema gojsonschema.JSONLoader +} + +func NewSchema(name string) *Schema { + path := fmt.Sprintf("file://schemas/%s.jsonschema", name) + + return &Schema{schema: gojsonschema.NewReferenceLoader(path)} +} + +func (s *Schema) Validate(source string) error { + // loader := gojsonschema.NewGoLoader(source) + loader := gojsonschema.NewStringLoader(source) + result, err := gojsonschema.Validate(s.schema, loader) + + if err != nil { + fmt.Printf("%#v %#v %#v %#v\n", source, loader, result, err) + return err + } + + if !result.Valid() { + schemaErrors := strings.Builder{} + for _, err := range result.Errors() { + schemaErrors.WriteString(err.String() + "\n") + } + return errors.New(schemaErrors.String()) + } + return nil +} diff --git a/internal/resource/schema_test.go b/internal/resource/schema_test.go new file mode 100644 index 0000000..3ce5f82 --- /dev/null +++ b/internal/resource/schema_test.go @@ -0,0 +1,80 @@ +// 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" + _ "net/http" + _ "net/http/httptest" + _ "net/url" + "os" + "path/filepath" + _ "strings" + "syscall" + "testing" + "time" +) + +func TestNewSchema(t *testing.T) { + s := NewSchema("document") + assert.NotEqual(t, nil, s) +} + +func TestSchemaValidate(t *testing.T) { + ctx := context.Background() + s := NewSchema("file") + assert.NotEqual(t, nil, s) + + file, _ := filepath.Abs(filepath.Join(TempDir, "fooread.txt")) + + declarationAttributes := ` + path: "%s" + owner: "nobody" + group: "nobody" + mode: "0600" + atime: 2001-12-15T01:01:01.000000001Z + ctime: %s + mtime: 2001-12-15T01:01:01.000000001Z + content: |- + test line 1 + test line 2 + filetype: "regular" + state: present +` + + decl := fmt.Sprintf(declarationAttributes, file, "2001-12-15T01:01:01.000000001Z") + + testFile := NewFile() + e := testFile.LoadDecl(decl) + assert.Equal(t, nil, e) + testFile.Apply() + + jsonDoc, jsonErr := json.Marshal(testFile) + assert.Nil(t, jsonErr) + + schemaErr := s.Validate(string(jsonDoc)) + assert.Nil(t, schemaErr) + + f := NewFile() + assert.NotEqual(t, nil, f) + + f.Path = file + r, e := f.Read(ctx) + assert.Equal(t, nil, e) + assert.Equal(t, "nobody", f.Owner) + + info, statErr := os.Stat(file) + assert.Nil(t, statErr) + stat, ok := info.Sys().(*syscall.Stat_t) + assert.True(t, ok) + cTime := time.Unix(int64(stat.Ctim.Sec), int64(stat.Ctim.Nsec)) + + expected := fmt.Sprintf(declarationAttributes, file, cTime.Format(time.RFC3339Nano)) + assert.YAMLEq(t, expected, string(r)) +} diff --git a/internal/resource/schemas/user.jsonschema b/internal/resource/schemas/user.jsonschema new file mode 100644 index 0000000..774c0da --- /dev/null +++ b/internal/resource/schemas/user.jsonschema @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "user", + "description": "A user account", + "type": "object", + "required": [ "name" ], + "properties": { + "name": { + "type": "string" + }, + "uid": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "group": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "gecos": { + "type": "string", + "description": "User description" + }, + "createhome": { + "type": "boolean", + "description": "create user home directory" + }, + "shell": { + "type": "string", + "description": "login shell" + } + } +} diff --git a/internal/resource/types.go b/internal/resource/types.go index db8ddd1..4b1a57f 100644 --- a/internal/resource/types.go +++ b/internal/resource/types.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/url" + "strings" ) var ( @@ -13,6 +14,8 @@ var ( ResourceTypes *Types = NewTypes() ) +type TypeName string //`json:"type"` + type TypeFactory func(*url.URL) Resource type Types struct { @@ -45,3 +48,12 @@ func (t *Types) Has(typename string) bool { } return false } + +func (n *TypeName) UnmarshalJSON(b []byte) error { + ResourceTypeName := strings.Trim(string(b), "\"") + if ResourceTypes.Has(ResourceTypeName) { + *n = TypeName(ResourceTypeName) + return nil + } + return fmt.Errorf("%w: %s", ErrUnknownResourceType, ResourceTypeName) +} diff --git a/internal/resource/types_test.go b/internal/resource/types_test.go index 2ce83cc..b8fd4a2 100644 --- a/internal/resource/types_test.go +++ b/internal/resource/types_test.go @@ -4,6 +4,7 @@ package resource import ( _ "context" "decl/tests/mocks" + "encoding/json" "github.com/stretchr/testify/assert" "net/url" "testing" @@ -51,3 +52,14 @@ func TestResourceTypesHasType(t *testing.T) { assert.True(t, resourceTypes.Has("foo")) } + +func TestResourceTypeName(t *testing.T) { + type fooResourceName struct { + Name TypeName `json:"type"` + } + fooTypeName := &fooResourceName{} + jsonType := `{ "type": "file" }` + e := json.Unmarshal([]byte(jsonType), &fooTypeName) + assert.Nil(t, e) + assert.Equal(t, "file", string(fooTypeName.Name)) +} diff --git a/internal/resource/user.go b/internal/resource/user.go index 8068d35..d22bbf6 100644 --- a/internal/resource/user.go +++ b/internal/resource/user.go @@ -17,16 +17,16 @@ import ( type User struct { loader YamlLoader - Name string `yaml:"name"` - UID int `yaml:"uid"` - Group string `yaml:"group"` - Groups []string `yaml:"groups",omitempty` - Gecos string `yaml:"gecos"` - Home string `yaml:"home"` - CreateHome bool `yaml:"createhome"omitempty` - Shell string `yaml:"shell"` + Name string `json:"name" yaml:"name"` + UID int `json:"uid,omitempty" yaml:"uid,omitempty"` + Group string `json:"group,omitempty" yaml:"group,omitempty"` + Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"` + Gecos string `json:"gecos,omitempty" yaml:"gecos,omitempty"` + Home string `json:"home" yaml:"home"` + CreateHome bool `json:"createhome,omitempty" yaml:"createhome,omitempty"` + Shell string `json:"shell,omitempty" yaml:"shell,omitempty"` - State string `yaml:"state"` + State string `json:"state" yaml:"state"` } func NewUser() *User { diff --git a/tests/mocks/resource.go b/tests/mocks/resource.go index 69ac797..1b17041 100644 --- a/tests/mocks/resource.go +++ b/tests/mocks/resource.go @@ -4,6 +4,8 @@ package mocks import ( "context" _ "gopkg.in/yaml.v3" + "encoding/json" + "fmt" ) type MockResource struct { @@ -38,3 +40,12 @@ func (m *MockResource) Read(ctx context.Context) ([]byte, error) { func (m *MockResource) Type() string { return m.InjectType() } + +func (m *MockResource) UnmarshalJSON(data []byte) error { + fmt.Printf("UnmarshalJSON %#v\n", string(data)) + panic(data) + if err := json.Unmarshal(data, m); err != nil { + return err + } + return nil +}