VERSION = "1.0.0" 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_PREFIX = os.getenv("S3_PREFIX", "go-registry.containerk8s.dev.nubes.ru") 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 keys (from LIVE Go registry — TWO keys: primary + legacy) # --------------------------------------------------------------------------- GPG_PRIMARY_KEY_ID = "3EC4673EB798238A" GPG_PRIMARY_ASCII_ARMOR = """-----BEGIN PGP PUBLIC KEY BLOCK----- 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 6cZ0NQ== =3Ea2 -----END PGP PUBLIC KEY BLOCK-----""" GPG_LEGACY_KEY_ID = "CB3A0DF161ECC416" GPG_LEGACY_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"{S3_PREFIX}/{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 "_darwin_amd64.zip" in filename: seen[ver]["platforms"].append({"os": "darwin", "arch": "amd64"}) 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"{S3_PREFIX}/{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_PRIMARY_KEY_ID, "ascii_armor": GPG_PRIMARY_ASCII_ARMOR, }, { "key_id": GPG_LEGACY_KEY_ID, "ascii_armor": GPG_LEGACY_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""" Terra Registry v{VERSION}

Terra Registry & Documentation Server v{VERSION}

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)