v1.0.0 — полный Flask: 7 роутов (discovery, versions, download, proxy, docs, healthz, readyz)
This commit is contained in:
+336
-2
@@ -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/<path:provider_path>")
|
||||
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/<path:doc_path>")
|
||||
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"<h1>tf_registry v{VERSION}</h1>"
|
||||
return """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Terra Registry</title></head>
|
||||
<body>
|
||||
<h1>Terra Registry & Documentation Server</h1>
|
||||
<p>Status: <span style="color: green">ONLINE</span></p>
|
||||
<hr>
|
||||
<p>Powered by Nubes Cloud S3 Storage</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main (mandatory for Nubes managed service)
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
|
||||
Reference in New Issue
Block a user