Skip to content

Commit

Permalink
Merge pull request #178 from calvinmclean/feature/migrate
Browse files Browse the repository at this point in the history
Add migrate feature for changes to resources
  • Loading branch information
calvinmclean authored Sep 3, 2024
2 parents f98453d + b066d8f commit 4216b9a
Show file tree
Hide file tree
Showing 15 changed files with 689 additions and 39 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/go
{
"name": "automated-garden",
"image": "mcr.microsoft.com/devcontainers/go:1-1.22-bullseye",
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bullseye",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: "1.22"
go-version: "1.23"
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.22"
go-version: "1.23"

- name: Test
run: task -t GithubActionTasks.yml main:unit-test
Expand All @@ -62,7 +62,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.22"
go-version: "1.23"

- name: Integration Test
run: task -t GithubActionTasks.yml main:integration-test
Expand Down
2 changes: 1 addition & 1 deletion garden-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

# build go app
FROM golang:1.22-alpine AS build
FROM golang:1.23-alpine AS build
RUN mkdir /build
ADD . /build
WORKDIR /build
Expand Down
29 changes: 29 additions & 0 deletions garden-app/cmd/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import (
"fmt"

"github.com/calvinmclean/automated-garden/garden-app/pkg/storage"
"github.com/calvinmclean/automated-garden/garden-app/server"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

var migrateCommand = &cobra.Command{
Use: "migrate",
Short: "Run storage migrations to update all resources",
RunE: func(cmd *cobra.Command, _ []string) error {
var config server.Config
err := viper.Unmarshal(&config)
if err != nil {
return fmt.Errorf("unable to read config from file: %w", err)
}

storageClient, err := storage.NewClient(config.StorageConfig)
if err != nil {
return fmt.Errorf("unable to initialize storage client: %v", err)
}

return storageClient.RunMigrations(cmd.Context())
},
}
2 changes: 2 additions & 0 deletions garden-app/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func Execute() {

command.AddCommand(controllerCommand)

command.AddCommand(migrateCommand)

viper.SetEnvPrefix("GARDEN_APP")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
Expand Down
45 changes: 12 additions & 33 deletions garden-app/gardens.yaml.example
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
Garden_chokmn1nhf81274ru2mg: '{"name":"Indoor Seed Starting","topic_prefix":"seed-garden","id":"chokmn1nhf81274ru2mg","max_zones":3,"created_at":"2023-05-27T00:14:20.324Z","light_schedule":{"duration":"16h0m0s","start_time":"06:00:00-07:00"}}'
Garden_cos1pt0n1e43o39cs40g: '{"name":"Front Yard","topic_prefix":"front-yard","id":"cos1pt0n1e43o39cs40g","max_zones":4,"created_at":"2024-05-05T16:58:10.206246-07:00"}'
WaterSchedule_chokmq1nhf81274ru2n0: '{"id":"chokmq1nhf81274ru2n0","duration":"1s","interval":"30s","start_time":"15:00:00Z","end_date":"2024-05-05T17:01:10.335976-07:00","name":"WaterEvery30s"}'
WaterSchedule_cii72s9nhf8f7gdpckug: '{"id":"cii72s9nhf8f7gdpckug","duration":"1h0m0s","interval":"480h0m0s","start_time":"12:00:00Z","name":"Winter Trees","description":"Water deeply and infrequently in the winter","active_period":{"start_month":"October","end_month":"March"}}'
WaterSchedule_cjbg22a8tio6of9s8o0g: '{"id":"cjbg22a8tio6of9s8o0g","duration":"30s","interval":"24h0m0s","start_time":"15:00:00Z","name":"Seedlings","description":"Water seedlings a bit every day"}'
WaterSchedule_cos1s28n1e43sc2vb4k0: '{"id":"cos1s28n1e43sc2vb4k0","duration":"1h30m0s","interval":"240h0m0s","start_time":"12:00:00Z","name":"Summer Trees","description":"Water deeply every 10 days","active_period":{"start_month":"April","end_month":"September"}}'
WaterSchedule_cos1suon1e43sc2vb4kg: '{"id":"cos1suon1e43sc2vb4kg","duration":"45m0s","interval":"120h0m0s","start_time":"15:04:05Z","name":"Shrubs","description":"Water shrubs every 5 days"}'
Zone_chokn19nhf81274ru2o0: |
{
"name": "Zone 1",
"id": "chokn19nhf81274ru2o0",
"garden_id": "chokmn1nhf81274ru2mg",
"position": 0,
"created_at": "2023-05-27T00:15:01.683Z",
"water_schedule_ids": [
"cjbg22a8tio6of9s8o0g"
]
}
Zone_cij42vpnhf85d3acsgu0: '{"name":"Zone 2","id":"cij42vpnhf85d3acsgu0","garden_id":"chokmn1nhf81274ru2mg","position":0,"created_at":"2023-07-06T04:22:23.331Z","end_date":"2024-05-05T17:00:29.204187-07:00","water_schedule_ids":["chokmq1nhf81274ru2n0"],"skip_count":null}'
Zone_cij436pnhf85d3acsgug: |
{
"name": "Zone 1",
"id": "cij436pnhf85d3acsgug",
"garden_id": "cihpp51nhf84tr94jtfg",
"position": 0,
"created_at": "2023-07-06T04:22:51.608Z",
"water_schedule_ids": [
"cii72s9nhf8f7gdpckug"
],
"skip_count": null
}
Zone_cos1q8gn1e43o39cs410: '{"name":"Trees","details":{"description":"This zone controls watering to two trees that are watered deeply"},"id":"cos1q8gn1e43o39cs410","garden_id":"cos1pt0n1e43o39cs40g","position":0,"created_at":"2024-05-05T16:58:49.357958-07:00","water_schedule_ids":["cos1s28n1e43sc2vb4k0","cii72s9nhf8f7gdpckug"],"skip_count":null}'
Zone_cos1qf0n1e43o39cs41g: '{"name":"Shrubs","details":{"description":"This zone has a few shrubs that need water more frequently"},"id":"cos1qf0n1e43o39cs41g","garden_id":"cos1pt0n1e43o39cs40g","position":2,"created_at":"2024-05-05T16:59:08.973836-07:00","water_schedule_ids":["cos1suon1e43sc2vb4kg"],"skip_count":null}'
Garden_chokmn1nhf81274ru2mg: '{"name":"Indoor Seed Starting","topic_prefix":"seed-garden","id":"chokmn1nhf81274ru2mg","max_zones":3,"created_at":"2023-05-27T00:14:20.324Z","light_schedule":{"duration":"16h0m0s","start_time":"06:00:00-07:00"},"version":1}'
Garden_cos1pt0n1e43o39cs40g: '{"name":"Front Yard","topic_prefix":"front-yard","id":"cos1pt0n1e43o39cs40g","max_zones":4,"created_at":"2024-05-05T16:58:10.206246-07:00","version":1}'
WaterSchedule_chokmq1nhf81274ru2n0: '{"id":"chokmq1nhf81274ru2n0","duration":"1s","interval":"30s","start_date":null,"start_time":"15:00:00Z","end_date":"2024-05-05T17:01:10.335976-07:00","name":"WaterEvery30s","version":1}'
WaterSchedule_cii72s9nhf8f7gdpckug: '{"id":"cii72s9nhf8f7gdpckug","duration":"1h0m0s","interval":"480h0m0s","start_date":null,"start_time":"12:00:00Z","name":"Winter Trees","description":"Water deeply and infrequently in the winter","active_period":{"start_month":"October","end_month":"March"},"version":1}'
WaterSchedule_cjbg22a8tio6of9s8o0g: '{"id":"cjbg22a8tio6of9s8o0g","duration":"30s","interval":"24h0m0s","start_date":null,"start_time":"15:00:00Z","name":"Seedlings","description":"Water seedlings a bit every day","version":1}'
WaterSchedule_cos1s28n1e43sc2vb4k0: '{"id":"cos1s28n1e43sc2vb4k0","duration":"1h30m0s","interval":"240h0m0s","start_date":null,"start_time":"12:00:00Z","name":"Summer Trees","description":"Water deeply every 10 days","active_period":{"start_month":"April","end_month":"September"},"version":1}'
WaterSchedule_cos1suon1e43sc2vb4kg: '{"id":"cos1suon1e43sc2vb4kg","duration":"45m0s","interval":"120h0m0s","start_date":null,"start_time":"15:04:05Z","name":"Shrubs","description":"Water shrubs every 5 days","version":1}'
Zone_chokn19nhf81274ru2o0: '{"name":"Zone 1","id":"chokn19nhf81274ru2o0","garden_id":"chokmn1nhf81274ru2mg","position":0,"created_at":"2023-05-27T00:15:01.683Z","water_schedule_ids":["cjbg22a8tio6of9s8o0g"],"skip_count":null,"version":1}'
Zone_cij42vpnhf85d3acsgu0: '{"name":"Zone 2","id":"cij42vpnhf85d3acsgu0","garden_id":"chokmn1nhf81274ru2mg","position":0,"created_at":"2023-07-06T04:22:23.331Z","end_date":"2024-05-05T17:00:29.204187-07:00","water_schedule_ids":["chokmq1nhf81274ru2n0"],"skip_count":null,"version":1}'
Zone_cij436pnhf85d3acsgug: '{"name":"Zone 1","id":"cij436pnhf85d3acsgug","garden_id":"cihpp51nhf84tr94jtfg","position":0,"created_at":"2023-07-06T04:22:51.608Z","water_schedule_ids":["cii72s9nhf8f7gdpckug"],"skip_count":null,"version":1}'
Zone_cos1q8gn1e43o39cs410: '{"name":"Trees","details":{"description":"This zone controls watering to two trees that are watered deeply"},"id":"cos1q8gn1e43o39cs410","garden_id":"cos1pt0n1e43o39cs40g","position":0,"created_at":"2024-05-05T16:58:49.357958-07:00","water_schedule_ids":["cos1s28n1e43sc2vb4k0","cii72s9nhf8f7gdpckug"],"skip_count":null,"version":1}'
Zone_cos1qf0n1e43o39cs41g: '{"name":"Shrubs","details":{"description":"This zone has a few shrubs that need water more frequently"},"id":"cos1qf0n1e43o39cs41g","garden_id":"cos1pt0n1e43o39cs40g","position":2,"created_at":"2024-05-05T16:59:08.973836-07:00","water_schedule_ids":["cos1suon1e43sc2vb4kg"],"skip_count":null,"version":1}'
2 changes: 1 addition & 1 deletion garden-app/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/calvinmclean/automated-garden/garden-app

go 1.22
go 1.23

toolchain go1.23.0

Expand Down
16 changes: 16 additions & 0 deletions garden-app/pkg/garden.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const (
HealthStatusDown HealthStatus = "DOWN"
HealthStatusUp HealthStatus = "UP"
HealthStatusUnknown HealthStatus = "N/A"

currentGardenVersion = uint(1)
)

// Garden is the representation of a single garden-controller device
Expand All @@ -32,6 +34,15 @@ type Garden struct {
LightSchedule *LightSchedule `json:"light_schedule,omitempty" yaml:"light_schedule,omitempty"`
TemperatureHumiditySensor *bool `json:"temperature_humidity_sensor,omitempty" yaml:"temperature_humidity_sensor,omitempty"`
NotificationClientID *string `json:"notification_client_id,omitempty" yaml:"notification_client_id,omitempty"`
Version uint `json:"version,omitempty" yaml:"version"`
}

func (g *Garden) GetVersion() uint {
return g.Version
}

func (g *Garden) SetVersion(v uint) {
g.Version = v
}

func (g *Garden) GetID() string {
Expand Down Expand Up @@ -163,6 +174,9 @@ func (g *Garden) Bind(r *http.Request) error {
g.CreatedAt = &now
fallthrough
case http.MethodPut:
if g.Version == 0 {
g.Version = currentGardenVersion
}
if g.CreatedAt == nil || g.CreatedAt.IsZero() {
g.CreatedAt = &now
}
Expand Down Expand Up @@ -234,5 +248,7 @@ func (g *Garden) Bind(r *http.Request) error {
}

func (g *Garden) Render(_ http.ResponseWriter, _ *http.Request) error {
// Version is excluded from responses because it's not important external information
g.Version = 0
return nil
}
30 changes: 30 additions & 0 deletions garden-app/pkg/storage/migrate/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package migrate

import (
"errors"
"fmt"
)

var (
ErrNotFound = errors.New("migration not found")
ErrInvalidToType = errors.New("unexpected To type")
ErrInvalidFromType = errors.New("unexpected From type")
)

type Error struct {
Err error
Name string
Version uint
}

func (e Error) Unwrap() error {
return e.Err
}

func (e Error) Error() string {
return fmt.Sprintf("error running migration %q/%d: %s", e.Name, e.Version, e.Err.Error())
}

func errNotFound(version uint) Error {
return Error{ErrNotFound, "Unknown", version}
}
115 changes: 115 additions & 0 deletions garden-app/pkg/storage/migrate/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package migrate

import (
"iter"
)

type Versioned interface {
GetVersion() uint
}

type IncrementVersion interface {
SetVersion(uint)
}

type Migration interface {
Migrate(Versioned) (any, error)
Name() string
}

type Func[From, To Versioned] func(From) (To, error)

type migration[From, To Versioned] struct {
name string
migrate Func[From, To]
}

func (m *migration[From, To]) Name() string {
return m.name
}

func (m *migration[From, To]) Migrate(from Versioned) (any, error) {
f, ok := from.(From)
if !ok {
return nil, ErrInvalidFromType
}

return m.migrate(f)
}

func NewMigration[From, To Versioned](name string, migrate Func[From, To]) Migration {
return &migration[From, To]{
name: name,
migrate: migrate,
}
}

func All[From, To Versioned](migrations []Migration, from []From) ([]To, error) {
result := []To{}

for _, f := range from {
to, err := migrateToFinalVersion[From, To](migrations, f)
if err != nil {
return nil, err
}

result = append(result, to)
}

return result, nil
}

func Each[From, To Versioned](migrations []Migration, from []From) iter.Seq2[To, error] {
return func(yield func(To, error) bool) {
for _, f := range from {
to, err := migrateToFinalVersion[From, To](migrations, f)
shouldContinue := yield(to, err)
if !shouldContinue {
return
}
}
}
}

func runMigration[From, To Versioned](migrations []Migration, from From) (To, error) {
v := from.GetVersion()

if v >= uint(len(migrations)) {
return *new(To), errNotFound(v)
}
m := migrations[v]

to, err := m.Migrate(from)
if err != nil {
return *new(To), Error{err, m.Name(), v}
}

if versionSetter, ok := to.(IncrementVersion); ok {
versionSetter.SetVersion(v + 1)
}

out, ok := to.(To)
if !ok {
return *new(To), Error{ErrInvalidToType, m.Name(), from.GetVersion()}
}

return out, err
}

func migrateToFinalVersion[From, To Versioned](migrations []Migration, from From) (To, error) {
var next Versioned
var err error

next = from
for {
next, err = runMigration[Versioned, Versioned](migrations, next)
if err != nil {
return *new(To), err
}

result, ok := next.(To)
if ok {
return result, nil
}
}
}
Loading

0 comments on commit 4216b9a

Please sign in to comment.