From 43a2274b7ef684b24facfd90c1ed6be2ea86524a Mon Sep 17 00:00:00 2001 From: Matthew Rich Date: Sun, 5 May 2024 17:48:54 -0700 Subject: [PATCH] update container/iptables resources --- .gitea/workflows/release.yaml | 2 +- Makefile | 3 +- README.md | 2 +- cmd/cli/main.go | 13 +- internal/resource/command.go | 36 +- internal/resource/container.go | 32 +- internal/resource/container_network.go | 168 +++++ internal/resource/container_network_test.go | 47 ++ internal/resource/declaration.go | 7 +- internal/resource/declaration_test.go | 6 +- internal/resource/decoder_test.go | 4 +- internal/resource/document.go | 3 +- internal/resource/document_test.go | 11 +- internal/resource/exec.go | 5 + internal/resource/file.go | 15 +- internal/resource/file_test.go | 76 +++ internal/resource/http.go | 7 +- internal/resource/iptables.go | 575 ++++++++++++++++-- internal/resource/iptables_test.go | 58 ++ internal/resource/mock_foo_resource_test.go | 2 + internal/resource/mock_resource_test.go | 6 + internal/resource/network_route.go | 5 + internal/resource/os.go | 26 +- internal/resource/os_test.go | 7 +- internal/resource/package.go | 5 + internal/resource/resource.go | 22 +- internal/resource/schema.go | 1 + .../schemas/container-declaration.jsonschema | 17 + .../resource/schemas/container.jsonschema | 14 + .../container_network-declaration.jsonschema | 17 + .../schemas/container_network.jsonschema | 14 + internal/resource/schemas/document.jsonschema | 4 +- .../schemas/user-declaration.jsonschema | 3 + internal/resource/schemas/user.jsonschema | 5 +- internal/resource/user.go | 304 +++++++-- internal/resource/user_test.go | 45 +- internal/source/iptable.go | 69 +++ internal/target/decl.go | 154 +++++ internal/target/doctarget.go | 35 ++ internal/target/tar.go | 94 +++ internal/target/tar_test.go | 14 + internal/target/types.go | 100 +++ internal/target/types_test.go | 89 +++ tests/mocks/container.go | 5 + 44 files changed, 1940 insertions(+), 187 deletions(-) create mode 100644 internal/resource/container_network.go create mode 100644 internal/resource/container_network_test.go create mode 100644 internal/resource/schemas/container-declaration.jsonschema create mode 100644 internal/resource/schemas/container.jsonschema create mode 100644 internal/resource/schemas/container_network-declaration.jsonschema create mode 100644 internal/resource/schemas/container_network.jsonschema create mode 100644 internal/source/iptable.go create mode 100644 internal/target/decl.go create mode 100644 internal/target/doctarget.go create mode 100644 internal/target/tar.go create mode 100644 internal/target/tar_test.go create mode 100644 internal/target/types.go create mode 100644 internal/target/types_test.go diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index e246306..9410f65 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -15,4 +15,4 @@ jobs: - uses: actions/checkout@v3 - uses: ncipollo/release-action@v1 with: - artifacts: "decl" + artifacts: "jx" diff --git a/Makefile b/Makefile index d5d3397..453b1e7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 5c18d87..0cd248f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/cli/main.go b/cmd/cli/main.go index ad75474..15302b3 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -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) { diff --git a/internal/resource/command.go b/internal/resource/command.go index 1fde2ac..0028532 100644 --- a/internal/resource/command.go +++ b/internal/resource/command.go @@ -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 } diff --git a/internal/resource/container.go b/internal/resource/container.go index 6037833..f0be2cc 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -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) } diff --git a/internal/resource/container_network.go b/internal/resource/container_network.go new file mode 100644 index 0000000..be95227 --- /dev/null +++ b/internal/resource/container_network.go @@ -0,0 +1,168 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +// Container resource +package resource + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" +_ "github.com/docker/docker/api/types/mount" +_ "github.com/docker/docker/api/types/network" +_ "github.com/docker/docker/api/types/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 "" +} diff --git a/internal/resource/container_network_test.go b/internal/resource/container_network_test.go new file mode 100644 index 0000000..55ea308 --- /dev/null +++ b/internal/resource/container_network_test.go @@ -0,0 +1,47 @@ +// Copyright 2024 Matthew Rich . 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) +} diff --git a/internal/resource/declaration.go b/internal/resource/declaration.go index a5f4185..815520a 100644 --- a/internal/resource/declaration.go +++ b/internal/resource/declaration.go @@ -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) diff --git a/internal/resource/declaration_test.go b/internal/resource/declaration_test.go index f130612..6035dc2 100644 --- a/internal/resource/declaration_test.go +++ b/internal/resource/declaration_test.go @@ -1,4 +1,6 @@ // Copyright 2024 Matthew Rich . 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) } diff --git a/internal/resource/decoder_test.go b/internal/resource/decoder_test.go index d291eea..95f849d 100644 --- a/internal/resource/decoder_test.go +++ b/internal/resource/decoder_test.go @@ -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" diff --git a/internal/resource/document.go b/internal/resource/document.go index e5b623d..d2a56ed 100644 --- a/internal/resource/document.go +++ b/internal/resource/document.go @@ -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 diff --git a/internal/resource/document_test.go b/internal/resource/document_test.go index 8a58204..10a10ff 100644 --- a/internal/resource/document_test.go +++ b/internal/resource/document_test.go @@ -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 diff --git a/internal/resource/exec.go b/internal/resource/exec.go index 57685ba..8ff002c 100644 --- a/internal/resource/exec.go +++ b/internal/resource/exec.go @@ -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) } diff --git a/internal/resource/file.go b/internal/resource/file.go index 166ccd2..d701faf 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -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) { diff --git a/internal/resource/file_test.go b/internal/resource/file_test.go index fe05653..5282708 100644 --- a/internal/resource/file_test.go +++ b/internal/resource/file_test.go @@ -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) + +} diff --git a/internal/resource/http.go b/internal/resource/http.go index 8d56a69..9a04344 100644 --- a/internal/resource/http.go +++ b/internal/resource/http.go @@ -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 } diff --git a/internal/resource/iptables.go b/internal/resource/iptables.go index 3234b72..0b26976 100644 --- a/internal/resource/iptables.go +++ b/internal/resource/iptables.go @@ -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 +} + diff --git a/internal/resource/iptables_test.go b/internal/resource/iptables_test.go index f7f92ae..7a285c9 100644 --- a/internal/resource/iptables_test.go +++ b/internal/resource/iptables_test.go @@ -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) +} diff --git a/internal/resource/mock_foo_resource_test.go b/internal/resource/mock_foo_resource_test.go index 4eff1b8..150835a 100644 --- a/internal/resource/mock_foo_resource_test.go +++ b/internal/resource/mock_foo_resource_test.go @@ -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 }, } } diff --git a/internal/resource/mock_resource_test.go b/internal/resource/mock_resource_test.go index 9926737..aed9b28 100644 --- a/internal/resource/mock_resource_test.go +++ b/internal/resource/mock_resource_test.go @@ -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 } diff --git a/internal/resource/network_route.go b/internal/resource/network_route.go index b1a77f8..511835d 100644 --- a/internal/resource/network_route.go +++ b/internal/resource/network_route.go @@ -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) } diff --git a/internal/resource/os.go b/internal/resource/os.go index 29d0cc7..1708a72 100644 --- a/internal/resource/os.go +++ b/internal/resource/os.go @@ -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) { diff --git a/internal/resource/os_test.go b/internal/resource/os_test.go index a727350..7bea6be 100644 --- a/internal/resource/os_test.go +++ b/internal/resource/os_test.go @@ -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) { diff --git a/internal/resource/package.go b/internal/resource/package.go index 3f2d9cd..e47100a 100644 --- a/internal/resource/package.go +++ b/internal/resource/package.go @@ -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) } diff --git a/internal/resource/resource.go b/internal/resource/resource.go index d1989e5..f7c93ba 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -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 } -*/ diff --git a/internal/resource/schema.go b/internal/resource/schema.go index a9d5ba6..5b6632d 100644 --- a/internal/resource/schema.go +++ b/internal/resource/schema.go @@ -14,6 +14,7 @@ import ( ) //go:embed schemas/*.jsonschema +//go:embed schemas/*.schema.json var schemaFiles embed.FS type Schema struct { diff --git a/internal/resource/schemas/container-declaration.jsonschema b/internal/resource/schemas/container-declaration.jsonschema new file mode 100644 index 0000000..1fe02d2 --- /dev/null +++ b/internal/resource/schemas/container-declaration.jsonschema @@ -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" + } + } +} diff --git a/internal/resource/schemas/container.jsonschema b/internal/resource/schemas/container.jsonschema new file mode 100644 index 0000000..fc792bb --- /dev/null +++ b/internal/resource/schemas/container.jsonschema @@ -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})$" + } + } +} diff --git a/internal/resource/schemas/container_network-declaration.jsonschema b/internal/resource/schemas/container_network-declaration.jsonschema new file mode 100644 index 0000000..c04aa90 --- /dev/null +++ b/internal/resource/schemas/container_network-declaration.jsonschema @@ -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" + } + } +} diff --git a/internal/resource/schemas/container_network.jsonschema b/internal/resource/schemas/container_network.jsonschema new file mode 100644 index 0000000..59941bc --- /dev/null +++ b/internal/resource/schemas/container_network.jsonschema @@ -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})$" + } + } +} diff --git a/internal/resource/schemas/document.jsonschema b/internal/resource/schemas/document.jsonschema index f47b856..86057f2 100644 --- a/internal/resource/schemas/document.jsonschema +++ b/internal/resource/schemas/document.jsonschema @@ -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" } ] } } diff --git a/internal/resource/schemas/user-declaration.jsonschema b/internal/resource/schemas/user-declaration.jsonschema index f198642..7c230b7 100644 --- a/internal/resource/schemas/user-declaration.jsonschema +++ b/internal/resource/schemas/user-declaration.jsonschema @@ -10,6 +10,9 @@ "description": "Resource type name.", "enum": [ "user" ] }, + "transition": { + "$ref": "storagetransition.schema.json" + }, "attributes": { "$ref": "user.jsonschema" } diff --git a/internal/resource/schemas/user.jsonschema b/internal/resource/schemas/user.jsonschema index 250fbe7..c0deba2 100644 --- a/internal/resource/schemas/user.jsonschema +++ b/internal/resource/schemas/user.jsonschema @@ -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" diff --git a/internal/resource/user.go b/internal/resource/user.go index 332f261..c673868 100644 --- a/internal/resource/user.go +++ b/internal/resource/user.go @@ -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 } diff --git a/internal/resource/user_test.go b/internal/resource/user_test.go index 2502646..803d746 100644 --- a/internal/resource/user_test.go +++ b/internal/resource/user_test.go @@ -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" diff --git a/internal/source/iptable.go b/internal/source/iptable.go new file mode 100644 index 0000000..814ada9 --- /dev/null +++ b/internal/source/iptable.go @@ -0,0 +1,69 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package source + +import ( +_ "context" +_ "encoding/json" +_ "fmt" +_ "gopkg.in/yaml.v3" + "net/url" +_ "path/filepath" + "decl/internal/resource" +_ "os" +_ "io" + "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 +} diff --git a/internal/target/decl.go b/internal/target/decl.go new file mode 100644 index 0000000..dbd1b43 --- /dev/null +++ b/internal/target/decl.go @@ -0,0 +1,154 @@ +// Copyright 2024 Matthew Rich . 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 +} diff --git a/internal/target/doctarget.go b/internal/target/doctarget.go new file mode 100644 index 0000000..2481dce --- /dev/null +++ b/internal/target/doctarget.go @@ -0,0 +1,35 @@ +// Copyright 2024 Matthew Rich . 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 +} diff --git a/internal/target/tar.go b/internal/target/tar.go new file mode 100644 index 0000000..c1ac122 --- /dev/null +++ b/internal/target/tar.go @@ -0,0 +1,94 @@ +// Copyright 2024 Matthew Rich . 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 +} diff --git a/internal/target/tar_test.go b/internal/target/tar_test.go new file mode 100644 index 0000000..5bed971 --- /dev/null +++ b/internal/target/tar_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package target + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewTarSource(t *testing.T) { + s := NewTar() + assert.NotNil(t, s) +} + diff --git a/internal/target/types.go b/internal/target/types.go new file mode 100644 index 0000000..3e05646 --- /dev/null +++ b/internal/target/types.go @@ -0,0 +1,100 @@ +// Copyright 2024 Matthew Rich . 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) +} diff --git a/internal/target/types_test.go b/internal/target/types_test.go new file mode 100644 index 0000000..82e3f53 --- /dev/null +++ b/internal/target/types_test.go @@ -0,0 +1,89 @@ +// Copyright 2024 Matthew Rich . 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)) +} diff --git a/tests/mocks/container.go b/tests/mocks/container.go index 1e6d94c..f6a4cb3 100644 --- a/tests/mocks/container.go +++ b/tests/mocks/container.go @@ -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) +}