diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..baaf503 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2024 Matthew Rich . All rights reserved. diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..fd26565 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,53 @@ +// Copyright 2024 Matthew Rich . 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) +} diff --git a/internal/resource/container.go b/internal/resource/container.go new file mode 100644 index 0000000..92c46d9 --- /dev/null +++ b/internal/resource/container.go @@ -0,0 +1,244 @@ +// Copyright 2024 Matthew Rich . 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 "" +} diff --git a/internal/resource/container_test.go b/internal/resource/container_test.go new file mode 100644 index 0000000..2f1aa05 --- /dev/null +++ b/internal/resource/container_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 Matthew Rich . 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) { + +} diff --git a/internal/resource/declaration.go b/internal/resource/declaration.go new file mode 100644 index 0000000..1dac5b4 --- /dev/null +++ b/internal/resource/declaration.go @@ -0,0 +1,81 @@ +// Copyright 2024 Matthew Rich . 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 +} diff --git a/internal/resource/document.go b/internal/resource/document.go index 0a3b660..ae60e2a 100644 --- a/internal/resource/document.go +++ b/internal/resource/document.go @@ -1,14 +1,16 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( _ "fmt" +_ "log" "io" "gopkg.in/yaml.v3" +_ "net/url" ) type Document struct { - Nodes []yaml.Node `yaml:"resources"` - ResourceDecls []Resource `-` + ResourceDecls []Declaration `yaml:"resources"` } func NewDocument() *Document { @@ -18,18 +20,48 @@ func NewDocument() *Document { func (d *Document) Load(r io.Reader) error { yamlDecoder := yaml.NewDecoder(r) yamlDecoder.Decode(d) - d.ResourceDecls = make([]Resource, len(d.Nodes)) - for i,node := range(d.Nodes) { - resourceDecl := NewDeclaration() - node.Decode(resourceDecl) - if r,e := ResourceTypes.New(resourceDecl.Type); e == nil { - resourceDecl.Attributes.Decode(r) - d.ResourceDecls[i] = r + for i := range(d.ResourceDecls) { + if _,e := d.ResourceDecls[i].LoadResourceFromYaml(); e != nil { + return e } } return nil } -func (d *Document) Resources() []Resource { +func (d *Document) Resources() []Declaration { 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 +} diff --git a/internal/resource/document_test.go b/internal/resource/document_test.go index 95dbbd5..52e2f9e 100644 --- a/internal/resource/document_test.go +++ b/internal/resource/document_test.go @@ -1,6 +1,8 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( + "context" "os" "fmt" "log" @@ -22,7 +24,7 @@ func TestDocumentLoader(t *testing.T) { } defer os.RemoveAll(dir) - file := filepath.Join(dir, "foo.txt") + file,_ := filepath.Abs(filepath.Join(dir, "foo.txt")) document := fmt.Sprintf(` --- @@ -57,3 +59,57 @@ resources: resources := d.Resources() assert.Equal(t, 2, len(resources)) } + +func TestDocumentGenerator(t *testing.T) { + ctx := context.Background() + + fileContent := `// Copyright 2024 Matthew Rich . 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)) +} diff --git a/internal/resource/file.go b/internal/resource/file.go index 90b579e..8f9e5ec 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -1,6 +1,9 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( + "context" + "errors" "fmt" "os" "os/user" @@ -8,10 +11,30 @@ import ( "syscall" "gopkg.in/yaml.v3" "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() { - 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 { @@ -20,12 +43,27 @@ type File struct { Owner string `yaml:"owner"` Group string `yaml:"group"` Mode string `yaml:"mode"` - Content string `yaml:"content"` + Content string `yaml:"content",omitempty` + FileType FileType `yaml:"filetype"` State string `yaml:"state"` } 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 { @@ -47,31 +85,38 @@ func (f *File) Apply() error { return gidErr } - //e := os.Stat(f.path) - //if os.IsNotExist(e) { - createdFile,e := os.Create(f.Path) - if e != nil { - return e - } - 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 { - return chmodErr + //e := os.Stat(f.path) + //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) + if e != nil { + return e + } + defer createdFile.Close() + + if chmodErr := createdFile.Chmod(os.FileMode(mode)); chmodErr != nil { + return chmodErr + } + _,writeErr := createdFile.Write([]byte(f.Content)) + if writeErr != nil { + return writeErr + } } - _,writeErr := createdFile.Write([]byte(f.Content)) - if writeErr != nil { - return writeErr + if chownErr := os.Chown(f.Path, uid, gid); chownErr != nil { + return chownErr } + } } @@ -82,7 +127,21 @@ func (f *File) LoadDecl(yamlFileResourceDeclaration string) error { 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) if e != nil { @@ -90,16 +149,22 @@ func (f *File) Read() ([]byte, error) { } 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 - panic(userErr) + //panic(userErr) + f.Owner = userId + } else { + f.Owner = fileUser.Name } - fileGroup, groupErr := user.LookupGroupId(strconv.Itoa(int(stat.Gid))) + fileGroup, groupErr := user.LookupGroupId(groupId) if groupErr != nil { - panic(groupErr) + //panic(groupErr) + f.Group = groupId + } else { + f.Group = fileGroup.Name } - f.Owner = fileUser.Name - f.Group = fileGroup.Name } f.Mode = fmt.Sprintf("%04o", info.Mode().Perm()) @@ -117,3 +182,20 @@ func (f *File) Read() ([]byte, error) { f.State = "present" 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") + } +} diff --git a/internal/resource/file_test.go b/internal/resource/file_test.go index c399b87..63ba8b0 100644 --- a/internal/resource/file_test.go +++ b/internal/resource/file_test.go @@ -1,38 +1,41 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( - "fmt" -_ "context" - "testing" -_ "net/http" -_ "net/http/httptest" -_ "net/url" -_ "io" - "os" -_ "log" - "path/filepath" - "github.com/stretchr/testify/assert" -_ "encoding/json" -_ "strings" + "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 TestNewFileResource(t *testing.T) { - f := NewFile() - assert.NotEqual(t, nil, f) + f := NewFile() + assert.NotEqual(t, nil, f) } func TestApplyResourceTransformation(t *testing.T) { - f := NewFile() - assert.NotEqual(t, nil, f) + f := NewFile() + assert.NotEqual(t, nil, f) - //e := f.Apply() - //assert.Equal(t, nil, e) + //e := f.Apply() + //assert.Equal(t, nil, e) } 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" owner: "nobody" group: "nobody" @@ -40,28 +43,29 @@ func TestReadFile(t *testing.T) { content: |- test line 1 test line 2 + filetype: "regular" state: present `, file) - testFile := NewFile() - e := testFile.LoadDecl(decl) - assert.Equal(t, nil, e) - testFile.Apply() + testFile := NewFile() + e := testFile.LoadDecl(decl) + assert.Equal(t, nil, e) + testFile.Apply() - f := NewFile() - assert.NotEqual(t, nil, f) + f := NewFile() + assert.NotEqual(t, nil, f) - f.Path = file - r,e := f.Read() - assert.Equal(t, nil, e) - assert.Equal(t, "nobody", f.Owner) - assert.YAMLEq(t, decl, string(r)) + f.Path = file + r, e := f.Read(ctx) + assert.Equal(t, nil, e) + assert.Equal(t, "nobody", f.Owner) + assert.YAMLEq(t, decl, string(r)) } 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" owner: "nobody" group: "nobody" @@ -72,20 +76,57 @@ func TestCreateFile(t *testing.T) { state: present `, file) - f := NewFile() - e := f.LoadDecl(decl) - assert.Equal(t, nil, e) - assert.Equal(t, "nobody", f.Owner) + 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.FileExists(t, file, nil) - s,e := os.Stat(file) - assert.Equal(t, nil, e) + applyErr := f.Apply() + assert.Equal(t, nil, applyErr) + assert.FileExists(t, file, nil) + s, e := os.Stat(file) + assert.Equal(t, nil, e) - assert.Greater(t, s.Size(), int64(0)) + assert.Greater(t, s.Size(), int64(0)) - f.State = "absent" - assert.Equal(t, nil, f.Apply()) - assert.NoFileExists(t, file, nil) + f.State = "absent" + assert.Equal(t, nil, f.Apply()) + 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) } diff --git a/internal/resource/os.go b/internal/resource/os.go index c618f26..8557010 100644 --- a/internal/resource/os.go +++ b/internal/resource/os.go @@ -1,3 +1,4 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( @@ -5,6 +6,14 @@ import ( "strconv" ) +func LookupUIDString(userName string) string { + user, userLookupErr := user.Lookup(userName) + if userLookupErr != nil { + return "" + } + return user.Uid +} + func LookupUID(userName string) (int,error) { user, userLookupErr := user.Lookup(userName) if userLookupErr != nil { diff --git a/internal/resource/os_test.go b/internal/resource/os_test.go index 2638b47..1632de4 100644 --- a/internal/resource/os_test.go +++ b/internal/resource/os_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( diff --git a/internal/resource/resource.go b/internal/resource/resource.go index bb7bc40..74ca252 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -1,42 +1,34 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource 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() string + URI() string + //SetURI(string) error + ResolveId(context.Context) string ResourceLoader StateTransformer + ResourceReader } -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 +// validate the type/uri +type ResourceValidator interface { + Validate() error } type ResourceCreator interface { - Create() error + Create(context.Context) error } type ResourceReader interface { - Read() ([]byte, error) + Read(context.Context) ([]byte, error) } type ResourceUpdater interface { @@ -47,11 +39,14 @@ type ResourceDeleter interface { Delete() error } +type ResourceDecoder struct { -func NewDeclaration() *Declaration { - return &Declaration{} } -func (d *Declaration) LoadDecl(yamlResourceDeclaration string) error { - return YamlLoadDecl(yamlResourceDeclaration, d) +func NewResource(uri string) Resource { + r,e := ResourceTypes.New(uri) + if e == nil { + return r + } + return nil } diff --git a/internal/resource/types.go b/internal/resource/types.go index f17c825..a157fe7 100644 --- a/internal/resource/types.go +++ b/internal/resource/types.go @@ -1,14 +1,18 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( + "errors" "fmt" + "net/url" ) var ( + ErrUnknownResourceType = errors.New("Unknown resource type") ResourceTypes *Types = NewTypes() ) -type TypeFactory func() Resource +type TypeFactory func(*url.URL) Resource type Types struct { registry map[string]TypeFactory @@ -22,9 +26,21 @@ func (t *Types) Register(name string, factory TypeFactory) { t.registry[name] = factory } -func (t *Types) New(name string) (Resource, error) { - if r,ok := t.registry[name]; ok { - return r(), nil +func (t *Types) New(uri string) (Resource, error) { + u,e := url.Parse(uri) + 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 } diff --git a/internal/resource/types_test.go b/internal/resource/types_test.go index 8b7d89f..fbca305 100644 --- a/internal/resource/types_test.go +++ b/internal/resource/types_test.go @@ -1,9 +1,12 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package resource import ( + "context" "testing" "github.com/stretchr/testify/assert" "decl/tests/mocks" + "net/url" ) func TestNewResourceTypes(t *testing.T) { @@ -13,6 +16,8 @@ func TestNewResourceTypes(t *testing.T) { func TestNewResourceTypesRegister(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 }, } @@ -20,13 +25,39 @@ func TestNewResourceTypesRegister(t *testing.T) { resourceTypes := NewTypes() 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, 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")) +} diff --git a/tests/mocks/container.go b/tests/mocks/container.go index 3eb23a9..39d54cb 100644 --- a/tests/mocks/container.go +++ b/tests/mocks/container.go @@ -1,15 +1,41 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package mocks -/* - 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 { - return &http.Client{ - Transport: transportEnsureBody(transportFunc(doer)), - } +type MockContainerClient struct { + InjectContainerCreate func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) + 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() +} diff --git a/tests/mocks/fooresource.go b/tests/mocks/fooresource.go new file mode 100644 index 0000000..bd27b06 --- /dev/null +++ b/tests/mocks/fooresource.go @@ -0,0 +1,16 @@ +// Copyright 2024 Matthew Rich . 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 }, + } +} diff --git a/tests/mocks/resource.go b/tests/mocks/resource.go index 099842b..85de41d 100644 --- a/tests/mocks/resource.go +++ b/tests/mocks/resource.go @@ -1,8 +1,21 @@ +// Copyright 2024 Matthew Rich . All rights reserved. package mocks +import ( + "context" +_ "gopkg.in/yaml.v3" +) + type MockResource struct { + InjectURI func() string + InjectType func() string InjectLoadDecl func(string) 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 { @@ -12,3 +25,11 @@ func (m *MockResource) LoadDecl(yamlResourceDeclaration string) error { func (m *MockResource) Apply() error { return m.InjectApply() } + +func (m *MockResource) Read(ctx context.Context) ([]byte, error) { + return m.InjectRead(ctx) +} + +func (m *MockResource) Type() string { + return m.InjectType() +}