diff --git a/requirements.txt b/requirements.txt index 7e10602..aa55d98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask +boto3 diff --git a/site/app.py b/site/app.py index 5cfd2b7..5181088 100644 --- a/site/app.py +++ b/site/app.py @@ -1,14 +1,348 @@ VERSION = "1.0.0" -from flask import Flask +import os +import mimetypes +import urllib.parse +from flask import Flask, Response, jsonify, request, stream_with_context + +import boto3 +from botocore.config import Config + +# --------------------------------------------------------------------------- +# Config (from ENV, matching Go defaults) +# --------------------------------------------------------------------------- +HOSTNAME = os.getenv("REGISTRY_HOSTNAME", "localhost:8080") +S3_ENDPOINT = os.getenv("S3_ENDPOINT", "") +S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "") +S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "") +S3_BUCKET = os.getenv("S3_BUCKET", "terraform-registry") + +# --------------------------------------------------------------------------- +# GPG public key (hardcoded, same as registrykeys/public_key.go) +# --------------------------------------------------------------------------- +GPG_KEY_ID = "CB3A0DF161ECC416" +GPG_ASCII_ARMOR = """-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=q8Tw +-----END PGP PUBLIC KEY BLOCK-----""" + +# --------------------------------------------------------------------------- +# S3 client (MinIO-compatible, Secure=true in Go → use_ssl=True + s3v4) +# --------------------------------------------------------------------------- +s3 = boto3.client( + "s3", + endpoint_url=f"https://{S3_ENDPOINT}", + aws_access_key_id=S3_ACCESS_KEY, + aws_secret_access_key=S3_SECRET_KEY, + config=Config(signature_version="s3v4"), + use_ssl=True, +) + +# --------------------------------------------------------------------------- +# Flask app +# --------------------------------------------------------------------------- app = Flask(__name__) +# --------------------------------------------------------------------------- +# Route: Discovery +# GET /.well-known/terraform.json +# --------------------------------------------------------------------------- +@app.route("/.well-known/terraform.json") +def discovery(): + return jsonify({"providers.v1": "/v1/providers/"}) + + +# --------------------------------------------------------------------------- +# Route: Provider router +# GET /v1/providers/{ns}/{name}/versions +# GET /v1/providers/{ns}/{name}/{ver}/download/{os}/{arch} +# --------------------------------------------------------------------------- +@app.route("/v1/providers/") +def provider_router(provider_path): + parts = provider_path.split("/") + + # /v1/providers/{ns}/{name}/versions + if len(parts) == 3 and parts[2] == "versions": + return _list_versions(parts[0], parts[1]) + + # /v1/providers/{ns}/{name}/{ver}/download/{os}/{arch} + if len(parts) == 6 and parts[3] == "download": + return _download_version(parts[0], parts[1], parts[2], parts[4], parts[5]) + + return "Not Found", 404 + + +def _list_versions(namespace, name): + """List all versions and platforms by scanning S3 objects.""" + prefix = f"{HOSTNAME}/{namespace}/{name}/" + seen = {} + + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=S3_BUCKET, Prefix=prefix): + for obj in page.get("Contents", []): + key = obj["Key"] + key_parts = key.split("/") + if len(key_parts) < 5: + continue + ver = key_parts[3] + filename = key_parts[4] + + if ver not in seen: + seen[ver] = { + "version": ver, + "protocols": ["5.0"], + "platforms": [], + } + + if "_linux_amd64.zip" in filename: + seen[ver]["platforms"].append({"os": "linux", "arch": "amd64"}) + if "_windows_amd64.zip" in filename: + seen[ver]["platforms"].append({"os": "windows", "arch": "amd64"}) + + versions = list(seen.values()) + return jsonify({ + "id": f"{namespace}/{name}", + "versions": versions, + "warnings": [], + }) + + +def _download_version(namespace, name, version, os_type, arch): + """Build download metadata JSON with S3 proxy URLs.""" + base = f"{HOSTNAME}/{namespace}/{name}/{version}" + filename = f"terraform-provider-{name}_{version}_{os_type}_{arch}.zip" + full_key = f"{base}/{filename}" + shasums_key = f"{base}/terraform-provider-{name}_{version}_SHA256SUMS" + sig_key = f"{base}/terraform-provider-{name}_{version}_SHA256SUMS.sig" + + # Extract shasum for this file from SHA256SUMS + shasum = "" + try: + resp = s3.get_object(Bucket=S3_BUCKET, Key=shasums_key) + for line in resp["Body"].read().decode("utf-8", errors="replace").splitlines(): + if filename in line: + shasum = line.split()[0] + break + except Exception: + pass + + base_url = f"https://{HOSTNAME}" + download_url = f"{base_url}/v1/proxy?bucket={S3_BUCKET}&key={urllib.parse.quote(full_key)}" + shasums_url = f"{base_url}/v1/proxy?bucket={S3_BUCKET}&key={urllib.parse.quote(shasums_key)}" + sig_url = f"{base_url}/v1/proxy?bucket={S3_BUCKET}&key={urllib.parse.quote(sig_key)}" + + return jsonify({ + "protocols": ["5.0"], + "os": os_type, + "arch": arch, + "filename": filename, + "download_url": download_url, + "shasums_url": shasums_url, + "shasums_signature_url": sig_url, + "shasum": shasum, + "signing_keys": { + "gpg_public_keys": [ + { + "key_id": GPG_KEY_ID, + "ascii_armor": GPG_ASCII_ARMOR, + } + ] + }, + }) + + +# --------------------------------------------------------------------------- +# Route: Proxy (S3 streaming) +# GET /v1/proxy?bucket=...&key=... +# --------------------------------------------------------------------------- +@app.route("/v1/proxy") +def proxy(): + bucket = request.args.get("bucket", "") + key = request.args.get("key", "") + + if not bucket or not key: + return "Missing bucket or key params", 400 + + try: + head = s3.head_object(Bucket=bucket, Key=key) + obj = s3.get_object(Bucket=bucket, Key=key) + + return Response( + stream_with_context(obj["Body"].iter_chunks(chunk_size=65536)), + content_type="application/octet-stream", + headers={ + "Content-Length": str(head["ContentLength"]), + "Last-Modified": head["LastModified"].strftime("%a, %d %b %Y %H:%M:%S GMT"), + }, + ) + except Exception: + return "File not found", 404 + + +# --------------------------------------------------------------------------- +# Route: Docs (static from S3) +# GET /docs/{ns}/{name}/{ver}/... +# --------------------------------------------------------------------------- +@app.route("/docs/") +def docs(doc_path): + # Parse: docs/{namespace}/{name}/{version}/...rest + parts = doc_path.strip("/").split("/") + if len(parts) < 3: + return "Bad docs path", 400 + + namespace = parts[0] + name = parts[1] + version = parts[2] + rest = "/".join(parts[3:]) if len(parts) > 3 else "" + + # Build candidate S3 keys (exact 1:1 with Go tryCandidateKeys) + candidates = [] + + if rest == "": + candidates.append(f"docs/{namespace}/{name}/{version}/index.html") + else: + # 1) exact path + candidates.append(f"docs/{namespace}/{name}/{version}/{rest}") + + # 2) if looks like directory or no extension → try index.html under it + rest_ext = os.path.splitext(rest)[1] # ".css" or "" + if rest.endswith("/") or rest_ext == "": + candidates.append( + f"docs/{namespace}/{name}/{version}/{rest.rstrip('/')}/index.html" + ) + + # 3) fallback: root index.html + candidates.append(f"docs/{namespace}/{name}/{version}/index.html") + + for s3_key in candidates: + try: + head = s3.head_object(Bucket=S3_BUCKET, Key=s3_key) + obj = s3.get_object(Bucket=S3_BUCKET, Key=s3_key) + + # Content-Type (1:1 with Go docsHandler) + ext = os.path.splitext(s3_key)[1] # ".html", ".css", etc. + ctype = mimetypes.types_map.get(ext, "") + if not ctype: + if ext == ".html" or s3_key.endswith("index.html"): + ctype = "text/html; charset=utf-8" + else: + ctype = "application/octet-stream" + + return Response( + stream_with_context(obj["Body"].iter_chunks(chunk_size=65536)), + content_type=ctype, + headers={ + "Content-Length": str(head["ContentLength"]), + "Last-Modified": head["LastModified"].strftime( + "%a, %d %b %Y %H:%M:%S GMT" + ), + }, + ) + except Exception: + continue + + return "Documentation not found", 404 + + +# --------------------------------------------------------------------------- +# Route: Healthz +# GET /healthz +# --------------------------------------------------------------------------- +@app.route("/healthz") +def healthz(): + return "ok", 200 + + +# --------------------------------------------------------------------------- +# Route: Readyz +# GET /readyz +# --------------------------------------------------------------------------- +@app.route("/readyz") +def readyz(): + return "ok", 200 + + +# --------------------------------------------------------------------------- +# Route: Root (LAST — must not shadow /docs) +# GET / +# --------------------------------------------------------------------------- @app.route("/") def index(): - return f"

tf_registry v{VERSION}

" + return """ + +Terra Registry + +

Terra Registry & Documentation Server

+

Status: ONLINE

+
+

Powered by Nubes Cloud S3 Storage

+ +""" +# --------------------------------------------------------------------------- +# Main (mandatory for Nubes managed service) +# --------------------------------------------------------------------------- if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)