commit dddf5f8db9707ebc3dc4eb0649d0057418325228 Author: juvdiaz Date: Mon Jun 1 08:47:59 2026 -0600 Initial Go learning scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5ab8aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +.idea/ +.vscode/ +bin/ +coverage.out +tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..57d1056 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: test +test: + go test ./... + +.PHONY: fmt +fmt: + go fmt ./... + +.PHONY: tidy +tidy: + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..d23eac7 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# mini-platform-control-plane + +Learning project for building a small Go control plane for local environments +and workloads. + +The project is intentionally local-first. It starts with in-memory components +and fake providers, then adds gRPC, Temporal, MongoDB, Helm rendering, +Kubernetes integration, and observability in incremental milestones. + +## Learning Goals + +- Idiomatic package design. +- Small interfaces. +- `context.Context` cancellation. +- Wrapped errors with `errors.Is` and `errors.As`. +- Table-driven tests. +- Concurrency safety. +- Idempotent operations. +- gRPC and protobuf APIs. +- Temporal workflows and activities. +- Clean separation between API, domain, storage, provider, orchestration, and + infrastructure adapters. + +## Local Commands + +```bash +make test +make fmt +make tidy +``` + +## Initial Scope + +Milestone 1 defines the domain model: + +- `Environment` +- `Workload` +- `Operation` +- typed validation errors +- basic status transitions + +No private packages or internal infrastructure are required. + +See: + +- [Architecture](docs/architecture.md) +- [Milestones](docs/milestones.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2960b1c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,58 @@ +# Architecture + +`mini-platform-control-plane` models a small local management plane. + +The terminology follows this shape: + +```text +Fleet -> Environment -> Workload +``` + +For this project, `Fleet` is mostly conceptual at first. The first concrete +resources are environments, workloads, and operations. + +## Recommended Start + +Start local-first: + +1. gRPC-only API. +2. In-memory storage. +3. Fake cloud provider. +4. Fake Kubernetes applier. +5. Temporal, MongoDB, Helm, and real Kubernetes adapters added later. + +This keeps the first milestones focused on Go rather than infrastructure. + +## Package Boundaries + +```text +cmd/mpctl CLI entrypoint +cmd/control-plane gRPC API server +cmd/worker Temporal worker + +api/proto protobuf contracts +internal/domain core model and validation +internal/service application use cases +internal/storage storage interfaces and implementations +internal/provider cloud provider abstraction +internal/orchestration Temporal workflows and activities +internal/transport/grpc gRPC handlers +internal/helm Helm rendering adapter +internal/kube Kubernetes apply adapter +internal/observability logging, metrics, and tracing +internal/config configuration loading +test/e2e local end-to-end tests +deploy Docker, Compose, and Kubernetes assets +``` + +The domain and service packages must not depend on Cobra, gRPC, Temporal, +MongoDB, Helm, or Kubernetes packages. Those dependencies belong in adapter +packages. + +## Initial Architecture Decision + +Use gRPC first and add an HTTP gateway later only if it teaches a useful lesson. + +Use in-memory storage first. Add MongoDB after the storage contract is stable. +A file-backed store is optional, but it is not needed for the first learning +path. diff --git a/docs/milestones.md b/docs/milestones.md new file mode 100644 index 0000000..3ee9449 --- /dev/null +++ b/docs/milestones.md @@ -0,0 +1,157 @@ +# Milestones + +## 1. Domain Model + +Objective: define environments, workloads, operations, statuses, and typed +validation errors. + +Go concepts: + +- packages +- structs +- methods +- constants +- wrapped errors +- table-driven tests + +Files: + +- `internal/domain/environment.go` +- `internal/domain/workload.go` +- `internal/domain/operation.go` +- `internal/domain/errors.go` +- `internal/domain/*_test.go` + +Tests: + +- environment validation +- workload validation +- operation status validation +- `errors.Is` behavior for sentinel errors + +Done when: + +- `go test ./...` passes +- invalid domain objects return useful wrapped errors + +## 2. In-Memory Storage + +Objective: add storage interfaces and a concurrency-safe memory implementation. + +Go concepts: + +- interfaces +- `sync.RWMutex` +- context propagation +- idempotent create/delete +- storage contract tests + +## 3. Application Services + +Objective: add use cases that coordinate storage and provider interfaces. + +Go concepts: + +- dependency injection +- small interfaces +- context cancellation +- business error mapping + +## 4. gRPC API + +Objective: expose environment and workload APIs over gRPC. + +Go concepts: + +- protobuf contracts +- generated Go code +- gRPC handlers +- gRPC status errors +- bufconn tests + +## 5. CLI `mpctl` + +Objective: add Cobra commands backed by the gRPC client. + +Go concepts: + +- command construction +- Viper config loading +- command tests +- output formatting + +## 6. Workload Deployment Path + +Objective: deploy workloads using a fake cloud provider and idempotency keys. + +Go concepts: + +- retries +- cancellation +- interface mocks +- retryable vs permanent errors + +## 7. Temporal Workflows + +Objective: move long-running create/delete/deploy operations into workflows. + +Go concepts: + +- workflow determinism +- activities +- Temporal test environment +- cancellation + +## 8. Helm And Kubernetes Adapters + +Objective: render workload manifests and apply them through a fake Kubernetes +client, then later a real one. + +Go concepts: + +- adapter boundaries +- YAML/object validation +- interface-driven tests + +## 9. Observability + +Objective: add structured logs, Prometheus metrics, and OpenTelemetry traces. + +Go concepts: + +- `log/slog` +- metrics labels +- trace/span propagation +- observability tests + +## 10. MongoDB Storage + +Objective: add a MongoDB implementation behind the same storage interfaces. + +Go concepts: + +- integration tests +- serialization +- repository contract reuse + +## 11. Local End-to-End Test + +Objective: run the API, worker, and local dependencies together. + +Go concepts: + +- black-box tests +- process orchestration +- test fixtures + +## 12. Kubernetes Deployment + +Objective: package and deploy into `kind` or `k3d`, then later the homelab +cluster. + +Go concepts: + +- container builds +- runtime config +- readiness checks +- Helm chart values diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..160e01a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/juvdiaz/mini-platform-control-plane + +go 1.23 diff --git a/internal/domain/environment.go b/internal/domain/environment.go new file mode 100644 index 0000000..48826de --- /dev/null +++ b/internal/domain/environment.go @@ -0,0 +1,51 @@ +package domain + +import ( + "fmt" + "strings" + "time" +) + +type EnvironmentStatus string + +const ( + EnvironmentStatusCreating EnvironmentStatus = "CREATING" + EnvironmentStatusActive EnvironmentStatus = "ACTIVE" + EnvironmentStatusDeleting EnvironmentStatus = "DELETING" + EnvironmentStatusDeleted EnvironmentStatus = "DELETED" + EnvironmentStatusFailed EnvironmentStatus = "FAILED" +) + +type Environment struct { + ID string + Name string + Status EnvironmentStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +func (e Environment) Validate() error { + if strings.TrimSpace(e.ID) == "" { + return fmt.Errorf("%w: environment id is required", ErrInvalidArgument) + } + if strings.TrimSpace(e.Name) == "" { + return fmt.Errorf("%w: environment name is required", ErrInvalidArgument) + } + if !e.Status.Valid() { + return fmt.Errorf("%w: environment status %q", ErrInvalidStatus, e.Status) + } + return nil +} + +func (s EnvironmentStatus) Valid() bool { + switch s { + case EnvironmentStatusCreating, + EnvironmentStatusActive, + EnvironmentStatusDeleting, + EnvironmentStatusDeleted, + EnvironmentStatusFailed: + return true + default: + return false + } +} diff --git a/internal/domain/environment_test.go b/internal/domain/environment_test.go new file mode 100644 index 0000000..f1004c0 --- /dev/null +++ b/internal/domain/environment_test.go @@ -0,0 +1,54 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestEnvironmentValidate(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + env Environment + want error + }{ + { + name: "valid environment", + env: Environment{ + ID: "env-1", + Name: "dev", + Status: EnvironmentStatusActive, + CreatedAt: now, + UpdatedAt: now, + }, + }, + { + name: "missing id", + env: Environment{ + Name: "dev", + Status: EnvironmentStatusActive, + }, + want: ErrInvalidArgument, + }, + { + name: "invalid status", + env: Environment{ + ID: "env-1", + Name: "dev", + Status: EnvironmentStatus("UNKNOWN"), + }, + want: ErrInvalidStatus, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.env.Validate() + if !errors.Is(err, tt.want) { + t.Fatalf("Validate() error = %v, want %v", err, tt.want) + } + }) + } +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..3d87288 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,8 @@ +package domain + +import "errors" + +var ( + ErrInvalidArgument = errors.New("invalid argument") + ErrInvalidStatus = errors.New("invalid status") +) diff --git a/internal/domain/operation.go b/internal/domain/operation.go new file mode 100644 index 0000000..3890af1 --- /dev/null +++ b/internal/domain/operation.go @@ -0,0 +1,53 @@ +package domain + +import ( + "fmt" + "strings" + "time" +) + +type OperationStatus string + +const ( + OperationStatusRunning OperationStatus = "RUNNING" + OperationStatusSucceeded OperationStatus = "SUCCEEDED" + OperationStatusFailed OperationStatus = "FAILED" + OperationStatusCanceled OperationStatus = "CANCELED" +) + +type Operation struct { + ID string + TargetID string + Type string + Status OperationStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +func (o Operation) Validate() error { + if strings.TrimSpace(o.ID) == "" { + return fmt.Errorf("%w: operation id is required", ErrInvalidArgument) + } + if strings.TrimSpace(o.TargetID) == "" { + return fmt.Errorf("%w: operation target id is required", ErrInvalidArgument) + } + if strings.TrimSpace(o.Type) == "" { + return fmt.Errorf("%w: operation type is required", ErrInvalidArgument) + } + if !o.Status.Valid() { + return fmt.Errorf("%w: operation status %q", ErrInvalidStatus, o.Status) + } + return nil +} + +func (s OperationStatus) Valid() bool { + switch s { + case OperationStatusRunning, + OperationStatusSucceeded, + OperationStatusFailed, + OperationStatusCanceled: + return true + default: + return false + } +} diff --git a/internal/domain/operation_test.go b/internal/domain/operation_test.go new file mode 100644 index 0000000..633a5cf --- /dev/null +++ b/internal/domain/operation_test.go @@ -0,0 +1,52 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestOperationValidate(t *testing.T) { + tests := []struct { + name string + op Operation + want error + }{ + { + name: "valid operation", + op: Operation{ + ID: "op-1", + TargetID: "env-1", + Type: "create-environment", + Status: OperationStatusRunning, + }, + }, + { + name: "missing target", + op: Operation{ + ID: "op-1", + Type: "create-environment", + Status: OperationStatusRunning, + }, + want: ErrInvalidArgument, + }, + { + name: "invalid status", + op: Operation{ + ID: "op-1", + TargetID: "env-1", + Type: "create-environment", + Status: OperationStatus("UNKNOWN"), + }, + want: ErrInvalidStatus, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.op.Validate() + if !errors.Is(err, tt.want) { + t.Fatalf("Validate() error = %v, want %v", err, tt.want) + } + }) + } +} diff --git a/internal/domain/workload.go b/internal/domain/workload.go new file mode 100644 index 0000000..b68d4e1 --- /dev/null +++ b/internal/domain/workload.go @@ -0,0 +1,57 @@ +package domain + +import ( + "fmt" + "strings" + "time" +) + +type WorkloadStatus string + +const ( + WorkloadStatusPending WorkloadStatus = "PENDING" + WorkloadStatusDeploying WorkloadStatus = "DEPLOYING" + WorkloadStatusRunning WorkloadStatus = "RUNNING" + WorkloadStatusFailed WorkloadStatus = "FAILED" +) + +type Workload struct { + ID string + EnvironmentID string + Name string + Image string + Status WorkloadStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +func (w Workload) Validate() error { + if strings.TrimSpace(w.ID) == "" { + return fmt.Errorf("%w: workload id is required", ErrInvalidArgument) + } + if strings.TrimSpace(w.EnvironmentID) == "" { + return fmt.Errorf("%w: environment id is required", ErrInvalidArgument) + } + if strings.TrimSpace(w.Name) == "" { + return fmt.Errorf("%w: workload name is required", ErrInvalidArgument) + } + if strings.TrimSpace(w.Image) == "" { + return fmt.Errorf("%w: workload image is required", ErrInvalidArgument) + } + if !w.Status.Valid() { + return fmt.Errorf("%w: workload status %q", ErrInvalidStatus, w.Status) + } + return nil +} + +func (s WorkloadStatus) Valid() bool { + switch s { + case WorkloadStatusPending, + WorkloadStatusDeploying, + WorkloadStatusRunning, + WorkloadStatusFailed: + return true + default: + return false + } +} diff --git a/internal/domain/workload_test.go b/internal/domain/workload_test.go new file mode 100644 index 0000000..39fa86f --- /dev/null +++ b/internal/domain/workload_test.go @@ -0,0 +1,55 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestWorkloadValidate(t *testing.T) { + tests := []struct { + name string + w Workload + want error + }{ + { + name: "valid workload", + w: Workload{ + ID: "workload-1", + EnvironmentID: "env-1", + Name: "api", + Image: "example/api:v1", + Status: WorkloadStatusPending, + }, + }, + { + name: "missing image", + w: Workload{ + ID: "workload-1", + EnvironmentID: "env-1", + Name: "api", + Status: WorkloadStatusPending, + }, + want: ErrInvalidArgument, + }, + { + name: "invalid status", + w: Workload{ + ID: "workload-1", + EnvironmentID: "env-1", + Name: "api", + Image: "example/api:v1", + Status: WorkloadStatus("UNKNOWN"), + }, + want: ErrInvalidStatus, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.w.Validate() + if !errors.Is(err, tt.want) { + t.Fatalf("Validate() error = %v, want %v", err, tt.want) + } + }) + } +}