docs(milestone): complete v0.2 — infrastructure-as-code + kubernetes
---ci--- project: atelier phase: 5 milestone: v0.2 status: complete requirements: covered: [ATELIER-36, ATELIER-37, ATELIER-38, ATELIER-39, ATELIER-40, ATELIER-41, ATELIER-42, ATELIER-43, ATELIER-44, ATELIER-45, ATELIER-46, ATELIER-47, ATELIER-48, ATELIER-49, ATELIER-50, ATELIER-51, ATELIER-52, ATELIER-53, ATELIER-54, ATELIER-55, ATELIER-56, ATELIER-57, ATELIER-58, ATELIER-59] partial: [] ---/ci---
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Bad Example: Bare Pod, No Resources
|
||||
|
||||
> A Kubernetes manifest that violates Atelier's Kubernetes principles. Each violation is cited.
|
||||
|
||||
## The Code
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: api
|
||||
namespace: default
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: api:latest # :latest, unversioned
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
value: "postgres://admin:hunter2@db:5432/app" # secret in plaintext, in the manifest
|
||||
```
|
||||
|
||||
The team applies it with `kubectl apply -f api-pod.yaml`. When the pod crashes, they `kubectl delete pod api && kubectl apply -f api-pod.yaml` to "restart" it. There are no probes, no resource requests, no RBAC, no NetworkPolicy.
|
||||
|
||||
## What Makes It Bad
|
||||
|
||||
### Bare Pod, No Controller (k8s P2 Pods are Mortal)
|
||||
- A `kind: Pod` with no controller. When the node dies, the pod does not come back. When the team needs three replicas, they copy the YAML twice and rename it.
|
||||
- The "restart" workflow (`delete pod && apply`) is manual recovery — exactly the manual-mutation anti-pattern from `domains/devops/`.
|
||||
- **Fix:** use a `Deployment`. The controller replaces dead pods, scales, and rolls back. See `domains/kubernetes/workloads.md`.
|
||||
|
||||
### No Resource Requests (k8s P4 Requests and Limits are Contracts)
|
||||
- The container has no `resources.requests` or `resources.limits`. It is `BestEffort` — first evicted under node pressure. The scheduler has no signal to place it well; it lands wherever there is room, then gets killed when the node is full.
|
||||
- A workload without requests is an unbounded gamble on the scheduler.
|
||||
- **Fix:** set CPU and memory requests on every prod container; set a memory limit; consider a CPU limit. See `domains/kubernetes/workloads.md`.
|
||||
|
||||
### No Probes (k8s P5 Probes Drive Health)
|
||||
- No `readinessProbe` — the Service routes traffic to the pod before it is ready. Users see 502s during startup.
|
||||
- No `livenessProbe` — a wedged container runs forever; no one notices until the outage.
|
||||
- The platform cannot heal what it cannot see. A pod without probes is invisible to the controller's reconciliation.
|
||||
- **Fix:** define readiness and liveness probes that check the workload's own health. See `domains/kubernetes/workloads.md`.
|
||||
|
||||
### `:latest` Image Tag (k8s P1 + IaC P5 Version Everything)
|
||||
- `image: api:latest` is unversioned. Every `kubectl apply` pulls whatever is newest at that moment. Two pods "running the same manifest" run different images if `latest` moved between applies.
|
||||
- Rollback is impossible — there is no version to roll back to.
|
||||
- **Fix:** pin the image to a version or a digest: `image: registry.example.com/api:v1.4.2` or `image: registry.example.com/api@sha256:...`. See `domains/kubernetes/workloads.md` and `domains/infrastructure-as-code/terraform.md` (P5 Version Everything).
|
||||
|
||||
### Secret in Plaintext in the Manifest (k8s P9 Config and Secrets are Separate, IaC P10)
|
||||
- `DATABASE_URL` with the password is in the manifest in plaintext. If the manifest is committed (it is), the secret is in git.
|
||||
- Rotating the secret requires editing the manifest and re-applying — no separation of config from secret.
|
||||
- **Fix:** put the URL in a `Secret` (created out-of-band or via a secrets tool) and reference it with `valueFrom.secretKeyRef`. The manifest contains the reference, not the value. See `domains/kubernetes/rbac.md` and `domains/security/secrets.md`.
|
||||
|
||||
### `default` Namespace (k8s P6 Namespaces Bound Blast Radius)
|
||||
- The pod runs in `default`. There is no namespace boundary for quota, RBAC, or NetworkPolicy. Every other workload in `default` can reach it; an outage in one affects the namespace all share.
|
||||
- **Fix:** give every prod workload a named namespace sized to its blast radius. `default` is for nothing in production. See `domains/kubernetes/networking.md` and `domains/kubernetes/workloads.md`.
|
||||
|
||||
### No RBAC, No NetworkPolicy (k8s P7 RBAC by Intent, P6 Namespaces Bound Blast)
|
||||
- No `serviceAccountName` — the pod uses the `default` ServiceAccount, a shared identity.
|
||||
- No `NetworkPolicy` — every pod in the cluster can reach `api`. The network is flat by default.
|
||||
- **Fix:** a dedicated ServiceAccount with a least-privilege Role bound by intent. A default-deny NetworkPolicy with explicit allows. See `domains/kubernetes/rbac.md` and `domains/kubernetes/networking.md`.
|
||||
|
||||
## The Cascade
|
||||
|
||||
The violations compound. A bare pod with no probes crashes silently and is not restarted. `:latest` means the "restart" pulls a different image than the one that crashed. The plaintext secret in the manifest is now in git, so rotating it means editing the manifest and re-applying — and the new manifest still has no controller, so the next crash starts the manual loop again. `default` namespace means the blast radius is the whole cluster.
|
||||
|
||||
## Cross-Domain Links
|
||||
|
||||
- `domains/devops/P4 Rollback First` — with `:latest` and no controller, there is no rollback path.
|
||||
- `domains/security/authorization` — the shared `default` ServiceAccount and flat network are the k8s expression of zero-trust violation.
|
||||
- `domains/errors/P2 Fail Loudly` — a pod with no probes fails silently; the platform has no signal to act on.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Bad Example: Unlocked Local State
|
||||
|
||||
> An IaC configuration that violates Atelier's Infrastructure as Code principles. Each violation is cited.
|
||||
|
||||
## The Code
|
||||
|
||||
```hcl
|
||||
# main.tf — committed to the repo
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
# no version pin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
access_key = "AKIAIOSFODNN7EXAMPLE" # committed
|
||||
secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" # committed
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket" "logs" {
|
||||
bucket = "app-logs-prod"
|
||||
}
|
||||
|
||||
resource "aws_db_instance" "primary" {
|
||||
identifier = "app-prod"
|
||||
username = "admin"
|
||||
password = "hunter2" # committed, in plaintext, in state
|
||||
allocated_storage = 20
|
||||
engine = "postgres"
|
||||
instance_class = "db.t3.micro"
|
||||
skip_final_snapshot = true
|
||||
}
|
||||
```
|
||||
|
||||
The team runs `terraform apply` from their laptops. The state file `terraform.tfstate` is committed alongside `main.tf` "so everyone is on the same page."
|
||||
|
||||
## What Makes It Bad
|
||||
|
||||
### Local State in a Shared Environment (IaC P8 Remote State with Locking)
|
||||
- State is `terraform.tfstate` on each laptop. Two team members run `terraform apply` simultaneously; the second to finish silently overwrites the first's changes. There is no lock.
|
||||
- The state file is committed to the repo. It contains the DB password in plaintext. It is a secret-bearing artifact in version control.
|
||||
- **Fix:** use a remote backend with locking (S3 + DynamoDB, GCS, etc.). Never commit state. See `domains/infrastructure-as-code/state.md`.
|
||||
|
||||
### Hardcoded Secrets (IaC P10 Secrets Never in Code)
|
||||
- `access_key` and `secret_key` are committed in `main.tf`. The DB `password` is committed and also written to state in plaintext.
|
||||
- The secrets are now in the git history. Rotating them is not optional; the history must be scrubbed or the credentials rotated and the old ones revoked.
|
||||
- **Fix:** credentials from environment, a secrets manager, or a `data` source (`aws_secretsmanager_secret_version`). Mark sensitive attributes `sensitive = true`. See `domains/security/secrets.md`.
|
||||
|
||||
### Unpinned Provider (IaC P5 Version Everything)
|
||||
- The `aws` provider has no `version`. The next `terraform init` pulls whatever is latest — a different provider version can change resource behavior with no review.
|
||||
- **Fix:** pin `version = "~> 5.0"`. Commit the lock file (`.terraform.lock.hcl`). See `domains/infrastructure-as-code/terraform.md`.
|
||||
|
||||
### Manual Drift, No Plan Review (IaC P4 Plan Before Apply, P9 Drift is Recoverable)
|
||||
- The team applies from laptops with no `plan` review. When the DB password is wrong, someone SSHes in and changes it manually — drift that `plan` will later report as a surprise.
|
||||
- Manual changes to managed resources are an incident, not a shortcut. Each one is a future `plan` diff that no one can explain.
|
||||
- **Fix:** run `terraform plan` in CI; review the diff; `apply` from CI on merge. Treat every drift report as an incident to investigate. See `domains/infrastructure-as-code/state.md` (Drift and Reconciliation).
|
||||
|
||||
### No Module Composition (IaC P6 Modules Compose)
|
||||
- The S3 bucket and DB instance are inline. When the team needs a second bucket, they copy-paste the block and rename it. The two copies drift over time.
|
||||
- **Fix:** a versioned module for each reusable pattern. The difference is a variable, not a copy. See `domains/infrastructure-as-code/modules.md` (the module-vs-copy boundary).
|
||||
|
||||
## The Cascade
|
||||
|
||||
The violations compound. Unlocked local state lets two `apply` runs race. Committed secrets mean the race loser's changes — and the secrets — are in the repo. Manual drift hides the corruption until a `plan` surfaces a diff no one can explain. The unpinned provider means that diff might be the provider's fault, not the team's, and no one can tell which.
|
||||
|
||||
## Cross-Domain Links
|
||||
|
||||
- `domains/security/secrets.md` — secret hygiene is non-tradeable; this example violates it in three places.
|
||||
- `domains/security/supply-chain.md` — committed credentials in git are a supply-chain incident.
|
||||
- `domains/devops/P6 Configuration as Code` — config in the repo is correct; committed *state and secrets* is the violation.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Good Example: Kubernetes Deployment
|
||||
|
||||
> A Kubernetes Deployment that follows Atelier's Kubernetes principles. Each aspect cites the principle it satisfies.
|
||||
|
||||
## The Deployment
|
||||
|
||||
A stateless web service deployed as a Deployment with probes, resource contracts, RBAC, and a rolling update strategy — the canonical "production workload" pattern.
|
||||
|
||||
### Manifest
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api
|
||||
namespace: api-prod
|
||||
labels:
|
||||
app: api
|
||||
tier: web
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: api
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: api
|
||||
tier: web
|
||||
spec:
|
||||
serviceAccountName: api
|
||||
automountServiceAccountToken: false
|
||||
containers:
|
||||
- name: api
|
||||
image: registry.example.com/api:v1.4.2 # pinned, not :latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz/ready
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz/live
|
||||
port: 8080
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: api-config
|
||||
key: log_level
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-secrets
|
||||
key: database_url
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/api
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: api-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: api
|
||||
namespace: api-prod
|
||||
spec:
|
||||
selector:
|
||||
app: api
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: api-deny-ingress
|
||||
namespace: api-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: api
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
tier: edge
|
||||
```
|
||||
|
||||
## What Makes It Good
|
||||
|
||||
### Controller, Not Bare Pod (k8s P2 Pods are Mortal)
|
||||
- A `Deployment` manages the pods. If one dies, the controller replaces it. A bare pod has no recovery.
|
||||
- See `domains/kubernetes/workloads.md`.
|
||||
|
||||
### Resource Contracts (k8s P4 Requests and Limits are Contracts)
|
||||
- Every container has CPU and memory requests and a memory limit. The workload is `Burstable`, not `BestEffort` (first evicted under pressure).
|
||||
- See `domains/kubernetes/workloads.md` for QoS classes.
|
||||
|
||||
### Probes (k8s P5 Probes Drive Health)
|
||||
- `readinessProbe` gates traffic: a pod that is not ready is removed from the Service's endpoints.
|
||||
- `livenessProbe` restarts a wedged container.
|
||||
- The probes check the workload's own health (`/healthz/ready`, `/healthz/live`), not a dependency. A liveness probe that calls the database would cascade-restart on a DB blip.
|
||||
- See `domains/kubernetes/workloads.md`.
|
||||
|
||||
### Image Pinning (k8s P1 + IaC P5 Version Everything)
|
||||
- `image: registry.example.com/api:v1.4.2` — pinned to a version, not `:latest`. A pod restart pulls the same image it was built with.
|
||||
- See `domains/infrastructure-as-code/terraform.md` and `domains/devops/P7 Immutability` for the immutability angle.
|
||||
|
||||
### RBAC (k8s P7 RBAC by Intent, Not Identity)
|
||||
- `serviceAccountName: api` — the workload runs as a dedicated ServiceAccount, not the `default` shared identity.
|
||||
- `automountServiceAccountToken: false` — the workload does not call the API, so it gets no token. See `domains/kubernetes/rbac.md`.
|
||||
- A matching `Role` + `RoleBinding` (not shown) would grant `get, list, watch` on `configmaps` in this namespace — least privilege, scoped by intent.
|
||||
|
||||
### Config and Secrets Separate (k8s P9 Config and Secrets are Separate)
|
||||
- `LOG_LEVEL` from a ConfigMap (non-sensitive). `DATABASE_URL` from a Secret (sensitive). Both injected at runtime; neither baked into the image.
|
||||
- A configuration change does not require a rebuild. A secret rotation does not require an image redeploy.
|
||||
- See `domains/kubernetes/rbac.md` and `domains/security/secrets.md`.
|
||||
|
||||
### Namespaces Bound Blast Radius (k8s P6 Namespaces Bound Blast Radius)
|
||||
- The workload lives in `api-prod`, not `default`. The namespace is the unit of quota, RBAC, and NetworkPolicy. A problem in `api-prod` does not leak to other workloads.
|
||||
- See `domains/kubernetes/networking.md`.
|
||||
|
||||
### NetworkPolicy Default-Deny (k8s P6, P7)
|
||||
- The `NetworkPolicy` allows ingress only from the `edge` namespace. Without it, every pod in the cluster could reach `api`. Default-deny is the baseline; allows are the exceptions.
|
||||
- See `domains/kubernetes/networking.md`.
|
||||
|
||||
### Roll Forward, Roll Back (k8s P10 Roll Forward Roll Back)
|
||||
- `strategy: RollingUpdate` with `maxSurge: 1, maxUnavailable: 0` — the rollout adds a new pod before removing an old one. Availability is maintained.
|
||||
- `kubectl rollout undo deployment/api` reverts to the previous ReplicaSet. The rollback is tested before it is needed.
|
||||
- See `domains/kubernetes/workloads.md` and `domains/devops/P5 Progressive Delivery`.
|
||||
|
||||
### Cross-Domain Links
|
||||
- `domains/devops/P4 Rollback First` — the rollout strategy makes the deploy reversible.
|
||||
- `domains/security/authorization` — the ServiceAccount + Role model is the k8s expression of least-privilege authorization.
|
||||
- `domains/observability/metrics` — the probes are the platform's observability into the workload's health; the workload's own metrics complete the picture.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Good Example: Terraform Module
|
||||
|
||||
> A reusable Terraform module that follows Atelier's Infrastructure as Code principles. Each aspect cites the principle it satisfies.
|
||||
|
||||
## The Module
|
||||
|
||||
A versioned module that provisions an S3 bucket with logging, versioning, and encryption — the canonical "secure bucket" pattern, composed rather than copy-pasted.
|
||||
|
||||
### Consumer Call
|
||||
|
||||
```hcl
|
||||
module "logs_bucket" {
|
||||
source = "registry.example.com/infra/secure-bucket/aws"
|
||||
version = "1.2.0"
|
||||
|
||||
name = "app-logs"
|
||||
region = "us-east-1"
|
||||
force_destroy = false
|
||||
retention_days = 90
|
||||
}
|
||||
```
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
secure-bucket/
|
||||
├── main.tf # the resource
|
||||
├── variables.tf # typed inputs
|
||||
├── outputs.tf # the interface to consumers
|
||||
├── versions.tf # provider pin
|
||||
└── README.md # the module contract
|
||||
```
|
||||
|
||||
### `versions.tf` (P5 Version Everything)
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `variables.tf` (P1 Declarative Intent, C2 Clarity)
|
||||
|
||||
```hcl
|
||||
variable "name" {
|
||||
type = string
|
||||
description = "Globally unique bucket name."
|
||||
validation {
|
||||
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.name))
|
||||
error_message = "Bucket name must be lowercase, 3-63 chars, DNS-compatible."
|
||||
}
|
||||
}
|
||||
|
||||
variable "retention_days" {
|
||||
type = number
|
||||
default = 30
|
||||
description = "S3 lifecycle transition age in days."
|
||||
}
|
||||
```
|
||||
|
||||
### `main.tf` (P1, P3 State is Truth, P10 Secrets Never in Code)
|
||||
|
||||
```hcl
|
||||
resource "aws_s3_bucket" "this" {
|
||||
bucket = var.name
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "this" {
|
||||
bucket = aws_s3_bucket.this.id
|
||||
versioning_configuration {
|
||||
status = "Enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
|
||||
bucket = aws_s3_bucket.this.id
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "this" {
|
||||
bucket = aws_s3_bucket.this.id
|
||||
rule {
|
||||
id = "retention"
|
||||
status = "Enabled"
|
||||
filter { prefix = "" }
|
||||
expiration { days = var.retention_days }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What Makes It Good
|
||||
|
||||
### Composition (IaC P6 Modules Compose, C6 Composability)
|
||||
- The bucket pattern is one module, versioned once, consumed many times. A new consumer does not copy 40 lines of HCL — they call the module with a `name` and a `retention_days`.
|
||||
- See `domains/infrastructure-as-code/modules.md` for the module-vs-copy boundary.
|
||||
|
||||
### Pinning (IaC P5 Version Everything)
|
||||
- The consumer pins `version = "1.2.0"`. The module pins its provider (`version = "~> 5.0"`) and the required Terraform version. A commit is a complete, reproducible world.
|
||||
- No `latest` anywhere. See `domains/infrastructure-as-code/terraform.md`.
|
||||
|
||||
### State Discipline (IaC P3 State is Truth, P8 Remote State with Locking)
|
||||
- The consumer's root configuration declares a remote backend with locking (S3 + DynamoDB, GCS, etc.). The module itself does not declare a backend — the consumer owns state.
|
||||
- See `domains/infrastructure-as-code/state.md` for backend selection and locking.
|
||||
|
||||
### Secrets Hygiene (IaC P10 Secrets Never in Code)
|
||||
- The bucket is encrypted at rest (SSE-S3 AES256). No secret is hardcoded; encryption is a provider-managed default. If KMS were used, the key would come from a `data` source or a dedicated KMS module — never a literal.
|
||||
- See `domains/security/secrets.md` for the general secrets principles.
|
||||
|
||||
### Plan Before Apply (IaC P4 Plan Before Apply)
|
||||
- The consumer runs `terraform plan` before `apply`. The plan shows the new bucket, versioning, encryption, and lifecycle. Every line is reviewed. The plan is the contract review; `apply` is the signature.
|
||||
|
||||
### Cross-Domain Links
|
||||
- `domains/devops/P1 Reproducibility` — the module makes the bucket reproducible from source.
|
||||
- `domains/devops/P6 Configuration as Code` — the bucket is config, not a console click.
|
||||
- `domains/security/supply-chain` — a versioned, signed module from a trusted registry is a supply-chain control.
|
||||
Reference in New Issue
Block a user