diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8dd7dc0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## Description + +Please include a summary of the change and which issue is fixed or feature is added. + +## Type of change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing tests pass locally with my changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1afe006 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Install Task + uses: arduino/setup-task@v1 + + - name: Lint and Test + run: | + task lint + task test + + release: + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm install -g semantic-release @semantic-release/git @semantic-release/github + semantic-release + + goreleaser: + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b238b2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# SQLite database files +*.db +*.db-journal + +# IDE specific files +.idea +.vscode +*.swp +*.swo +*~ + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Local development files +.env +.env.local +*.local.yaml +*.local.yml + +# Debug files +debug +__debug_bin diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..b2ba22b --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,52 @@ +project_name: duet + +before: + hooks: + - go mod tidy + +builds: + - main: ./cmd/duet + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..36ebba8 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,15 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github", + [ + "@semantic-release/git", + { + "assets": ["go.mod", "go.sum"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/Earthfile b/Earthfile new file mode 100644 index 0000000..52b6261 --- /dev/null +++ b/Earthfile @@ -0,0 +1,38 @@ +VERSION 0.7 +FROM golang:1.21-alpine +WORKDIR /duet + +deps: + COPY go.mod go.sum ./ + RUN go mod download + SAVE ARTIFACT go.mod AS LOCAL go.mod + SAVE ARTIFACT go.sum AS LOCAL go.sum + +lint: + FROM golangci/golangci-lint:latest + COPY . . + RUN golangci-lint run + +build: + FROM +deps + COPY . . + RUN CGO_ENABLED=0 go build -o duet cmd/duet/main.go + SAVE ARTIFACT duet AS LOCAL dist/duet + +test: + FROM +deps + COPY . . + RUN go test -race -coverprofile=coverage.out ./... + SAVE ARTIFACT coverage.out AS LOCAL coverage.out + +docker: + FROM alpine:latest + COPY +build/duet /usr/local/bin/duet + ENTRYPOINT ["/usr/local/bin/duet"] + SAVE IMAGE duet:latest + +all: + BUILD +lint + BUILD +test + BUILD +build + BUILD +docker diff --git a/README.md b/README.md new file mode 100644 index 0000000..62b9365 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Duet + +Duet is a powerful infrastructure and configuration management tool that orchestrates both infrastructure provisioning and system configuration in harmony. Using Lua as its configuration language, Duet provides a unified approach to managing your entire infrastructure lifecycle. + +## Features + +- Infrastructure as Code (similar to Terraform/Pulumi) +- Configuration Management (similar to Ansible) +- Lua-based configuration language +- State management using SQLite +- Idempotent operations +- AWS provider support (more coming soon) + +## Quick Start + +```bash +# Install Duet +go install github.com/rebelopsio/duet/cmd/duet@latest + +# Create a configuration file +cat > infra.lua << EOF +local config = { + infrastructure = { + aws = { + region = "us-west-2", + ec2 = { + instance_type = "t2.micro", + ami = "ami-0c55b159cbfafe1f0", + subnet_id = "subnet-xxxxxxxx" + } + } + } +} + +function deploy_infrastructure() + return config.infrastructure +end + +function configure_instance(host) + local success, err = install_package("cowsay", host) + if not success then + error("Failed to install cowsay: " .. err) + end + return true +end +EOF + +# Plan your changes +duet plan infra.lua + +# Apply your changes +duet apply infra.lua +``` + +## Architecture + +Duet is built with a clear separation of concerns: + +- Infrastructure as Code (IaC) Engine: Manages infrastructure provisioning +- Configuration Management Engine: Handles system configuration +- Lua Engine: Processes configuration files +- State Store: Maintains system state using SQLite + +## Development + +```bash +# Clone the repository +git clone https://github.com/rebelopsio/duet.git + +# Install dependencies +go mod download + +# Build +go build -o duet cmd/duet/main.go + +# Run tests +go test ./... +``` + +## Contributing + +Contributions are welcome! Please read our contributing guidelines before submitting pull requests. + +## License + +MIT License - see LICENSE file for details diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..1605082 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,67 @@ +version: "3" + +vars: + GO_MODULE: github.com/rebelopsio/duet + BUILD_DIR: dist + COVERAGE_DIR: coverage + +tasks: + default: + cmds: + - task: test + + clean: + desc: Clean build artifacts + cmds: + - rm -rf {{.BUILD_DIR}} + - rm -rf {{.COVERAGE_DIR}} + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run + + test: + desc: Run tests + cmds: + - mkdir -p {{.COVERAGE_DIR}} + - go test -race -coverprofile={{.COVERAGE_DIR}}/coverage.out -covermode=atomic ./... + - go tool cover -html={{.COVERAGE_DIR}}/coverage.out -o {{.COVERAGE_DIR}}/coverage.html + + build: + desc: Build binary + cmds: + - task: clean + - mkdir -p {{.BUILD_DIR}} + - go build -o {{.BUILD_DIR}}/duet cmd/duet/main.go + + install-tools: + desc: Install development tools + cmds: + - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + - go install github.com/goreleaser/goreleaser@latest + + generate: + desc: Run go generate + cmds: + - go generate ./... + + pre-commit: + desc: Run pre-commit checks + cmds: + - task: generate + - task: lint + - task: test + + check-license: + desc: Check license headers + cmds: + - | + find . -type f -name "*.go" -not -path "./vendor/*" -exec sh -c ' + for file do + if ! grep -q "Copyright" "$file"; then + echo "Missing license header in $file" + exit 1 + fi + done + ' sh {} + diff --git a/cmd/duet/main.go b/cmd/duet/main.go new file mode 100644 index 0000000..7a35a57 --- /dev/null +++ b/cmd/duet/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/rebelopsio/duet/internal/core/state" +) + +var ( + cfgFile string + store *state.Store +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "duet", + Short: "Duet - Infrastructure and Configuration in Harmony", + Long: `Duet is a tool that orchestrates both infrastructure provisioning +and configuration management using Lua as its configuration language. +Complete documentation is available at https://github.com/rebeleopsio/duet`, +} + +var applyCmd = &cobra.Command{ + Use: "apply [file]", + Short: "Apply infrastructure and configuration changes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return handleApply(args[0]) + }, +} + +var planCmd = &cobra.Command{ + Use: "plan [file]", + Short: "Show planned changes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return handlePlan(args[0]) + }, +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.duet.yaml)") + + rootCmd.AddCommand(applyCmd) + rootCmd.AddCommand(planCmd) + + // Initialize state store + var err error + store, err = state.NewStore("duet.db") + if err != nil { + log.Fatal(err) + } +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".duet") + } + + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} + +func handleApply(filename string) error { + // Implementation will go here + return nil +} + +func handlePlan(filename string) error { + // Implementation will go here + return nil +} diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..acbd38b --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..14f6c04 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to Duet + +First off, thank you for considering contributing to Duet! It's people like you that make Duet such a great tool. + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the issue list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. + +### Suggesting Enhancements + +If you have a suggestion for a new feature or enhancement, first check the issue list to see if it's already been proposed. If it hasn't, feel free to create a new issue detailing your suggestion. + +### Pull Requests + +1. Fork the repository +2. Create a new branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run the tests (`task test`) +5. Commit your changes (`git commit -m 'feat: add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +### Commit Messages + +We use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. This helps us automatically determine version numbers and generate changelogs. + +Examples: + +- `feat: add new AWS provider` +- `fix: correct SSH connection handling` +- `docs: update installation instructions` +- `chore: update dependencies` + +## Development Setup + +1. Install Go 1.21 or later +2. Install Task (`go install github.com/go-task/task/v3/cmd/task@latest`) +3. Clone the repository +4. Install dependencies (`task install-tools`) +5. Run tests (`task test`) + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/aws-ec2/config.lua b/examples/aws-ec2/config.lua new file mode 100644 index 0000000..e69de29 diff --git a/examples/aws-ec2/infra.lua b/examples/aws-ec2/infra.lua new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c13c8c3 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module github.com/rebelopsio/duet + +go 1.23.2 + +require ( + github.com/aws/aws-sdk-go-v2 v1.32.5 + github.com/aws/aws-sdk-go-v2/config v1.28.5 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + github.com/yuin/gopher-lua v1.1.1 + golang.org/x/crypto v0.21.0 + gorm.io/driver/sqlite v1.5.6 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect + github.com/aws/smithy-go v1.22.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..01ad3f0 --- /dev/null +++ b/go.sum @@ -0,0 +1,121 @@ +github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= +github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= +github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 h1:RhSoBFT5/8tTmIseJUXM6INTXTQDF8+0oyxWBnozIms= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0/go.mod h1:mzj8EEjIHSN2oZRXiw1Dd+uB4HZTl7hC8nBzX9IZMWw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= +gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/internal/config/executor/executor.go b/internal/config/executor/executor.go new file mode 100644 index 0000000..75449db --- /dev/null +++ b/internal/config/executor/executor.go @@ -0,0 +1,27 @@ +package executor + +import ( + "context" + "fmt" + + "github.com/rebelopsio/duet/internal/config/ssh" +) + +type Executor struct { + sshClient *ssh.Client +} + +func NewExecutor(sshConfig *ssh.Config) (*Executor, error) { + client, err := ssh.NewClient(sshConfig) + if err != nil { + return nil, fmt.Errorf("failed to create SSH client: %w", err) + } + + return &Executor{ + sshClient: client, + }, nil +} + +func (e *Executor) Execute(ctx context.Context, command string) (string, error) { + return e.sshClient.Execute(ctx, command) +} diff --git a/internal/config/ssh/client.go b/internal/config/ssh/client.go new file mode 100644 index 0000000..d0be050 --- /dev/null +++ b/internal/config/ssh/client.go @@ -0,0 +1,29 @@ +package ssh + +import ( + "context" + + "golang.org/x/crypto/ssh" +) + +type Config struct { + Host string + User string + PrivateKey string + Port int +} + +type Client struct { + config *Config + client *ssh.Client +} + +func NewClient(config *Config) (*Client, error) { + // Implementation + return &Client{config: config}, nil +} + +func (c *Client) Execute(ctx context.Context, command string) (string, error) { + // Implementation + return "", nil +} diff --git a/internal/config/tasks/package.go b/internal/config/tasks/package.go new file mode 100644 index 0000000..4a5ee04 --- /dev/null +++ b/internal/config/tasks/package.go @@ -0,0 +1,22 @@ +package tasks + +import ( + "context" + + "github.com/rebelopsio/duet/internal/config/executor" +) + +type PackageManager struct { + executor *executor.Executor +} + +func NewPackageManager(executor *executor.Executor) *PackageManager { + return &PackageManager{ + executor: executor, + } +} + +func (pm *PackageManager) Install(ctx context.Context, packageName string) error { + // Implementation + return nil +} diff --git a/internal/core/lua/engine.go b/internal/core/lua/engine.go new file mode 100644 index 0000000..e3895c7 --- /dev/null +++ b/internal/core/lua/engine.go @@ -0,0 +1,47 @@ +package lua + +import ( + "fmt" + + lua "github.com/yuin/gopher-lua" +) + +type Engine struct { + state *lua.LState +} + +func NewEngine() *Engine { + return &Engine{ + state: lua.NewState(), + } +} + +func (e *Engine) Close() { + if e.state != nil { + e.state.Close() + } +} + +func (e *Engine) LoadFile(filename string) error { + return e.state.DoFile(filename) +} + +func (e *Engine) CallFunction(name string, args ...lua.LValue) (lua.LValue, error) { + fn := e.state.GetGlobal(name) + if fn == lua.LNil { + return nil, fmt.Errorf("function %s not found", name) + } + + err := e.state.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, args...) + if err != nil { + return nil, fmt.Errorf("error calling function %s: %w", name, err) + } + + ret := e.state.Get(-1) + e.state.Pop(1) + return ret, nil +} diff --git a/internal/core/state/models.go b/internal/core/state/models.go new file mode 100644 index 0000000..f640e11 --- /dev/null +++ b/internal/core/state/models.go @@ -0,0 +1,17 @@ +package state + +import ( + "encoding/json" + "time" +) + +type ResourceState struct { + LastUpdated time.Time `json:"last_updated"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Provider string `json:"provider"` + Status string `json:"status"` + Metadata json.RawMessage `json:"metadata"` + ConfigApplied bool `json:"config_applied"` +} diff --git a/internal/core/state/store.go b/internal/core/state/store.go new file mode 100644 index 0000000..3371951 --- /dev/null +++ b/internal/core/state/store.go @@ -0,0 +1,40 @@ +package state + +import ( + "context" + "fmt" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Store struct { + db *gorm.DB +} + +func NewStore(dbPath string) (*Store, error) { + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.AutoMigrate(&ResourceState{}); err != nil { + return nil, fmt.Errorf("failed to migrate database: %w", err) + } + + return &Store{db: db}, nil +} + +func (s *Store) SaveResource(ctx context.Context, resource *ResourceState) error { + result := s.db.WithContext(ctx).Save(resource) + return result.Error +} + +func (s *Store) GetResource(ctx context.Context, id string) (*ResourceState, error) { + var resource ResourceState + result := s.db.WithContext(ctx).First(&resource, "id = ?", id) + if result.Error != nil { + return nil, result.Error + } + return &resource, nil +} diff --git a/internal/iac/planner/planner.go b/internal/iac/planner/planner.go new file mode 100644 index 0000000..2db2167 --- /dev/null +++ b/internal/iac/planner/planner.go @@ -0,0 +1,40 @@ +package planner + +import ( + "context" + + "github.com/rebelopsio/duet/internal/core/state" + "github.com/rebelopsio/duet/internal/iac/provider" +) + +type Change struct { + Resource provider.Resource + Config map[string]interface{} + Type string + Provider string +} + +type Plan struct { + Changes []Change +} + +type Planner struct { + store *state.Store + providers map[string]provider.Provider +} + +func NewPlanner(store *state.Store) *Planner { + return &Planner{ + store: store, + providers: make(map[string]provider.Provider), + } +} + +func (p *Planner) RegisterProvider(provider provider.Provider) { + p.providers[provider.Name()] = provider +} + +func (p *Planner) CreatePlan(ctx context.Context, config map[string]interface{}) (*Plan, error) { + // Implementation + return &Plan{}, nil +} diff --git a/internal/iac/provider/aws/aws.go b/internal/iac/provider/aws/aws.go new file mode 100644 index 0000000..c7040cf --- /dev/null +++ b/internal/iac/provider/aws/aws.go @@ -0,0 +1,34 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/config" +) + +type AWSProvider struct { + ec2Client *EC2Client + region string +} + +func NewAWSProvider(ctx context.Context, region string) (*AWSProvider, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("unable to load AWS config: %w", err) + } + + ec2Client, err := NewEC2Client(cfg) + if err != nil { + return nil, err + } + + return &AWSProvider{ + region: region, + ec2Client: ec2Client, + }, nil +} + +func (p *AWSProvider) Name() string { + return "aws" +} diff --git a/internal/iac/provider/aws/ec2.go b/internal/iac/provider/aws/ec2.go new file mode 100644 index 0000000..03e2c5b --- /dev/null +++ b/internal/iac/provider/aws/ec2.go @@ -0,0 +1,23 @@ +package aws + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" +) + +type EC2Client struct { + client *ec2.Client +} + +func NewEC2Client(cfg aws.Config) (*EC2Client, error) { + return &EC2Client{ + client: ec2.NewFromConfig(cfg), + }, nil +} + +func (c *EC2Client) CreateInstance(ctx context.Context, config map[string]interface{}) (string, error) { + // Implementation + return "", nil +} diff --git a/internal/iac/provider/provider.go b/internal/iac/provider/provider.go new file mode 100644 index 0000000..5f3ead0 --- /dev/null +++ b/internal/iac/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +import ( + "context" +) + +type Resource interface { + ID() string + Type() string + Metadata() map[string]interface{} +} + +type Provider interface { + Name() string + Create(ctx context.Context, resourceType string, config map[string]interface{}) (Resource, error) + Read(ctx context.Context, resourceType string, id string) (Resource, error) + Update(ctx context.Context, resource Resource, config map[string]interface{}) error + Delete(ctx context.Context, resource Resource) error +} diff --git a/pkg/types/resource.go b/pkg/types/resource.go new file mode 100644 index 0000000..2b2e2e2 --- /dev/null +++ b/pkg/types/resource.go @@ -0,0 +1,175 @@ +// pkg/types/resource.go +package types + +import ( + "encoding/json" + "fmt" + "time" +) + +// ResourceType represents the type of infrastructure resource +type ResourceType string + +// Common resource types +const ( + ResourceTypeInstance ResourceType = "instance" + ResourceTypeVolume ResourceType = "volume" + ResourceTypeNetwork ResourceType = "network" + ResourceTypeStorage ResourceType = "storage" +) + +// ResourceStatus represents the current state of a resource +type ResourceStatus string + +// Resource status constants +const ( + StatusPending ResourceStatus = "pending" + StatusCreating ResourceStatus = "creating" + StatusRunning ResourceStatus = "running" + StatusUpdating ResourceStatus = "updating" + StatusDeleting ResourceStatus = "deleting" + StatusDeleted ResourceStatus = "deleted" + StatusFailed ResourceStatus = "failed" + StatusUnavailable ResourceStatus = "unavailable" +) + +// Resource represents any infrastructure or configuration resource +type Resource interface { + // GetID returns the unique identifier of the resource + GetID() string + + // GetType returns the type of the resource + GetType() ResourceType + + // GetProvider returns the provider responsible for this resource + GetProvider() string + + // GetStatus returns the current status of the resource + GetStatus() ResourceStatus + + // GetMetadata returns additional resource-specific data + GetMetadata() map[string]interface{} + + // GetTags returns the tags associated with the resource + GetTags() map[string]string + + // GetCreatedAt returns when the resource was created + GetCreatedAt() time.Time + + // GetUpdatedAt returns when the resource was last updated + GetUpdatedAt() time.Time +} + +// BaseResource provides a basic implementation of the Resource interface +type BaseResource struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata"` + Tags map[string]string `json:"tags"` + ID string `json:"id"` + Type ResourceType `json:"type"` + Provider string `json:"provider"` + Status ResourceStatus `json:"status"` +} + +// Implementation of Resource interface for BaseResource +func (r *BaseResource) GetID() string { return r.ID } +func (r *BaseResource) GetType() ResourceType { return r.Type } +func (r *BaseResource) GetProvider() string { return r.Provider } +func (r *BaseResource) GetStatus() ResourceStatus { return r.Status } +func (r *BaseResource) GetMetadata() map[string]interface{} { return r.Metadata } +func (r *BaseResource) GetTags() map[string]string { return r.Tags } +func (r *BaseResource) GetCreatedAt() time.Time { return r.CreatedAt } +func (r *BaseResource) GetUpdatedAt() time.Time { return r.UpdatedAt } + +// ResourceChange represents a change to be made to a resource +type ResourceChange struct { + Resource Resource + ChangedProps map[string]interface{} + ChangeType ChangeType +} + +// ChangeType represents the type of change to be made +type ChangeType string + +const ( + ChangeTypeCreate ChangeType = "create" + ChangeTypeUpdate ChangeType = "update" + ChangeTypeDelete ChangeType = "delete" + ChangeTypeNoOp ChangeType = "no-op" +) + +// ResourceError represents an error that occurred while managing a resource +type ResourceError struct { + Resource Resource + Err error + Message string +} + +func (e *ResourceError) Error() string { + return fmt.Sprintf("resource error [%s/%s]: %s: %v", + e.Resource.GetProvider(), + e.Resource.GetID(), + e.Message, + e.Err) +} + +// ResourceDependency represents a dependency between resources +type ResourceDependency struct { + Resource Resource + DependsOn []string + RequiredBy []string +} + +// ResourceMetadata provides helper functions for working with resource metadata +type ResourceMetadata map[string]interface{} + +// GetString safely retrieves a string value from metadata +func (m ResourceMetadata) GetString(key string) (string, error) { + v, ok := m[key] + if !ok { + return "", fmt.Errorf("key %s not found in metadata", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("value for key %s is not a string", key) + } + return s, nil +} + +// GetInt safely retrieves an int value from metadata +func (m ResourceMetadata) GetInt(key string) (int, error) { + v, ok := m[key] + if !ok { + return 0, fmt.Errorf("key %s not found in metadata", key) + } + i, ok := v.(int) + if !ok { + return 0, fmt.Errorf("value for key %s is not an int", key) + } + return i, nil +} + +// ToJSON converts the metadata to a JSON string +func (m ResourceMetadata) ToJSON() (string, error) { + bytes, err := json.Marshal(m) + if err != nil { + return "", fmt.Errorf("failed to marshal metadata to JSON: %w", err) + } + return string(bytes), nil +} + +// FromJSON populates the metadata from a JSON string +func (m *ResourceMetadata) FromJSON(data string) error { + return json.Unmarshal([]byte(data), m) +} + +// Validate checks if required metadata fields are present +func (m ResourceMetadata) Validate(required []string) error { + for _, field := range required { + if _, ok := m[field]; !ok { + return fmt.Errorf("required metadata field %s is missing", field) + } + } + return nil +}