Initial Go learning scaffold
This commit is contained in:
commit
dddf5f8db9
|
|
@ -0,0 +1,7 @@
|
|||
.DS_Store
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
bin/
|
||||
coverage.out
|
||||
tmp/
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.PHONY: test
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
.PHONY: tidy
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/juvdiaz/mini-platform-control-plane
|
||||
|
||||
go 1.23
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrInvalidStatus = errors.New("invalid status")
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue