Initial Go learning scaffold

This commit is contained in:
juvdiaz 2026-06-01 08:47:59 -06:00
commit dddf5f8db9
13 changed files with 613 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.DS_Store
.env
.idea/
.vscode/
bin/
coverage.out
tmp/

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
.PHONY: test
test:
go test ./...
.PHONY: fmt
fmt:
go fmt ./...
.PHONY: tidy
tidy:
go mod tidy

47
README.md Normal file
View File

@ -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)

58
docs/architecture.md Normal file
View File

@ -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.

157
docs/milestones.md Normal file
View File

@ -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

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/juvdiaz/mini-platform-control-plane
go 1.23

View File

@ -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
}
}

View File

@ -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)
}
})
}
}

View File

@ -0,0 +1,8 @@
package domain
import "errors"
var (
ErrInvalidArgument = errors.New("invalid argument")
ErrInvalidStatus = errors.New("invalid status")
)

View File

@ -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
}
}

View File

@ -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)
}
})
}
}

View File

@ -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
}
}

View File

@ -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)
}
})
}
}