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/cloud-setup.html b/samples/defang-provider-handoff/cloud-setup.html new file mode 100644 index 00000000..d81b5d75 --- /dev/null +++ b/samples/defang-provider-handoff/cloud-setup.html @@ -0,0 +1,161 @@ + + + + + + AWS Provider Setup + + + +
+ + + + + +
+ Loading invite details... +
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + 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..c66ff3f9 --- /dev/null +++ b/samples/defang-provider-handoff/index.html @@ -0,0 +1,25 @@ + + + + + + Cloud Setup + + + +
+ + + +
+ + + 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..a1f57459 --- /dev/null +++ b/samples/defang-provider-handoff/main.js @@ -0,0 +1,201 @@ +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 DEFAULT_API_URL = "https://api.defang.io"; + +const TOTAL_STEPS = 5; +let currentStep = 1; + +// Populated by fetchInvite() on page load. +let inviteData = null; + +const inviteId = document.location.pathname.split("/").pop().trim(); + +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 || isSuccess; + 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 gitDetails = inviteData.gitDetails; + const org = gitDetails.orgs?.[0] ?? ""; + const repoPattern = gitDetails.repoPattern ?? "*"; + const refType = gitDetails.refType ?? "all"; + const refPattern = gitDetails.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 apiUrl = DEFAULT_API_URL; + const accountId = document.getElementById("account-id").value; + const region = document.getElementById("region").value; + + const res = await fetch(`${apiUrl}/cloud-invites/${inviteId}/complete`, { + method: "POST", + headers: { + "Content-Type": "application/json", }, + body: JSON.stringify({ + awsAccountId: accountId, + awsRegion: region, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + data.message || "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()}`; +} + +async function fetchInvite() { + const apiUrl = DEFAULT_API_URL; + + const res = await fetch(`${apiUrl}/cloud-invites/${inviteId}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + data.message || "This invite link is invalid or has expired.", + ); + } + return res.json(); +} + +document.addEventListener("DOMContentLoaded", async function () { + const error = document.getElementById("error"); + const loading = document.getElementById("loading"); + + if (inviteId === "") { + error.textContent = + "Missing required query parameters: " + missing.join(", "); + error.hidden = false; + loading.hidden = true; + document.querySelector(".progress").hidden = true; + document.querySelector(".actions").hidden = true; + return; + } + + try { + inviteData = await fetchInvite(); + loading.hidden = true; + showStep(1); + } catch (err) { + loading.hidden = true; + error.textContent = err.message; + error.hidden = false; + document.querySelector(".progress").hidden = true; + document.querySelector(".actions").hidden = true; + } + + document.getElementById("btn-next").addEventListener("click", onNextClick); + document.getElementById("btn-back").addEventListener("click", function () { + if (currentStep > 1) showStep(currentStep - 1); + }); +}); diff --git a/samples/defang-provider-handoff/nginx.conf b/samples/defang-provider-handoff/nginx.conf new file mode 100644 index 00000000..c43e4bab --- /dev/null +++ b/samples/defang-provider-handoff/nginx.conf @@ -0,0 +1,28 @@ +http { + include mime.types; + sendfile on; + server { + listen 8080; + server_name localhost; + + location = / { + root /usr/share/nginx/html; + try_files /index.html =404; + } + + location / { + return 404; + } + + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + root /usr/share/nginx/html; + } + + location /cloud-setup/ { + root /usr/share/nginx/html; + try_files /cloud-setup.html =404; + } + } +} + +events {}