From 193214ac58ac8a7e97d89bf276c1011b1a207f08 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 2 Mar 2026 17:32:10 -0800 Subject: [PATCH 01/11] provider handoff service --- samples/defang-provider-handoff/Dockerfile | 18 ++ samples/defang-provider-handoff/compose.yaml | 23 ++ samples/defang-provider-handoff/index.html | 156 +++++++++++++ samples/defang-provider-handoff/main.css | 217 +++++++++++++++++++ samples/defang-provider-handoff/main.js | 180 +++++++++++++++ samples/defang-provider-handoff/nginx.conf | 15 ++ 6 files changed, 609 insertions(+) create mode 100644 samples/defang-provider-handoff/Dockerfile create mode 100644 samples/defang-provider-handoff/compose.yaml create mode 100644 samples/defang-provider-handoff/index.html create mode 100644 samples/defang-provider-handoff/main.css create mode 100644 samples/defang-provider-handoff/main.js create mode 100644 samples/defang-provider-handoff/nginx.conf diff --git a/samples/defang-provider-handoff/Dockerfile b/samples/defang-provider-handoff/Dockerfile new file mode 100644 index 00000000..454b452c --- /dev/null +++ b/samples/defang-provider-handoff/Dockerfile @@ -0,0 +1,18 @@ +# Use nginx as the base image +FROM nginx:stable + +# Set working directory to /app +WORKDIR /app + +# Copy project files to nginx directory +COPY . /usr/share/nginx/html + +# Set correct permissions for the project files +RUN chmod -R 755 /usr/share/nginx/html && chown -R nginx:nginx /usr/share/nginx/html + +# Copy the config file to nginx directory +COPY nginx.conf /etc/nginx/nginx.conf + +# Expose the port your app runs on +EXPOSE 8080 + diff --git a/samples/defang-provider-handoff/compose.yaml b/samples/defang-provider-handoff/compose.yaml new file mode 100644 index 00000000..a3b3bc6a --- /dev/null +++ b/samples/defang-provider-handoff/compose.yaml @@ -0,0 +1,23 @@ +services: + app: + restart: unless-stopped + build: + context: . + dockerfile: Dockerfile + ports: + - target: 8080 + published: 8080 + mode: ingress + deploy: + resources: + reservations: + memory: 256M + healthcheck: + test: + - CMD + - curl + - -f + - http://localhost:8080/ + interval: 30s + timeout: 10s + retries: 3 diff --git a/samples/defang-provider-handoff/index.html b/samples/defang-provider-handoff/index.html new file mode 100644 index 00000000..7db1c8bb --- /dev/null +++ b/samples/defang-provider-handoff/index.html @@ -0,0 +1,156 @@ + + + + + + AWS Provider Setup + + + +
+ + + + + +
+

AWS Provider Setup

+

+ This wizard will connect your AWS account to Defang by creating a + CloudFormation stack that sets up the required IAM roles for CI/CD. +

+

Before you begin, make sure you have:

+
    +
  • Your 12-digit AWS Account ID
  • +
  • Access to the AWS region where you want to deploy
  • +
  • Permission to create CloudFormation stacks and IAM roles
  • +
+
+ + + + + + + + + + + + + +
+ + +
+
+ + + diff --git a/samples/defang-provider-handoff/main.css b/samples/defang-provider-handoff/main.css new file mode 100644 index 00000000..e9417d93 --- /dev/null +++ b/samples/defang-provider-handoff/main.css @@ -0,0 +1,217 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #f5f5f5; + color: #1a1a1a; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.container { + background: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 460px; +} + +/* ── Progress indicator ── */ + +.progress { + display: flex; + align-items: center; + margin-bottom: 2rem; +} + +.progress-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.progress-dot { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + border: 2px solid #ccc; + color: #999; + background: #fff; + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.progress-label { + font-size: 0.72rem; + color: #999; + transition: color 0.2s; +} + +.progress-line { + flex: 1; + height: 2px; + background: #ccc; + margin: 0 0.5rem; + margin-bottom: 1.25rem; + transition: background 0.2s; +} + +.progress-step.active .progress-dot { + border-color: #0066cc; + background: #0066cc; + color: #fff; +} + +.progress-step.active .progress-label { + color: #0066cc; + font-weight: 600; +} + +.progress-step.completed .progress-dot { + border-color: #0066cc; + background: #e8f0fb; + color: #0066cc; +} + +.progress-step.completed .progress-label { + color: #0066cc; +} + +.progress-step.completed + .progress-line { + background: #0066cc; +} + +/* ── Error ── */ + +.error { + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + background: #fff3f3; + border: 1px solid #f5c6c6; + border-radius: 4px; + color: #c0392b; + font-size: 0.9rem; +} + +/* ── Step panels ── */ + +.step-panel h1 { + margin: 0 0 1rem; + font-size: 1.3rem; +} + +.step-panel p, .step-panel ul, .step-panel ol { + margin: 0 0 0.75rem; + font-size: 0.95rem; + line-height: 1.55; + color: #444; +} + +.step-panel ul { + padding-left: 1.25rem; +} + +.step-panel li { + margin-bottom: 0.3rem; +} + +/* ── Form ── */ + +.field { + display: flex; + flex-direction: column; + margin-bottom: 1.25rem; +} + +label { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.375rem; +} + +input, select { + padding: 0.5rem 0.75rem; + font-size: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + outline: none; + transition: border-color 0.15s; +} + +input:focus, select:focus { + border-color: #0066cc; +} + +/* ── Summary (step 3) ── */ + +.summary { + margin: 0 0 1rem; + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1rem; + font-size: 0.95rem; +} + +.summary dt { + font-weight: 600; + color: #555; +} + +.summary dd { + margin: 0; + color: #1a1a1a; +} + +.hint { + font-size: 0.85rem; + color: #777; +} + +/* ── Actions ── */ + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.75rem; +} + +button { + padding: 0.575rem 1.25rem; + font-size: 0.95rem; + font-weight: 600; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} + +button:not(.btn-secondary) { + background: #0066cc; + color: #fff; +} + +button:not(.btn-secondary):hover { + background: #0052a3; +} + +.btn-secondary { + background: #f0f0f0; + color: #333; +} + +.btn-secondary:hover { + background: #e0e0e0; +} diff --git a/samples/defang-provider-handoff/main.js b/samples/defang-provider-handoff/main.js new file mode 100644 index 00000000..922b00ff --- /dev/null +++ b/samples/defang-provider-handoff/main.js @@ -0,0 +1,180 @@ +const AWS_TEMPLATE_URL = + "https://s3.us-west-2.amazonaws.com/defang-public-readonly/defang-cd.yaml"; +const GITHUB_OIDC_ISSUER = "token.actions.githubusercontent.com"; +const AWS_CIROLE_NAME = "defang-cd-CIRole"; + +const REQUIRED_PARAMS = [ + "session", + "org", + "provider", + "repoPattern", + "refType", + "refPattern", +]; + +const TOTAL_STEPS = 5; +let currentStep = 1; + +function getParams() { + return new URLSearchParams(window.location.search); +} + +function showStep(n) { + for (let i = 1; i <= TOTAL_STEPS; i++) { + document.getElementById("step-" + i).hidden = i !== n; + const stepEl = document.querySelector( + ".progress-step[data-step='" + i + "']", + ); + if (stepEl) { + stepEl.classList.toggle("active", i === n); + stepEl.classList.toggle("completed", i < n); + } + } + const isSuccess = n === TOTAL_STEPS; + document.getElementById("btn-back").hidden = n === 1; + document.getElementById("btn-next").hidden = isSuccess; + document.getElementById("btn-next").textContent = + n === 3 ? "Launch" : n === 4 ? "Confirm" : "Next"; + document.querySelector(".progress").hidden = isSuccess; + document.querySelector(".actions").hidden = isSuccess; + currentStep = n; +} + +function onNextClick() { + if (currentStep === 2) { + const form = document.getElementById("provider-form"); + if (!form.reportValidity()) return; + showStep(3); + } else if (currentStep === 3) { + const accountId = document.getElementById("account-id").value; + const region = document.getElementById("region").value; + onSubmit({ accountId, region }); + showStep(4); + } else if (currentStep === 4) { + onConfirm(); + } else { + showStep(currentStep + 1); + } +} + +function onSubmit(properties) { + const params = getParams(); + const org = params.get("org"); + const repoPattern = params.get("repoPattern"); + const refType = params.get("refType"); + const refPattern = params.get("refPattern"); + const { accountId, region } = properties; + const cloudformationURL = makeQuickCreateURL({ + accountId, + region, + org, + refType, + repoPattern, + refPattern, + }); + + // open this URL in a new tab to avoid losing the query parameters (and thus state) of the current page + window.open(cloudformationURL, "_blank"); + document.getElementById("cf-link").href = cloudformationURL; +} + +async function onConfirm() { + const confirmError = document.getElementById("confirm-error"); + confirmError.hidden = true; + try { + const session = getParams().get("session"); + // TODO: replace this endpoint + const res = await fetch("https://graphql.defang.io/completeCloudInvite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session }), + }); + if (!res.ok) { + throw new Error("Request failed with status " + res.status); + } + showStep(TOTAL_STEPS); + } catch (err) { + confirmError.textContent = err.message; + confirmError.hidden = false; + } +} + +function makeQuickCreateURL({ + accountId, + region, + org, + refType, + repoPattern, + refPattern, +}) { + let oidcSubjects; + if (refType === "all") { + oidcSubjects = `repo:${org}/${repoPattern}:*`; + } else if (refType === "branch") { + oidcSubjects = `repo:${org}/${repoPattern}:ref:refs/heads/${refPattern}`; + } else { + oidcSubjects = `repo:${org}/${repoPattern}:environment:${refPattern}`; + } + const oidcAudiences = [ + `https://github.com/${org}`, // the default audience + `sts.amazonaws.com`, // the audience set by the configure-aws-credentials GitHub action + ]; + return makeCFStackCreateURL(AWS_TEMPLATE_URL, { + accountId, + region, + stackName: "defang-cd", // NOTE: same name as in CLI, to avoid creating multiple buckets per region + params: { + CIRoleName: AWS_CIROLE_NAME, // fixed, so we can anticipate AWS_ROLE_ARN + OidcProviderAudiences: oidcAudiences, + OidcProviderIssuer: GITHUB_OIDC_ISSUER, + OidcProviderSubjects: [oidcSubjects], + }, + }); +} + +function makeCFStackCreateURL(templateURL, args) { + const query = `region=${encodeURIComponent(args.region)}`; + const fragmentParams = new URLSearchParams({ + templateURL, + }); + if (args.stackName) { + fragmentParams.set("stackName", args.stackName); + } + for (const [k, v] of Object.entries(args.params ?? {})) { + fragmentParams.set(`param_${k}`, toString(v)); + } + + let baseUrl = `${args.region}.console.aws.amazon.com/cloudformation/home`; + // Use account-specific URL if account ID is provided + if (args.accountId) { + baseUrl = `${args.accountId}.${baseUrl}`; + } + return `https://${baseUrl}?${query}#/stacks/create/review?${fragmentParams.toString()}`; +} + +document.addEventListener("DOMContentLoaded", function () { + const error = document.getElementById("error"); + const params = getParams(); + const missing = REQUIRED_PARAMS.filter(function (key) { + return !params.has(key); + }); + + if (missing.length > 0) { + error.textContent = + "Missing required query parameters: " + missing.join(", "); + error.hidden = false; + document.querySelector(".progress").hidden = true; + document.querySelector(".actions").hidden = true; + for (let i = 1; i <= TOTAL_STEPS; i++) { + document.getElementById("step-" + i).hidden = true; + } + return; + } + + document.getElementById("btn-next").addEventListener("click", onNextClick); + document.getElementById("btn-back").addEventListener("click", function () { + if (currentStep > 1) showStep(currentStep - 1); + }); + + showStep(1); +}); diff --git a/samples/defang-provider-handoff/nginx.conf b/samples/defang-provider-handoff/nginx.conf new file mode 100644 index 00000000..a11dc953 --- /dev/null +++ b/samples/defang-provider-handoff/nginx.conf @@ -0,0 +1,15 @@ +http { + include mime.types; + sendfile on; + server { + listen 8080; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html; + } + } +} + +events {} From c53c9a3ca112505ade373ba5f4e982c210e7d647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 3 Mar 2026 14:15:07 +0000 Subject: [PATCH 02/11] wire up provider handoff sample to cloud-invites REST API Replace stub query params with real API calls: fetch invite details on page load using inviteId + secret, and call the complete endpoint with AWS account details. Adds loading state and configurable apiUrl param for local testing. Co-Authored-By: Claude Opus 4.6 --- samples/defang-provider-handoff/index.html | 7 +- samples/defang-provider-handoff/main.js | 92 ++++++++++++++++------ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/samples/defang-provider-handoff/index.html b/samples/defang-provider-handoff/index.html index 7db1c8bb..13a22215 100644 --- a/samples/defang-provider-handoff/index.html +++ b/samples/defang-provider-handoff/index.html @@ -32,8 +32,13 @@ + +
+ Loading invite details... +
+ -
+