initial version

This commit is contained in:
Matthew Rich 2024-03-20 12:23:31 -07:00
parent 4b5382d7aa
commit 13aacbe28d
17 changed files with 928 additions and 128 deletions

1
COPYRIGHT Normal file
View File

@ -0,0 +1 @@
Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.

53
cmd/cli/main.go Normal file
View File

@ -0,0 +1,53 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package main
import (
"os"
"flag"
"log"
_ "fmt"
_ "gopkg.in/yaml.v3"
"decl/internal/resource"
)
func main() {
file := flag.String("resource-file", "", "Resource file path")
resourceUri := flag.String("import-resource", "", "Add an existing resource")
flag.Parse()
var resourceFile *os.File
var inputFileErr error
if *file != "" {
resourceFile,inputFileErr = os.Open(*file)
} else {
if stdinInfo, stdinErr := os.Stdin.Stat(); stdinErr == nil {
if (stdinInfo.Mode() & os.ModeCharDevice) == 0 {
resourceFile = os.Stdin
}
} else {
return
}
}
if inputFileErr != nil {
log.Fatal(inputFileErr)
}
d := resource.NewDocument()
if e := d.Load(resourceFile); e != nil {
log.Fatal(e)
}
if applyErr := d.Apply(); applyErr != nil {
log.Fatal(applyErr)
}
if *resourceUri != "" {
d.AddResource(*resourceUri)
}
d.Generate(os.Stdout)
}

View File

@ -0,0 +1,244 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
"context"
"fmt"
_ "os"
_ "gopkg.in/yaml.v3"
_ "os/exec"
_ "strings"
"log"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/docker/docker/api/types/network"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gopkg.in/yaml.v3"
"net/url"
"path/filepath"
)
type ContainerClient interface {
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error)
ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error)
ContainerInspect(context.Context, string) (types.ContainerJSON, error)
ContainerRemove(context.Context, string, container.RemoveOptions) error
Close() error
}
type Container struct {
loader YamlLoader
Id string `yaml:"ID",omitempty`
Name string `yaml:"name"`
Path string `yaml:"path"`
Cmd []string `yaml:"cmd",omitempty`
Args []string `yaml:"args",omitempty`
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"`
/*
Mounts []MountPoint
Config *container.Config
NetworkSettings *NetworkSettings
*/
State string `yaml:"state"`
apiClient ContainerClient
}
func init() {
ResourceTypes.Register("container", func(u *url.URL) Resource {
c := NewContainer(nil)
c.Name = filepath.Join(u.Hostname(), u.Path)
return c
})
}
func NewContainer(containerClientApi ContainerClient) *Container {
var apiClient ContainerClient = containerClientApi
if apiClient == nil {
var err error
apiClient, err = client.NewClientWithOpts(client.FromEnv)
if err != nil {
panic(err)
}
}
return &Container{
loader: YamlLoadDecl,
apiClient: apiClient,
}
}
func (c *Container) URI() string {
return fmt.Sprintf("container://%s", c.Id)
}
func (c *Container) SetURI(uri string) error {
resourceUri, e := url.Parse(uri)
if resourceUri.Scheme == c.Type() {
c.Name, e = filepath.Abs(filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI()))
} else {
e = fmt.Errorf("%w: %s is not a %s", ErrInvalidResourceURI, uri, c.Type())
}
return e
}
func (c *Container) Apply() error {
ctx := context.Background()
switch c.State {
case "absent":
return c.Delete(ctx)
case "present":
return c.Create(ctx)
}
return nil
}
func (c *Container) LoadDecl(yamlFileResourceDeclaration string) error {
return c.loader(yamlFileResourceDeclaration, c)
}
/*
apiClient, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
panic(err)
}
defer apiClient.Close()
containers, err := apiClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
panic(err)
}
for _, ctr := range containers {
fmt.Printf("%s %s (status: %s)\n", ctr.ID, ctr.Image, ctr.Status)
}
}
*/
func (c *Container) Create(ctx context.Context) error {
config := &container.Config {
Image: c.Image,
Cmd: c.Cmd,
Tty: false,
}
resp, err := c.apiClient.ContainerCreate(ctx, config, nil, nil, nil, c.Name)
if err != nil {
panic(err)
}
c.Id = resp.ID
/*
statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
panic(err)
}
case <-statusCh:
}
*/
return err
}
// produce yaml representation of any resource
func (c *Container) Read(ctx context.Context) ([]byte, error) {
var containerID string
filterArgs := filters.NewArgs()
filterArgs.Add("name", "/" + c.Name)
containers,err := c.apiClient.ContainerList(ctx, types.ContainerListOptions{
All: true,
Filters: filterArgs,
})
if err != nil {
panic(fmt.Errorf("%w: %s %s", err, c.Type(), c.Name))
}
for _, container := range containers {
for _, containerName := range container.Names {
if containerName == c.Name {
containerID = container.ID
}
}
}
containerJSON, err := c.apiClient.ContainerInspect(ctx, containerID)
if client.IsErrNotFound(err) {
c.State = "absent"
} else {
c.State = "present"
c.Id = containerJSON.ID
c.Name = containerJSON.Name
c.Path = containerJSON.Path
c.Image = containerJSON.Image
if containerJSON.State != nil {
c.ContainerState = *containerJSON.State
}
c.Created = containerJSON.Created
c.ResolvConfPath = containerJSON.ResolvConfPath
c.HostnamePath = containerJSON.HostnamePath
c.HostsPath = containerJSON.HostsPath
c.LogPath = containerJSON.LogPath
c.RestartCount = containerJSON.RestartCount
c.Driver = containerJSON.Driver
}
return yaml.Marshal(c)
}
func (c *Container) Delete(ctx context.Context) error {
err := c.apiClient.ContainerRemove(ctx, c.Id, types.ContainerRemoveOptions{
RemoveVolumes: true,
Force: false,
})
if err != nil {
log.Printf("Failed to remove: %s\n", c.Id)
panic(err)
}
return err
}
func (c *Container) Type() string { return "container" }
func (c *Container) ResolveId(ctx context.Context) string {
filterArgs := filters.NewArgs()
filterArgs.Add("name", "/" + c.Name)
containers,err := c.apiClient.ContainerList(ctx, types.ContainerListOptions{
All: true,
Filters: filterArgs,
})
if err != nil {
panic(fmt.Errorf("%w: %s %s", err, c.Type(), c.Name))
}
for _, container := range containers {
for _, containerName := range container.Names {
if containerName == c.Name {
if c.Id == "" {
c.Id = container.ID
}
return container.ID
}
}
}
return ""
}

View File

@ -0,0 +1,95 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
_ "fmt"
"context"
"testing"
_ "net/http"
_ "net/http/httptest"
_ "net/url"
_ "io"
_ "os"
"github.com/stretchr/testify/assert"
_ "encoding/json"
_ "strings"
"decl/tests/mocks"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
func TestNewContainerResource(t *testing.T) {
c := NewContainer(&mocks.MockContainerClient{})
assert.NotEqual(t, nil, c)
}
func TestReadContainer(t *testing.T) {
ctx := context.Background()
decl := `
name: "testcontainer"
image: "alpine"
state: present
`
m := &mocks.MockContainerClient {
InjectContainerList: func(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error) {
return []types.Container{
{ ID: "123456789abc" },
{ ID: "123456789def" },
}, nil
},
InjectContainerInspect: func(ctx context.Context, containerID string) (types.ContainerJSON, error) {
return types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: "123456789abc",
Name: "test",
Image: "alpine",
} }, nil
},
}
c := NewContainer(m)
assert.NotEqual(t, nil, c)
e := c.LoadDecl(decl)
assert.Equal(t, nil, e)
assert.Equal(t, "testcontainer", c.Name)
resourceYaml, readContainerErr := c.Read(ctx)
assert.Equal(t, nil, readContainerErr)
assert.Greater(t, len(resourceYaml), 0)
}
func TestCreateContainer(t *testing.T) {
m := &mocks.MockContainerClient {
InjectContainerCreate: func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) {
return container.CreateResponse{ ID: "abcdef012", Warnings: []string{} }, nil
},
InjectContainerRemove: func(context.Context, string, container.RemoveOptions) error {
return nil
},
}
decl := `
name: "testcontainer"
image: "alpine"
state: present
`
c := NewContainer(m)
e := c.LoadDecl(decl)
assert.Equal(t, nil, e)
assert.Equal(t, "testcontainer", c.Name)
applyErr := c.Apply()
assert.Equal(t, nil, applyErr)
c.State = "absent"
applyDeleteErr := c.Apply()
assert.Equal(t, nil, applyDeleteErr)
}
func TestContainerResolveId(t *testing.T) {
}

View File

@ -0,0 +1,81 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
"context"
"fmt"
_ "log"
"gopkg.in/yaml.v3"
)
type Declaration struct {
Type string `yaml:"type"`
Attributes yaml.Node `yaml:"attributes"`
Implementation Resource `-`
}
type ResourceLoader interface {
LoadDecl(string) error
}
type StateTransformer interface {
Apply() error
}
type YamlLoader func(string, any) error
func YamlLoadDecl(yamlFileResourceDeclaration string, resource any) error {
if err := yaml.Unmarshal([]byte(yamlFileResourceDeclaration), resource); err != nil {
return err
}
return nil
}
func NewDeclaration() *Declaration {
return &Declaration{}
}
func (d *Declaration) LoadDecl(yamlResourceDeclaration string) error {
return YamlLoadDecl(yamlResourceDeclaration, d)
}
func (d *Declaration) NewResource() error {
uri := fmt.Sprintf("%s://", d.Type)
newResource, err := ResourceTypes.New(uri)
d.Implementation = 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
}
func (d *Declaration) SetURI(uri string) error {
d.Implementation = NewResource(uri)
if d.Implementation == nil {
panic("unknown resource")
}
d.Type = d.Implementation.Type()
d.Implementation.Read(context.Background()) // fix
return nil
}

View File

@ -1,14 +1,16 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
_ "fmt" _ "fmt"
_ "log"
"io" "io"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
_ "net/url"
) )
type Document struct { type Document struct {
Nodes []yaml.Node `yaml:"resources"` ResourceDecls []Declaration `yaml:"resources"`
ResourceDecls []Resource `-`
} }
func NewDocument() *Document { func NewDocument() *Document {
@ -18,18 +20,48 @@ func NewDocument() *Document {
func (d *Document) Load(r io.Reader) error { func (d *Document) Load(r io.Reader) error {
yamlDecoder := yaml.NewDecoder(r) yamlDecoder := yaml.NewDecoder(r)
yamlDecoder.Decode(d) yamlDecoder.Decode(d)
d.ResourceDecls = make([]Resource, len(d.Nodes)) for i := range(d.ResourceDecls) {
for i,node := range(d.Nodes) { if _,e := d.ResourceDecls[i].LoadResourceFromYaml(); e != nil {
resourceDecl := NewDeclaration() return e
node.Decode(resourceDecl)
if r,e := ResourceTypes.New(resourceDecl.Type); e == nil {
resourceDecl.Attributes.Decode(r)
d.ResourceDecls[i] = r
} }
} }
return nil return nil
} }
func (d *Document) Resources() []Resource { func (d *Document) Resources() []Declaration {
return d.ResourceDecls return d.ResourceDecls
} }
func (d *Document) Apply() error {
for i := range(d.ResourceDecls) {
if e := d.ResourceDecls[i].Resource().Apply(); e != nil {
return e
}
}
return nil
}
func (d *Document) Generate(w io.Writer) (error) {
yamlEncoder := yaml.NewEncoder(w)
yamlEncoder.Encode(d)
return yamlEncoder.Close()
}
func (d *Document) AddResourceDeclaration(resourceType string, resourceDeclaration Resource) {
decl := NewDeclaration()
decl.Type = resourceType
decl.Implementation = resourceDeclaration
decl.UpdateYamlFromResource()
d.ResourceDecls = append(d.ResourceDecls, *decl)
}
func (d *Document) AddResource(uri string) error {
//parsedResourceURI, e := url.Parse(uri)
//if e == nil {
decl := NewDeclaration()
decl.SetURI(uri)
decl.UpdateYamlFromResource()
d.ResourceDecls = append(d.ResourceDecls, *decl)
//}
return nil
}

View File

@ -1,6 +1,8 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"context"
"os" "os"
"fmt" "fmt"
"log" "log"
@ -22,7 +24,7 @@ func TestDocumentLoader(t *testing.T) {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
file := filepath.Join(dir, "foo.txt") file,_ := filepath.Abs(filepath.Join(dir, "foo.txt"))
document := fmt.Sprintf(` document := fmt.Sprintf(`
--- ---
@ -57,3 +59,57 @@ resources:
resources := d.Resources() resources := d.Resources()
assert.Equal(t, 2, len(resources)) assert.Equal(t, 2, len(resources))
} }
func TestDocumentGenerator(t *testing.T) {
ctx := context.Background()
fileContent := `// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
`
file,_ := filepath.Abs(filepath.Join(TempDir, "foo.txt"))
err := os.WriteFile(file, []byte(fileContent), 0644)
assert.Nil(t, err)
expected := fmt.Sprintf(`
resources:
- type: file
attributes:
path: %s
owner: "root"
group: "root"
mode: "0644"
content: |
%s
filetype: "regular"
state: present
`, file, fileContent)
var documentYaml strings.Builder
d := NewDocument()
assert.NotEqual(t, nil, d)
f,e := ResourceTypes.New("file://")
assert.Nil(t, e)
assert.NotNil(t, f)
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)
assert.Greater(t, documentYaml.Len(), 0)
assert.YAMLEq(t, documentYaml.String(), expected)
}
func TestDocumentAddResource(t *testing.T) {
file,_ := filepath.Abs(filepath.Join(TempDir, "foo.txt"))
err := os.WriteFile(file, []byte(""), 0644)
assert.Nil(t, err)
d := NewDocument()
assert.NotNil(t, d)
d.AddResource(fmt.Sprintf("file://%s", file))
}

View File

@ -1,6 +1,9 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"context"
"errors"
"fmt" "fmt"
"os" "os"
"os/user" "os/user"
@ -8,10 +11,30 @@ import (
"syscall" "syscall"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"strconv" "strconv"
"path/filepath"
"net/url"
) )
type FileType string
const (
RegularFile FileType = "regular"
DirectoryFile FileType = "directory"
BlockDeviceFile FileType = "block"
CharacterDeviceFile FileType = "char"
NamedPipeFile FileType = "pipe"
SymbolicLinkFile FileType = "symlink"
SocketFile FileType = "socket"
)
var ErrInvalidResourceURI error = errors.New("Invalid resource URI")
func init() { func init() {
ResourceTypes.Register("file", func() Resource { return NewFile() }) ResourceTypes.Register("file", func(u *url.URL) Resource {
f := NewFile()
f.Path = filepath.Join(u.Hostname(), u.Path)
return f
})
} }
type File struct { type File struct {
@ -20,12 +43,27 @@ type File struct {
Owner string `yaml:"owner"` Owner string `yaml:"owner"`
Group string `yaml:"group"` Group string `yaml:"group"`
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
Content string `yaml:"content"` Content string `yaml:"content",omitempty`
FileType FileType `yaml:"filetype"`
State string `yaml:"state"` State string `yaml:"state"`
} }
func NewFile() *File { func NewFile() *File {
return &File{ loader: YamlLoadDecl } return &File{ loader: YamlLoadDecl, FileType: RegularFile }
}
func (f *File) URI() string {
return fmt.Sprintf("file://%s", f.Path)
}
func (f *File) SetURI(uri string) error {
resourceUri, e := url.Parse(uri)
if resourceUri.Scheme == "file" {
f.Path, e = filepath.Abs(filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI()))
} else {
e = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, uri)
}
return e
} }
func (f *File) Apply() error { func (f *File) Apply() error {
@ -47,32 +85,39 @@ func (f *File) Apply() error {
return gidErr return gidErr
} }
mode,modeErr := strconv.ParseInt(f.Mode, 8, 64)
if modeErr != nil {
return modeErr
}
//e := os.Stat(f.path) //e := os.Stat(f.path)
//if os.IsNotExist(e) { //if os.IsNotExist(e) {
switch f.FileType {
case DirectoryFile:
os.MkdirAll(f.Path, os.FileMode(mode))
default:
fallthrough
case RegularFile:
createdFile,e := os.Create(f.Path) createdFile,e := os.Create(f.Path)
if e != nil { if e != nil {
return e return e
} }
defer createdFile.Close() defer createdFile.Close()
if chownErr := createdFile.Chown(uid, gid); chownErr != nil {
return chownErr
}
mode,modeErr := strconv.ParseInt(f.Mode, 8, 64)
if modeErr != nil {
return modeErr
}
if chmodErr := createdFile.Chmod(os.FileMode(mode)); chmodErr != nil { if chmodErr := createdFile.Chmod(os.FileMode(mode)); chmodErr != nil {
return chmodErr return chmodErr
} }
_,writeErr := createdFile.Write([]byte(f.Content)) _,writeErr := createdFile.Write([]byte(f.Content))
if writeErr != nil { if writeErr != nil {
return writeErr return writeErr
} }
} }
if chownErr := os.Chown(f.Path, uid, gid); chownErr != nil {
return chownErr
}
}
} }
return nil return nil
@ -82,7 +127,21 @@ func (f *File) LoadDecl(yamlFileResourceDeclaration string) error {
return f.loader(yamlFileResourceDeclaration, f) return f.loader(yamlFileResourceDeclaration, f)
} }
func (f *File) Read() ([]byte, error) { func (f *File) ResolveId(ctx context.Context) string {
filePath, fileAbsErr := filepath.Abs(f.Path)
if fileAbsErr != nil {
panic(fileAbsErr)
}
return filePath
}
func (f *File) Read(ctx context.Context) ([]byte, error) {
filePath, fileAbsErr := filepath.Abs(f.Path)
if fileAbsErr != nil {
panic(fileAbsErr)
}
f.Path = filePath
info, e := os.Stat(f.Path) info, e := os.Stat(f.Path)
if e != nil { if e != nil {
@ -90,17 +149,23 @@ func (f *File) Read() ([]byte, error) {
} }
if stat, ok := info.Sys().(*syscall.Stat_t); ok { if stat, ok := info.Sys().(*syscall.Stat_t); ok {
fileUser, userErr := user.LookupId(strconv.Itoa(int(stat.Uid))) userId := strconv.Itoa(int(stat.Uid))
groupId := strconv.Itoa(int(stat.Gid))
fileUser, userErr := user.LookupId(userId)
if userErr != nil { //UnknownUserIdError if userErr != nil { //UnknownUserIdError
panic(userErr) //panic(userErr)
} f.Owner = userId
fileGroup, groupErr := user.LookupGroupId(strconv.Itoa(int(stat.Gid))) } else {
if groupErr != nil {
panic(groupErr)
}
f.Owner = fileUser.Name f.Owner = fileUser.Name
}
fileGroup, groupErr := user.LookupGroupId(groupId)
if groupErr != nil {
//panic(groupErr)
f.Group = groupId
} else {
f.Group = fileGroup.Name f.Group = fileGroup.Name
} }
}
f.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) f.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
@ -117,3 +182,20 @@ func (f *File) Read() ([]byte, error) {
f.State = "present" f.State = "present"
return yaml.Marshal(f) return yaml.Marshal(f)
} }
func (f *File) Type() string { return "file" }
func (f *FileType) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
switch s {
case string(RegularFile), string(DirectoryFile), string(BlockDeviceFile), string(CharacterDeviceFile), string(NamedPipeFile), string(SymbolicLinkFile), string(SocketFile):
*f = FileType(s)
return nil
default:
return errors.New("invalid FileType value")
}
}

View File

@ -1,19 +1,21 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"context"
_ "encoding/json"
"fmt" "fmt"
_ "context"
"testing"
_ "net/http"
_ "net/http/httptest"
_ "net/url"
_ "io"
"os"
_ "log"
"path/filepath"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
_ "encoding/json" "gopkg.in/yaml.v3"
_ "strings" _ "io"
_ "log"
_ "net/http"
_ "net/http/httptest"
_ "net/url"
"os"
"path/filepath"
_ "strings"
"testing"
) )
func TestNewFileResource(t *testing.T) { func TestNewFileResource(t *testing.T) {
@ -30,7 +32,8 @@ func TestApplyResourceTransformation(t *testing.T) {
} }
func TestReadFile(t *testing.T) { func TestReadFile(t *testing.T) {
file := filepath.Join(TempDir, "fooread.txt") ctx := context.Background()
file, _ := filepath.Abs(filepath.Join(TempDir, "fooread.txt"))
decl := fmt.Sprintf(` decl := fmt.Sprintf(`
path: "%s" path: "%s"
@ -40,6 +43,7 @@ func TestReadFile(t *testing.T) {
content: |- content: |-
test line 1 test line 1
test line 2 test line 2
filetype: "regular"
state: present state: present
`, file) `, file)
@ -52,14 +56,14 @@ func TestReadFile(t *testing.T) {
assert.NotEqual(t, nil, f) assert.NotEqual(t, nil, f)
f.Path = file f.Path = file
r,e := f.Read() r, e := f.Read(ctx)
assert.Equal(t, nil, e) assert.Equal(t, nil, e)
assert.Equal(t, "nobody", f.Owner) assert.Equal(t, "nobody", f.Owner)
assert.YAMLEq(t, decl, string(r)) assert.YAMLEq(t, decl, string(r))
} }
func TestCreateFile(t *testing.T) { func TestCreateFile(t *testing.T) {
file := filepath.Join(TempDir, "foo.txt") file, _ := filepath.Abs(filepath.Join(TempDir, "foo.txt"))
decl := fmt.Sprintf(` decl := fmt.Sprintf(`
path: "%s" path: "%s"
@ -80,7 +84,7 @@ func TestCreateFile(t *testing.T) {
applyErr := f.Apply() applyErr := f.Apply()
assert.Equal(t, nil, applyErr) assert.Equal(t, nil, applyErr)
assert.FileExists(t, file, nil) assert.FileExists(t, file, nil)
s,e := os.Stat(file) s, e := os.Stat(file)
assert.Equal(t, nil, e) assert.Equal(t, nil, e)
assert.Greater(t, s.Size(), int64(0)) assert.Greater(t, s.Size(), int64(0))
@ -89,3 +93,40 @@ func TestCreateFile(t *testing.T) {
assert.Equal(t, nil, f.Apply()) assert.Equal(t, nil, f.Apply())
assert.NoFileExists(t, file, nil) assert.NoFileExists(t, file, nil)
} }
func TestFileType(t *testing.T) {
fileType := []byte(`
filetype: "directory"
`)
var testFile File
err := yaml.Unmarshal(fileType, &testFile)
assert.Nil(t, err)
}
func TestFileDirectory(t *testing.T) {
file, _ := filepath.Abs(filepath.Join(TempDir, "testdir"))
decl := fmt.Sprintf(`
path: "%s"
owner: "nobody"
group: "nobody"
mode: "0700"
filetype: "directory"
state: present
`, file)
f := NewFile()
e := f.LoadDecl(decl)
assert.Equal(t, nil, e)
assert.Equal(t, "nobody", f.Owner)
applyErr := f.Apply()
assert.Equal(t, nil, applyErr)
assert.DirExists(t, file)
f.State = "absent"
deleteErr := f.Apply()
assert.Nil(t, deleteErr)
assert.NoDirExists(t, file)
}

View File

@ -1,3 +1,4 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
@ -5,6 +6,14 @@ import (
"strconv" "strconv"
) )
func LookupUIDString(userName string) string {
user, userLookupErr := user.Lookup(userName)
if userLookupErr != nil {
return ""
}
return user.Uid
}
func LookupUID(userName string) (int,error) { func LookupUID(userName string) (int,error) {
user, userLookupErr := user.Lookup(userName) user, userLookupErr := user.Lookup(userName)
if userLookupErr != nil { if userLookupErr != nil {

View File

@ -1,3 +1,4 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (

View File

@ -1,42 +1,34 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"gopkg.in/yaml.v3" "context"
_ "fmt"
_ "gopkg.in/yaml.v3"
_ "net/url"
) )
type Declaration struct {
Type string `yaml:"type"`
Attributes yaml.Node `yaml:"attributes"`
}
type Resource interface { type Resource interface {
Type() string
URI() string
//SetURI(string) error
ResolveId(context.Context) string
ResourceLoader ResourceLoader
StateTransformer StateTransformer
ResourceReader
} }
type ResourceLoader interface { // validate the type/uri
LoadDecl(string) error type ResourceValidator interface {
} Validate() error
type StateTransformer interface {
Apply() error
}
type YamlLoader func(string, any) error
func YamlLoadDecl(yamlFileResourceDeclaration string, resource any) error {
if err := yaml.Unmarshal([]byte(yamlFileResourceDeclaration), resource); err != nil {
return err
}
return nil
} }
type ResourceCreator interface { type ResourceCreator interface {
Create() error Create(context.Context) error
} }
type ResourceReader interface { type ResourceReader interface {
Read() ([]byte, error) Read(context.Context) ([]byte, error)
} }
type ResourceUpdater interface { type ResourceUpdater interface {
@ -47,11 +39,14 @@ type ResourceDeleter interface {
Delete() error Delete() error
} }
type ResourceDecoder struct {
func NewDeclaration() *Declaration {
return &Declaration{}
} }
func (d *Declaration) LoadDecl(yamlResourceDeclaration string) error { func NewResource(uri string) Resource {
return YamlLoadDecl(yamlResourceDeclaration, d) r,e := ResourceTypes.New(uri)
if e == nil {
return r
}
return nil
} }

View File

@ -1,14 +1,18 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"errors"
"fmt" "fmt"
"net/url"
) )
var ( var (
ErrUnknownResourceType = errors.New("Unknown resource type")
ResourceTypes *Types = NewTypes() ResourceTypes *Types = NewTypes()
) )
type TypeFactory func() Resource type TypeFactory func(*url.URL) Resource
type Types struct { type Types struct {
registry map[string]TypeFactory registry map[string]TypeFactory
@ -22,9 +26,21 @@ func (t *Types) Register(name string, factory TypeFactory) {
t.registry[name] = factory t.registry[name] = factory
} }
func (t *Types) New(name string) (Resource, error) { func (t *Types) New(uri string) (Resource, error) {
if r,ok := t.registry[name]; ok { u,e := url.Parse(uri)
return r(), nil if u == nil || e != nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownResourceType, e)
} }
return nil, fmt.Errorf("Unknown type: %s", name)
if r,ok := t.registry[u.Scheme]; ok {
return r(u), nil
}
return nil, fmt.Errorf("%w: %s", ErrUnknownResourceType, u.Scheme)
}
func (t *Types) Has(typename string) bool {
if _,ok := t.registry[typename]; ok {
return true
}
return false
} }

View File

@ -1,9 +1,12 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource package resource
import ( import (
"context"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"decl/tests/mocks" "decl/tests/mocks"
"net/url"
) )
func TestNewResourceTypes(t *testing.T) { func TestNewResourceTypes(t *testing.T) {
@ -13,6 +16,8 @@ func TestNewResourceTypes(t *testing.T) {
func TestNewResourceTypesRegister(t *testing.T) { func TestNewResourceTypesRegister(t *testing.T) {
m := &mocks.MockResource { m := &mocks.MockResource {
InjectType: func() string { return "foo" },
InjectRead: func(ctx context.Context) ([]byte, error) { return nil,nil },
InjectLoadDecl: func(string) error { return nil }, InjectLoadDecl: func(string) error { return nil },
InjectApply: func() error { return nil }, InjectApply: func() error { return nil },
} }
@ -20,13 +25,39 @@ func TestNewResourceTypesRegister(t *testing.T) {
resourceTypes := NewTypes() resourceTypes := NewTypes()
assert.NotEqual(t, nil, resourceTypes) assert.NotEqual(t, nil, resourceTypes)
resourceTypes.Register("foo", func() Resource { return m }) resourceTypes.Register("foo", func(*url.URL) Resource { return m })
r,e := resourceTypes.New("foo") r,e := resourceTypes.New("foo://")
assert.Equal(t, nil, e) assert.Equal(t, nil, e)
assert.Equal(t, m, r) assert.Equal(t, m, r)
} }
func TestResourceTypesLoadResource(t *testing.T) { func TestResourceTypesFromURI(t *testing.T) {
m := &mocks.MockResource {
InjectType: func() string { return "foo" },
InjectRead: func(ctx context.Context) ([]byte, error) { return nil,nil },
InjectLoadDecl: func(string) error { return nil },
InjectApply: func() error { return nil },
}
resourceTypes := NewTypes()
assert.NotEqual(t, nil, resourceTypes)
resourceTypes.Register("foo", func(*url.URL) Resource { return m })
r,e := resourceTypes.New("foo://bar")
assert.Equal(t, nil, e)
assert.Equal(t, m, r)
} }
func TestResourceTypesHasType(t *testing.T) {
m := mocks.NewFooResource()
resourceTypes := NewTypes()
assert.NotNil(t, resourceTypes)
resourceTypes.Register("foo", func(*url.URL) Resource { return m })
assert.True(t, resourceTypes.Has("foo"))
}

View File

@ -1,15 +1,41 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package mocks package mocks
/*
import ( import (
"net/http/httptest" "context"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/docker/docker/api/types"
) )
func newMockClient(doer func(*http.Request) (*http.Response, error)) *http.Client { type MockContainerClient struct {
return &http.Client{ InjectContainerCreate func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error)
Transport: transportEnsureBody(transportFunc(doer)), InjectContainerList func(context.Context, types.ContainerListOptions) ([]types.Container, error)
} InjectContainerInspect func(context.Context, string) (types.ContainerJSON, error)
InjectContainerRemove func(context.Context, string, container.RemoveOptions) error
InjectClose func() error
} }
*/ func (m *MockContainerClient) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) {
return m.InjectContainerCreate(ctx, config, hostConfig, networkingConfig, platform, containerName)
}
func (m *MockContainerClient) ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error) {
return m.InjectContainerList(ctx, options)
}
func (m *MockContainerClient) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) {
return m.InjectContainerInspect(ctx, containerID)
}
func (m *MockContainerClient) ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error {
return m.InjectContainerRemove(ctx, containerID, options)
}
func (m *MockContainerClient) Close() error {
if m.InjectClose == nil {
return nil
}
return m.InjectClose()
}

View File

@ -0,0 +1,16 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package mocks
import (
"context"
_ "gopkg.in/yaml.v3"
)
func NewFooResource() *MockResource {
return &MockResource {
InjectType: func() string { return "foo" },
InjectRead: func(ctx context.Context) ([]byte, error) { return nil,nil },
InjectLoadDecl: func(string) error { return nil },
InjectApply: func() error { return nil },
}
}

View File

@ -1,8 +1,21 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package mocks package mocks
import (
"context"
_ "gopkg.in/yaml.v3"
)
type MockResource struct { type MockResource struct {
InjectURI func() string
InjectType func() string
InjectLoadDecl func(string) error InjectLoadDecl func(string) error
InjectApply func() error InjectApply func() error
InjectRead func(context.Context) ([]byte, error)
}
func (m *MockResource) URI() string {
return m.InjectURI()
} }
func (m *MockResource) LoadDecl(yamlResourceDeclaration string) error { func (m *MockResource) LoadDecl(yamlResourceDeclaration string) error {
@ -12,3 +25,11 @@ func (m *MockResource) LoadDecl(yamlResourceDeclaration string) error {
func (m *MockResource) Apply() error { func (m *MockResource) Apply() error {
return m.InjectApply() return m.InjectApply()
} }
func (m *MockResource) Read(ctx context.Context) ([]byte, error) {
return m.InjectRead(ctx)
}
func (m *MockResource) Type() string {
return m.InjectType()
}