diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7c895025b2..4606f4434a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -73,6 +73,7 @@ # ---* Fully Eng Owned /aml-ai/**/* @GoogleCloudPlatform/aml-ai @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers +/alm/**/* @GoogleCloudPlatform/alm @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /appengine/**/* @GoogleCloudPlatform/serverless-runtimes @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /appengine/standard_python3/spanner/* @GoogleCloudPlatform/api-spanner-python @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers /asset/**/* @GoogleCloudPlatform/cloud-asset-analysis-team @GoogleCloudPlatform/python-samples-reviewers @GoogleCloudPlatform/cloud-samples-reviewers diff --git a/alm/ui-examples/Dockerfile b/alm/ui-examples/Dockerfile new file mode 100644 index 0000000000..eed99de3a0 --- /dev/null +++ b/alm/ui-examples/Dockerfile @@ -0,0 +1,35 @@ +# Use a slim version to keep the image small +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Prevent Python from writing .pyc files and enable unbuffered logging +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +# - 'curl' is required to download the gcloud SDK installer +# - 'build-essential' is kept in case any Python packages need compiling +# Note: We dropped 'jq' because Python's requests library handles JSON parsing now! +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + build-essential \ + && curl -sSL https://sdk.cloud.google.com | bash -s -- --disable-prompts \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set up the Cloud SDK path so Python's subprocess can find 'gcloud' +ENV PATH="/root/google-cloud-sdk/bin:${PATH}" + +# Install Python packages +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy app code into container +COPY . . + +# Cloud Run sets the $PORT environment variable automatically +# We use exec form for the CMD to handle signals properly +CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 app:app + diff --git a/alm/ui-examples/app.py b/alm/ui-examples/app.py new file mode 100644 index 0000000000..bd24ef5ba2 --- /dev/null +++ b/alm/ui-examples/app.py @@ -0,0 +1,159 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# [START alm_ui_sample] +import os +import requests +import subprocess +from flask import Flask, render_template, request, make_response, redirect, url_for + +app = Flask(__name__) + +GCP_REGIONS = [ + "africa-south1", "asia-east1", "asia-east2", "asia-northeast1", "asia-northeast2", + "asia-northeast3", "asia-south1", "asia-south2", "asia-southeast1", "asia-southeast2", + "australia-southeast1", "australia-southeast2", "europe-central2", "europe-north1", + "europe-southwest1", "europe-west1", "europe-west2", "europe-west3", "europe-west4", + "europe-west6", "europe-west8", "europe-west9", "europe-west10", "europe-west12", + "me-central1", "me-central2", "me-west1", "northamerica-northeast1", "northamerica-northeast2", + "southamerica-east1", "southamerica-west1", "us-central1", "us-east1", "us-east4", + "us-east5", "us-south1", "us-west1", "us-west2", "us-west3", "us-west4" +] + +# --- PAGE 1: Apigee Generate & Deploy --- +@app.route('/', methods=['GET', 'POST']) +def generate_api(): + action_result = None + error_message = None + + if request.method == 'POST': + # Get dynamic variables from the form + apigee_org = request.form.get('apigee_org') + revision = "12" #request.form.get('revision') + service_account = request.form.get('service_account') + + # Hardcoded variables + apigee_env = "prod" + apigee_api = "saas-runtime" + + try: + # --- TOKEN GENERATION --- + print("Generating gcloud token...") + token_cmd = ["gcloud", "auth", "print-access-token", f"--impersonate-service-account={service_account}"] + token = subprocess.check_output(token_cmd, text=True).strip() + + headers = {"Authorization": f"Bearer {token}"} + zip_filename = f"{apigee_api}_rev{revision}.zip" + + # --- STEP 1: Download --- + print(f"Step 1: Downloading {apigee_api} revision {revision}...") + download_url = f"https://apigee.googleapis.com/v1/organizations/{apigee_org}/apis/{apigee_api}/revisions/{revision}?format=bundle" + dl_response = requests.get(download_url, headers=headers) + dl_response.raise_for_status() + + # Save the zip file locally + with open(zip_filename, 'wb') as f: + f.write(dl_response.content) + + # --- STEP 2: Import --- + print(f"Step 2: Importing proxy bundle...") + import_url = f"https://apigee.googleapis.com/v1/organizations/{apigee_org}/apis?name={apigee_api}&action=import" + + # Open the file and send as multipart/form-data + with open(zip_filename, 'rb') as f: + files = {'file': (zip_filename, f, 'application/zip')} + import_response = requests.post(import_url, headers=headers, files=files) + + import_response.raise_for_status() + new_revision = import_response.json().get('revision') + print(f"Imported successfully! New Apigee Revision: {new_revision}") + + # --- STEP 3: Deploy --- + print(f"Step 3: Deploying revision {new_revision} to {apigee_env}...") + deploy_url = f"https://apigee.googleapis.com/v1/organizations/{apigee_org}/environments/{apigee_env}/apis/{apigee_api}/revisions/{new_revision}/deployments?serviceAccount={service_account}&override=true" + deploy_response = requests.post(deploy_url, headers=headers) + deploy_response.raise_for_status() + + # Clean up the local zip file + if os.path.exists(zip_filename): + os.remove(zip_filename) + + action_result = f"Success! Revision {new_revision} of {apigee_api} deployed to {apigee_env}." + + # Set a cookie just in case Page 2 needs to know what revision was created + resp = make_response(render_template('page1.html', action_result=action_result)) + resp.set_cookie('last_deployed_revision', str(new_revision)) + return resp + + except subprocess.CalledProcessError as e: + error_message = f"Gcloud Token Error: Failed to impersonate service account. Make sure gcloud is authenticated." + except requests.exceptions.RequestException as e: + error_message = f"API Error: {e}" + except Exception as e: + error_message = f"System Error: {str(e)}" + + # Clean up file if it failed halfway through + if 'zip_filename' in locals() and os.path.exists(zip_filename): + os.remove(zip_filename) + + return render_template('page1.html', action_result=action_result, error=error_message) + + +# --- PAGE 2: GCP Region Selector --- +@app.route('/deploy', methods=['GET', 'POST']) +def deploy_page(): + selected_region = None + api_response_data = None + operation_state = None + + if request.method == 'POST': + selected_region = request.form.get('region') + action = request.form.get('action') + + if action == 'deploy': + url = "https://apigee.saas8384.saas-example.com/v1/saas/operations" + headers = {"Content-Type": "application/json"} + payload = {"region": selected_region} + + try: + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + api_response_data = response.json() + except requests.exceptions.RequestException as e: + api_response_data = {"error": str(e)} + + elif action == 'status': + operation_id = "depr-25094d41-7768-4ef3-bd8d-e5bf67ca09d6" + url = f"https://apigee.saas8384.saas-example.com/v1/saas/operations/{operation_id}?region={selected_region}" + + try: + response = requests.get(url) + response.raise_for_status() + operation_state = response.json().get('state', 'STATE_UNKNOWN') + except requests.exceptions.RequestException as e: + operation_state = "ERROR_FETCHING_STATE" + + return render_template( + 'page2.html', + regions=GCP_REGIONS, + selected_region=selected_region, + api_response=api_response_data, + operation_state=operation_state + ) + +if __name__ == '__main__': + port = int(os.environ.get("PORT", 8080)) + app.run(host='0.0.0.0', port=port, debug=True) + +# [END alm_ui_sample] diff --git a/alm/ui-examples/read-me.txt b/alm/ui-examples/read-me.txt new file mode 100644 index 0000000000..d044cf86ac --- /dev/null +++ b/alm/ui-examples/read-me.txt @@ -0,0 +1,30 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +source .venv/bin/activate +python app.py + + + + +export _REGION="us-central1" +export _PREFIX="region-deployment" +export _projectID="fleet-coyote-478218-u8" +export _version="v2.01.0" + +gcloud artifacts repositories create ${_PREFIX} --repository-format=docker --location=$_REGION --project=${_projectID} + +docker build --tag ${_REGION}-docker.pkg.dev/${_projectID}/${_PREFIX}/${_version}:latest . + +docker push ${_REGION}-docker.pkg.dev/${_projectID}/${_PREFIX}/${_version}:latest \ No newline at end of file diff --git a/alm/ui-examples/requirements.txt b/alm/ui-examples/requirements.txt new file mode 100644 index 0000000000..2d766d3e58 --- /dev/null +++ b/alm/ui-examples/requirements.txt @@ -0,0 +1,9 @@ +blinker==1.9.0 +click==8.4.1 +Flask==3.0.3 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +Werkzeug==3.1.8 +gunicorn +requests==2.34.2 \ No newline at end of file diff --git a/alm/ui-examples/static/style.css b/alm/ui-examples/static/style.css new file mode 100644 index 0000000000..0f5d4784bf --- /dev/null +++ b/alm/ui-examples/static/style.css @@ -0,0 +1,169 @@ +/* Base Google/Material Styles */ +body { + font-family: 'Roboto', sans-serif; + background-color: #f1f3f4; + color: #202124; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + height: 100vh; +} + +/* Mock GCP Console Header */ +.gcp-header { + width: 100%; + background-color: #ffffff; + border-bottom: 1px solid #dadce0; + padding: 12px 24px; + box-sizing: border-box; + display: flex; + align-items: center; + box-shadow: 0 1px 2px 0 rgba(60,64,67,0.3); +} + +.gcp-header h1 { + margin: 0; + font-size: 18px; + font-weight: 500; + color: #5f6368; +} + +/* Card Container for Content */ +.card { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15); + width: 100%; + max-width: 450px; + padding: 24px; + margin-top: 60px; + box-sizing: border-box; +} + +.card h2 { + font-size: 22px; + font-weight: 400; + margin-top: 0; + margin-bottom: 24px; +} + +/* Form Elements */ +.form-group { + margin-bottom: 24px; +} + +label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + color: #3c4043; +} + +select { + width: 100%; + padding: 10px 14px; + font-size: 14px; + font-family: 'Roboto', sans-serif; + color: #202124; + border: 1px solid #dadce0; + border-radius: 4px; + background-color: #fff; + appearance: none; + background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%235f6368%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); + background-repeat: no-repeat; + background-position: right 12px top 50%; + background-size: 10px auto; + transition: border-color 0.2s; +} + +select:focus { + outline: none; + border-color: #1a73e8; + box-shadow: inset 0 0 0 1px #1a73e8; +} + +/* Google Material Button */ +button { + background-color: #1a73e8; + color: white; + border: none; + border-radius: 4px; + padding: 10px 24px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + font-family: 'Roboto', sans-serif; + transition: background-color 0.2s, box-shadow 0.2s; +} + +button:hover { + background-color: #1b66c9; + box-shadow: 0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15); +} + +/* Success Alert */ +.result-alert { + margin-top: 24px; + padding: 16px; + background-color: #e8f0fe; + border-radius: 8px; + display: flex; + align-items: center; + font-size: 14px; + color: #1967d2; +} + +.result-alert svg { + margin-right: 12px; + fill: #1967d2; +} + + +/* Text Inputs (Matching your select styles) */ +input[type="text"] { + width: 100%; + padding: 10px 14px; + font-size: 14px; + font-family: 'Roboto', sans-serif; + color: #202124; + border: 1px solid #dadce0; + border-radius: 4px; + background-color: #fff; + box-sizing: border-box; /* Prevents it from spilling out of the container */ + transition: border-color 0.2s; + margin-top: 4px; +} + +input[type="text"]:focus { + outline: none; + border-color: #1a73e8; + box-shadow: inset 0 0 0 1px #1a73e8; +} + +/* Make sure your select also has box-sizing so they align perfectly */ +select { + box-sizing: border-box; +} + +/* Secondary Link Button (Next Page / Back) */ +.secondary-btn { + display: inline-block; + padding: 10px 24px; + background-color: #ffffff; + color: #1a73e8; + border: 1px solid #dadce0; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + text-decoration: none; + text-align: center; + transition: background-color 0.2s; + font-family: 'Roboto', sans-serif; +} + +.secondary-btn:hover { + background-color: #f8f9fa; +} \ No newline at end of file