diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e1b3d..db26aa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Archnet CI/CD +name: Archnet validation on: push: @@ -6,38 +6,100 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: validate: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - name: Validate YAML files + - name: Validate YAML syntax + shell: bash run: | - find . -name "*.yml" -o -name "*.yaml" | head -20 - echo "YAML structure validated" + set -euo pipefail + python -m pip install --disable-pip-version-check PyYAML==6.0.2 + python - <<'PY' + from pathlib import Path + import yaml + paths = sorted([*Path('.').rglob('*.yaml'), *Path('.').rglob('*.yml')]) + if not paths: + raise SystemExit('No YAML files found') + for path in paths: + with path.open(encoding='utf-8') as stream: + list(yaml.safe_load_all(stream)) + print('Parsed:', path) + PY - - name: Validate Shell Scripts + - name: Validate shell syntax + shell: bash run: | - find . -name "*.sh" -exec bash -n {} \; - echo "Shell scripts OK" + set -euo pipefail + while IFS= read -r -d '' file; do + bash -n "$file" + done < <(find . -type f -name '*.sh' -print0) - - name: Check Project Structure + - name: Validate required directories + shell: bash run: | - ls -la - echo "terraform/ argocd/ observability/ apps/ docs/ k3s/" | tr ' ' '\n' | while read dir; do - [ -d "$dir" ] && echo "OK: $dir exists" || echo "MISSING: $dir" + set -euo pipefail + for dir in terraform argocd observability apps docs k3s; do + test -d "$dir" || { echo "Missing: $dir" >&2; exit 1; } done - security: + - name: Test infrastructure safety guard + run: python -m unittest discover -s tests -v + + - name: Verify cloud deployment safety lock + run: python scripts/check_cloud_lock.py + + terraform: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: 1.12.2 + terraform_wrapper: false + - name: Benchmark source policy without Terraform execution + run: python scripts/benchmark_cloud_lock.py + - name: Verify empty Terraform plan and deny enablement + run: bash scripts/verify_cloud_plan.sh + + kubernetes: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install pinned kind CLI + run: | + GOBIN="$RUNNER_TEMP/bin" go install sigs.k8s.io/kind@v0.30.0 + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Verify disposable Kubernetes control plane + run: bash scripts/smoke_kind.sh - - name: Run Trivy - uses: aquasecurity/trivy-action@master + security: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Scan filesystem with Trivy + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 with: + cache: 'false' + version: v0.69.3 scan-type: fs scan-ref: . severity: CRITICAL - exit-code: 0 + exit-code: '1' diff --git a/.github/workflows/full-module-iam.yml b/.github/workflows/full-module-iam.yml new file mode 100644 index 0000000..19447ef --- /dev/null +++ b/.github/workflows/full-module-iam.yml @@ -0,0 +1,98 @@ +name: Full module IAM acceptance +on: pull_request +permissions: + contents: read +jobs: + native: + runs-on: ubuntu-latest + timeout-minutes: 12 + strategy: + fail-fast: false + matrix: + include: + - repository: terraform-aws-modules/terraform-aws-eks + ref: v20.37.2 + policy: assume_role_policy + principal: Service + expected: eks.amazonaws.com + - repository: cloudposse/terraform-aws-eks-iam-role + ref: 0c2347ca06fb54579a0e57d03e20aed3c1045157 + policy: service_account_assume_role + principal: Federated + expected: arn:aws:iam::111122223333:oidc-provider/example.invalid + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: ${{ matrix.repository }} + ref: ${{ matrix.ref }} + persist-credentials: false + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd + with: + terraform_version: 1.12.2 + terraform_wrapper: false + - name: Test actual policy from upstream root + shell: bash + env: + POLICY: ${{ matrix.policy }} + PRINCIPAL: ${{ matrix.principal }} + EXPECTED: ${{ matrix.expected }} + CHECKPOINT_DISABLE: '1' + run: | + set -euo pipefail + mkdir archnet-tests + cat > archnet-tests/policy.tftest.hcl <> archnet-tests/policy.tftest.hcl <<'EOF' + + variables { + eks_cluster_oidc_issuer_url = "https://example.invalid" + service_account_namespace = "default" + service_account_name = "example" + } + + override_data { + target = data.aws_caller_identity.current[0] + override_during = plan + values = { account_id = "111122223333" } + } + EOF + fi + terraform init -backend=false -input=false + tf=$(command -v terraform) + run_offline() { + sudo unshare --net -- env -i PATH=/usr/bin:/bin CHECKPOINT_DISABLE=1 AWS_EC2_METADATA_DISABLED=true "$tf" test -test-directory=archnet-tests -no-color + } + run_offline + if [[ "$PRINCIPAL" == Service ]]; then + sed -i '0,/identifiers = \["eks.amazonaws.com"\]/{s/identifiers = \["eks.amazonaws.com"\]/identifiers = ["ec2.amazonaws.com"]/;}' main.tf + else + sed -i 's/account_id = "111122223333"/account_id = "444455556666"/' archnet-tests/policy.tftest.hcl + fi + if run_offline > "$RUNNER_TEMP/negative.log" 2>&1; then + echo "Changed principal passed unexpectedly" >&2 + exit 1 + fi + cat "$RUNNER_TEMP/negative.log" + grep -Fx 'ARCHNET_PRINCIPAL_MISMATCH' "$RUNNER_TEMP/negative.log" diff --git a/.github/workflows/iam-experiment.yml b/.github/workflows/iam-experiment.yml new file mode 100644 index 0000000..4078bfc --- /dev/null +++ b/.github/workflows/iam-experiment.yml @@ -0,0 +1,20 @@ +name: Offline IAM policy experiment +on: + pull_request: + branches: [main] +permissions: + contents: read +jobs: + offline-policy: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd + with: + terraform_version: 1.12.2 + terraform_wrapper: false + - name: Run extracted renderers without network access + run: bash scripts/test_offline_iam.sh diff --git a/README.md b/README.md index 4762d34..631f0be 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,9 @@ -# Archnet: Zero-Trust Internal Developer Platform +# ARCHNET -![CI/CD](https://github.com/EdwinJdevops/ARCHNET/actions/workflows/ci.yml/badge.svg) -![License](https://img.shields.io/badge/license-MIT-blue) -![Stack](https://img.shields.io/badge/stack-k3s%20%7C%20ArgoCD%20%7C%20Prometheus-brightgreen) +Experimental repository for reviewing a cloud-disabled infrastructure baseline in pull requests. CI checks whether the Terraform root proposes zero changes without cloud credentials and rejects an attempt to enable provisioning. A disposable kind cluster checks local Kubernetes API readiness and object reconciliation prerequisites. -> A production-grade Internal Developer Platform built by a self-taught DevOps Engineer from Nigeria. +- [Reproduce the cloud-disabled Terraform benchmark](docs/CLOUD_LOCK_BENCHMARK.md) +- [Architecture, demand evidence and alternatives](docs/ENGINEERING_AUDIT.md) +- [Cost boundary and local Kubernetes instructions](docs/DEPLOYMENT_SAFETY.md) -## Problem It Solves -Most teams deploy to Kubernetes manually — no automation, no observability, no drift detection. Archnet automates the entire platform lifecycle. - -## Architecture -Developer → Git Push -→ GitHub Actions (CI) -→ Docker Registry -→ ArgoCD (GitOps) -→ k3s Cluster -→ Prometheus + Grafana -→ AlertManager → Slack - -## Tech Stack -| Tool | Purpose | -|------|---------| -| k3s | Lightweight production Kubernetes | -| ArgoCD | GitOps deployments + drift detection | -| Sealed-Secrets | Zero-trust secret management | -| Prometheus | Metrics collection | -| Grafana | Dashboards + alerting | -| Loki | Log aggregation | -| GitHub Actions | CI/CD pipeline | -| Terraform | AWS infrastructure as code | - -## Key Features -- Self-healing deployments (ArgoCD drift detection) -- Secrets encrypted at rest in Git -- Full observability stack (metrics + logs + alerts) -- RBAC audit logging -- Zero-trust network policies -- Single-command infrastructure provisioning - -## Quick Start -```bash -# 1. Clone -git clone https://github.com/EdwinJdevops/ARCHNET -cd ARCHNET - -# 2. Provision AWS infrastructure -cd terraform -terraform init -terraform plan -terraform apply - -# 3. Access ArgoCD -kubectl port-forward svc/argocd-server -n argocd 8080:443 - -# 4. Access Grafana -kubectl port-forward svc/grafana -n monitoring 3000:3000 -``` - -## Project Structure -archnet/ -├── .github/workflows/ # CI/CD pipeline -├── terraform/ # AWS infrastructure -├── k3s/ # Cluster setup -├── argocd/ # GitOps manifests -├── observability/ # Prometheus, Grafana, Loki -├── apps/ # Application deployments -└── docs/ # Architecture + decisions - -## Security Model -- Default deny network policies -- RBAC least-privilege access -- Sealed-Secrets (decrypt only inside cluster) -- Trivy image scanning pre-deploy -- No secrets in environment variables - -## Documentation -- [Architecture](docs/ARCHITECTURE.md) -- [Tech Decisions](docs/TECH_DECISIONS.md) -- [Security](docs/SECURITY.md) - -## Author -**Edwin** | DevOps & Cloud Engineer | Nigeria -[LinkedIn](https://linkedin.com/in/YOUR_LINKEDIN) · [Hashnode](https://YOUR_HASHNODE.hashnode.dev) - ---- -> Built to solve real infrastructure problems. Not a tutorial project. +The Argo CD Application is reference configuration. The Kubernetes smoke test does not deploy it, validate application workloads or demonstrate production readiness. diff --git a/argocd/application.yaml b/argocd/application.yaml index 90b5b3f..a16cf61 100644 --- a/argocd/application.yaml +++ b/argocd/application.yaml @@ -1,3 +1,5 @@ +# Reference only: not applied by default. +# Replace the repository and revision with a reviewed, immutable release before use. apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -6,15 +8,12 @@ metadata: spec: project: default source: - repoURL: https://github.com/YOUR_USERNAME/archnet.git - targetRevision: HEAD + repoURL: https://github.com/EdwinJdevops/ARCHNET.git + targetRevision: main path: apps destination: server: https://kubernetes.default.svc namespace: default syncPolicy: - automated: - prune: true - selfHeal: true syncOptions: - - CreateNamespace=true + - CreateNamespace=false diff --git a/docs/CLOUD_LOCK_BENCHMARK.md b/docs/CLOUD_LOCK_BENCHMARK.md new file mode 100644 index 0000000..229cb5a --- /dev/null +++ b/docs/CLOUD_LOCK_BENCHMARK.md @@ -0,0 +1,22 @@ +# Cloud-disabled Terraform benchmark + +Run from the repository root on Linux or WSL with Python 3, Bash, Terraform 1.12.2 and jq. + +```sh +python scripts/benchmark_cloud_lock.py +bash scripts/verify_cloud_plan.sh +``` + +Use a fresh checkout without existing Terraform state. These are the same commands CI runs. The source benchmark copies the checked-in root and policy into a temporary directory and uses the same provider-free `terraform_data` fixture in two isolated regression cases. It first adds an unexpected file, then removes that file and appends the declaration to the approved `main.tf`. It never invokes Terraform and leaves the checkout untouched. The real plan script checks the live source inventory before initializing Terraform. + +| Case | Expected evidence | +| --- | --- | +| Clean source | Source guard exits 0. | +| Unexpected declaration | Source guard exits 1 and names the added file. | +| Declaration appended to approved file | Source guard exits 1 and identifies the modified `terraform/main.tf`. | +| Disabled Terraform root | Terraform exits 0 with zero changes, and the plan JSON policy passes. | +| Explicit enablement | Terraform rejects the input with the cloud-disabled diagnostic. | + +The job fails if any decision differs. Logs report elapsed time for each source check and each Terraform plan command. Source timings include Python process startup. Timings describe that run and exclude queue time and setup. View the Terraform job in the [workflow history](https://github.com/EdwinJdevops/ARCHNET/actions/workflows/ci.yml) for the commit under review. Kubernetes smoke results appear in a separate job. + +This benchmark verifies the disabled-root contract and two representative source regressions. It provides no evidence about adoption, production operation or the performance of a live AWS plan. diff --git a/docs/DEPLOYMENT_SAFETY.md b/docs/DEPLOYMENT_SAFETY.md new file mode 100644 index 0000000..da59d1d --- /dev/null +++ b/docs/DEPLOYMENT_SAFETY.md @@ -0,0 +1,19 @@ +# Deployment safety and cost boundary + +The infrastructure ceiling is **$10/month**. This baseline authorizes **$0 in live cloud spend**. There are no executable AWS resources, AWS provider, remote backend, AMI reads or deployment steps in the Terraform root. The previous AWS design is available in [the audited revision](https://github.com/EdwinJdevops/ARCHNET/tree/b260163d4861a2f476d35707c024079bf7ecf026/terraform). The empty-root test uses fresh checkout state. Anyone who has previously applied the old root should keep that state separate and review it independently before running Terraform against it. + +CI checks the Terraform source inventory before initialization, requires a no-change saved plan and inspects its JSON for resources, drift, outputs, providers and module calls. Setting `enable_cloud_deployment=true` must fail with the specific validation diagnostic. The earlier literal ingress checker remains covered by its unit tests and is scoped to the retired AWS design. Repository checks can be edited in a PR, so any future enforced merge rule needs trusted review of workflow and policy changes. + +## Disposable Kubernetes smoke test + +On Linux or WSL with a working Linux Docker engine, Bash and Go, run from the repository root: + +```sh +go install sigs.k8s.io/kind@v0.30.0 +export PATH="$(go env GOPATH)/bin:$PATH" +bash scripts/smoke_kind.sh +``` + +With a custom GOBIN, add that directory to PATH instead. The script uses a digest-pinned Kubernetes node image, a unique cluster name and a temporary kubeconfig. It checks node readiness, CoreDNS rollout and a ConfigMap round trip. It attempts cluster teardown on exit and fails if teardown fails. The test uses public dependency downloads and local compute. It covers neither Argo CD nor workload deployment, DNS queries, recovery or production operations. + +Any future AWS proposal needs separate authorization, a reviewed bootstrap and admin access path, and a full cost estimate covering compute, storage, public IPs, data transfer, monitoring, snapshots and tax. Billing alerts do not cap spending. diff --git a/docs/ENGINEERING_AUDIT.md b/docs/ENGINEERING_AUDIT.md new file mode 100644 index 0000000..44dd297 --- /dev/null +++ b/docs/ENGINEERING_AUDIT.md @@ -0,0 +1,32 @@ +# ARCHNET engineering audit + +## Candidate workflow + +A platform engineer reviewing changes to an intentionally cloud-disabled sandbox needs commit-specific proof that the disabled Terraform root still proposes zero changes, without granting the pull request cloud credentials. Limit this experiment to the zero-change contract and a disposable local Kubernetes check. The original repo combined AWS scaffolding, a reference Argo CD Application and static CI. At the audited revision, the false flag suppressed EC2 while six networking resources remained unconditional and an AMI data source could still query AWS. Argo CD pointed at the apps directory on main with manual sync. The repository provides no verified bootstrap, recovery or operator handoff. + +## Evidence and alternatives + +- [Atlantis issue #3297](https://github.com/runatlantis/atlantis/issues/3297) records a request to isolate plan from apply and the maintenance burden of provider allowlists. [Atlantis security documentation](https://www.runatlantis.io/docs/security.html) explains why planning pull-request code with infrastructure credentials creates a trust boundary. [Issue #2144](https://github.com/runatlantis/atlantis/issues/2144) describes separate Terraform project scopes and credentials. These are examples of plan execution and credential scoping friction. They do not demonstrate demand for ARCHNET or establish how often teams need this specific zero-change check. A repository issue search returned no ARCHNET issues. +- [Terraform provider mocks](https://developer.hashicorp.com/terraform/language/tests/mocking) suit testing modules with provider resources and data sources when real credentials are unavailable. Here the root intentionally contains no providers or resources, so a real empty plan directly tests the narrower invariant. +- [Conftest](https://open-policy-agent.github.io/conftest/) can evaluate JSON with reusable policy across repositories. A small plan predicate is enough for this one invariant. Atlantis suits teams that need a coordinated plan/apply workflow and can operate the required trust boundaries. [kind](https://kind.sigs.k8s.io/docs/user/quick-start/) supplies the local cluster directly. + +The [reproducible benchmark](CLOUD_LOCK_BENCHMARK.md) lets a solo maintainer publish review evidence for clean and regressed configurations. Before expanding scope, look for concrete reports from unrelated teams and record their existing safeguards and unresolved gaps. Adoption, savings and differentiation remain unvalidated. + +## Public evidence review + +| Source | Observed friction | Scope of the evidence | +| --- | --- | --- | +| [Atlantis #3297](https://github.com/runatlantis/atlantis/issues/3297) | A requester wanted isolated planning and reported maintenance cost for provider allowlists. | Supports plan isolation as a concern. The reported workflow includes live infrastructure. | +| [AWS provider #29141](https://github.com/hashicorp/terraform-provider-aws/issues/29141) | A plan failed to resolve credentials from the configured file. The reporter used an environment variable as a workaround. | A concrete credential failure. No cloud-disabled PR workflow is described. | +| [Terraform #17034](https://github.com/hashicorp/terraform/issues/17034) | Terraform maintainers describe why plan can read external data even when implicit refresh is disabled. | Explains the mechanism that made ARCHNET's AMI declaration relevant. It is a design record. | +| [Terragrunt authentication](https://docs.terragrunt.com/features/units/authentication/) | Documents separate read-only plan and read-write apply roles. | A practical alternative when a plan must read live infrastructure. | + +The public record corroborates credential and planning friction. The repository audit is the only directly observed example in this review of a cloud-disabled root that still declared live AWS dependencies. Keep the benchmark as a reusable engineering baseline. Expand the product only when another concrete case identifies the same contract and an unmet need. + +## Merge verification + +The four checks run on the draft PR. Active required-check enforcement on `main` could not be verified from available repository rules access. A green run confirms execution for the tested revision. Before a future merge, inspect the active rule for `main` and confirm it requires the expected checks and protects changes to the workflow and policy. The PR remains draft. + +## Verification baseline + +[Run #19](https://github.com/EdwinJdevops/ARCHNET/actions/runs/42384163801) passed validation, Terraform formatting/configuration and Trivy on [the audited commit](https://github.com/EdwinJdevops/ARCHNET/commit/b260163d4861a2f476d35707c024079bf7ecf026). That run did not execute a Terraform plan or start a cluster. Review the checks on the current PR head for the new gates. diff --git a/docs/OFFLINE_IAM_EXPERIMENT.md b/docs/OFFLINE_IAM_EXPERIMENT.md new file mode 100644 index 0000000..a577a4d --- /dev/null +++ b/docs/OFFLINE_IAM_EXPERIMENT.md @@ -0,0 +1,5 @@ +# Offline IAM policy rendering experiment + +The fixtures extract the trust policy renderer from [EKS v20.37.2](https://github.com/terraform-aws-modules/terraform-aws-eks/blob/v20.37.2/main.tf) and [Cloud Posse at 0c2347c](https://github.com/cloudposse/terraform-aws-eks-iam-role/blob/0c2347ca06fb54579a0e57d03e20aed3c1045157/main.tf). They supply fixed inputs for surrounding module logic and account identity. The actual AWS provider renders the document. The harness compares a principal in its generated JSON before and after changing a renderer input. + +CI downloads the provider before testing. Plans run in a separate Linux network namespace with an empty environment and literal dummy credentials. The fixtures declare no managed resources and the harness runs only plans. The experiment tests isolated policy rendering. It does not establish that complete upstream modules can plan without cloud reads, that the policies are production-ready, or that maintainers need a new tool. diff --git a/scripts/benchmark_cloud_lock.py b/scripts/benchmark_cloud_lock.py new file mode 100644 index 0000000..0781b6f --- /dev/null +++ b/scripts/benchmark_cloud_lock.py @@ -0,0 +1,63 @@ +"""Run the real source guard against an isolated copy of the approved root.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +REPO = Path(__file__).resolve().parents[1] +GUARD = Path("scripts/check_cloud_lock.py") +FIXTURE = REPO / "tests/fixtures/unexpected-resource.tf.fixture" + + +def check(root, case, expected_code, expected_message): + started = time.perf_counter() + result = subprocess.run( + [sys.executable, str(root / GUARD)], + capture_output=True, + text=True, + timeout=10, + ) + elapsed = time.perf_counter() - started + output = result.stdout + result.stderr + print(f"{case}: exit={result.returncode}, elapsed={elapsed:.3f}s", flush=True) + print(output, end="", flush=True) + if result.returncode != expected_code or expected_message not in output: + raise SystemExit(f"Unexpected policy decision for {case}") + + +def main(): + with tempfile.TemporaryDirectory(prefix="archnet-policy-") as directory: + root = Path(directory) + (root / GUARD.parent).mkdir() + shutil.copyfile(REPO / GUARD, root / GUARD) + (root / "terraform").mkdir() + for name in ("main.tf", "variables.tf"): + relative = Path("terraform") / name + shutil.copyfile(REPO / relative, root / relative) + check(root, "clean source", 0, "PASS: provider-free Terraform source") + unexpected = root / "terraform/unexpected.tf" + shutil.copyfile(FIXTURE, unexpected) + check( + root, + "unexpected declaration", + 1, + "Unexpected Terraform source inventory: ['terraform/main.tf', " + "'terraform/unexpected.tf', 'terraform/variables.tf']", + ) + unexpected.unlink() + + with (root / "terraform/main.tf").open("ab") as source: + source.write(b"\n" + FIXTURE.read_bytes()) + check( + root, + "declaration appended to approved file", + 1, + "Terraform source differs from the reviewed cloud-disabled root: " + "terraform/main.tf", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_cloud_lock.py b/scripts/check_cloud_lock.py new file mode 100644 index 0000000..1ede483 --- /dev/null +++ b/scripts/check_cloud_lock.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Reject any executable Terraform configuration beyond the reviewed empty root.""" +from pathlib import Path +import sys + +repo = Path(__file__).resolve().parents[1] +expected = { + "terraform/main.tf": "terraform {\n required_version = \"= 1.12.2\"\n}\n", + "terraform/variables.tf": "variable \"enable_cloud_deployment\" {\n description = \"Cloud provisioning is disabled.\"\n type = bool\n default = false\n nullable = false\n\n validation {\n condition = var.enable_cloud_deployment == false\n error_message = \"Cloud deployment is intentionally disabled.\"\n }\n}\n", +} +actual = { + path.relative_to(repo).as_posix() + for path in repo.rglob("*") + if path.name.endswith((".tf", ".tf.json")) +} +if actual != expected.keys(): + sys.exit(f"Unexpected Terraform source inventory: {sorted(actual)}") +for name, source in expected.items(): + path = repo / name + if path.is_symlink() or path.read_text(encoding="utf-8") != source: + sys.exit(f"Terraform source differs from the reviewed cloud-disabled root: {name}") +print("PASS: provider-free Terraform source and deny-true lock") diff --git a/scripts/check_infrastructure_safety.py b/scripts/check_infrastructure_safety.py new file mode 100644 index 0000000..25dc93f --- /dev/null +++ b/scripts/check_infrastructure_safety.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Conservative guard for simple inline Terraform ingress declarations. + +This is not a complete HCL parser or a substitute for plan-time policy. +""" +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] +TERRAFORM = ROOT / "terraform" / "main.tf" + +def check_source(source: str) -> list[str]: + errors = [] + blocks = re.findall(r"\bingress\s*\{([^{}]*)\}", source, re.S) + if not blocks: + return ["No inline ingress blocks found; manual policy review required"] + for block in blocks: + if not re.search(r'\b(?:cidr_blocks)\s*=\s*\[[^\]]*"0\.0\.0\.0/0"[^\]]*\]', block, re.S): + continue + start = re.search(r"\bfrom_port\s*=\s*(\d+)", block) + end = re.search(r"\bto_port\s*=\s*(\d+)", block) + protocol = re.search(r'\bprotocol\s*=\s*"([^"]+)"', block) + if not start or not end or not protocol: + errors.append("Unresolved public ingress rule; manual review required") + continue + lo, hi = int(start.group(1)), int(end.group(1)) + if protocol.group(1).lower() in {"tcp", "6", "-1"} and (lo <= 22 <= hi or lo <= 6443 <= hi or protocol.group(1) == "-1"): + errors.append("Public SSH or Kubernetes API ingress detected") + return errors + +def check() -> list[str]: + if not TERRAFORM.is_file(): + return ["Missing terraform/main.tf"] + return check_source(TERRAFORM.read_text(encoding="utf-8")) + +if __name__ == "__main__": + errors = check() + for error in errors: + print("ERROR:", error, file=sys.stderr) + sys.exit(1 if errors else 0) diff --git a/scripts/smoke_kind.sh b/scripts/smoke_kind.sh new file mode 100644 index 0000000..28d480a --- /dev/null +++ b/scripts/smoke_kind.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ $(kind version) == "kind v0.30.0 "* ]] +work_dir=$(mktemp -d) +cluster="archnet-$(basename "$work_dir" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]')" +export KUBECONFIG="$work_dir/kubeconfig" +export KIND_EXPERIMENTAL_PROVIDER=docker +cleanup() { + local status=$? + trap - EXIT + kind delete cluster --name "$cluster" || status=1 + rm -rf -- "$work_dir" + exit "$status" +} +trap cleanup EXIT + +kind create cluster --name "$cluster" --wait 180s \ + --image kindest/node:v1.34.0@sha256:7416a61b42b1662ca6ca89f02028ac133a309a2a30ba309614e8ec94d976dc5a + +kubectl() { + docker exec "$cluster-control-plane" kubectl --kubeconfig=/etc/kubernetes/admin.conf "$@" +} +kubectl wait --for=condition=Ready node --all --timeout=120s +kubectl rollout status deployment/coredns -n kube-system --timeout=120s +kubectl create namespace archnet-smoke +kubectl -n archnet-smoke create configmap smoke --from-literal=ready=true +[[ $(kubectl -n archnet-smoke get configmap smoke -o 'jsonpath={.data.ready}') == true ]] diff --git a/scripts/test_offline_iam.sh b/scripts/test_offline_iam.sh new file mode 100644 index 0000000..3df16fb --- /dev/null +++ b/scripts/test_offline_iam.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(mktemp -d) +trap 'rm -rf -- "$root"' EXIT +terraform_bin=$(command -v terraform) +for module in eks cloudposse; do + mkdir "$root/$module" + cp "tests/fixtures/iam/$module.tf.fixture" "$root/$module/main.tf" + ( + cd "$root/$module" + CHECKPOINT_DISABLE=1 terraform init -backend=false -input=false + ) +done + +render() { + sudo unshare --net -- env -i PATH=/usr/bin:/bin CHECKPOINT_DISABLE=1 \ + AWS_EC2_METADATA_DISABLED=true "$terraform_bin" -chdir="$1" \ + plan -input=false -refresh=false -no-color -out="$1/result.plan" + "$terraform_bin" -chdir="$1" show -json "$1/result.plan" | + jq -e --argjson expected "$2" '.planned_values.outputs.principal_matches.value == $expected' +} + +for module in eks cloudposse; do + dir="$root/$module" + echo "$module: original principal must match" + render "$dir" true + if [[ "$module" == eks ]]; then + sed -i 's/identifiers = \["eks.amazonaws.com"\]/identifiers = ["ec2.amazonaws.com"]/' "$dir/main.tf" + else + sed -i '/aws_account_number *=/s/111122223333/444455556666/' "$dir/main.tf" + fi + echo "$module: changed principal must fail the same assertion" + render "$dir" false +done diff --git a/scripts/verify_cloud_plan.sh b/scripts/verify_cloud_plan.sh new file mode 100644 index 0000000..5fea457 --- /dev/null +++ b/scripts/verify_cloud_plan.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +python "$repo_dir/scripts/check_cloud_lock.py" +cd -- "$repo_dir/terraform" +export CHECKPOINT_DISABLE=1 AWS_EC2_METADATA_DISABLED=true +terraform init -backend=false -input=false +terraform fmt -check -recursive -diff +terraform validate -no-color + +TIMEFORMAT='Elapsed: %3R s' +plan_dir=$(mktemp -d) +trap 'rm -rf -- "$plan_dir"' EXIT +echo 'CASE: disabled baseline must produce zero changes' +time terraform plan -input=false -refresh=false -detailed-exitcode -out="$plan_dir/disabled.tfplan" +terraform show -json "$plan_dir/disabled.tfplan" | jq -e ' + .format_version == "1.2" and .errored == false and + .variables.enable_cloud_deployment.value == false and + ((.resource_changes // []) | length == 0) and + ((.resource_drift // []) | length == 0) and + ((.output_changes // {}) | length == 0) and + ((.configuration.provider_config // {}) | length == 0) and + ((.configuration.root_module.resources // []) | length == 0) and + ((.configuration.root_module.module_calls // {}) | length == 0) +' +echo 'CASE: explicit enablement must be rejected' +if time terraform plan -input=false -refresh=false -no-color -var=enable_cloud_deployment=true >"$plan_dir/denied.log" 2>&1; then + echo "Cloud enablement unexpectedly passed" >&2 + exit 1 +fi +cat "$plan_dir/denied.log" +grep -F 'Cloud deployment is intentionally disabled.' "$plan_dir/denied.log" diff --git a/terraform/main.tf b/terraform/main.tf index 974a29b..f86be10 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,119 +1,3 @@ terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 4.50" - } - } -} - -provider "aws" { - region = var.aws_region -} - -data "aws_ami" "ubuntu" { - most_recent = true - owners = ["099720109477"] - filter { - name = "name" - values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] - } - filter { - name = "root-device-type" - values = ["ebs"] - } -} - -resource "aws_vpc" "archnet" { - cidr_block = "10.0.0.0/16" - enable_dns_hostnames = true - enable_dns_support = true - tags = { Name = "archnet-vpc" } -} - -resource "aws_subnet" "public" { - vpc_id = aws_vpc.archnet.id - cidr_block = "10.0.1.0/24" - availability_zone = "${var.aws_region}a" - map_public_ip_on_launch = true - tags = { Name = "archnet-public" } -} - -resource "aws_internet_gateway" "archnet" { - vpc_id = aws_vpc.archnet.id - tags = { Name = "archnet-igw" } -} - -resource "aws_route_table" "public" { - vpc_id = aws_vpc.archnet.id - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.archnet.id - } - tags = { Name = "archnet-rt" } -} - -resource "aws_route_table_association" "public" { - subnet_id = aws_subnet.public.id - route_table_id = aws_route_table.public.id -} - -resource "aws_security_group" "archnet" { - vpc_id = aws_vpc.archnet.id - name = "archnet-sg" - description = "Archnet security group" - - ingress { - from_port = 22 - to_port = 22 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = 6443 - to_port = 6443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = { Name = "archnet-sg" } -} - -resource "aws_instance" "k3s" { - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - subnet_id = aws_subnet.public.id - vpc_security_group_ids = [aws_security_group.archnet.id] - key_name = var.key_name - - root_block_device { - volume_size = 50 - volume_type = "gp3" - delete_on_termination = true - } - - user_data = base64encode(file("${path.module}/../k3s/install.sh")) - tags = { Name = "archnet-k3s-master" } + required_version = "= 1.12.2" } diff --git a/terraform/outputs.tf b/terraform/outputs.tf deleted file mode 100644 index fadbf7c..0000000 --- a/terraform/outputs.tf +++ /dev/null @@ -1,9 +0,0 @@ -output "k3s_public_ip" { - value = aws_instance.k3s.public_ip - description = "Public IP of k3s master" -} - -output "ssh_command" { - value = "ssh -i ~/.ssh/YOUR_KEY.pem ubuntu@${aws_instance.k3s.public_ip}" - description = "SSH command" -} diff --git a/terraform/variables.tf b/terraform/variables.tf index 1591354..188604e 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -1,12 +1,11 @@ -variable "aws_region" { - default = "us-east-1" -} - -variable "instance_type" { - default = "t3.medium" -} +variable "enable_cloud_deployment" { + description = "Cloud provisioning is disabled." + type = bool + default = false + nullable = false -variable "key_name" { - description = "EC2 key pair name (must exist in AWS)" - type = string + validation { + condition = var.enable_cloud_deployment == false + error_message = "Cloud deployment is intentionally disabled." + } } diff --git a/tests/fixtures/iam/cloudposse.tf.fixture b/tests/fixtures/iam/cloudposse.tf.fixture new file mode 100644 index 0000000..9bc4681 --- /dev/null +++ b/tests/fixtures/iam/cloudposse.tf.fixture @@ -0,0 +1,61 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 5.100.0" + } + } +} +provider "aws" { + region = "us-east-1" + access_key = "offline-fixture" + secret_key = "offline-fixture" + skip_credentials_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_region_validation = true +} +locals { + enabled = true + aws_account_number = "111122223333" + eks_cluster_oidc_issuer = "example.invalid" + service_account_namespace_name_list = ["default:example"] +} +data "aws_iam_policy_document" "service_account_assume_role" { + count = local.enabled ? 1 : 0 + + statement { + actions = [ + "sts:AssumeRoleWithWebIdentity" + ] + + effect = "Allow" + + principals { + type = "Federated" + identifiers = [format("arn:%s:iam::%s:oidc-provider/%s", "aws", local.aws_account_number, local.eks_cluster_oidc_issuer)] + } + + condition { + test = "StringLike" + values = formatlist("system:serviceaccount:%s", local.service_account_namespace_name_list) + variable = format("%s:sub", local.eks_cluster_oidc_issuer) + + } + condition { + test = "StringEquals" + values = ["sts.amazonaws.com"] + variable = format("%s:aud", local.eks_cluster_oidc_issuer) + } + } + + lifecycle { + precondition { + condition = length(local.eks_cluster_oidc_issuer) > 0 + error_message = "The eks_cluster_oidc_issuer_url value must have a value." + } + } +} +output "principal_matches" { + value = try(jsondecode(data.aws_iam_policy_document.service_account_assume_role[0].json).Statement[0].Principal.Federated == "arn:aws:iam::111122223333:oidc-provider/example.invalid", false) +} diff --git a/tests/fixtures/iam/eks.tf.fixture b/tests/fixtures/iam/eks.tf.fixture new file mode 100644 index 0000000..ae5056b --- /dev/null +++ b/tests/fixtures/iam/eks.tf.fixture @@ -0,0 +1,52 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 5.100.0" + } + } +} +provider "aws" { + region = "us-east-1" + access_key = "offline-fixture" + secret_key = "offline-fixture" + skip_credentials_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_region_validation = true +} +locals { + create = true + create_outposts_local_cluster = false +} +variable "create_iam_role" { + default = true +} +data "aws_iam_policy_document" "assume_role_policy" { + count = local.create && var.create_iam_role ? 1 : 0 + + statement { + sid = "EKSClusterAssumeRole" + actions = [ + "sts:AssumeRole", + "sts:TagSession", + ] + + principals { + type = "Service" + identifiers = ["eks.amazonaws.com"] + } + + dynamic "principals" { + for_each = local.create_outposts_local_cluster ? [1] : [] + + content { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } + } +} +output "principal_matches" { + value = try(jsondecode(data.aws_iam_policy_document.assume_role_policy[0].json).Statement[0].Principal.Service == "eks.amazonaws.com", false) +} diff --git a/tests/fixtures/unexpected-resource.tf.fixture b/tests/fixtures/unexpected-resource.tf.fixture new file mode 100644 index 0000000..535cde4 --- /dev/null +++ b/tests/fixtures/unexpected-resource.tf.fixture @@ -0,0 +1,3 @@ +resource "terraform_data" "unexpected" { + input = "cloud-lock regression fixture" +} diff --git a/tests/test_infrastructure_safety.py b/tests/test_infrastructure_safety.py new file mode 100644 index 0000000..549c6a3 --- /dev/null +++ b/tests/test_infrastructure_safety.py @@ -0,0 +1,35 @@ +import unittest +from scripts.check_infrastructure_safety import check_source + +def rule(start, end, cidrs='"0.0.0.0/0"', protocol="tcp"): + return f'''ingress {{ + from_port = {start} + to_port = {end} + protocol = "{protocol}" + cidr_blocks = [{cidrs}] + }}''' + +class IngressTests(unittest.TestCase): + def test_public_ssh_denied(self): + self.assertTrue(check_source(rule(22, 22))) + + def test_public_kubernetes_api_denied(self): + self.assertTrue(check_source(rule(6443, 6443))) + + def test_port_range_denied(self): + self.assertTrue(check_source(rule(1, 65535))) + + def test_public_https_allowed(self): + self.assertFalse(check_source(rule(443, 443))) + + def test_private_ssh_allowed_by_narrow_guard(self): + self.assertFalse(check_source(rule(22, 22, '"10.0.0.0/8"'))) + + def test_unresolved_public_port_denied(self): + self.assertTrue(check_source('ingress { from_port = var.port to_port = 6443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }')) + + def test_no_inline_rules_requires_review(self): + self.assertTrue(check_source('resource "aws_security_group" "example" {}')) + +if __name__ == "__main__": + unittest.main() diff --git a/variables.tf b/variables.tf deleted file mode 100644 index 1591354..0000000 --- a/variables.tf +++ /dev/null @@ -1,12 +0,0 @@ -variable "aws_region" { - default = "us-east-1" -} - -variable "instance_type" { - default = "t3.medium" -} - -variable "key_name" { - description = "EC2 key pair name (must exist in AWS)" - type = string -}