Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,24 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.10
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
49 changes: 49 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
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=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
1 change: 1 addition & 0 deletions internal/state/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
// Package state stores deployment state and runtime metadata.
// Deployment rows for plan/apply are implemented in internal/state/sqlite (§14.1).
package state
21 changes: 21 additions & 0 deletions internal/state/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package state

import "time"

// AppliedResource is one row in applied_resources (design doc §14.1).
type AppliedResource struct {
Kind string
Name string
Env string
SpecHash string
NormalizedSpecJSON string
AppliedAt time.Time
}

// AppliedProject is one row in applied_projects (design doc §14.1).
type AppliedProject struct {
ProjectName string
Env string
Version string
AppliedAt time.Time
}
3 changes: 3 additions & 0 deletions internal/state/sqlite/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package sqlite implements deployment state storage in SQLite (design doc §14.1).
// Use [Open] with a file DSN; [Migrate] runs versioned SQL from /migrations/sqlite.
package sqlite
92 changes: 92 additions & 0 deletions internal/state/sqlite/migrations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package sqlite

import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"

sqlitemigrations "github.com/LAA-Software-Engineering/agentic-control-plane/migrations/sqlite"
)

const createMigrationsTable = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER NOT NULL PRIMARY KEY,
applied_at TEXT NOT NULL
);`

// Migrate applies embedded SQL migrations in lexical order (001_, 002_, …).
// Each file is run at most once; versions are recorded in schema_migrations.
// Re-running Migrate is safe (idempotent).
func Migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, createMigrationsTable); err != nil {
return fmt.Errorf("schema_migrations table: %w", err)
}

entries, err := sqlitemigrations.Files.ReadDir(".")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})

for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
ver, ok := migrationVersion(e.Name())
if !ok {
continue
}
var one int
err := db.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, ver).Scan(&one)
if err == nil {
continue
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}

body, err := sqlitemigrations.Files.ReadFile(e.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", e.Name(), err)
}

tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("exec migration %s: %w", e.Name(), err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
ver, time.Now().UTC().Format(time.RFC3339Nano),
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", e.Name(), err)
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}

func migrationVersion(filename string) (int, bool) {
i := strings.IndexByte(filename, '_')
if i <= 0 {
return 0, false
}
v, err := strconv.Atoi(filename[:i])
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
149 changes: 149 additions & 0 deletions internal/state/sqlite/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package sqlite

import (
"context"
"database/sql"
"fmt"
"time"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
_ "modernc.org/sqlite" // register "sqlite" driver
)

// Store persists deployment state (design doc §14.1) in SQLite.
type Store struct {
db *sql.DB
}

// Open opens or creates a database at dsn and runs migrations.
// dsn is passed to database/sql (e.g. absolute path to a .db file); see modernc.org/sqlite docs.
func Open(ctx context.Context, dsn string) (*Store, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetConnMaxLifetime(0)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := Migrate(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db}, nil
}

// Close releases the database handle.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}

// UpsertAppliedResource inserts or replaces a row for (kind, name, env).
func (s *Store) UpsertAppliedResource(ctx context.Context, r state.AppliedResource) error {
at := r.AppliedAt.UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `
INSERT INTO applied_resources (kind, name, env, spec_hash, normalized_spec_json, applied_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(kind, name, env) DO UPDATE SET
spec_hash = excluded.spec_hash,
normalized_spec_json = excluded.normalized_spec_json,
applied_at = excluded.applied_at
`, r.Kind, r.Name, r.Env, r.SpecHash, r.NormalizedSpecJSON, at)
return err
}

// GetAppliedResource returns the row for env and ResourceID, or sql.ErrNoRows.
func (s *Store) GetAppliedResource(ctx context.Context, env string, id spec.ResourceID) (*state.AppliedResource, error) {
row := s.db.QueryRowContext(ctx, `
SELECT kind, name, env, spec_hash, normalized_spec_json, applied_at
FROM applied_resources
WHERE env = ? AND kind = ? AND name = ?
`, env, id.Kind, id.Name)
var r state.AppliedResource
var at string
if err := row.Scan(&r.Kind, &r.Name, &r.Env, &r.SpecHash, &r.NormalizedSpecJSON, &at); err != nil {
return nil, err
}
t, err := parseSQLiteTime(at)
if err != nil {
return nil, fmt.Errorf("applied_at: %w", err)
}
r.AppliedAt = t
return &r, nil
}

// ListAppliedResourcesByEnv lists all applied resources for the given environment.
func (s *Store) ListAppliedResourcesByEnv(ctx context.Context, env string) ([]state.AppliedResource, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT kind, name, env, spec_hash, normalized_spec_json, applied_at
FROM applied_resources
WHERE env = ?
ORDER BY kind, name
`, env)
if err != nil {
return nil, err
}
defer rows.Close()

var out []state.AppliedResource
for rows.Next() {
var r state.AppliedResource
var at string
if err := rows.Scan(&r.Kind, &r.Name, &r.Env, &r.SpecHash, &r.NormalizedSpecJSON, &at); err != nil {
return nil, err
}
t, err := parseSQLiteTime(at)
if err != nil {
return nil, err
}
r.AppliedAt = t
out = append(out, r)
}
return out, rows.Err()
}

// UpsertAppliedProject inserts or replaces a row for (project_name, env).
func (s *Store) UpsertAppliedProject(ctx context.Context, p state.AppliedProject) error {
at := p.AppliedAt.UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx, `
INSERT INTO applied_projects (project_name, env, version, applied_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_name, env) DO UPDATE SET
version = excluded.version,
applied_at = excluded.applied_at
`, p.ProjectName, p.Env, p.Version, at)
return err
}

// GetAppliedProject returns the row for project name and env, or sql.ErrNoRows.
func (s *Store) GetAppliedProject(ctx context.Context, env, projectName string) (*state.AppliedProject, error) {
row := s.db.QueryRowContext(ctx, `
SELECT project_name, env, version, applied_at
FROM applied_projects
WHERE env = ? AND project_name = ?
`, env, projectName)
var p state.AppliedProject
var at string
if err := row.Scan(&p.ProjectName, &p.Env, &p.Version, &at); err != nil {
return nil, err
}
t, err := parseSQLiteTime(at)
if err != nil {
return nil, err
}
p.AppliedAt = t
return &p, nil
}

func parseSQLiteTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, s)
}
Loading
Loading