update container/iptables resources
Some checks failed
Lint / golangci-lint (push) Failing after 9m50s
Declarative Tests / test (push) Failing after 10s

This commit is contained in:
Matthew Rich 2024-05-05 17:48:54 -07:00
parent f25fa59449
commit 43a2274b7e
44 changed files with 1940 additions and 187 deletions

View File

@ -15,4 +15,4 @@ jobs:
- uses: actions/checkout@v3
- uses: ncipollo/release-action@v1
with:
artifacts: "decl"
artifacts: "jx"

View File

@ -9,4 +9,5 @@ jx-cli:
go build -o jx $(LDFLAGS) ./cmd/cli/main.go
test: jx-cli
go test ./...
go test -coverprofile=artifacts/coverage.profile ./...
go tool cover -html=artifacts/coverage.profile -o artifacts/code-coverage.html

View File

@ -13,7 +13,7 @@ These tools work with YAML descriptions of resources (E.g. files, users, contain
Testing the current version involves checking out main and building.
```
git clone https://gitea.rosskeen.house/Declarative/decl.git
git clone https://gitea.rosskeen.house/doublejynx/jx.git
make test

View File

@ -217,24 +217,27 @@ func DiffSubCommand(cmd *flag.FlagSet, output io.Writer) (err error) {
rightDocuments := make([]*resource.Document, 0, 100)
slog.Info("jx diff subcommand", "left", leftSource, "right", rightSource, "flagset", cmd)
leftDocuments = append(leftDocuments, LoadSourceURI(leftSource)...)
if rightSource == "" {
slog.Info("jx diff clone", "docs", leftDocuments)
for i, doc := range leftDocuments {
rightDocuments = append(rightDocuments, LoadSourceURI(leftSource)...)
slog.Info("jx diff clone", "docs", rightDocuments)
for i, doc := range rightDocuments {
if doc != nil {
rightDocuments = append(rightDocuments, doc.Clone())
leftDocuments = append(leftDocuments, doc.Clone())
for _,resourceDeclaration := range leftDocuments[i].Resources() {
if _, e := resourceDeclaration.Resource().Read(ctx); e != nil {
return e
slog.Info("jx diff ", "err", e)
//return e
}
}
}
}
} else {
leftDocuments = append(leftDocuments, LoadSourceURI(leftSource)...)
rightDocuments = append(rightDocuments, LoadSourceURI(rightSource)...)
}
slog.Info("jx diff ", "right", rightDocuments, "left", leftDocuments)
index := 0
for {
if index >= len(rightDocuments) && index >= len(leftDocuments) {

View File

@ -36,15 +36,35 @@ func NewCommand() *Command {
return nil, err
}
cmd := exec.Command(c.Path, args...)
slog.Info("execute() - cmd", "path", c.Path, "args", args)
output, stdoutPipeErr := cmd.StdoutPipe()
if stdoutPipeErr != nil {
return nil, stdoutPipeErr
}
stderr, pipeErr := cmd.StderrPipe()
if pipeErr != nil {
return nil, pipeErr
}
output, err := cmd.Output()
if startErr := cmd.Start(); startErr != nil {
return nil, startErr
}
slog.Info("execute() - start", "cmd", cmd)
stdOutOutput, _ := io.ReadAll(output)
stdErrOutput, _ := io.ReadAll(stderr)
slog.Info("execute()", "path", c.Path, "args", args, "output", output, "error", stdErrOutput)
return output, err
slog.Info("execute() - io", "stdout", string(stdOutOutput), "stderr", string(stdErrOutput))
waitErr := cmd.Wait()
slog.Info("execute()", "path", c.Path, "args", args, "output", string(stdOutOutput), "error", string(stdErrOutput))
if len(stdErrOutput) > 0 {
return stdOutOutput, fmt.Errorf(string(stdErrOutput))
}
return stdOutOutput, waitErr
}
return c
}
@ -60,15 +80,21 @@ func (c *Command) LoadDecl(yamlResourceDeclaration string) error {
}
func (c *Command) Template(value any) ([]string, error) {
var args []string = make([]string, len(c.Args))
var args []string = make([]string, 0, len(c.Args) * 2)
for i, arg := range c.Args {
var commandLineArg strings.Builder
err := template.Must(template.New(fmt.Sprintf("arg%d", i)).Parse(string(arg))).Execute(&commandLineArg, value)
if err != nil {
return nil, err
}
args[i] = commandLineArg.String()
if commandLineArg.Len() > 0 {
splitArg := strings.Split(commandLineArg.String(), " ")
slog.Info("Template()", "split", splitArg, "len", len(splitArg))
args = append(args, splitArg...)
}
}
slog.Info("Template()", "Args", c.Args, "lencargs", len(c.Args), "args", args, "lenargs", len(args), "value", value)
return args, nil
}

View File

@ -13,17 +13,19 @@ import (
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gopkg.in/yaml.v3"
_ "gopkg.in/yaml.v3"
"log/slog"
"net/url"
_ "os"
_ "os/exec"
_ "os"
_ "os/exec"
"path/filepath"
_ "strings"
"encoding/json"
"io"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type ContainerClient interface {
@ -42,6 +44,7 @@ type Container struct {
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"`
Ports []string `json:"ports,omitempty" yaml:"ports,omitempty"`
Environment map[string]string `json:"environment" yaml:"environment"`
Image string `json:"image" yaml:"image"`
ResolvConfPath string `json:"resolvconfpath" yaml:"resolvconfpath"`
@ -61,13 +64,14 @@ type Container struct {
GraphDriver types.GraphDriverData `json:"graphdriver" yaml:"graphdriver"`
SizeRw *int64 `json:",omitempty" yaml:",omitempty"`
SizeRootFs *int64 `json:",omitempty" yaml:",omitempty"`
Networks []string `json:"networks,omitempty" yaml:"networks,omitempty"`
/*
Mounts []MountPoint
Config *container.Config
NetworkSettings *NetworkSettings
*/
State string `yaml:"state"`
State string `yaml:"state,omitempty" json:"state,omitempty"`
apiClient ContainerClient
}
@ -121,11 +125,16 @@ func (c *Container) Clone() Resource {
GraphDriver: c.GraphDriver,
SizeRw: c.SizeRw,
SizeRootFs: c.SizeRootFs,
Networks: c.Networks,
State: c.State,
apiClient: c.apiClient,
}
}
func (c *Container) StateMachine() machine.Stater {
return ProcessMachine()
}
func (c *Container) URI() string {
return fmt.Sprintf("container://%s", c.Id)
}
@ -173,11 +182,17 @@ func (c *Container) LoadDecl(yamlResourceDeclaration string) error {
func (c *Container) Create(ctx context.Context) error {
numberOfEnvironmentVariables := len(c.Environment)
portset := nat.PortSet {}
for _, port := range c.Ports {
portset[nat.Port(port)] = struct{}{}
}
config := &container.Config{
Image: c.Image,
Cmd: c.Cmd,
Entrypoint: c.Entrypoint,
Tty: false,
ExposedPorts: portset,
}
config.Env = make([]string, numberOfEnvironmentVariables)
@ -194,7 +209,16 @@ func (c *Container) Create(ctx context.Context) error {
}
}
resp, err := c.apiClient.ContainerCreate(ctx, config, &c.HostConfig, nil, nil, c.Name)
networkConfig := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{},
}
settings := &network.EndpointSettings{}
for _, network := range c.Networks {
networkConfig.EndpointsConfig[network] = settings
}
resp, err := c.apiClient.ContainerCreate(ctx, config, &c.HostConfig, networkConfig, nil, c.Name)
if err != nil {
panic(err)
}

View File

@ -0,0 +1,168 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
// Container resource
package resource
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
_ "github.com/docker/docker/api/types/mount"
_ "github.com/docker/docker/api/types/network"
_ "github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/client"
"gopkg.in/yaml.v3"
_ "log/slog"
"net/url"
_ "os"
_ "os/exec"
"path/filepath"
_ "strings"
"encoding/json"
"io"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type ContainerNetworkClient interface {
ContainerClient
NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error)
}
type ContainerNetwork struct {
Id string `json:"ID,omitempty" yaml:"ID,omitempty"`
Name string `json:"name" yaml:"name"`
State string `yaml:"state"`
apiClient ContainerNetworkClient
}
func init() {
ResourceTypes.Register("container_network", func(u *url.URL) Resource {
n := NewContainerNetwork(nil)
n.Name = filepath.Join(u.Hostname(), u.Path)
return n
})
}
func NewContainerNetwork(containerClientApi ContainerNetworkClient) *ContainerNetwork {
var apiClient ContainerNetworkClient = containerClientApi
if apiClient == nil {
var err error
apiClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
panic(err)
}
}
return &ContainerNetwork{
apiClient: apiClient,
}
}
func (n *ContainerNetwork) Clone() Resource {
return &ContainerNetwork {
Id: n.Id,
Name: n.Name,
State: n.State,
apiClient: n.apiClient,
}
}
func (n *ContainerNetwork) StateMachine() machine.Stater {
return StorageMachine()
}
func (n *ContainerNetwork) URI() string {
return fmt.Sprintf("container_network://%s", n.Name)
}
func (n *ContainerNetwork) SetURI(uri string) error {
resourceUri, e := url.Parse(uri)
if e == nil {
if resourceUri.Scheme == n.Type() {
n.Name, e = filepath.Abs(filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI()))
} else {
e = fmt.Errorf("%w: %s is not a %s", ErrInvalidResourceURI, uri, n.Type())
}
}
return e
}
func (n *ContainerNetwork) JSON() ([]byte, error) {
return json.Marshal(n)
}
func (n *ContainerNetwork) Validate() error {
return fmt.Errorf("failed")
}
func (n *ContainerNetwork) Apply() error {
ctx := context.Background()
switch n.State {
case "absent":
return n.Delete(ctx)
case "present":
return n.Create(ctx)
}
return nil
}
func (n *ContainerNetwork) Load(r io.Reader) error {
d := NewYAMLDecoder(r)
return d.Decode(n)
}
func (n *ContainerNetwork) LoadDecl(yamlResourceDeclaration string) error {
d := NewYAMLStringDecoder(yamlResourceDeclaration)
return d.Decode(n)
}
func (n *ContainerNetwork) Create(ctx context.Context) error {
networkResp, err := n.apiClient.NetworkCreate(ctx, n.Name, types.NetworkCreate{
Driver: "bridge",
})
if err != nil {
panic(err)
}
n.Id = networkResp.ID
return nil
}
// produce yaml representation of any resource
func (n *ContainerNetwork) Read(ctx context.Context) ([]byte, error) {
return yaml.Marshal(n)
}
func (n *ContainerNetwork) Delete(ctx context.Context) error {
return nil
}
func (n *ContainerNetwork) Type() string { return "container_network" }
func (n *ContainerNetwork) ResolveId(ctx context.Context) string {
filterArgs := filters.NewArgs()
filterArgs.Add("name", "/"+n.Name)
containers, err := n.apiClient.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filterArgs,
})
if err != nil {
panic(fmt.Errorf("%w: %s %s", err, n.Type(), n.Name))
}
for _, container := range containers {
for _, containerName := range container.Names {
if containerName == n.Name {
if n.Id == "" {
n.Id = container.ID
}
return container.ID
}
}
}
return ""
}

View File

@ -0,0 +1,47 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
"context"
"decl/tests/mocks"
_ "encoding/json"
_ "fmt"
_ "github.com/docker/docker/api/types"
_ "github.com/docker/docker/api/types/container"
_ "github.com/docker/docker/api/types/network"
"github.com/stretchr/testify/assert"
_ "io"
_ "net/http"
_ "net/http/httptest"
_ "net/url"
_ "os"
_ "strings"
"testing"
)
func TestNewContainerNetworkResource(t *testing.T) {
c := NewContainerNetwork(&mocks.MockContainerClient{})
assert.NotNil(t, c)
}
func TestReadContainerNetwork(t *testing.T) {
ctx := context.Background()
decl := `
name: "testcontainernetwork"
state: present
`
m := &mocks.MockContainerClient{
}
n := NewContainerNetwork(m)
assert.NotNil(t, n)
e := n.LoadDecl(decl)
assert.Nil(t, e)
assert.Equal(t, "testcontainernetwork", n.Name)
resourceYaml, readContainerErr := n.Read(ctx)
assert.Equal(t, nil, readContainerErr)
assert.Greater(t, len(resourceYaml), 0)
}

View File

@ -14,7 +14,7 @@ import (
type DeclarationType struct {
Type TypeName `json:"type" yaml:"type"`
Transition string `json:"transition" yaml:"transition"`
Transition string `json:"transition,omitempty" yaml:"transition,omitempty"`
}
type Declaration struct {
@ -64,6 +64,11 @@ func (d *Declaration) Resource() Resource {
return d.Attributes
}
func (d *Declaration) Apply() error {
stater := d.Attributes.StateMachine()
stater.Trigger(d.Transition)
}
func (d *Declaration) SetURI(uri string) error {
slog.Info("Declaration.SetURI()", "uri", uri, "declaration", d)
d.Attributes = NewResource(uri)

View File

@ -1,4 +1,6 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package resource
import (
@ -97,7 +99,7 @@ func TestDeclarationJson(t *testing.T) {
"type": "user",
"attributes": {
"name": "testuser",
"uid": 10012
"uid": "10012"
}
}
`
@ -106,6 +108,6 @@ func TestDeclarationJson(t *testing.T) {
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)
assert.Equal(t, "10012", userResourceDeclaration.Attributes.(*User).UID)
}

View File

@ -18,7 +18,7 @@ func TestNewYAMLDecoder(t *testing.T) {
func TestNewDecoderDecodeJSON(t *testing.T) {
decl := `{
"name": "testuser",
"uid": 12001,
"uid": "12001",
"group": "12001",
"home": "/home/testuser",
"state": "present"
@ -41,7 +41,7 @@ func TestNewDecoderDecodeJSON(t *testing.T) {
func TestNewJSONStringDecoder(t *testing.T) {
decl := `{
"name": "testuser",
"uid": 12001,
"uid": "12001",
"group": "12001",
"home": "/home/testuser",
"state": "present"

View File

@ -4,7 +4,7 @@ package resource
import (
"encoding/json"
"fmt"
_ "fmt"
"gopkg.in/yaml.v3"
"io"
"log/slog"
@ -147,7 +147,6 @@ func (d *Document) Diff(with *Document, output io.Writer) (string, error) {
for _,diff := range yamldiff.Do(yamlDiff, withDiff, opts...) {
slog.Info("Diff()", "diff", diff)
fmt.Printf("yaml %#v with %#v\n", yamlDiff, withDiff)
_,e := output.Write([]byte(diff.Dump()))
if e != nil {
return "", e

View File

@ -45,9 +45,10 @@ resources:
- type: user
attributes:
name: "testuser"
uid: 10022
uid: "10022"
group: "10022"
home: "/home/testuser"
createhome: true
state: present
`, file)
d := NewDocument()
@ -138,9 +139,10 @@ resources:
- type: user
attributes:
name: "testuser"
uid: 10022
uid: "10022"
group: "10022"
home: "/home/testuser"
createhome: true
state: present
`
d := NewDocument()
@ -169,9 +171,10 @@ resources:
- type: user
attributes:
name: "testuser"
uid: 10022
uid: "10022"
group: "10022"
home: "/home/testuser"
createhome: true
state: present
`
d := NewDocument()
@ -194,7 +197,7 @@ resources:
- type: user
attributes:
name: "testuser"
uid: 10022
uid: "10022"
home: "/home/testuser"
state: present
- type: file

View File

@ -13,6 +13,7 @@ import (
"path/filepath"
_ "strings"
"io"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type Exec struct {
@ -48,6 +49,10 @@ func (x *Exec) Clone() Resource {
}
}
func (x *Exec) StateMachine() machine.Stater {
return ProcessMachine()
}
func (x *Exec) URI() string {
return fmt.Sprintf("exec://%s", x.Id)
}

View File

@ -18,6 +18,7 @@ import (
"syscall"
"time"
"crypto/sha256"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type FileType string
@ -34,6 +35,9 @@ const (
var ErrInvalidResourceURI error = errors.New("Invalid resource URI")
var ErrInvalidFileInfo error = errors.New("Invalid FileInfo")
var ErrInvalidFileMode error = errors.New("Invalid Mode")
var ErrInvalidFileOwner error = errors.New("Unknown User")
var ErrInvalidFileGroup error = errors.New("Unknown Group")
func init() {
ResourceTypes.Register("file", func(u *url.URL) Resource {
@ -60,7 +64,7 @@ type File struct {
Size int64 `json:"size,omitempty" yaml:"size,omitempty"`
Target string `json:"target,omitempty" yaml:"target,omitempty"`
FileType FileType `json:"filetype" yaml:"filetype"`
State string `json:"state" yaml:"state"`
State string `json:"state,omitempty" yaml:"state,omitempty"`
}
type ResourceFileInfo struct {
@ -100,6 +104,10 @@ func (f *File) Clone() Resource {
}
}
func (f *File) StateMachine() machine.Stater {
return StorageMachine()
}
func (f *File) URI() string {
return fmt.Sprintf("file://%s", f.Path)
}
@ -134,7 +142,7 @@ func (f *File) Apply() error {
{
uid, uidErr := LookupUID(f.Owner)
if uidErr != nil {
return uidErr
return fmt.Errorf("%w: unkwnon user %d", ErrInvalidFileOwner, uid)
}
gid, gidErr := LookupGID(f.Group)
@ -145,9 +153,8 @@ func (f *File) Apply() error {
slog.Info("File.Mode", "mode", f.Mode)
mode, modeErr := strconv.ParseInt(f.Mode, 8, 64)
if modeErr != nil {
return modeErr
return fmt.Errorf("%w: invalid mode %d", ErrInvalidFileMode, mode)
}
slog.Info("File.Mode Parse", "mode", mode, "err", modeErr)
//e := os.Stat(f.path)
//if os.IsNotExist(e) {

View File

@ -19,6 +19,7 @@ import (
"syscall"
"testing"
"time"
"os/user"
)
func TestNewFileResource(t *testing.T) {
@ -26,6 +27,20 @@ func TestNewFileResource(t *testing.T) {
assert.NotEqual(t, nil, f)
}
func TestNewFileNormalized(t *testing.T) {
file := fmt.Sprintf("%s/%s", TempDir, "bar/../fooread.txt")
absFilePath,_ := filepath.Abs(file)
f := NewNormalizedFile()
assert.NotNil(t, f)
f.SetURI("file://" + file)
assert.NotEqual(t, file, f.Path)
assert.Equal(t, absFilePath, f.Path)
assert.NotEqual(t, "file://" + file, f.URI())
assert.Equal(t, "file://" + absFilePath, f.URI())
}
func TestApplyResourceTransformation(t *testing.T) {
f := NewFile()
assert.NotEqual(t, nil, f)
@ -280,3 +295,64 @@ func TestFileResourceFileInfo(t *testing.T) {
fi := f.FileInfo()
assert.Equal(t, os.FileMode(0600), fi.Mode().Perm())
}
func TestFileClone(t *testing.T) {
ctx := context.Background()
testFile := filepath.Join(TempDir, "testorig.txt")
testCloneFile := filepath.Join(TempDir, "testclone.txt")
f := NewFile()
assert.NotNil(t, f)
f.Path = testFile
f.Mode = "0600"
f.State = "present"
assert.Nil(t, f.Apply())
f.Read(ctx)
time.Sleep(100 * time.Millisecond)
clone := f.Clone().(*File)
assert.Equal(t, f, clone)
clone.Mtime = time.Time{}
clone.Path = testCloneFile
assert.Nil(t, clone.Apply())
f.Read(ctx)
clone.Read(ctx)
fmt.Printf("file %#v\nclone %#v\n", f, clone)
assert.NotEqual(t, f.Mtime, clone.Mtime)
}
func TestFileErrors(t *testing.T) {
ctx := context.Background()
testFile := filepath.Join(TempDir, "testerr.txt")
f := NewFile()
assert.NotNil(t, f)
f.Path = testFile
f.Mode = "631"
f.State = "present"
assert.Nil(t, f.Apply())
read := NewFile()
read.Path = testFile
read.Read(ctx)
assert.Equal(t, "0631", read.Mode)
f.Mode = "900"
assert.ErrorAs(t, f.Apply(), &ErrInvalidFileMode, "Apply should fail with NumError when converting invalid octal")
read.Read(ctx)
assert.Equal(t, "0631", read.Mode)
f.Mode = "0631"
f.Owner = "bar"
uidErr := f.Apply()
var UnknownUser user.UnknownUserError
assert.Error(t, uidErr, UnknownUser)
}

View File

@ -14,6 +14,7 @@ _ "os"
"encoding/json"
"strings"
"log/slog"
"gitea.rosskeen.house/rosskeen.house/machine"
)
func init() {
@ -38,7 +39,7 @@ type HTTP struct {
Endpoint string `yaml:"endpoint" json:"endpoint"`
Headers []HTTPHeader `yaml:"headers,omitempty" json:"headers,omitempty"`
Body string `yaml:"body,omitempty" json:"body,omitempty"`
State string `yaml:"state" json:"state"`
State string `yaml:"state,omitempty" json:"state,omitempty"`
}
func NewHTTP() *HTTP {
@ -55,6 +56,10 @@ func (h *HTTP) Clone() Resource {
}
}
func (h *HTTP) StateMachine() machine.Stater {
return StorageMachine()
}
func (h *HTTP) URI() string {
return h.Endpoint
}

View File

@ -6,7 +6,7 @@ import (
"context"
_ "encoding/hex"
"encoding/json"
_ "errors"
"errors"
"fmt"
"gopkg.in/yaml.v3"
"io"
@ -16,17 +16,26 @@ _ "os/exec"
"strconv"
"strings"
"log/slog"
"gitea.rosskeen.house/rosskeen.house/machine"
)
func init() {
ResourceTypes.Register("iptable", func(u *url.URL) Resource {
i := NewIptable()
i.Table = IptableName(u.Hostname())
fields := strings.Split(u.Path, "/")
slog.Info("iptables factory", "iptable", i, "uri", u, "field", fields)
i.Chain = IptableChain(fields[1])
id, _ := strconv.ParseUint(fields[2], 10, 32)
i.Id = uint(id)
if len(u.Path) > 0 {
fields := strings.Split(u.Path, "/")
slog.Info("iptables factory", "iptable", i, "uri", u, "fields", fields, "number_fields", len(fields))
i.Chain = IptableChain(fields[1])
if len(fields) < 3 {
i.ResourceType = IptableTypeChain
} else {
i.ResourceType = IptableTypeRule
id, _ := strconv.ParseUint(fields[2], 10, 32)
i.Id = uint(id)
}
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.ResourceType.NewCRUD()
}
return i
})
}
@ -84,10 +93,17 @@ type ExtensionFlag struct {
type IptablePort uint16
type IptableType string
const (
IptableTypeRule = "rule"
IptableTypeChain = "chain"
)
// Manage the state of iptables rules
// iptable://filter/INPUT/0
type Iptable struct {
Id uint `json:"id" yaml:"id"`
Id uint `json:"id,omitempty" yaml:"id,omitempty"`
Table IptableName `json:"table" yaml:"table"`
Chain IptableChain `json:"chain" yaml:"chain"`
Destination IptableCIDR `json:"destination,omitempty" yaml:"destination,omitempty"`
@ -99,9 +115,11 @@ type Iptable struct {
Match []string `json:"match,omitempty" yaml:"match,omitempty"`
Flags []ExtensionFlag `json:"extension_flags,omitempty" yaml:"extension_flags,omitempty"`
Proto IptableProto `json:"proto,omitempty" yaml:"proto,omitempty"`
Jump string `json:"jump" yaml:"jump"`
Jump string `json:"jump,omitempty" yaml:"jump,omitempty"`
State string `json:"state" yaml:"state"`
ChainLength uint `json:"-" yaml:"-"`
ResourceType IptableType `json:"resourcetype,omitempty" yaml:"resourcetype,omitempty"`
CreateCommand *Command `yaml:"-" json:"-"`
ReadCommand *Command `yaml:"-" json:"-"`
UpdateCommand *Command `yaml:"-" json:"-"`
@ -109,13 +127,13 @@ type Iptable struct {
}
func NewIptable() *Iptable {
i := &Iptable{}
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD()
i := &Iptable{ ResourceType: IptableTypeRule }
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.ResourceType.NewCRUD()
return i
}
func (i *Iptable) Clone() Resource {
return &Iptable {
newIpt := &Iptable {
Id: i.Id,
Table: i.Table,
Chain: i.Chain,
@ -125,8 +143,15 @@ func (i *Iptable) Clone() Resource {
Out: i.Out,
Match: i.Match,
Proto: i.Proto,
ResourceType: i.ResourceType,
State: i.State,
}
newIpt.CreateCommand, newIpt.ReadCommand, newIpt.UpdateCommand, newIpt.DeleteCommand = newIpt.ResourceType.NewCRUD()
return newIpt
}
func (i *Iptable) StateMachine() machine.Stater {
return StorageMachine()
}
func (i *Iptable) URI() string {
@ -140,8 +165,13 @@ func (i *Iptable) SetURI(uri string) error {
i.Table = IptableName(resourceUri.Hostname())
fields := strings.Split(resourceUri.Path, "/")
i.Chain = IptableChain(fields[1])
id, _ := strconv.ParseUint(fields[2], 10, 32)
i.Id = uint(id)
if len(fields) < 3 {
i.ResourceType = IptableTypeChain
} else {
i.ResourceType = IptableTypeRule
id, _ := strconv.ParseUint(fields[2], 10, 32)
i.Id = uint(id)
}
} else {
e = fmt.Errorf("%w: %s is not an iptable rule", ErrInvalidResourceURI, uri)
}
@ -166,7 +196,7 @@ func (i *Iptable) UnmarshalJSON(data []byte) error {
if unmarshalErr := json.Unmarshal(data, i); unmarshalErr != nil {
return unmarshalErr
}
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD()
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.ResourceType.NewCRUD()
return nil
}
@ -175,7 +205,7 @@ func (i *Iptable) UnmarshalYAML(value *yaml.Node) error {
if unmarshalErr := value.Decode((*decodeIptable)(i)); unmarshalErr != nil {
return unmarshalErr
}
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.NewCRUD()
i.CreateCommand, i.ReadCommand, i.UpdateCommand, i.DeleteCommand = i.ResourceType.NewCRUD()
return nil
}
@ -184,12 +214,17 @@ func (i *Iptable) NewCRUD() (create *Command, read *Command, update *Command, de
}
func (i *Iptable) Apply() error {
ctx := context.Background()
switch i.State {
case "absent":
case "present":
err := i.Create(ctx)
if err != nil {
return err
}
}
return nil
_,e := i.Read(context.Background())
return e
}
func (i *Iptable) Load(r io.Reader) error {
@ -208,6 +243,177 @@ func (i *Iptable) ResolveId(ctx context.Context) string {
return fmt.Sprintf("%d", i.Id)
}
func (i *Iptable) SetFlagValue(opt, value string) bool {
switch opt {
case "-i":
i.In = value
return true
case "-o":
i.Out = value
return true
case "-m":
for _,search := range i.Match {
if search == value {
return true
}
}
i.Match = append(i.Match, value)
return true
case "-s":
i.Source = IptableCIDR(value)
return true
case "-d":
i.Destination = IptableCIDR(value)
return true
case "-p":
i.Proto = IptableProto(value)
return true
case "-j":
i.Jump = value
return true
case "--dport":
port,_ := strconv.ParseUint(value, 10, 16)
i.Dport = IptablePort(port)
return true
case "--sport":
port,_ := strconv.ParseUint(value, 10, 16)
i.Sport = IptablePort(port)
return true
default:
if opt[0] == '-' {
i.Flags = append(i.Flags, ExtensionFlag{ Name: strings.Trim(opt, "-"), Value: strings.TrimSpace(value)})
return true
}
}
return false
}
func (i *Iptable) GetFlagValue(opt string) any {
switch opt {
case "-i":
return i.In
case "-o":
return i.Out
case "-m":
return i.Match
case "-s":
return i.Source
case "-d":
return i.Destination
case "-p":
return i.Proto
case "-j":
return i.Jump
case "--dport":
return strconv.Itoa(int(i.Dport))
case "--sport":
return strconv.Itoa(int(i.Sport))
default:
if opt[0] == '-' {
return i.Flags
}
}
return nil
}
func (i *Iptable) SetRule(flags []string) (assigned bool) {
assigned = true
for index, flag := range flags {
if flag[0] == '-' {
flag := flags[index]
value := flags[index + 1]
if value[0] != '-' {
if ! i.SetFlagValue(flag, value) {
assigned = false
}
}
}
}
return
}
func (i *Iptable) MatchRule(flags []string) (match bool) {
match = true
for index, flag := range flags {
if flag[0] == '-' {
value := flags[index + 1]
switch v := i.GetFlagValue(flag).(type) {
case []string:
for _,element := range v {
if element == value {
continue
}
}
match = false
case []ExtensionFlag:
for _,element := range v {
if element.Name == flag && element.Value == value {
continue
}
}
match = false
case IptableCIDR:
if v == IptableCIDR(value) {
continue
}
match = false
case IptableName:
if v == IptableName(value) {
continue
}
match = false
case IptableChain:
if v == IptableChain(value) {
continue
}
match = false
default:
if v.(string) == value {
continue
}
match = false
}
}
}
return
}
func (i *Iptable) ReadChainLength() error {
c := NewCommand()
c.Path = "iptables"
c.Args = []CommandArg{
CommandArg("-S"),
CommandArg("{{ .Chain }}"),
}
output,err := c.Execute(i)
if err == nil {
linesCount := strings.Count(string(output), "\n")
if linesCount > 0 {
i.ChainLength = uint(linesCount) - 1
} else {
i.ChainLength = 0
}
}
return err
}
func (i *Iptable) Create(ctx context.Context) error {
if i.Id > 0 {
if lenErr := i.ReadChainLength(); lenErr != nil {
return lenErr
}
}
_, err := i.CreateCommand.Execute(i)
//slog.Info("IptableChain Create()", "err", err, "errstr", err.Error(), "iptable", i, "createcommand", i.CreateCommand)
// TODO add Command status/error handler rather than using the read extractor
if i.CreateCommand.Extractor != nil {
if err != nil {
return i.CreateCommand.Extractor([]byte(err.Error()), i)
}
}
return nil
}
func (i *Iptable) Read(ctx context.Context) ([]byte, error) {
out, err := i.ReadCommand.Execute(i)
if err != nil {
@ -222,24 +428,93 @@ func (i *Iptable) Read(ctx context.Context) ([]byte, error) {
func (i *Iptable) Type() string { return "iptable" }
func (i *IptableType) NewCRUD() (create *Command, read *Command, update *Command, del *Command) {
switch *i {
case IptableTypeRule:
return NewIptableCreateCommand(), NewIptableReadCommand(), NewIptableUpdateCommand(), NewIptableDeleteCommand()
case IptableTypeChain:
return NewIptableChainCreateCommand(), NewIptableChainReadCommand(), NewIptableChainUpdateCommand(), NewIptableChainDeleteCommand()
default:
}
return nil, nil, nil, nil
}
func (i *IptableType) UnmarshalValue(value string) error {
switch value {
case string(IptableTypeRule), string(IptableTypeChain):
*i = IptableType(value)
return nil
default:
return errors.New("invalid IptableType value")
}
}
func (i *IptableType) UnmarshalJSON(data []byte) error {
var s string
if unmarshalRouteTypeErr := json.Unmarshal(data, &s); unmarshalRouteTypeErr != nil {
return unmarshalRouteTypeErr
}
return i.UnmarshalValue(s)
}
func (i *IptableType) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
return i.UnmarshalValue(s)
}
func NewIptableCreateCommand() *Command {
c := NewCommand()
c.Path = "iptables"
c.Args = []CommandArg{
CommandArg("-t"),
CommandArg("{{ .Table }}"),
CommandArg("-R"),
CommandArg("{{ if le .Id .ChainLength }}-R{{ else }}-A{{ end }}"),
CommandArg("{{ .Chain }}"),
CommandArg("{{ .Id }}"),
CommandArg("{{ if le .Id .ChainLength }}{{ .Id }}{{ end }}"),
CommandArg("{{ if .In }}-i {{ .In }}{{ else if .Out }}-o {{ .Out }}{{ end }}"),
CommandArg("{{ range .Match }}-m {{ . }} {{ end }}"),
CommandArg("{{ range .Match }}-m {{ . }} {{- end }}"),
CommandArg("{{ if .Source }}-s {{ .Source }}{{ end }}"),
CommandArg("{{ if .Sport }}--sport {{ .Sport }}{{ end }}"),
CommandArg("{{ if .Destination }}-d {{ .Destination }}{{ end }}"),
CommandArg("{{ if .Dport }}--dport {{ .Dport }}{{ end }}"),
CommandArg("{{ if .Proto }}-p {{ .Proto }}{{ end }}"),
CommandArg("{{ range .Flags }} --{{ .Name }} {{ .Value }} {{ end }}"),
CommandArg("{{ if .Jump }}-j {{ .Jump }}{{ end }}"),
}
return c
}
func IptableExtractRule(lineNumber uint, ruleLine string, target *Iptable) (state string, err error) {
state = "absent"
ruleFields := strings.Split(strings.TrimSpace(ruleLine), " ")
slog.Info("IptableExtractRule()", "lineNumber", lineNumber, "ruleLine", ruleLine, "target", target)
if ruleFields[0] == "-A" {
flags := ruleFields[2:]
if target.Id > 0 {
if target.Id == lineNumber {
slog.Info("IptableExtractRule() SetRule", "lineNumber", lineNumber, "flags", flags, "target", target)
if target.SetRule(flags) {
state = "present"
err = nil
}
}
} else {
if target.MatchRule(flags) {
target.Id = lineNumber
state = "present"
err = nil
}
}
} else {
err = fmt.Errorf("Invalid rule %d %s", lineNumber, ruleLine)
}
return
}
func NewIptableReadCommand() *Command {
c := NewCommand()
c.Path = "iptables"
@ -248,50 +523,73 @@ func NewIptableReadCommand() *Command {
CommandArg("{{ .Table }}"),
CommandArg("-S"),
CommandArg("{{ .Chain }}"),
CommandArg("{{ .Id }}"),
CommandArg("{{ if .Id }}{{ .Id }}{{ end }}"),
}
c.Extractor = func(out []byte, target any) error {
i := target.(*Iptable)
ruleFields := strings.Split(strings.TrimSpace(string(out)), " ")
switch ruleFields[0] {
case "-A":
//chain := ruleFields[1]
flags := ruleFields[2:]
for optind,opt := range flags {
if optind > len(flags) - 2 {
break
}
optValue := flags[optind + 1]
switch opt {
case "-i":
i.In = optValue
case "-o":
i.Out = optValue
case "-m":
i.Match = append(i.Match, optValue)
case "-s":
i.Source = IptableCIDR(optValue)
case "-d":
i.Destination = IptableCIDR(optValue)
case "-p":
i.Proto = IptableProto(optValue)
case "-j":
i.Jump = optValue
case "--dport":
port,_ := strconv.ParseUint(optValue, 10, 16)
i.Dport = IptablePort(port)
case "--sport":
port,_ := strconv.ParseUint(optValue, 10, 16)
i.Sport = IptablePort(port)
default:
if opt[0] == '-' {
i.Flags = append(i.Flags, ExtensionFlag{ Name: strings.Trim(opt, "-"), Value: strings.TrimSpace(optValue)})
}
if i.Id > 0 {
return RuleExtractor(out, target)
}
state := "absent"
var lineNumber uint = 1
lines := strings.Split(string(out), "\n")
numberOfLines := len(lines)
for _, line := range lines {
matchState, err := IptableExtractRule(lineNumber, line, i)
if matchState == "present" {
state = matchState
break
}
if err == nil {
lineNumber++
}
}
i.State = state
if numberOfLines > 0 {
i.ChainLength = uint(numberOfLines) - 1
} else {
i.ChainLength = 0
}
return nil
}
return c
}
func NewIptableReadChainCommand() *Command {
c := NewCommand()
c.Path = "iptables"
c.Args = []CommandArg{
CommandArg("-t"),
CommandArg("{{ .Table }}"),
CommandArg("-S"),
CommandArg("{{ .Chain }}"),
}
c.Extractor = func(out []byte, target any) error {
IptableChainRules := target.(*[]*Iptable)
numberOfChainRules := len(*IptableChainRules)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
numberOfLines := len(lines)
diff := (numberOfLines - 1) - numberOfChainRules
if diff > 0 {
for i := 0; i < diff; i++ {
*IptableChainRules = append(*IptableChainRules, NewIptable())
}
}
for lineIndex, line := range lines[1:] {
i := (*IptableChainRules)[lineIndex]
i.Id = uint(lineIndex + 1)
ruleFields := strings.Split(strings.TrimSpace(line), " ")
if ruleFields[0] == "-A" {
flags := ruleFields[2:]
if i.SetRule(flags) {
i.State = "present"
} else {
i.State = "absent"
}
}
i.State = "present"
default:
i.State = "absent"
}
return nil
}
@ -299,9 +597,166 @@ func NewIptableReadCommand() *Command {
}
func NewIptableUpdateCommand() *Command {
return nil
return NewIptableCreateCommand()
}
func NewIptableDeleteCommand() *Command {
return nil
}
func NewIptableChainCreateCommand() *Command {
c := NewCommand()
c.Path = "iptables"
c.Args = []CommandArg{
CommandArg("-t"),
CommandArg("{{ .Table }}"),
CommandArg("-N"),
CommandArg("{{ .Chain }}"),
}
c.Extractor = func(out []byte, target any) error {
slog.Info("IptableChain Extractor", "output", out, "command", c)
for _,line := range strings.Split(string(out), "\n") {
if line == "iptables: Chain already exists." {
return nil
}
}
return fmt.Errorf(string(out))
}
return c
}
func ChainExtractor(out []byte, target any) error {
i := target.(*Iptable)
rules := strings.Split(string(out), "\n")
for _,rule := range rules {
ruleFields := strings.Split(strings.TrimSpace(string(rule)), " ")
switch ruleFields[0] {
case "-N", "-A":
chain := ruleFields[1]
if chain == string(i.Chain) {
i.State = "present"
return nil
} else {
i.State = "absent"
}
default:
i.State = "absent"
}
}
return nil
}
func RuleExtractor(out []byte, target any) (err error) {
ipt := target.(*Iptable)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
err = fmt.Errorf("Failed to extract rule by Id: %d", ipt.Id)
ipt.State = "absent"
var lineIndex uint = 1
if uint(len(lines)) >= ipt.Id {
lineIndex = ipt.Id
} else if len(lines) > 2 {
return
}
ruleFields := strings.Split(strings.TrimSpace(lines[lineIndex]), " ")
slog.Info("RuleExtractor()", "lines", lines, "line", lines[lineIndex], "fields", ruleFields, "index", lineIndex)
if ruleFields[0] == "-A" {
if ipt.SetRule(ruleFields[2:]) {
ipt.State = "present"
err = nil
}
}
return
}
func RuleExtractorMatchFlags(out []byte, target any) (err error) {
ipt := target.(*Iptable)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var linesCount uint = uint(len(lines))
err = fmt.Errorf("Failed to extract rule")
if linesCount > 0 {
ipt.ChainLength = linesCount - 1
ipt.State = "absent"
for linesIndex, line := range lines {
ruleFields := strings.Split(strings.TrimSpace(line), " ")
slog.Info("RuleExtractorMatchFlags()", "lines", lines, "line", line, "fields", ruleFields, "index", linesIndex)
if ruleFields[0] == "-A" {
flags := ruleFields[2:]
if ipt.MatchRule(flags) {
slog.Info("RuleExtractorMatchFlags()", "flags", flags, "ipt", ipt)
err = nil
ipt.State = "present"
ipt.Id = uint(linesIndex)
return
}
}
}
}
return
}
func RuleExtractorById(out []byte, target any) (err error) {
ipt := target.(*Iptable)
state := "absent"
lines := strings.Split(string(out), "\n")
err = fmt.Errorf("Failed to extract rule by Id: %d", ipt.Id)
ipt.ChainLength = 0
for _, line := range lines {
ruleFields := strings.Split(strings.TrimSpace(line), " ")
if ruleFields[0] == "-A" {
ipt.ChainLength++
flags := ruleFields[2:]
slog.Info("RuleExtractorById()", "target", ipt)
if ipt.Id == ipt.ChainLength {
if ipt.SetRule(flags) {
slog.Info("RuleExtractorById() SetRule", "flags", flags, "target", ipt)
state = "present"
err = nil
}
}
}
}
ipt.State = state
return
}
func NewIptableChainReadCommand() *Command {
c := NewCommand()
c.Path = "iptables"
c.Args = []CommandArg{
CommandArg("-t"),
CommandArg("{{ .Table }}"),
CommandArg("-S"),
CommandArg("{{ .Chain }}"),
}
c.Extractor = func(out []byte, target any) error {
i := target.(*Iptable)
rules := strings.Split(string(out), "\n")
for _,rule := range rules {
ruleFields := strings.Split(strings.TrimSpace(string(rule)), " ")
slog.Info("IptableChain Extract()", "fields", ruleFields)
switch ruleFields[0] {
case "-N", "-A":
chain := ruleFields[1]
if chain == string(i.Chain) {
i.State = "present"
return nil
} else {
i.State = "absent"
}
default:
i.State = "absent"
}
}
return nil
}
return c
}
func NewIptableChainUpdateCommand() *Command {
return NewIptableChainCreateCommand()
}
func NewIptableChainDeleteCommand() *Command {
return nil
}

View File

@ -74,3 +74,61 @@ func TestReadIptable(t *testing.T) {
assert.NotNil(t, r)
assert.Equal(t, "eth0", testRule.In)
}
func TestCreateIptable(t *testing.T) {
testRule := NewIptable()
assert.NotNil(t, testRule)
}
func TestIptableSetFlagValue(t *testing.T) {
i := NewIptable()
assert.NotNil(t, i)
i.SetFlagValue("-i", "eth0")
assert.Equal(t, "eth0", i.In)
}
func TestIptableChainExtractor(t *testing.T) {
ipt := NewIptable()
assert.NotNil(t, ipt)
ipt.Chain = IptableChain("FOO")
assert.Nil(t, ChainExtractor([]byte("-N FOO\n"), ipt))
assert.Equal(t, IptableChain("FOO"), ipt.Chain)
}
func TestIptableRuleExtractorById(t *testing.T) {
ipt := NewIptable()
assert.NotNil(t, ipt)
ipt.Table = IptableName("filter")
ipt.Chain = IptableChain("FOO")
ipt.Id = 1
data := []byte(`
-N FOO
-A FOO -s 192.168.0.1/32 -j ACCEPT
`)
assert.Nil(t, RuleExtractor(data, ipt))
assert.Equal(t, IptableChain("FOO"), ipt.Chain)
assert.Equal(t, IptableCIDR("192.168.0.1/32"), ipt.Source)
}
func TestIptableRuleExtractorByFlags(t *testing.T) {
ipt := NewIptable()
assert.NotNil(t, ipt)
ipt.Table = IptableName("filter")
ipt.Chain = IptableChain("FOO")
ipt.Source = IptableCIDR("192.168.0.1/32")
ipt.Jump = "ACCEPT"
data := []byte(`
-N FOO
-A FOO -d 192.168.0.3/32 -j ACCEPT
-A FOO -s 192.168.0.3/32 -j ACCEPT
-A FOO -s 192.168.0.1/32 -j ACCEPT
`)
assert.Nil(t, RuleExtractorMatchFlags(data, ipt))
assert.Equal(t, uint(3), ipt.Id, ipt.Chain)
assert.Equal(t, IptableChain("FOO"), ipt.Chain)
assert.Equal(t, IptableCIDR("192.168.0.1/32"), ipt.Source)
}

View File

@ -5,6 +5,7 @@ package resource
import (
"context"
_ "gopkg.in/yaml.v3"
"gitea.rosskeen.house/rosskeen.house/machine"
)
func NewFooResource() *MockResource {
@ -14,5 +15,6 @@ func NewFooResource() *MockResource {
InjectRead: func(ctx context.Context) ([]byte, error) { return nil,nil },
InjectLoadDecl: func(string) error { return nil },
InjectApply: func() error { return nil },
InjectStateMachine: func() machine.Stater { return nil },
}
}

View File

@ -7,6 +7,7 @@ import (
_ "gopkg.in/yaml.v3"
"encoding/json"
_ "fmt"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type MockResource struct {
@ -17,12 +18,17 @@ type MockResource struct {
InjectValidate func() error
InjectApply func() error
InjectRead func(context.Context) ([]byte, error)
InjectStateMachine func() machine.Stater
}
func (m *MockResource) Clone() Resource {
return nil
}
func (m *MockResource) StateMachine() machine.Stater {
return nil
}
func (m *MockResource) SetURI(uri string) error {
return nil
}

View File

@ -15,6 +15,7 @@ import (
"regexp"
_ "strconv"
"strings"
"gitea.rosskeen.house/rosskeen.house/machine"
)
func init() {
@ -138,6 +139,10 @@ func (n *NetworkRoute) Clone() Resource {
}
}
func (n *NetworkRoute) StateMachine() machine.Stater {
return StorageMachine()
}
func (n *NetworkRoute) URI() string {
return fmt.Sprintf("route://%s", n.Id)
}

View File

@ -6,6 +6,7 @@ import (
"os/user"
"strconv"
"regexp"
"log/slog"
)
var MatchId *regexp.Regexp = regexp.MustCompile(`^[0-9]+$`)
@ -19,29 +20,36 @@ func LookupUIDString(userName string) string {
}
func LookupUID(userName string) (int, error) {
var userLookupErr error
var UID string
if MatchId.MatchString(userName) {
user, userLookupErr := user.LookupId(userName)
if userLookupErr != nil {
//return -1, userLookupErr
user, err := user.LookupId(userName)
slog.Info("LookupUID() numeric", "user", user, "userLookupErr", err)
if err != nil {
userLookupErr = err
UID = userName
} else {
UID = user.Uid
}
} else {
user, userLookupErr := user.Lookup(userName)
if userLookupErr != nil {
return -1, userLookupErr
if user, err := user.Lookup(userName); err != nil {
return -1, err
} else {
UID = user.Uid
}
UID = user.Uid
}
uid, uidErr := strconv.Atoi(UID)
slog.Info("LookupUID()", "uid", uid, "uidErr", uidErr)
if uidErr != nil {
return -1, uidErr
if userLookupErr != nil {
return -1, userLookupErr
} else {
return -1, uidErr
}
}
return uid, nil
return uid, userLookupErr
}
func LookupGID(groupName string) (int, error) {

View File

@ -4,7 +4,6 @@ package resource
import (
_ "context"
_ "encoding/json"
_ "fmt"
"github.com/stretchr/testify/assert"
_ "io"
_ "net/http"
@ -20,9 +19,9 @@ func TestLookupUID(t *testing.T) {
assert.Nil(t, e)
assert.Equal(t, 65534, uid)
nuid, ne := LookupUID("1001")
assert.Nil(t, ne)
assert.Equal(t, 1001, nuid)
nuid, ne := LookupUID("10101")
assert.Error(t, ne, "user: unknonwn userid ", ne)
assert.Equal(t, 10101, nuid)
}
func TestLookupGID(t *testing.T) {

View File

@ -15,6 +15,7 @@ import (
_ "os/exec"
"path/filepath"
"strings"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type PackageType string
@ -94,6 +95,10 @@ func (p *Package) Clone() Resource {
return newp
}
func (p *Package) StateMachine() machine.Stater {
return StorageMachine()
}
func (p *Package) URI() string {
return fmt.Sprintf("package://%s?version=%s&type=%s", p.Name, p.Version, p.PackageType)
}

View File

@ -9,12 +9,14 @@ import (
_ "fmt"
_ "gopkg.in/yaml.v3"
_ "net/url"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type ResourceSelector func(r *Declaration) bool
type Resource interface {
Type() string
StateMachine() machine.Stater
URI() string
SetURI(string) error
ResolveId(context.Context) string
@ -55,14 +57,22 @@ func NewResource(uri string) Resource {
}
return nil
}
/*
func Machine() {
func StorageMachine() machine.Stater {
// start_destroy -> absent -> start_create -> present -> start_destroy
stater := machine.New("absent")
stater.AddStates("absent", "start_create", "present", "start_delete")
stater.AddTransition("creating", "absent", "start_create")
stater.AddStates("absent", "start_create", "present", "start_delete", "start_read", "start_update")
stater.AddTransition("create", "absent", "start_create")
stater.AddTransition("created", "start_create", "present")
stater.AddTransition("deleting", "present", "start_delete")
stater.AddTransition("read", "*", "start_read")
stater.AddTransition("state_read", "start_read", "present")
stater.AddTransition("update", "*", "start_update")
stater.AddTransition("updated", "start_update", "present")
stater.AddTransition("delete", "*", "start_delete")
stater.AddTransition("deleted", "start_delete", "absent")
return stater
}
func ProcessMachine() machine.Stater {
return nil
}
*/

View File

@ -14,6 +14,7 @@ import (
)
//go:embed schemas/*.jsonschema
//go:embed schemas/*.schema.json
var schemaFiles embed.FS
type Schema struct {

View File

@ -0,0 +1,17 @@
{
"$id": "container-declaration.jsonschema",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "declaration",
"type": "object",
"required": [ "type", "attributes" ],
"properties": {
"type": {
"type": "string",
"description": "Resource type name.",
"enum": [ "container" ]
},
"attributes": {
"$ref": "container.jsonschema"
}
}
}

View File

@ -0,0 +1,14 @@
{
"$id": "container.jsonschema",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "container",
"description": "A docker container",
"type": "object",
"required": [ "name" ],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z]([-_a-z0-9]{0,31})$"
}
}
}

View File

@ -0,0 +1,17 @@
{
"$id": "container_network-declaration.jsonschema",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "declaration",
"type": "object",
"required": [ "type", "attributes" ],
"properties": {
"type": {
"type": "string",
"description": "Resource type name.",
"enum": [ "container_network" ]
},
"attributes": {
"$ref": "container_network.jsonschema"
}
}
}

View File

@ -0,0 +1,14 @@
{
"$id": "container_network.jsonschema",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "container_network",
"description": "A docker container network",
"type": "object",
"required": [ "name" ],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z]([-_a-z0-9]{0,31})$"
}
}
}

View File

@ -16,7 +16,9 @@
{ "$ref": "user-declaration.jsonschema" },
{ "$ref": "exec-declaration.jsonschema" },
{ "$ref": "network_route-declaration.jsonschema" },
{ "$ref": "iptable-declaration.jsonschema" }
{ "$ref": "iptable-declaration.jsonschema" },
{ "$ref": "container-declaration.jsonschema" },
{ "$ref": "container_network-declaration.jsonschema" }
]
}
}

View File

@ -10,6 +10,9 @@
"description": "Resource type name.",
"enum": [ "user" ]
},
"transition": {
"$ref": "storagetransition.schema.json"
},
"attributes": {
"$ref": "user.jsonschema"
}

View File

@ -11,9 +11,8 @@
"pattern": "^[a-z]([-_a-z0-9]{0,31})$"
},
"uid": {
"type": "integer",
"minimum": 0,
"maximum": 65535
"type": "string",
"pattern": "^[0-9]*$"
},
"group": {
"type": "string"

View File

@ -6,44 +6,67 @@ import (
"context"
"fmt"
"gopkg.in/yaml.v3"
"log/slog"
_ "log/slog"
"net/url"
_ "os"
"os/exec"
"os/user"
"io"
"strconv"
"strings"
"encoding/json"
"errors"
"gitea.rosskeen.house/rosskeen.house/machine"
)
type decodeUser User
type UserType string
const (
UserTypeAddUser = "adduser"
UserTypeUserAdd = "useradd"
)
type User struct {
Name string `json:"name" yaml:"name"`
UID int `json:"uid,omitempty" yaml:"uid,omitempty"`
UID string `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"`
UserType UserType `json:"-" yaml:"-"`
State string `json:"state" yaml:"state"`
CreateCommand *Command `json:"-" yaml:"-"`
ReadCommand *Command `json:"-" yaml:"-"`
UpdateCommand *Command `json:"-" yaml:"-"`
DeleteCommand *Command `json:"-" yaml:"-"`
State string `json:"state,omitempty" yaml:"state,omitempty"`
}
func NewUser() *User {
return &User{}
return &User{ CreateHome: true }
}
func init() {
ResourceTypes.Register("user", func(u *url.URL) Resource {
user := NewUser()
user.Name = u.Path
user.UID, _ = LookupUID(u.Path)
user.Name = u.Hostname()
user.UID = LookupUIDString(u.Hostname())
if _, addUserPathErr := exec.LookPath("adduser"); addUserPathErr == nil {
user.UserType = UserTypeAddUser
}
if _, pathErr := exec.LookPath("useradd"); pathErr == nil {
user.UserType = UserTypeUserAdd
}
user.CreateCommand, user.ReadCommand, user.UpdateCommand, user.DeleteCommand = user.UserType.NewCRUD()
return user
})
}
func (u *User) Clone() Resource {
return &User {
newu := &User {
Name: u.Name,
UID: u.UID,
Group: u.Group,
@ -53,7 +76,14 @@ func (u *User) Clone() Resource {
CreateHome: u.CreateHome,
Shell: u.Shell,
State: u.State,
UserType: u.UserType,
}
newu.CreateCommand, newu.ReadCommand, newu.UpdateCommand, newu.DeleteCommand = u.UserType.NewCRUD()
return newu
}
func (u *User) StateMachine() machine.Stater {
return StorageMachine()
}
func (u *User) SetURI(uri string) error {
@ -85,43 +115,11 @@ func (u *User) Apply() error {
case "present":
_, NoUserExists := LookupUID(u.Name)
if NoUserExists != nil {
var userCommandName string = "useradd"
args := make([]string, 0, 7)
if u.UID >= 0 {
args = append(args, "-u", fmt.Sprintf("%d", u.UID))
}
if _, pathErr := exec.LookPath("useradd"); pathErr != nil {
if _, addUserPathErr := exec.LookPath("adduser"); addUserPathErr == nil {
userCommandName = "adduser"
if addUserCommandErr := u.AddUserCommand(&args); addUserCommandErr != nil {
return addUserCommandErr
}
}
} else {
if userAddCommandErr := u.UserAddCommand(&args); userAddCommandErr != nil {
return userAddCommandErr
}
}
args = append(args, u.Name)
cmd := exec.Command(userCommandName, args...)
cmdOutput, cmdErr := cmd.CombinedOutput()
slog.Info("user command", "command", cmd.String(), "output", string(cmdOutput))
cmdErr := u.Create(context.Background())
return cmdErr
}
case "absent":
var userDelCommandName string = "userdel"
args := make([]string, 0, 7)
if _, pathErr := exec.LookPath("userdel"); pathErr != nil {
if _, delUserPathErr := exec.LookPath("deluser"); delUserPathErr == nil {
userDelCommandName = "deluser"
}
}
args = append(args, u.Name)
cmd := exec.Command(userDelCommandName, args...)
cmdOutput, cmdErr := cmd.CombinedOutput()
slog.Info("user command", "command", cmd.String(), "output", string(cmdOutput))
cmdErr := u.Delete()
return cmdErr
}
return nil
@ -166,29 +164,205 @@ func (u *User) UserAddCommand(args *[]string) error {
func (u *User) Type() string { return "user" }
func (u *User) Read(ctx context.Context) ([]byte, error) {
var readUser *user.User
var e error
if u.Name != "" {
readUser, e = user.Lookup(u.Name)
}
if u.UID >= 0 {
readUser, e = user.LookupId(strconv.Itoa(u.UID))
func (u *User) Create(ctx context.Context) (error) {
_, err := u.CreateCommand.Execute(u)
if err != nil {
return err
}
if e != nil {
panic(e)
}
u.Name = readUser.Username
u.UID, _ = strconv.Atoi(readUser.Uid)
if readGroup, groupErr := user.LookupGroupId(readUser.Gid); groupErr == nil {
u.Group = readGroup.Name
} else {
panic(groupErr)
}
u.Home = readUser.HomeDir
u.Gecos = readUser.Name
return yaml.Marshal(u)
_,e := u.Read(ctx)
return e
}
func (u *User) Read(ctx context.Context) ([]byte, error) {
exErr := u.ReadCommand.Extractor(nil, u)
if exErr != nil {
u.State = "absent"
}
if yaml, yamlErr := yaml.Marshal(u); yamlErr != nil {
return yaml, yamlErr
} else {
return yaml, exErr
}
}
func (u *User) Delete() (error) {
_, err := u.DeleteCommand.Execute(u)
if err != nil {
return err
}
return err
}
func (u *User) UnmarshalJSON(data []byte) error {
if unmarshalErr := json.Unmarshal(data, (*decodeUser)(u)); unmarshalErr != nil {
return unmarshalErr
}
u.CreateCommand, u.ReadCommand, u.UpdateCommand, u.DeleteCommand = u.UserType.NewCRUD()
return nil
}
func (u *User) UnmarshalYAML(value *yaml.Node) error {
if unmarshalErr := value.Decode((*decodeUser)(u)); unmarshalErr != nil {
return unmarshalErr
}
u.CreateCommand, u.ReadCommand, u.UpdateCommand, u.DeleteCommand = u.UserType.NewCRUD()
return nil
}
func (u *UserType) NewCRUD() (create *Command, read *Command, update *Command, del *Command) {
switch *u {
case UserTypeUserAdd:
return NewUserAddCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewUserDelDeleteCommand()
case UserTypeAddUser:
return NewAddUserCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewDelUserDeleteCommand()
default:
if _, addUserPathErr := exec.LookPath("adduser"); addUserPathErr == nil {
*u = UserTypeAddUser
return NewAddUserCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewDelUserDeleteCommand()
}
if _, pathErr := exec.LookPath("useradd"); pathErr == nil {
*u = UserTypeUserAdd
return NewUserAddCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewUserDelDeleteCommand()
}
return NewUserAddCreateCommand(), NewUserReadCommand(), NewUserUpdateCommand(), NewUserDelDeleteCommand()
}
return nil, nil, nil, nil
}
func (u *UserType) UnmarshalValue(value string) error {
switch value {
case string(UserTypeUserAdd), string(UserTypeAddUser):
*u = UserType(value)
return nil
default:
return errors.New("invalid UserType value")
}
}
func (u *UserType) UnmarshalJSON(data []byte) error {
var s string
if unmarshalUserTypeErr := json.Unmarshal(data, &s); unmarshalUserTypeErr != nil {
return unmarshalUserTypeErr
}
return u.UnmarshalValue(s)
}
func (u *UserType) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return err
}
return u.UnmarshalValue(s)
}
func NewUserAddCreateCommand() *Command {
c := NewCommand()
c.Path = "useradd"
c.Args = []CommandArg{
CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"),
CommandArg("{{ if .Gecos }}-c {{ .Gecos }}{{ end }}"),
CommandArg("{{ if .Group }}-g {{ .Group }}{{ end }}"),
CommandArg("{{ if .Groups }}-G {{ range .Groups }}{{ . }},{{- end }}{{ end }}"),
CommandArg("{{ if .Home }}-d {{ .Home }}{{ end }}"),
CommandArg("{{ if .CreateHome }}-m{{ else }}-M{{ end }}"),
CommandArg("{{ .Name }}"),
}
c.Extractor = func(out []byte, target any) error {
return nil
for _,line := range strings.Split(string(out), "\n") {
if line == "iptables: Chain already exists." {
return nil
}
}
return fmt.Errorf(string(out))
}
return c
}
func NewAddUserCreateCommand() *Command {
c := NewCommand()
c.Path = "adduser"
c.Args = []CommandArg{
CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"),
CommandArg("{{ if .Gecos }}-g {{ .Gecos }}{{ end }}"),
CommandArg("{{ if .Group }}-G {{ .Group }}{{ end }}"),
CommandArg("{{ if .Home }}-h {{ .Home }}{{ end }}"),
CommandArg("{{ if not .CreateHome }}-H{{ end }}"),
CommandArg("-D"),
CommandArg("{{ .Name }}"),
}
c.Extractor = func(out []byte, target any) error {
return nil
for _,line := range strings.Split(string(out), "\n") {
if line == "iptables: Chain already exists." {
return nil
}
}
return fmt.Errorf(string(out))
}
return c
}
func NewUserReadCommand() *Command {
c := NewCommand()
c.Extractor = func(out []byte, target any) error {
u := target.(*User)
u.State = "absent"
var readUser *user.User
var e error
if u.Name != "" {
readUser, e = user.Lookup(u.Name)
} else {
if u.UID != "" {
readUser, e = user.LookupId(u.UID)
}
}
if e == nil {
u.Name = readUser.Username
u.UID = readUser.Uid
u.Home = readUser.HomeDir
u.Gecos = readUser.Name
if readGroup, groupErr := user.LookupGroupId(readUser.Gid); groupErr == nil {
u.Group = readGroup.Name
} else {
return groupErr
}
if u.UID != "" {
u.State = "present"
}
}
return e
}
return c
}
func NewUserUpdateCommand() *Command {
return nil
}
func NewUserDelDeleteCommand() *Command {
c := NewCommand()
c.Path = "userdel"
c.Args = []CommandArg{
CommandArg("{{ .Name }}"),
}
c.Extractor = func(out []byte, target any) error {
return nil
}
return c
}
func NewDelUserDeleteCommand() *Command {
c := NewCommand()
c.Path = "deluser"
c.Args = []CommandArg{
CommandArg("{{ .Name }}"),
}
c.Extractor = func(out []byte, target any) error {
return nil
}
return c
}

View File

@ -2,9 +2,9 @@
package resource
import (
_ "context"
"context"
_ "encoding/json"
_ "fmt"
"fmt"
"github.com/stretchr/testify/assert"
_ "io"
_ "net/http"
@ -20,7 +20,28 @@ func TestNewUserResource(t *testing.T) {
assert.NotEqual(t, nil, u)
}
func TestReadUser(t *testing.T) {
ctx := context.Background()
decl := `
name: "nobody"
`
u := NewUser()
e := u.LoadDecl(decl)
assert.Nil(t, e)
assert.Equal(t, "nobody", u.Name)
fmt.Printf("%#v\n", u)
_, readErr := u.Read(ctx)
assert.Nil(t, readErr)
fmt.Printf("%#v\n", u)
assert.Equal(t, "65534", u.UID)
}
func TestCreateUser(t *testing.T) {
decl := `
name: "testuser"
uid: 12001
@ -28,16 +49,28 @@ func TestCreateUser(t *testing.T) {
home: "/home/testuser"
state: present
`
u := NewUser()
e := u.LoadDecl(decl)
assert.Equal(t, nil, e)
assert.Equal(t, "testuser", u.Name)
u.CreateCommand.Executor = func(value any) ([]byte, error) {
return []byte(``), nil
}
u.ReadCommand.Extractor = func(out []byte, target any) error {
return nil
}
u.DeleteCommand.Executor = func(value any) ([]byte, error) {
return nil, nil
}
applyErr := u.Apply()
assert.Equal(t, nil, applyErr)
uid, uidErr := LookupUID(u.Name)
assert.Equal(t, nil, uidErr)
assert.Equal(t, 12001, uid)
assert.Nil(t, applyErr)
assert.Equal(t, "12001", u.UID)
u.State = "absent"

View File

@ -0,0 +1,69 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package source
import (
_ "context"
_ "encoding/json"
_ "fmt"
_ "gopkg.in/yaml.v3"
"net/url"
_ "path/filepath"
"decl/internal/resource"
_ "os"
_ "io"
"strings"
"log/slog"
)
type Iptable struct {
Table string `yaml:"table" json:"table"`
Chain string `yaml:"chain" json:"chain"`
}
func NewIptable() *Iptable {
return &Iptable{}
}
func init() {
SourceTypes.Register([]string{"iptable"}, func(u *url.URL) DocSource {
t := NewIptable()
t.Table = u.Hostname()
t.Chain = strings.Split(u.RequestURI(), "/")[1]
slog.Info("iptable chain source factory", "table", t, "uri", u, "table", u.Hostname())
return t
})
}
func (i *Iptable) Type() string { return "iptable" }
func (i *Iptable) ExtractResources(filter ResourceSelector) ([]*resource.Document, error) {
documents := make([]*resource.Document, 0, 100)
slog.Info("iptable chain source ExtractResources()", "table", i)
iptRules := make([]*resource.Iptable, 0, 100)
cmd := resource.NewIptableReadChainCommand()
if out, err := cmd.Execute(i); err == nil {
slog.Info("iptable chain source ExtractResources()", "output", out)
if exErr := cmd.Extractor(out, &iptRules); exErr != nil {
return documents, exErr
}
for _, rule := range iptRules {
document := resource.NewDocument()
if rule == nil {
rule = resource.NewIptable()
}
rule.Table = resource.IptableName(i.Table)
rule.Chain = resource.IptableChain(i.Chain)
document.AddResourceDeclaration("iptable", rule)
documents = append(documents, document)
}
} else {
slog.Info("iptable chain source ExtractResources()", "output", out, "error", err)
return documents, err
}
return documents, nil
}

154
internal/target/decl.go Normal file
View File

@ -0,0 +1,154 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
_ "context"
_ "encoding/json"
_ "fmt"
_ "gopkg.in/yaml.v3"
"net/url"
"path/filepath"
"decl/internal/resource"
"os"
"compress/gzip"
"io"
_ "errors"
"log/slog"
)
const (
FormatYaml = "yaml"
FormatJson = "json"
)
type DeclFile struct {
Path string `yaml:"path" json:"path"`
Gzip bool `yaml:"gzip,omitempty" json:"gzip,omitempty"`
Format string `yaml:"format,omitempty" json:"format,omitempty"`
encoder resource.Encoder `yaml:"-" json:"-"`
}
func NewDeclFile() *DeclFile {
return &DeclFile{ Gzip: false }
}
func NewFileDocTarget(u *url.URL, format string, gzip bool, fileUri bool) DocTarget {
t := NewDeclFile()
t.Format = format
t.Gzip = gzip
if fileUri {
fileAbsolutePath, _ := filepath.Abs(filepath.Join(u.Hostname(), u.RequestURI()))
t.Path = fileAbsolutePath
} else {
t.Path = filepath.Join(u.Hostname(), u.Path)
}
return t
}
func init() {
TargetTypes.Register([]string{"decl", "file"}, func(u *url.URL) DocTarget {
t := NewDeclFile()
if u.Path != "-" {
t.Path,_ = filepath.Abs(filepath.Join(u.Hostname(), u.Path))
} else {
t.Path = "-"
}
if _,ok := u.Query()["gzip"]; ok {
t.Gzip = true
}
if format,ok := u.Query()["format"]; ok {
switch format[0] {
case string(FormatYaml):
t.Format = FormatYaml
case string(FormatJson):
t.Format = FormatJson
}
}
return t
})
TargetTypes.Register([]string{"yaml.gz","yml.gz"}, func(u *url.URL) DocTarget {
switch u.Scheme {
case "yaml", "yml", "file":
return NewFileDocTarget(u, FormatYaml, true, false)
}
return NewFileDocTarget(u, FormatYaml, true, false)
})
TargetTypes.Register([]string{"json.gz"}, func(u *url.URL) DocTarget {
switch u.Scheme {
case "json", "file":
return NewFileDocTarget(u, FormatJson, true, false)
}
return NewFileDocTarget(u, FormatJson, true, false)
})
TargetTypes.Register([]string{"yaml","yml"}, func(u *url.URL) DocTarget {
switch u.Scheme {
case "yaml", "yml", "file":
return NewFileDocTarget(u, FormatYaml, false, false)
}
return NewFileDocTarget(u, FormatYaml, false, false)
})
TargetTypes.Register([]string{"json"}, func(u *url.URL) DocTarget {
switch u.Scheme {
case "json", "file":
return NewFileDocTarget(u, FormatJson, false, false)
}
return NewFileDocTarget(u, FormatJson, false, false)
})
}
func (d *DeclFile) Type() string { return "decl" }
func (d *DeclFile) EmitResources(documents []*resource.Document, filter resource.ResourceSelector) (error) {
var file *os.File
var fileErr error
var fileWriter io.Writer
if d.Path == "" || d.Path == "-" {
file = os.Stdout
} else {
file, fileErr = os.Open(d.Path)
if fileErr != nil {
return fileErr
}
defer func() {
file.Close()
}()
}
if d.Gzip {
fileWriter = gzip.NewWriter(file)
} else {
fileWriter = file
}
switch d.Format {
case FormatJson:
d.encoder = resource.NewJSONEncoder(fileWriter)
case FormatYaml:
fallthrough
default:
d.encoder = resource.NewYAMLEncoder(fileWriter)
}
for _, doc := range documents {
emitDoc := resource.NewDocument()
if validationErr := doc.Validate(); validationErr != nil {
return validationErr
}
for _, declaration := range doc.Filter(filter) {
emitDoc.ResourceDecls = append(emitDoc.ResourceDecls, *declaration)
}
slog.Info("EmitResources", "doctarget", d, "encoder", d.encoder, "emit", emitDoc)
if documentErr := d.encoder.Encode(emitDoc); documentErr != nil {
slog.Info("EmitResources", "err", documentErr)
return documentErr
}
}
return nil
}

View File

@ -0,0 +1,35 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
_ "context"
_ "encoding/json"
_ "fmt"
_ "gopkg.in/yaml.v3"
_ "net/url"
_ "regexp"
_ "strings"
_ "os"
_ "io"
"decl/internal/resource"
)
// convert a document into some other container type
// move selector to resource pkg
// type ResourceSelector func(r resource.Resource) bool
type DocTarget interface {
Type() string
EmitResources(documents []*resource.Document, filter resource.ResourceSelector) error
}
func NewDocTarget(uri string) DocTarget {
s, e := TargetTypes.New(uri)
if e == nil {
return s
}
return nil
}

94
internal/target/tar.go Normal file
View File

@ -0,0 +1,94 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
_ "context"
_ "encoding/json"
_ "fmt"
_ "gopkg.in/yaml.v3"
"net/url"
"path/filepath"
"decl/internal/resource"
"compress/gzip"
"archive/tar"
_ "regexp"
"os"
"io"
"log"
"log/slog"
)
type Tar struct {
Path string `yaml:"path" json:"path"`
Gzip bool `yaml:"gzip" json:"gzip"`
}
func NewTar() *Tar {
return &Tar{ Gzip: false }
}
func init() {
TargetTypes.Register([]string{"tar"}, func(u *url.URL) DocTarget {
t := NewTar()
t.Path,_ = filepath.Abs(filepath.Join(u.Hostname(), u.Path))
return t
})
TargetTypes.Register([]string{"tar.gz", "tgz"}, func(u *url.URL) DocTarget {
t := NewTar()
if u.Scheme == "file" {
fileAbsolutePath, _ := filepath.Abs(filepath.Join(u.Hostname(), u.RequestURI()))
t.Path = fileAbsolutePath
} else {
t.Path = filepath.Join(u.Hostname(), u.Path)
}
t.Gzip = true
return t
})
}
func (t *Tar) Type() string { return "tar" }
func (t *Tar) EmitResources(documents []*resource.Document, filter resource.ResourceSelector) error {
file, fileErr := os.Create(t.Path)
if fileErr != nil {
return fileErr
}
var fileWriter io.WriteCloser
if t.Gzip {
fileWriter = gzip.NewWriter(file)
} else {
fileWriter = file
}
tarWriter := tar.NewWriter(fileWriter)
defer func() {
tarWriter.Close()
fileWriter.Close()
file.Close()
}()
for _,document := range documents {
for _,res := range document.Filter(func(d *resource.Declaration) bool {
if d.Type == "file" {
return true
}
return false
}) {
var f *resource.File = res.Attributes.(*resource.File)
slog.Info("Tar.EmitResources", "file", f)
hdr, fiErr := tar.FileInfoHeader(f.FileInfo(), "")
slog.Info("Tar.EmitResources", "header", hdr, "err", fiErr)
if err := tarWriter.WriteHeader(hdr); err != nil {
log.Fatal(err)
}
if _, err := tarWriter.Write([]byte(f.Content)); err != nil {
log.Fatal(err)
}
}
}
return nil
}

View File

@ -0,0 +1,14 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestNewTarSource(t *testing.T) {
s := NewTar()
assert.NotNil(t, s)
}

100
internal/target/types.go Normal file
View File

@ -0,0 +1,100 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
"errors"
"fmt"
"net/url"
"strings"
"path/filepath"
"log/slog"
)
var (
ErrUnknownTargetType = errors.New("Unknown target type")
TargetTypes *Types = NewTypes()
)
type TypeName string //`json:"type"`
type TypeFactory func(*url.URL) DocTarget
type Types struct {
registry map[string]TypeFactory
}
func NewTypes() *Types {
return &Types{registry: make(map[string]TypeFactory)}
}
func (t *Types) Register(names []string, factory TypeFactory) {
for _,name := range names {
t.registry[name] = factory
}
}
func (t *Types) FromExtension(path string) (TypeFactory, error) {
elements := strings.Split(path, ".")
numberOfElements := len(elements)
if numberOfElements > 2 {
if src := t.Get(strings.Join(elements[numberOfElements - 2: numberOfElements - 1], ".")); src != nil {
return src, nil
}
}
if src := t.Get(elements[numberOfElements - 1]); src != nil {
return src, nil
}
return nil, fmt.Errorf("%w: %s", ErrUnknownTargetType, path)
}
func (t *Types) New(uri string) (DocTarget, error) {
if uri == "" {
uri = "file://-"
}
u, e := url.Parse(uri)
if u == nil || e != nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownTargetType, e)
}
if u.Scheme == "" {
u.Scheme = "file"
}
path := filepath.Join(u.Hostname(), u.Path)
if d, lookupErr := t.FromExtension(path); d != nil {
slog.Info("Target.New", "target", t, "err", lookupErr)
return d(u), lookupErr
} else {
slog.Info("Target.New", "target", t, "err", lookupErr)
}
if r, ok := t.registry[u.Scheme]; ok {
return r(u), nil
}
return nil, fmt.Errorf("%w: %s", ErrUnknownTargetType, u.Scheme)
}
func (t *Types) Has(typename string) bool {
if _, ok := t.registry[typename]; ok {
return true
}
return false
}
func (t *Types) Get(typename string) TypeFactory {
if d, ok := t.registry[typename]; ok {
return d
}
return nil
}
func (n *TypeName) UnmarshalJSON(b []byte) error {
TargetTypeName := strings.Trim(string(b), "\"")
if TargetTypes.Has(TargetTypeName) {
*n = TypeName(TargetTypeName)
return nil
}
return fmt.Errorf("%w: %s", ErrUnknownTargetType, TargetTypeName)
}

View File

@ -0,0 +1,89 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
package target
import (
_ "context"
"encoding/json"
"github.com/stretchr/testify/assert"
"net/url"
"testing"
"decl/internal/resource"
)
type MockDocTarget struct {
InjectType func() string
InjectEmitResources func(documents []*resource.Document, filter resource.ResourceSelector) error
}
func (m *MockDocTarget) Type() string { return m.InjectType() }
func (m *MockDocTarget) EmitResources(documents []*resource.Document, filter resource.ResourceSelector) error { return m.InjectEmitResources(documents, filter) }
func NewFooDocTarget() DocTarget {
return &MockDocTarget{
InjectType: func() string { return "foo" },
InjectEmitResources: func(documents []*resource.Document, filter resource.ResourceSelector) error { return nil },
}
}
func NewMockFileDocTarget() DocTarget {
return &MockDocTarget{
InjectType: func() string { return "file" },
InjectEmitResources: func(documents []*resource.Document, filter resource.ResourceSelector) error { return nil },
}
}
func TestNewTargetTypes(t *testing.T) {
targetTypes := NewTypes()
assert.NotNil(t, targetTypes)
}
func TestNewTargetTypesRegister(t *testing.T) {
m := NewFooDocTarget()
targetTypes := NewTypes()
assert.NotNil(t, targetTypes)
targetTypes.Register([]string{"foo"}, func(*url.URL) DocTarget { return m })
r, e := targetTypes.New("foo://")
assert.Nil(t, e)
assert.Equal(t, m, r)
}
func TestResourceTypesFromURI(t *testing.T) {
m := NewFooDocTarget()
targetTypes := NewTypes()
assert.NotNil(t, targetTypes)
targetTypes.Register([]string{"foo"}, func(*url.URL) DocTarget { return m })
r, e := targetTypes.New("foo://bar")
assert.Nil(t, e)
assert.Equal(t, m, r)
}
func TestResourceTypesHasType(t *testing.T) {
m := NewFooDocTarget()
targetTypes := NewTypes()
assert.NotNil(t, targetTypes)
targetTypes.Register([]string{"foo"}, func(*url.URL) DocTarget { return m })
assert.True(t, targetTypes.Has("foo"))
}
func TestDocTargetTypeName(t *testing.T) {
TargetTypes.Register([]string{"file"}, func(*url.URL) DocTarget { return NewMockFileDocTarget() })
type fDocTargetName struct {
Name TypeName `json:"type"`
}
fTypeName := &fDocTargetName{}
jsonType := `{ "type": "file" }`
e := json.Unmarshal([]byte(jsonType), &fTypeName)
assert.Nil(t, e)
assert.Equal(t, "file", string(fTypeName.Name))
}

View File

@ -12,6 +12,7 @@ import (
type MockContainerClient struct {
InjectContainerStart func(ctx context.Context, containerID string, options container.StartOptions) error
InjectContainerCreate func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error)
InjectNetworkCreate func(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error)
InjectContainerList func(context.Context, container.ListOptions) ([]types.Container, error)
InjectContainerInspect func(context.Context, string) (types.ContainerJSON, error)
InjectContainerRemove func(context.Context, string, container.RemoveOptions) error
@ -47,3 +48,7 @@ func (m *MockContainerClient) Close() error {
}
return m.InjectClose()
}
func (m *MockContainerClient) NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) {
return m.InjectNetworkCreate(ctx, name, options)
}